Files
edentext/tests/dom/run.mjs
T
Steffen Becker abb4870544 fix: Save As is one entry per format, and the template follows the document's
Brave ships with the File System Access API off, so a save is a download and
the browser's dialog cannot hand a changed name back: the bytes must be built
in the format chosen beforehand, or a renamed file carries the other format.
One entry per format settles that on every route, the DOCX export entry goes
as its duplicate, and the DOM run covers the download route.
2026-09-04 19:30:13 +02:00

142 lines
7.4 KiB
JavaScript

// Browser DOM run: the layout the vitest suite cannot see. A corpus document is
// opened and its pagination watched through a settle, a reload, a zoom and an edit —
// the page count is what every layer on the page is measured against. Fails on any
// uncaught page error.
import { join } from 'node:path';
import { readFile } from 'node:fs/promises';
import { ROOT, MOD, checker, previewServer, openApp } from '../browser.mjs';
const PORT = +(process.env.DOM_PORT ?? 4185);
const { check, failures } = checker();
const server = await previewServer(PORT);
const { browser, page, pageErrors } = await openApp(PORT);
// "Page 1 of 4" in the status bar is the editor's own count, after its settle loop.
const pageCount = () => page.evaluate(() => {
const m = /of (\d+)/.exec(document.querySelector('.statusbar')?.textContent ?? '');
return m ? +m[1] : 0;
});
const settled = async (want) => {
await page.waitForFunction((n) => {
const m = /of (\d+)/.exec(document.querySelector('.statusbar')?.textContent ?? '');
return m && (n ? +m[1] === n : +m[1] > 1);
}, want, { timeout: 30_000 }).catch(() => {});
return pageCount();
};
try {
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
// A multi-page corpus document, through the file input (no picker in headless).
await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/05-breaks.odt'));
await page.waitForFunction(() => (document.querySelector('.tiptap')?.textContent ?? '').length > 200,
null, { timeout: 30_000 });
const opened = await settled();
check(opened > 1, `corpus document opens and paginates (${opened} pages)`);
// The settle loop re-measures for a while after the last change; the count it lands
// on has to be the one it keeps.
await page.waitForTimeout(2000);
const still = await pageCount();
check(still === opened, `the page count holds still after the settle (${opened} → ${still})`);
// The same document out of the autosave paginates the same way.
await page.waitForFunction(() => (localStorage.getItem('edentext-doc') ?? '').length > 1000,
null, { timeout: 15_000 });
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
const restored = await settled(opened);
check(restored === opened, `the restored document keeps its page count (${restored})`);
// Zoom is a transform over the same layout, so it must not move a page break.
await page.evaluate(() => localStorage.setItem('edentext-zoom', '50'));
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
const zoomed = await settled(opened);
check(zoomed === opened, `zooming out keeps the page count (${zoomed} at 50%)`);
await page.evaluate(() => localStorage.setItem('edentext-zoom', '100'));
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
await settled(opened);
// The caret is placed through the editor: a click lands wherever the element's centre
// happens to be. The focus itself arrives on the next animation frame, so a key sent
// before it is lost — wait for it.
const caretTo = async (pos) => {
await page.evaluate((p) => document.querySelector('.tiptap').editor.commands.focus(p), pos);
await page.waitForFunction(() => document.activeElement === document.querySelector('.tiptap'), null, { timeout: 5000 });
};
const firstParagraphEnd = () => page.evaluate(() => document.querySelector('.tiptap').editor.state.doc.firstChild.nodeSize - 1);
// A page break after the first paragraph pushes the rest down a page, and undo takes
// it back. (A break before the empty last paragraph yields no page.)
await caretTo(await firstParagraphEnd());
await page.keyboard.press(`${MOD}+Enter`);
const broken = await settled(opened + 1);
await caretTo('end');
await page.keyboard.press(`${MOD}+z`);
const undone = await settled(opened);
check(broken === opened + 1 && undone === opened,
`a page break adds a page and undo takes it back (${opened} → ${broken} → ${undone})`);
// The unsaved dot: an edit raises it, an undo back to the saved text clears it
// again (it is a checksum, not the document's identity), and so does opening a file.
// It follows on the next idle beat, so wait for the state rather than a fixed pause.
const dot = async (want) => {
await page.waitForFunction((w) => !!document.querySelector('.doc-dirty') === w, want, { timeout: 10_000 })
.catch(() => {});
return page.evaluate(() => !!document.querySelector('.doc-dirty'));
};
await caretTo('end');
await page.keyboard.type('nachtrag');
const marked = await dot(true);
await page.keyboard.press(`${MOD}+z`);
const backToSaved = await dot(false);
await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/04-table.odt'));
await page.waitForFunction(() => document.querySelector('.tiptap table td')?.textContent.trim(),
null, { timeout: 30_000 });
const opened2 = await dot(false);
check(marked && !backToSaved && !opened2,
`the dot follows the text (edit: ${marked}, undone: ${backToSaved}, after open: ${opened2})`);
// The page setup is in the file too, so a margin preset marks it as much as text does.
await page.locator('.ribbon-tab', { hasText: 'Layout' }).first().click();
await page.locator('.rb-label', { hasText: 'Margins' }).first().click();
await page.locator('.ribbon-menu button', { hasText: 'Narrow' }).first().click();
const afterMargins = await dot(true);
check(afterMargins, `a margin preset marks the document unsaved (${afterMargins})`);
// And so does the name the file is saved under. The file comes back in first, so the
// rename is the only thing standing between the document and its clean state.
await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/04-table.odt'));
await page.waitForFunction(() => document.querySelector('.tiptap table td')?.textContent.trim(),
null, { timeout: 30_000 });
const reopened = await dot(false);
await page.fill('.doc-name-input', 'Umbenannt');
const afterRename = await dot(true);
check(!reopened && afterRename, `a rename marks the document unsaved (${afterRename})`);
// Without the File System Access API (Brave ships with it off) a save is a download, so
// the format is settled before the bytes are built: Save As (.docx) hands over a DOCX
// whatever the browser's dialog does with the name.
await page.evaluate(() => { window.showSaveFilePicker = undefined; });
await page.click('.ribbon-tab-file');
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
page.locator('.ribbon-menu button', { hasText: '(.docx)' }).first().click(),
]);
const saved = await readFile(await download.path());
const isDocx = saved[0] === 0x50 && saved[1] === 0x4b && saved.includes('word/document.xml');
check(isDocx, `Save As (.docx) without a picker downloads a DOCX (${download.suggestedFilename()}, ${saved.length} bytes)`);
} catch (err) {
check(false, `dom run threw: ${err.message ?? err}`);
} finally {
check(pageErrors.length === 0, pageErrors.length ? `no uncaught page errors — got: ${pageErrors.join(' | ')}` : 'no uncaught page errors');
await browser.close();
if (server) process.kill(-server.pid);
}
process.exit(failures.length ? 1 : 0);