diff --git a/playwright.config.ts b/playwright.config.ts
index 7e934d7b..a8873384 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -4,10 +4,7 @@ const appPort = Number(process.env.PLAYWRIGHT_APP_PORT ?? 3001);
const cdnPort = Number(process.env.PLAYWRIGHT_CDN_PORT ?? 3010);
const host = process.env.PLAYWRIGHT_HOST ?? "127.0.0.1";
const isCI = !!process.env.CI;
-const headless =
- process.env.PLAYWRIGHT_HEADLESS !== undefined
- ? process.env.PLAYWRIGHT_HEADLESS !== "false"
- : isCI;
+const headless = true;
const appCommand = isCI
? `pnpm exec next start --port ${appPort} --hostname ${host}`
: `pnpm exec next dev --turbopack --port ${appPort} --hostname ${host}`;
diff --git a/public/packages/onlyoffice/9.3.0/x2t/x2t.js b/public/packages/onlyoffice/9.3.0/x2t/x2t.js
index 9fc1a749..672a1bde 100644
Binary files a/public/packages/onlyoffice/9.3.0/x2t/x2t.js and b/public/packages/onlyoffice/9.3.0/x2t/x2t.js differ
diff --git "a/src/components/onlyoffice-web-comp/docs/06-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\346\224\257\346\214\201\346\240\274\345\274\217.md" "b/src/components/onlyoffice-web-comp/docs/06-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\346\224\257\346\214\201\346\240\274\345\274\217.md"
index 07d67eac..d04fc656 100644
--- "a/src/components/onlyoffice-web-comp/docs/06-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\346\224\257\346\214\201\346\240\274\345\274\217.md"
+++ "b/src/components/onlyoffice-web-comp/docs/06-\346\263\250\346\204\217\344\272\213\351\241\271\344\270\216\346\224\257\346\214\201\346\240\274\345\274\217.md"
@@ -6,7 +6,7 @@
1. **静态资源**:将 OnlyOffice SDK(含 `web-apps/`、`sdkjs/`、`fonts/`、`x2t/`)放到站点可访问目录,默认 `public/packages/onlyoffice/9.3.0/`。自定义字体需在 `AllFonts.js` 的 `__custom_font_registry__` 中注册,详见 [10 - 字体配置](./10-字体配置.md)。
2. **环境变量**(可选):`NEXT_PUBLIC_APP_ROOT=/packages/onlyoffice/9.3.0`,与 `STATIC_RESOURCE.onlyoffice.root` 一致。
-3. **x2t Brotli**:`x2t/x2t.js`、`x2t.wasm` 为 Brotli 预压缩文件;**无需**配置 `Content-Encoding: br`,Worker 内 `fetch-brotli` 会自动解压。
+3. **x2t 资源**:`x2t/x2t.js` 为普通 JS 文本,`x2t.wasm` 为 Brotli 预压缩文件;**无需**为 `x2t.wasm` 配置 `Content-Encoding: br`,Worker 内 `fetch-brotli` 会自动解压。
4. **DOM 容器**:页面需预留编辑器挂载点(见 [01-快速开始](./01-快速开始.md))。
## 注意事项
diff --git a/src/components/onlyoffice-web-comp/internal/editor/plain-text-office.ts b/src/components/onlyoffice-web-comp/internal/editor/plain-text-office.ts
new file mode 100644
index 00000000..43fa0a95
--- /dev/null
+++ b/src/components/onlyoffice-web-comp/internal/editor/plain-text-office.ts
@@ -0,0 +1,236 @@
+import { concatBytes, crc32, writeU16, writeU32 } from "./zip";
+
+const encoder = new TextEncoder();
+
+function utf8(value: string) {
+ return encoder.encode(value);
+}
+
+function xml(strings: TemplateStringsArray, ...values: unknown[]) {
+ return strings
+ .reduce((result, value, index) => `${result}${value}${values[index] ?? ""}`, "")
+ .trim();
+}
+
+function escapeXml(value: string) {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function decodeText(buffer: ArrayBuffer) {
+ return new TextDecoder("utf-8").decode(buffer);
+}
+
+function zip(entries: Array<[string, string | Uint8Array]>) {
+ const localParts: Uint8Array[] = [];
+ const centralParts: Uint8Array[] = [];
+ let localOffset = 0;
+
+ for (const [name, content] of entries) {
+ const nameBytes = utf8(name);
+ const data = typeof content === "string" ? utf8(content) : content;
+ const crc = crc32(data);
+
+ const localHeader = new Uint8Array(30 + nameBytes.length);
+ writeU32(localHeader, 0, 0x04034b50);
+ writeU16(localHeader, 4, 20);
+ writeU16(localHeader, 6, 0);
+ writeU16(localHeader, 8, 0);
+ writeU16(localHeader, 10, 0);
+ writeU16(localHeader, 12, 0);
+ writeU32(localHeader, 14, crc);
+ writeU32(localHeader, 18, data.length);
+ writeU32(localHeader, 22, data.length);
+ writeU16(localHeader, 26, nameBytes.length);
+ writeU16(localHeader, 28, 0);
+ localHeader.set(nameBytes, 30);
+ localParts.push(localHeader, data);
+
+ const centralHeader = new Uint8Array(46 + nameBytes.length);
+ writeU32(centralHeader, 0, 0x02014b50);
+ writeU16(centralHeader, 4, 20);
+ writeU16(centralHeader, 6, 20);
+ writeU16(centralHeader, 8, 0);
+ writeU16(centralHeader, 10, 0);
+ writeU16(centralHeader, 12, 0);
+ writeU16(centralHeader, 14, 0);
+ writeU32(centralHeader, 16, crc);
+ writeU32(centralHeader, 20, data.length);
+ writeU32(centralHeader, 24, data.length);
+ writeU16(centralHeader, 28, nameBytes.length);
+ writeU16(centralHeader, 30, 0);
+ writeU16(centralHeader, 32, 0);
+ writeU16(centralHeader, 34, 0);
+ writeU16(centralHeader, 36, 0);
+ writeU32(centralHeader, 38, 0);
+ writeU32(centralHeader, 42, localOffset);
+ centralHeader.set(nameBytes, 46);
+ centralParts.push(centralHeader);
+
+ localOffset += localHeader.length + data.length;
+ }
+
+ const centralDirectory = concatBytes(centralParts);
+ const end = new Uint8Array(22);
+ writeU32(end, 0, 0x06054b50);
+ writeU16(end, 8, entries.length);
+ writeU16(end, 10, entries.length);
+ writeU32(end, 12, centralDirectory.length);
+ writeU32(end, 16, localOffset);
+
+ return concatBytes([...localParts, centralDirectory, end]).buffer;
+}
+
+const coreProps = xml`
+
+
+ Plain text fallback
+
+`;
+
+const appProps = xml`
+
+
+ OnlyOffice Web Comp
+
+`;
+
+export function createDocxFromText(buffer: ArrayBuffer) {
+ const paragraphs = decodeText(buffer)
+ .split(/\r\n|\r|\n/)
+ .map((line) => `${escapeXml(line)}`)
+ .join("");
+
+ return zip([
+ [
+ "[Content_Types].xml",
+ xml`
+
+
+
+
+
+
+
+
+ `,
+ ],
+ [
+ "_rels/.rels",
+ xml`
+
+
+
+
+
+
+ `,
+ ],
+ ["docProps/core.xml", coreProps],
+ ["docProps/app.xml", appProps],
+ [
+ "word/document.xml",
+ xml`
+
+
+
+ ${paragraphs || ''}
+
+
+
+
+
+
+ `,
+ ],
+ [
+ "word/_rels/document.xml.rels",
+ xml`
+
+
+ `,
+ ],
+ ]);
+}
+
+export function createXlsxFromText(buffer: ArrayBuffer) {
+ const text = escapeXml(decodeText(buffer));
+
+ return zip([
+ [
+ "[Content_Types].xml",
+ xml`
+
+
+
+
+
+
+
+
+
+
+ `,
+ ],
+ [
+ "_rels/.rels",
+ xml`
+
+
+
+
+
+
+ `,
+ ],
+ ["docProps/core.xml", coreProps],
+ ["docProps/app.xml", appProps],
+ [
+ "xl/workbook.xml",
+ xml`
+
+
+
+
+ `,
+ ],
+ [
+ "xl/_rels/workbook.xml.rels",
+ xml`
+
+
+
+
+
+ `,
+ ],
+ [
+ "xl/styles.xml",
+ xml`
+
+
+
+
+
+
+
+
+ `,
+ ],
+ [
+ "xl/worksheets/sheet1.xml",
+ xml`
+
+
+
+ ${text}
+
+
+ `,
+ ],
+ ]);
+}
diff --git a/src/components/onlyoffice-web-comp/internal/editor/server.ts b/src/components/onlyoffice-web-comp/internal/editor/server.ts
index 8a2a57c0..963b2afe 100644
--- a/src/components/onlyoffice-web-comp/internal/editor/server.ts
+++ b/src/components/onlyoffice-web-comp/internal/editor/server.ts
@@ -10,6 +10,7 @@ import {
} from "./types";
import { emptyDocx, emptyPdf, emptyPptx, emptyXlsx } from "./empty";
import { convertCsvBufferToXlsxBuffer } from "./csv-to-xlsx";
+import { createDocxFromText, createXlsxFromText } from "./plain-text-office";
import {
getDocumentType,
getFileExt,
@@ -91,6 +92,31 @@ function isOfficeZipFileType(fileType: string) {
);
}
+function isZipBytes(data: Uint8Array) {
+ return (
+ data.length >= 4 &&
+ data[0] === 0x50 &&
+ data[1] === 0x4b &&
+ ((data[2] === 0x03 && data[3] === 0x04) ||
+ (data[2] === 0x05 && data[3] === 0x06) ||
+ (data[2] === 0x07 && data[3] === 0x08))
+ );
+}
+
+function createPlainTextOfficeFallback(
+ buffer: ArrayBuffer,
+ fileType: string,
+) {
+ switch (getDocumentType(fileType)) {
+ case "word":
+ return { buffer: createDocxFromText(buffer), fileType: "docx" };
+ case "cell":
+ return { buffer: createXlsxFromText(buffer), fileType: "xlsx" };
+ default:
+ return null;
+ }
+}
+
const PDF_MAX_OUTPUT_BYTES = 50 * 1024 * 1024;
const PDF_EOF_MARKER = new TextEncoder().encode("%%EOF");
@@ -914,23 +940,33 @@ export class EditorServer {
buffer = await buffer();
}
+ const bytes = new Uint8Array(buffer);
+ const fallback =
+ isOfficeZipFileType(fileType) && !isZipBytes(bytes)
+ ? createPlainTextOfficeFallback(buffer, fileType)
+ : null;
+ if (fallback) {
+ buffer = fallback.buffer;
+ }
+ const sourceFileType = fallback?.fileType ?? fileType;
+
let output: Uint8Array | null = null;
let media: { [key: string]: Uint8Array } = {};
let themes: { [key: string]: Uint8Array } = {};
- if (fileType == "pdf") {
+ if (sourceFileType == "pdf") {
output = new Uint8Array(buffer);
- } else if (fileType === "csv") {
+ } else if (sourceFileType === "csv") {
({ output, media } = await this.loadCsvDocument(buffer));
} else {
({ output, media, themes } = await this.convertBufferToEditorBin(
buffer,
- fileType,
+ sourceFileType,
));
}
if (!output) {
- throw new Error(`Failed to convert ${fileType} file`);
+ throw new Error(`Failed to convert ${sourceFileType} file`);
}
if (this.urlsMap.size > 0) {
diff --git a/tests/e2e/files/manifest.json b/tests/e2e/files/manifest.json
index 8f464745..dd119208 100644
--- a/tests/e2e/files/manifest.json
+++ b/tests/e2e/files/manifest.json
@@ -19,5 +19,19 @@
"fileType": "DOCX",
"source": "extension/content mismatch rejection",
"size": 6524
+ },
+ {
+ "name": "plain-text-as-docx.docx",
+ "kind": "positive",
+ "fileType": "DOCX",
+ "source": "plain text content with DOCX extension fallback",
+ "size": 14
+ },
+ {
+ "name": "plain-text-as-xlsx.xlsx",
+ "kind": "positive",
+ "fileType": "XLSX",
+ "source": "plain text content with XLSX extension fallback",
+ "size": 15
}
]
\ No newline at end of file
diff --git a/tests/e2e/files/plain-text-as-docx.docx b/tests/e2e/files/plain-text-as-docx.docx
new file mode 100644
index 00000000..70224372
--- /dev/null
+++ b/tests/e2e/files/plain-text-as-docx.docx
@@ -0,0 +1,2 @@
+dsadads
+dasdas
\ No newline at end of file
diff --git a/tests/e2e/files/plain-text-as-xlsx.xlsx b/tests/e2e/files/plain-text-as-xlsx.xlsx
new file mode 100644
index 00000000..6beb67e7
--- /dev/null
+++ b/tests/e2e/files/plain-text-as-xlsx.xlsx
@@ -0,0 +1 @@
+dsadads.dasdas
diff --git a/tests/e2e/scripts/generate-office-files.mjs b/tests/e2e/scripts/generate-office-files.mjs
index b3222931..07695450 100644
--- a/tests/e2e/scripts/generate-office-files.mjs
+++ b/tests/e2e/scripts/generate-office-files.mjs
@@ -312,6 +312,20 @@ const fixtures = [
fileType: "DOCX",
source: "extension/content mismatch rejection",
},
+ {
+ name: "plain-text-as-docx.docx",
+ data: utf8("dsadads\ndasdas"),
+ kind: "positive",
+ fileType: "DOCX",
+ source: "plain text content with DOCX extension fallback",
+ },
+ {
+ name: "plain-text-as-xlsx.xlsx",
+ data: utf8("dsadads.dasdas\n"),
+ kind: "positive",
+ fileType: "XLSX",
+ source: "plain text content with XLSX extension fallback",
+ },
];
fs.rmSync(outputDir, { recursive: true, force: true });
diff --git a/tests/e2e/specs/onlyoffice-factory.contract.ts b/tests/e2e/specs/onlyoffice-factory.contract.ts
index 27d9d273..c9758c3b 100644
--- a/tests/e2e/specs/onlyoffice-factory.contract.ts
+++ b/tests/e2e/specs/onlyoffice-factory.contract.ts
@@ -2,7 +2,7 @@ export type ResourceMode = "local" | "cdn";
export type StepResult = {
name: string;
- status: "passed" | "failed";
+ status: "running" | "passed" | "failed";
detail?: string;
};
@@ -23,6 +23,7 @@ export const ONLYOFFICE_FACTORY_EXPECTED_STEPS = [
"generated negative fixtures",
"manager create",
"manager createWithFile",
+ "text fallback files",
"manager fromEditor",
"manager factory destroyAll",
] as const;
diff --git a/tests/e2e/specs/onlyoffice-factory.page.tsx b/tests/e2e/specs/onlyoffice-factory.page.tsx
index 906c345c..a3da2d46 100644
--- a/tests/e2e/specs/onlyoffice-factory.page.tsx
+++ b/tests/e2e/specs/onlyoffice-factory.page.tsx
@@ -29,6 +29,7 @@ export const CONTAINER_IDS = {
factory: "e2e-factory-editor",
create: "e2e-create-editor",
file: "e2e-file-editor",
+ textFallback: "e2e-text-fallback-editor",
fromEditor: "e2e-from-editor",
fixture: "e2e-fixture-editor",
} as const;
@@ -153,16 +154,30 @@ export function resetAll() {
converter.terminate();
}
-export async function runScenario(mode: ResourceMode, cdnOrigin: string) {
+export async function runScenario(
+ mode: ResourceMode,
+ cdnOrigin: string,
+ onStepsChange?: (steps: StepResult[]) => void,
+) {
const steps: StepResult[] = [];
const runStep = async (name: string, action: () => Promise) => {
+ const stepIndex =
+ steps.push({ name, status: "running", detail: "running" }) - 1;
+ onStepsChange?.([...steps]);
+
try {
const detail = await action();
- steps.push({ name, status: "passed", detail: detail || undefined });
+ steps[stepIndex] = {
+ name,
+ status: "passed",
+ detail: detail || undefined,
+ };
+ onStepsChange?.([...steps]);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
- steps.push({ name, status: "failed", detail });
+ steps[stepIndex] = { name, status: "failed", detail };
+ onStepsChange?.([...steps]);
throw new Error(`${name}: ${detail}`);
}
};
@@ -349,6 +364,52 @@ export async function runScenario(mode: ResourceMode, cdnOrigin: string) {
editorManagerFactory.destroy(CONTAINER_IDS.file);
});
+ await runStep("text fallback files", async () => {
+ const textDocx = await fetchPublicFile(
+ "/e2e/fixtures/plain-text-as-docx.docx",
+ "plain-text-as-docx.docx",
+ );
+ const docxManager = await withDocumentReady(() =>
+ OnlyOfficeManager.createWithFile(
+ {
+ containerId: CONTAINER_IDS.textFallback,
+ fileType: FILE_TYPE.DOCX,
+ defaultFileName: textDocx.name,
+ readOnly: false,
+ theme: DEFAULT_OFFICE_THEME,
+ },
+ textDocx,
+ ),
+ );
+
+ assert(docxManager.isReady(), "Text DOCX fallback was not ready");
+ await assertExport(docxManager, FILE_TYPE.DOCX);
+ docxManager.destroy();
+ editorManagerFactory.destroy(CONTAINER_IDS.textFallback);
+
+ const textXlsx = await fetchPublicFile(
+ "/e2e/fixtures/plain-text-as-xlsx.xlsx",
+ "plain-text-as-xlsx.xlsx",
+ );
+ const xlsxManager = await withDocumentReady(() =>
+ OnlyOfficeManager.createWithFile(
+ {
+ containerId: CONTAINER_IDS.textFallback,
+ fileType: FILE_TYPE.XLSX,
+ defaultFileName: textXlsx.name,
+ readOnly: false,
+ theme: DEFAULT_OFFICE_THEME,
+ },
+ textXlsx,
+ ),
+ );
+
+ assert(xlsxManager.isReady(), "Text XLSX fallback was not ready");
+ await assertExport(xlsxManager, FILE_TYPE.XLSX);
+ xlsxManager.destroy();
+ editorManagerFactory.destroy(CONTAINER_IDS.textFallback);
+ });
+
await runStep("manager fromEditor", async () => {
const editor = editorManagerFactory.get(CONTAINER_IDS.fromEditor);
const manager = OnlyOfficeManager.fromEditor(editor, {
@@ -427,7 +488,15 @@ export function OnlyOfficeFactoryE2EPage() {
setResult({ mode: params.mode, status: "running", steps: [] });
// 用例入口
- runScenario(params.mode, params.cdnOrigin)
+ let latestSteps: StepResult[] = [];
+ const updateSteps = (steps: StepResult[]) => {
+ latestSteps = steps;
+ if (!disposed) {
+ setResult((current) => ({ ...current, steps }));
+ }
+ };
+
+ runScenario(params.mode, params.cdnOrigin, updateSteps)
.then((steps) => {
if (!disposed) {
setResult({ mode: params.mode, status: "passed", steps });
@@ -438,6 +507,7 @@ export function OnlyOfficeFactoryE2EPage() {
setResult((current) => ({
...current,
status: "failed",
+ steps: latestSteps,
error: error instanceof Error ? error.message : String(error),
}));
}
@@ -477,6 +547,7 @@ export function OnlyOfficeFactoryE2EPage() {
+
diff --git a/tests/e2e/specs/onlyoffice-factory.spec.ts b/tests/e2e/specs/onlyoffice-factory.spec.ts
index 492148e0..534404cb 100644
--- a/tests/e2e/specs/onlyoffice-factory.spec.ts
+++ b/tests/e2e/specs/onlyoffice-factory.spec.ts
@@ -1,7 +1,15 @@
-import { expect, test } from "playwright/test";
+import {
+ expect,
+ test,
+ type Browser,
+ type Page,
+ type TestInfo,
+} from "playwright/test";
import {
ONLYOFFICE_FACTORY_EXPECTED_STEPS,
+ type ResourceMode,
type ScenarioResult,
+ type StepResult,
} from "./onlyoffice-factory.contract";
const cdnOrigin =
@@ -10,30 +18,85 @@ const cdnOrigin =
process.env.PLAYWRIGHT_CDN_PORT ?? 3010
}`;
-// factory API 在本地资源和独立 CDN 资源下都必须表现一致。
-for (const mode of ["local", "cdn"] as const) {
- test(`OnlyOffice factory APIs work with ${mode} resources`, async ({
- page,
- }, testInfo) => {
- const consoleLines: string[] = [];
-
- // 按行附加浏览器日志,方便 CI 中定位 x2t/iframe 错误;
- // 用例通过时又不会把 reporter 输出刷得太吵。
- page.on("console", (message) => {
- const text = `[${message.type()}] ${message.text()}`;
- consoleLines.push(text);
- testInfo.attach(`console-${consoleLines.length}`, {
- body: text,
- contentType: "text/plain",
- });
- });
+const scenarioTimeoutMs = 110_000;
+const testTimeoutMs = scenarioTimeoutMs + 30_000;
+
+type ScenarioRun = {
+ result: ScenarioResult;
+ consoleLines: string[];
+ waitError?: string;
+};
+
+function stepFailureMessage(mode: ResourceMode, name: string, step?: StepResult) {
+ if (!step) {
+ return `[${mode}] missing scenario step: ${name}`;
+ }
+
+ return `[${mode}] ${name} ${step.status}${
+ step.detail ? `\n${step.detail}` : ""
+ }`;
+}
+
+function assertStepPassed(
+ mode: ResourceMode,
+ name: string,
+ step?: StepResult,
+): asserts step is StepResult {
+ if (!step || step.status !== "passed") {
+ throw new Error(stepFailureMessage(mode, name, step));
+ }
+}
- page.on("pageerror", (error) => {
- consoleLines.push(`[pageerror] ${error.message}`);
+function firstBlockingStep(result: ScenarioResult) {
+ return result.steps.find((step) => step.status !== "passed");
+}
+
+async function readScenarioResult(page: Page) {
+ const text = await page
+ .getByTestId("scenario-result")
+ .innerText({ timeout: 5_000 })
+ .catch(() => null);
+ if (!text) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(text) as ScenarioResult;
+ } catch {
+ return null;
+ }
+}
+
+async function attachScenarioRun(testInfo: TestInfo, run: ScenarioRun) {
+ await testInfo.attach("scenario-result", {
+ body: JSON.stringify(run.result, null, 2),
+ contentType: "application/json",
+ });
+
+ if (run.consoleLines.length > 0) {
+ await testInfo.attach("browser-console", {
+ body: run.consoleLines.join("\n"),
+ contentType: "text/plain",
});
+ }
+}
+
+async function runScenarioPage(
+ browser: Browser,
+ mode: ResourceMode,
+): Promise {
+ const page = await browser.newPage();
+ const consoleLines: string[] = [];
+ let waitError: string | undefined;
+
+ page.on("console", (message) => {
+ consoleLines.push(`[${message.type()}] ${message.text()}`);
+ });
+ page.on("pageerror", (error) => {
+ consoleLines.push(`[pageerror] ${error.message}`);
+ });
- // Next 路由只保留薄 wrapper;OnlyOffice SDK、worker、iframe 都必须在真实
- // 浏览器上下文中运行,所以生命周期和 API 断言放在页面侧 scenario 中。
+ try {
const url = new URL("/e2e/onlyoffice-factory", "http://e2e.local");
url.searchParams.set("mode", mode);
if (mode === "cdn") {
@@ -43,33 +106,96 @@ for (const mode of ["local", "cdn"] as const) {
await page.goto(`${url.pathname}${url.search}`, {
waitUntil: "domcontentloaded",
});
-
await expect(page.getByTestId("scenario-status")).toBeVisible();
- // 等页面内 scenario 结束,而不是在 Playwright 里重复编排每个异步步骤;
- // 下方 JSON 结果是 browser scenario 和 spec 之间的稳定契约。
- await page.waitForFunction(
- () => {
- const status = document.querySelector(
- '[data-testid="scenario-status"]',
- )?.textContent;
- return status === "passed" || status === "failed";
+ await page
+ .waitForFunction(
+ () => {
+ const status = document.querySelector(
+ '[data-testid="scenario-status"]',
+ )?.textContent;
+ return status === "passed" || status === "failed";
+ },
+ undefined,
+ { timeout: scenarioTimeoutMs },
+ )
+ .catch((error) => {
+ waitError = error instanceof Error ? error.message : String(error);
+ });
+
+ const result = await readScenarioResult(page);
+ return {
+ result: result ?? {
+ mode,
+ status: "failed",
+ steps: [],
+ error: waitError ?? "Scenario result was not rendered",
},
- undefined,
- { timeout: 60_000 },
- );
-
- const result = JSON.parse(
- await page.getByTestId("scenario-result").innerText(),
- ) as ScenarioResult;
-
- expect(result.mode).toBe(mode);
- expect(result.status, JSON.stringify(result, null, 2)).toBe("passed");
- // 显式校验步骤列表,避免已知边界场景被静默跳过;
- // 例如钉钉非法 bookmark 的 x2t 导入回归。
- expect(result.steps.map((step) => step.name)).toEqual(
- ONLYOFFICE_FACTORY_EXPECTED_STEPS,
- );
- expect(result.steps.every((step) => step.status === "passed")).toBe(true);
+ consoleLines,
+ waitError,
+ };
+ } finally {
+ await page.close().catch(() => {});
+ }
+}
+
+for (const mode of ["local", "cdn"] as const) {
+ test.describe(`OnlyOffice factory APIs / ${mode}`, () => {
+ test.describe.configure({ mode: "serial" });
+
+ let run: ScenarioRun;
+
+ test.beforeAll(async ({ browser }, testInfo) => {
+ testInfo.setTimeout(testTimeoutMs);
+ run = await runScenarioPage(browser, mode);
+ });
+
+ test(`${mode} / scenario boot`, async ({}, testInfo) => {
+ await attachScenarioRun(testInfo, run);
+ expect(run.result.mode).toBe(mode);
+ expect(run.result.status).not.toBe("idle");
+ expect(run.waitError, run.waitError).toBeUndefined();
+ });
+
+ for (const name of ONLYOFFICE_FACTORY_EXPECTED_STEPS) {
+ test(`${mode} / ${name}`, async ({}, testInfo) => {
+ const step = run.result.steps.find((item) => item.name === name);
+ const blockingStep = firstBlockingStep(run.result);
+ if (!step && blockingStep) {
+ test.skip(
+ true,
+ `[${mode}] blocked after ${blockingStep.name}: ${
+ blockingStep.detail ?? blockingStep.status
+ }`,
+ );
+ }
+
+ if (!step || step.status !== "passed") {
+ await attachScenarioRun(testInfo, run);
+ }
+
+ assertStepPassed(mode, name, step);
+ });
+ }
+
+ test(`${mode} / scenario contract`, async ({}, testInfo) => {
+ const blockingStep = firstBlockingStep(run.result);
+ if (blockingStep) {
+ test.skip(
+ true,
+ `[${mode}] blocked after ${blockingStep.name}: ${
+ blockingStep.detail ?? blockingStep.status
+ }`,
+ );
+ }
+
+ await attachScenarioRun(testInfo, run);
+ expect(run.result.steps.map((step) => step.name)).toEqual(
+ ONLYOFFICE_FACTORY_EXPECTED_STEPS,
+ );
+ expect(run.result.status, JSON.stringify(run.result, null, 2)).toBe(
+ "passed",
+ );
+ });
});
}