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", + ); + }); }); }