diff --git a/docs/development/validation.md b/docs/development/validation.md index 1593cfa9..3708a6bd 100644 --- a/docs/development/validation.md +++ b/docs/development/validation.md @@ -265,4 +265,8 @@ OPENDIRECTOR_API_URL=http://127.0.0.1:8030 npm run dev -- --port 5190 CREATION_API_URL=http://127.0.0.1:8030 CREATION_WEB_URL=http://127.0.0.1:5190 node tests/browser/voice_library.mjs ``` +For a slow-response regression, set `VOICE_REOPEN_DELAY_MS=17000` for the browser command. It delays the reopened asset request beyond the ordinary 15-second interaction timeout. Cold navigation, saved-state and reopened-media waits have explicit 45-second limits; HTTP errors still fail immediately with their status. No generation is retried. + +慢响应回归:在浏览器命令的环境变量中设置 `VOICE_REOPEN_DELAY_MS=17000`,让重开后的资源请求超过普通交互的 15 秒等待。冷启动导航、保存状态和重开媒体分别有明确的 45 秒上限;HTTP 错误仍立即报告状态并失败,不重试生成。 + The bilingual browser workflow covers opening/outside click/Escape, keyboard tabs, four-column and narrow layouts, real previews and unavailable previews, metadata filters, scoped favorites, loading/error/retry, invalid and valid local audio, creation, selection, Drama3 transcript and Nano reference requirements, supported deletion and failure feedback, model/service switching, project reopening and persisted job input. `moss_speech.mjs` is retained as an alias for this integrated workflow. The pagination regression includes a 15,001-voice catalog. With a configured MOSS account, open the library, go to the next page, and return to the first. Verify the page counts and that returning to the first page restores its entries using read-only requests. Python tests verify provider fields, pagination, credentials, account boundaries, actual audio/container/duration validation, and equivalent Agent operations. Actual provider synthesis still requires a configured service, account permission and a recording authorized for use. diff --git a/tests/browser/voice_library.mjs b/tests/browser/voice_library.mjs index 6f73e8a1..9ac3b970 100644 --- a/tests/browser/voice_library.mjs +++ b/tests/browser/voice_library.mjs @@ -11,6 +11,17 @@ const headers = { "X-OpenDirector-Request": "1", "Content-Type": "application/json", }; +// Reopening includes cold module loading, project recovery and asset fetching. +// Keep ordinary interaction waits short, but budget this whole path explicitly. +const reopenTimeout = 45000; +// Optional deterministic slow-CI probe for the reopened asset request. +const reopenDelay = Number(process.env.VOICE_REOPEN_DELAY_MS ?? 0); +assert.ok( + Number.isFinite(reopenDelay) && reopenDelay >= 0 && reopenDelay < reopenTimeout, +); +async function waitForSaved(page, label) { + await page.getByText(label, { exact: true }).waitFor({ timeout: reopenTimeout }); +} const readTimeout = 15000; async function call(path, body, origin = api) { const response = await fetch(origin + path, { @@ -34,16 +45,17 @@ async function reopenAudio(page, assetPath, savedLabel) { let receive, timer; const received = new Promise((resolve) => { receive = resolve; }); const observe = (response) => { - if (new URL(response.url()).pathname !== assetPath) return; + if (new URL(response.url()).pathname !== assetPath || + response.request().method() !== "GET") return; statuses.push(response.status()); - if (response.ok()) receive(response); + receive(response); }; page.on("response", observe); try { // Observe early responses, but give navigation its own deadline. A cold // document has not started the project's media read yet. - await page.reload({ timeout: 30000 }); - await page.getByText(savedLabel, { exact: true }).waitFor(); + await page.reload({ waitUntil: "domcontentloaded", timeout: reopenTimeout }); + await waitForSaved(page, savedLabel); console.log(JSON.stringify({ event: "voice-reopen-ready", assetPath, elapsedMs: Date.now() - started, })); @@ -51,8 +63,8 @@ async function reopenAudio(page, assetPath, savedLabel) { received, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error( - `No successful reopened audio response; statuses=${JSON.stringify(statuses)}`, - )), readTimeout); + `No reopened audio response; statuses=${JSON.stringify(statuses)}`, + )), reopenTimeout); }), ]); } finally { @@ -265,8 +277,11 @@ try { ); }; try { - await page.goto(`${base}/ai-tool/ai-canvas/${id}?creation=unified`); - await page.getByText(L.saved, { exact: true }).waitFor(); + await page.goto(`${base}/ai-tool/ai-canvas/${id}?creation=unified`, { + waitUntil: "domcontentloaded", + timeout: reopenTimeout, + }); + await waitForSaved(page, L.saved); await page .getByRole("toolbar", { name: L.tools }) .getByRole("button", { name: L.add, exact: true }) @@ -497,8 +512,8 @@ try { .voiceId, "sample", ); - await page.reload(); - await page.getByText(L.saved, { exact: true }).waitFor(); + await page.reload({ waitUntil: "domcontentloaded", timeout: reopenTimeout }); + await waitForSaved(page, L.saved); await page.locator(".react-flow__node").first().click(); assert.match( await page @@ -827,7 +842,17 @@ try { }); const assetPath = `/api/creation/projects/${id}/assets/${asset.id}/content`; + if (reopenDelay) { + await page.route((url) => url.pathname === assetPath, async (route) => { + await new Promise((resolve) => setTimeout(resolve, reopenDelay)); + await route.continue(); + }); + } const reopenedAudio = await reopenAudio(page, assetPath, L.saved); + assert.ok( + reopenedAudio.ok(), + `Reopened audio GET ${assetPath}: HTTP ${reopenedAudio.status()}`, + ); assert.match(reopenedAudio.headers()["content-type"], /^audio\//); await page.locator(".react-flow__node").first().click(); assert.ok(