集成 OpenHub 与 WeKnora Page AI
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium, request } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||
const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`;
|
||||
const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task788-weknora-kb-folder-browser-flow-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json");
|
||||
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "weknora-kb-folder-browser-flow.png");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK788_WEKNORA_TIMEOUT_MS || 240_000);
|
||||
const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK788_WEKNORA_POLL_MS || 5_000);
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function writeJson(filePath, payload) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function failLayer(layer, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.layer = layer;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function signInRequest(context) {
|
||||
const response = await context.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function signInBrowser(context) {
|
||||
const response = await context.request.post(`${BASE_URL}/api/auth`, {
|
||||
data: {
|
||||
action: "auth:signIn",
|
||||
args: {
|
||||
provider: "password",
|
||||
params: {
|
||||
email: "mnote.e2e@example.com",
|
||||
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
|
||||
flow: "signIn",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(response.ok(), `浏览器上下文登录失败: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
async function apiJson(context, method, url, data) {
|
||||
const response = await context.fetch(url, {
|
||||
method,
|
||||
data,
|
||||
headers: {
|
||||
"x-mnote-actor-id": "mnote-e2e",
|
||||
"x-mnote-actor-type": "user",
|
||||
"x-mnote-workspace-id": WORKSPACE_ID,
|
||||
accept: "application/json",
|
||||
},
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = { rawText: text };
|
||||
}
|
||||
return { ok: response.ok(), status: response.status(), payload, text };
|
||||
}
|
||||
|
||||
async function status(context) {
|
||||
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
|
||||
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_status_http", `status HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
async function createKnowledgeBase(context, name, description) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/knowledge-bases`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
name,
|
||||
description,
|
||||
defaultToolEnabled: true,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_create_kb_http", `create KB HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "create KB provider 应为 weknora");
|
||||
assert(payload?.providerKnowledgeBaseId, `create KB 缺少 providerKnowledgeBaseId: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`);
|
||||
assert.equal(payload?.knowledgeBase?.provider, "weknora", "knowledgeBase provider 应为 weknora");
|
||||
assert.equal(payload?.knowledgeBase?.providerKbId, payload.providerKnowledgeBaseId, "knowledgeBase.providerKbId 应匹配 providerKnowledgeBaseId");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ingestFolder(context, folderPath, providerKnowledgeBaseId) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
force: true,
|
||||
providerKnowledgeBaseId,
|
||||
sources: [{ sourcePath: folderPath }],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_ingest_folder_http", `ingest folder HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
const payload = result.payload;
|
||||
assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora");
|
||||
const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : [];
|
||||
assert(configured.length >= 2, `文件夹入库应至少展开两个文件: ${JSON.stringify(configured, null, 2).slice(0, 1600)}`);
|
||||
const mapped = configured.filter((item) => item.mappingStatus === "provider_mapped" && item.providerKnowledgeBaseId === providerKnowledgeBaseId);
|
||||
assert(mapped.length >= 2, `文件夹入库未全部映射到新 KB: ${JSON.stringify(configured, null, 2).slice(0, 2000)}`);
|
||||
const registryEntries = payload.registry?.entries || [];
|
||||
const folderEntries = registryEntries.filter((entry) => String(entry.sourceRootRelativePath || "").startsWith(`${folderPath}/`));
|
||||
assert(folderEntries.length >= 2, "source registry 应写入文件夹下的 source");
|
||||
assert(folderEntries.every((entry) => entry.provider === "weknora"), "source registry provider 应为 weknora");
|
||||
assert(folderEntries.every((entry) => entry.providerKnowledgeBaseId === providerKnowledgeBaseId), "source registry 应写入新 KB id");
|
||||
return { payload, configured, mapped, folderEntries };
|
||||
}
|
||||
|
||||
async function searchUntilHit(context, query, folderPath, providerKnowledgeBaseId) {
|
||||
const startedAt = Date.now();
|
||||
let lastPayload = null;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, {
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
query,
|
||||
mode: "hybrid",
|
||||
topK: 10,
|
||||
chunkTopK: 10,
|
||||
includeChunkContent: true,
|
||||
providerKnowledgeBaseId,
|
||||
sourcePaths: [folderPath],
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw failLayer("mnote_search_http", `search HTTP ${result.status}`, { response: result.payload || result.text });
|
||||
}
|
||||
lastPayload = result.payload;
|
||||
const hit = (lastPayload.references || []).find((reference) => {
|
||||
const pathText = String(reference.sourceRootRelativePath || "");
|
||||
const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`;
|
||||
return reference.provider === "weknora"
|
||||
&& reference.providerKnowledgeBaseId === providerKnowledgeBaseId
|
||||
&& (pathText.startsWith(`${folderPath}/`) || quote.includes(query));
|
||||
});
|
||||
if (hit) return { payload: lastPayload, reference: hit };
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw failLayer("weknora_new_kb_folder_search_timeout", "等待新 KB 文件夹入库内容可搜索超时", {
|
||||
query,
|
||||
folderPath,
|
||||
providerKnowledgeBaseId,
|
||||
lastPayload,
|
||||
});
|
||||
}
|
||||
|
||||
function createFolderFixture(startedAt) {
|
||||
if (!fs.existsSync(ROOT_PATH)) {
|
||||
throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`);
|
||||
}
|
||||
const marker = `task788 weknora kb folder browser marker ${startedAt}`;
|
||||
const markers = [`${marker} alpha`, `${marker} beta`];
|
||||
const folderPath = `knowledge-rag-fixtures-7-68/task788-folder-${startedAt}`;
|
||||
const absoluteFolderPath = path.join(ROOT_PATH, folderPath);
|
||||
const sourcePaths = [`${folderPath}/alpha.md`, `${folderPath}/beta.md`];
|
||||
fs.mkdirSync(absoluteFolderPath, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(absoluteFolderPath, "alpha.md"),
|
||||
`# Task 788 Alpha\n\n${markers[0]}\n\nWeKnora folder browser flow alpha note.\n`,
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(absoluteFolderPath, "beta.md"),
|
||||
`# Task 788 Beta\n\n${markers[1]}\n\nWeKnora folder browser flow beta note.\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { folderPath, absoluteFolderPath, sourcePaths, marker, markers };
|
||||
}
|
||||
|
||||
async function verifyInBrowser(fixture, name) {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await signInBrowser(context);
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", ROOT_URI);
|
||||
url.searchParams.set("workspaceId", WORKSPACE_ID);
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]');
|
||||
await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
assert.equal(await panel.getAttribute("data-knowledge-rag-default-provider"), "weknora", "设置面板应声明 WeKnora provider");
|
||||
|
||||
await panel.locator('[data-testid="mnote-weknora-create-kb-name"]').fill(name);
|
||||
await panel.locator('[data-testid="mnote-weknora-create-kb-description"]').fill("MNote task788 temporary KB created through browser settings UI");
|
||||
await panel.locator('[data-knowledge-rag-action="create-kb"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const kbSelect = panel.locator('[data-testid="mnote-weknora-kb-select"]');
|
||||
await page.waitForFunction(
|
||||
(expectedName) => {
|
||||
const status = document.querySelector('[data-testid="mnote-weknora-create-kb-status"]');
|
||||
if (!(status instanceof HTMLElement) || status.getAttribute("data-status") !== "done") return false;
|
||||
const select = document.querySelector('[data-testid="mnote-weknora-kb-select"]');
|
||||
if (!(select instanceof HTMLSelectElement) || !select.value.trim()) return false;
|
||||
const selected = select.options[select.selectedIndex];
|
||||
return selected && selected.textContent.includes(expectedName);
|
||||
},
|
||||
name,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const providerKnowledgeBaseId = await kbSelect.inputValue();
|
||||
assert(providerKnowledgeBaseId, "UI 创建 KB 后应选中新 providerKnowledgeBaseId");
|
||||
|
||||
const firstSourceInput = panel.locator('[data-knowledge-rag-source-input]').first();
|
||||
await firstSourceInput.fill(fixture.folderPath);
|
||||
await panel.locator('[data-knowledge-rag-action="ingest"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const sourceRows = await Promise.all(fixture.sourcePaths.map(async (sourcePath) => {
|
||||
const row = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${sourcePath}"]`).first();
|
||||
await row.waitFor({ state: "visible", timeout: POLL_TIMEOUT_MS });
|
||||
await row.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
return sourcePath;
|
||||
}));
|
||||
|
||||
const kbSummary = panel.locator('[data-testid="mnote-weknora-kb-summary"]');
|
||||
await kbSummary.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const kbIds = await kbSummary.getAttribute("data-knowledge-rag-kb-ids");
|
||||
const sourceRowsForKb = await panel.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).count();
|
||||
assert(sourceRowsForKb >= 2, `source rows 未反映新建 KB id: ${providerKnowledgeBaseId}`);
|
||||
|
||||
await panel.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await panel.locator('[data-kb-rag-search-input]').fill(fixture.markers[0]);
|
||||
let searchResultSourcePath = null;
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
await panel.locator('[data-kb-rag-action="search"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const result = panel.locator(`[data-kb-rag-search-result="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).first();
|
||||
try {
|
||||
await result.waitFor({ state: "visible", timeout: POLL_INTERVAL_MS });
|
||||
searchResultSourcePath = await result.getAttribute("data-knowledge-rag-source-path");
|
||||
break;
|
||||
} catch {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
assert(searchResultSourcePath, `UI 检索未展示新 KB ${providerKnowledgeBaseId} 的结果`);
|
||||
await panel.locator('[data-kb-rag-search-result="true"] [data-knowledge-rag-action="open-source-reference"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
|
||||
return {
|
||||
providerKnowledgeBaseId,
|
||||
kbSummaryVisible: true,
|
||||
kbSummaryConfiguredKbIds: String(kbIds || "").split(",").filter(Boolean),
|
||||
sourceRowsForKb,
|
||||
sourceRowsVisible: sourceRows,
|
||||
searchResultVisible: true,
|
||||
searchResultSourcePath,
|
||||
searchVerifiedByApi: false,
|
||||
searchUiProviderKbScopeMissing: false,
|
||||
openReferenceControlsVisible: true,
|
||||
screenshot: SCREENSHOT_PATH,
|
||||
};
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined);
|
||||
throw failLayer("browser_ui_verify", error.message || String(error), { screenshot: SCREENSHOT_PATH });
|
||||
} finally {
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const context = await request.newContext({ baseURL: BASE_URL });
|
||||
try {
|
||||
await signInRequest(context);
|
||||
const startedAt = Date.now();
|
||||
const fixture = createFolderFixture(startedAt);
|
||||
const name = `task788-weknora-kb-${startedAt}`;
|
||||
const browserResult = await verifyInBrowser(fixture, name);
|
||||
const providerKnowledgeBaseId = browserResult.providerKnowledgeBaseId;
|
||||
const searchResult = await searchUntilHit(context, fixture.markers[0], fixture.folderPath, providerKnowledgeBaseId);
|
||||
const afterStatus = await status(context);
|
||||
const statusBase = (afterStatus.knowledgeBases?.bases || []).find((base) => base.providerKbId === providerKnowledgeBaseId);
|
||||
assert(statusBase, "status knowledgeBases 应包含新建 KB");
|
||||
assert(statusBase.sourceCount >= 2, `新建 KB sourceCount 应 >= 2: ${JSON.stringify(statusBase, null, 2)}`);
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
task: "task788-weknora-kb-folder-browser-flow-smoke",
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
uiCreatesKb: true,
|
||||
uiAddsFolder: true,
|
||||
browserVerifiesKb: true,
|
||||
browserVerifiesSearch: true,
|
||||
browserVerifiesOpenReferenceControls: true,
|
||||
apiVerifiesSearch: true,
|
||||
folderPath: fixture.folderPath,
|
||||
absoluteFolderPath: fixture.absoluteFolderPath,
|
||||
markers: fixture.markers,
|
||||
knowledgeBase: {
|
||||
name,
|
||||
providerKnowledgeBaseId,
|
||||
baseId: statusBase.baseId,
|
||||
defaultToolEnabled: statusBase.defaultToolEnabled,
|
||||
sourceCount: statusBase.sourceCount,
|
||||
status: statusBase.status,
|
||||
},
|
||||
ingest: {
|
||||
configuredCount: fixture.sourcePaths.length,
|
||||
mappedCount: fixture.sourcePaths.length,
|
||||
sourcePaths: fixture.sourcePaths,
|
||||
},
|
||||
apiSearch: {
|
||||
provider: searchResult.payload.provider,
|
||||
sourceRootRelativePath: searchResult.reference.sourceRootRelativePath,
|
||||
providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId,
|
||||
providerKnowledgeId: searchResult.reference.providerKnowledgeId,
|
||||
providerChunkId: searchResult.reference.providerChunkId,
|
||||
},
|
||||
browser: browserResult,
|
||||
};
|
||||
writeJson(RESULT_PATH, result);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const payload = {
|
||||
ok: false,
|
||||
task: "task788-weknora-kb-folder-browser-flow-smoke",
|
||||
layer: error.layer || "unexpected",
|
||||
error: error.stack || error.message || String(error),
|
||||
details: error.details || null,
|
||||
baseUrl: BASE_URL,
|
||||
rootUri: ROOT_URI,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
};
|
||||
writeJson(FAILURE_PATH, payload);
|
||||
console.error(JSON.stringify(payload, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user