Skip to content

Commit 90c3ca2

Browse files
committed
feat(ui-crawl): implement UI crawl test for toolbar interactions and editor stability
1 parent c9e695a commit 90c3ca2

5 files changed

Lines changed: 209 additions & 1 deletion

File tree

‎lib/onlyoffice-editor.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,11 @@ function createPersonalEditorInstance(config: {
985985
documentType: DOCUMENT_TYPE_MAP[normalizedType],
986986
editorConfig: {
987987
mode: 'edit',
988+
// No Document Server, no co-editing: pin the mode and hide the Review
989+
// tab's "Co-editing Mode" switch. Toggling it in this build throws an
990+
// uncaught "Cannot read properties of null (reading 'ranges')" in the
991+
// spreadsheet editor (found by the UI crawl).
992+
coEditing: { mode: 'fast', change: false },
988993
lang: editorLang,
989994
user: {
990995
id: 'local-user',

‎public/home.css‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@
117117
display: inline-block;
118118
min-width: 84px;
119119
font-size: 14px;
120+
height: 44px;
120121
}
121122
/* hide the raw option list pre-upgrade; the reserved width avoids layout shift */
122123
#landing-hero r-select.lang-select:not(:defined) {

‎public/landing.css‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ r-select.lang-select {
104104
display: inline-block;
105105
min-width: 84px;
106106
font-size: 14px;
107+
height: 44px;
107108
}
108109
/* hide the raw option list pre-upgrade; the reserved width avoids layout shift */
109110
r-select.lang-select:not(:defined) {

‎test/e2e/lib/l0.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,14 @@ const INIT_SCRIPT = () => {
7070
bucket.frameErrors.push({ kind: 'unhandledrejection', message: describe(event.reason), href: location.pathname });
7171
});
7272
window.addEventListener('error', (event) => {
73+
const err = event.error as { stack?: unknown } | undefined;
74+
const stack =
75+
typeof err?.stack === 'string'
76+
? ' @ ' + err.stack.split('\n').slice(1, 4).join(' <- ').replace(/\s+/g, ' ').slice(0, 300)
77+
: '';
7378
bucket.frameErrors.push({
7479
kind: 'error',
75-
message: describe(event.error ?? event.message),
80+
message: describe(event.error ?? event.message) + stack,
7681
href: location.pathname,
7782
});
7883
});

‎test/e2e/ui-crawl.spec.ts‎

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { mkdirSync, writeFileSync } from 'node:fs';
2+
import { buildDocx, buildPptx, toBase64 } from './lib/ooxml';
3+
import { expect, test } from './lib/l0';
4+
import { editorHealth, saveAndCapture, waitForEditorReady, SAVE_FORMAT_CODE } from './actions/editor';
5+
6+
declare const XLSX: any;
7+
declare function post(type: string, payload?: Record<string, unknown>): Promise<any>;
8+
9+
/**
10+
* UI crawl (strategy section 9.1 layer 2): every visible, enabled toolbar
11+
* button and every ribbon tab of each editor gets clicked once; dialogs and
12+
* menus that open are dismissed; after every click the editor must still be
13+
* alive (no fatal dialog, no critical asc_onError, page responsive), and at
14+
* the end the document must still save. Semantics are NOT asserted -- this
15+
* is the "every entry point is L0-touched at least once" layer that covers
16+
* the UI -> API glue the asc_* sweep cannot reach. Nightly-class (UI_CRAWL=1).
17+
* Findings go to the results dir as ui-crawl-<kind>.json (per-button outcome).
18+
*/
19+
type Outcome = { tab: string; id: string; title: string; ok: boolean; note?: string; ms: number };
20+
21+
test.describe('UI crawl', () => {
22+
test.skip(!process.env.UI_CRAWL, 'UI_CRAWL not set -- toolbar crawl is a nightly suite');
23+
test.describe.configure({ timeout: 900_000 });
24+
25+
const CASES = [
26+
{
27+
kind: 'docx',
28+
name: 'crawl.docx',
29+
code: SAVE_FORMAT_CODE.docx,
30+
b64: () => toBase64(buildDocx('crawl target 抓取目标')),
31+
},
32+
{ kind: 'xlsx', name: 'crawl.xlsx', code: SAVE_FORMAT_CODE.xlsx, b64: () => '' },
33+
{ kind: 'pptx', name: 'crawl.pptx', code: SAVE_FORMAT_CODE.pptx, b64: () => toBase64(buildPptx('crawl slide')) },
34+
] as const;
35+
36+
for (const c of CASES) {
37+
test(`${c.kind}: click every toolbar button once, editor stays alive and saves`, async ({ page, l0 }) => {
38+
// Random UI clicks legitimately raise informational errors; only Critical
39+
// (-1) counts as a finding, attributed to the button below.
40+
l0.allowAscError((e) => e.level !== '-1');
41+
l0.allowConsole(/./);
42+
// Uncaught frame errors are attributed per button below and reported as
43+
// findings there; keep the fixture from failing the test a second time.
44+
l0.allowFrameError(/./);
45+
await page.goto('/embed-demo.html');
46+
await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 });
47+
await page.evaluate(
48+
async ({ name, b64 }) => {
49+
let bytes: Uint8Array;
50+
if (name.endsWith('.xlsx')) {
51+
const wb = XLSX.utils.book_new();
52+
XLSX.utils.book_append_sheet(
53+
wb,
54+
XLSX.utils.aoa_to_sheet([
55+
['crawl', 1],
56+
['抓取', 2],
57+
]),
58+
'S',
59+
);
60+
bytes = new Uint8Array(XLSX.write(wb, { bookType: 'xlsx', type: 'array' }));
61+
} else {
62+
const bin = atob(b64);
63+
bytes = new Uint8Array(bin.length);
64+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
65+
}
66+
await post('document:open-buffer', { fileName: name, buffer: bytes.buffer, readonly: false });
67+
},
68+
{ name: c.name, b64: c.b64() },
69+
);
70+
await waitForEditorReady(page);
71+
72+
const editor = page.frameLocator('iframe').frameLocator('iframe[name="frameEditor"]');
73+
const outcomes: Outcome[] = [];
74+
let criticalBefore = (await l0.ascErrors()).filter((e) => e.level === '-1').length;
75+
let frameErrorsBefore = (await l0.frameErrors()).length;
76+
77+
// Dismiss whatever a click opened: modal windows (Escape / their close
78+
// button), dropdown menus (Escape), then give focus back to the canvas.
79+
const settle = async () => {
80+
for (let i = 0; i < 4; i++) {
81+
const modal = editor.locator('.asc-window.modal:visible, .asc-window.alert:visible').first();
82+
if (await modal.count()) {
83+
const close = modal
84+
.locator(
85+
'.close, .btn.normal.close, button:has-text("Cancel"), button:has-text("Close"), button:has-text("OK")',
86+
)
87+
.first();
88+
if (await close.count()) await close.click({ timeout: 2000 }).catch(() => {});
89+
else await page.keyboard.press('Escape');
90+
await page.waitForTimeout(200);
91+
continue;
92+
}
93+
if (await editor.locator('.dropdown-menu:visible').count()) {
94+
await page.keyboard.press('Escape');
95+
await page.waitForTimeout(150);
96+
continue;
97+
}
98+
break;
99+
}
100+
};
101+
102+
const tabs = await editor
103+
.locator('.toolbar .ribtab a[data-tab]')
104+
.evaluateAll((els) =>
105+
els.map((e) => ({ tab: e.getAttribute('data-tab') || '', text: (e.textContent || '').trim() })),
106+
);
107+
const started = Date.now();
108+
for (const t of tabs) {
109+
// Ribbon tabs are UI too; skip File (a full-screen menu with its own
110+
// sub-navigation, crawled last) and any tab that isn't visible.
111+
if (t.tab === 'file') continue;
112+
const tabLink = editor.locator(`.toolbar .ribtab a[data-tab="${t.tab}"]`).first();
113+
if (!(await tabLink.isVisible().catch(() => false))) continue;
114+
await tabLink.click({ timeout: 3000 }).catch(() => {});
115+
await page.waitForTimeout(300);
116+
await settle();
117+
118+
const buttons = await editor
119+
.locator('#toolbar button.btn-toolbar:not(.disabled), #toolbar button.dropdown-toggle:not(.disabled)')
120+
.evaluateAll((els) =>
121+
els
122+
.filter((e) => (e as HTMLElement).offsetParent !== null)
123+
.map((e, i) => ({
124+
i,
125+
// Buttons without an id are named after their toolbar slot
126+
// (slot-btn-*, slot-comment-*) so a finding names the control.
127+
id:
128+
e.id ||
129+
e.parentElement?.parentElement?.id ||
130+
e.parentElement?.id ||
131+
`${e.className.replace(/\s+/g, '.')}#${i}`,
132+
title: (
133+
e.getAttribute('data-hint-title') ||
134+
e.getAttribute('title') ||
135+
(e.textContent || '').trim()
136+
).slice(0, 40),
137+
})),
138+
);
139+
for (const b of buttons) {
140+
if (Date.now() - started > 600_000) break;
141+
const t0 = Date.now();
142+
const locator = editor
143+
.locator('#toolbar button.btn-toolbar:not(.disabled), #toolbar button.dropdown-toggle:not(.disabled)')
144+
.filter({ visible: true })
145+
.nth(b.i);
146+
let note = '';
147+
try {
148+
await locator.click({ timeout: 3000, force: true });
149+
await page.waitForTimeout(350);
150+
} catch (e) {
151+
note = `click failed: ${String((e as Error).message).slice(0, 60)}`;
152+
}
153+
await settle();
154+
const health = await editorHealth(page);
155+
const criticalNow = (await l0.ascErrors()).filter((e) => e.level === '-1').length;
156+
const alive = await page
157+
.evaluate(() => 1 + 1)
158+
.then(
159+
(v) => v === 2,
160+
() => false,
161+
);
162+
const frameErrors = await l0.frameErrors();
163+
const newFrameErrors = frameErrors.slice(frameErrorsBefore);
164+
const ok = alive && !health.fatalDialog && criticalNow === criticalBefore && newFrameErrors.length === 0;
165+
if (newFrameErrors.length)
166+
note += ` uncaught: ${newFrameErrors.map((e) => e.message.slice(0, 80)).join(' | ')}`;
167+
if (criticalNow !== criticalBefore) note += ` critical asc_onError x${criticalNow - criticalBefore}`;
168+
if (health.fatalDialog) note += ` fatal: ${health.fatalDialog.slice(0, 60)}`;
169+
criticalBefore = criticalNow;
170+
frameErrorsBefore = frameErrors.length;
171+
outcomes.push({ tab: t.tab, id: b.id, title: b.title, ok, note: note || undefined, ms: Date.now() - t0 });
172+
if (!alive) break;
173+
}
174+
}
175+
await settle();
176+
177+
// Still saves after the whole sweep.
178+
const saved = await saveAndCapture(page, c.code, 120_000);
179+
const dir = process.env.E2E_PORT ? `test-results-${process.env.E2E_PORT}` : 'test-results';
180+
mkdirSync(dir, { recursive: true });
181+
writeFileSync(
182+
`${dir}/ui-crawl-${c.kind}.json`,
183+
JSON.stringify({ kind: c.kind, tabs: tabs.length, buttons: outcomes.length, saved, outcomes }, null, 2),
184+
);
185+
const bad = outcomes.filter((o) => !o.ok);
186+
test.info().annotations.push({
187+
type: 'ui-crawl',
188+
description: `${c.kind}: ${outcomes.length} buttons across ${tabs.length} tabs, ${bad.length} findings, save ${saved.isZip ? 'ok' : 'FAILED'} (${saved.ms} ms)`,
189+
});
190+
console.log(`UI-CRAWL ${c.kind}: ${outcomes.length} buttons, ${bad.length} findings; save ${saved.isZip}`);
191+
for (const o of bad) console.log(` FINDING [${o.tab}] ${o.id} "${o.title}": ${o.note}`);
192+
expect(saved.isZip).toBe(true);
193+
expect(bad.map((o) => `${o.tab}/${o.id}: ${o.note}`)).toEqual([]);
194+
});
195+
}
196+
});

0 commit comments

Comments
 (0)