test: the monkey edits both zones, and the runs compare what is drawn
Header and footer go first, each with its own ops, and a variant flag is flipped while a zone is open. The round trip compares a list's drawn kind and marker rather than its node type and style name — a list that loses its name to a formatted marker carries the style's own char — and skips the marks of an inline atom, which no file keeps. A caret is snapped to a text position, a swallowed File-menu click is retried, and WebKit's converging ResizeObserver round is no page error. A new corpus pair brings all six zones through the import path; the baseline holds it and a re-recorded showcase entry.
This commit is contained in:
1 parent
6d4a7e08df
commit
055839655e
14 files changed
+487
-126
No files matched your search
@@ -18,7 +18,7 @@ npm run test:lo # LibreOffice legs: round trip, fuzz re-read, ODT/DOCX rend
|
||||
npm run test:smoke # boots the dist/ build headless (tests/smoke/run.mjs); BROWSER=firefox|webkit
|
||||
npm run test:dom # pagination + editing in the real browser (tests/dom/run.mjs); same BROWSER
|
||||
npm run test:layout # page counts vs LibreOffice, layout invariants, page starts vs baseline.json (tests/layout/)
|
||||
npm run test:monkey # random editing under invariants: schema, undo/redo, the saved file reads back (tests/monkey/)
|
||||
npm run test:monkey # random editing, body + header/footer, under invariants: schema, undo/redo, the saved file reads back
|
||||
npm run test:coverage # vitest + v8 coverage over src/ → coverage/index.html
|
||||
npm run test:parity # render parity vs LibreOffice (tests/render-parity/README.md)
|
||||
node scripts/make-thesaurus.mjs; node scripts/collect-licenses.mjs # re-vendor public/thesaurus/ (MyThes); regenerate public/licenses.txt
|
||||
|
||||
@@ -40,7 +40,61 @@ text box in a note breaks the ODT's XML, and a table in a cell is dropped on sav
|
||||
commands now refuse there; a page break inside a columns section, which the editor never
|
||||
shows and only DOCX writes, is now cleared as the blocks are wrapped; an `.odt` dropped
|
||||
every block of a list item after the first, and a header row that the table did not ask to
|
||||
repeat came back as ordinary cells.
|
||||
repeat came back as ordinary cells. Then: setting one of a heading's two vertical margins
|
||||
zeroed the other in the `.odt` (LibreOffice pairs them); a page break before a list item
|
||||
reached neither file; a list item's further blocks lost their own alignment, spacing and
|
||||
line height in the `.odt`; a run could be subscript and superscript at once; and typing
|
||||
over a selection that spans two blocks let the contenteditable's own span land a whole CSS
|
||||
font stack, and a weight already saying `bold`, in the document. A list in a table cell
|
||||
shifted every body list item's own style by as many items as the cell held; and a heading
|
||||
inside a list item came back from the `.odt` as a paragraph. In a note's own text the
|
||||
`.docx` lost a formula, a ruby and a leading tab, and a heading in a table cell lost the
|
||||
margin it did not set; a list toggle could pull a note anchor into a text box, where the
|
||||
`.docx` dropped anchor and note without a word. A running-head or date field in a zone
|
||||
lost its own formatting in the `.docx`: the library's simple field writes a bare run.
|
||||
Typing over a selection spanning two header cells left a half-header first row, which no
|
||||
file keeps. One finding was the checker's own: libxml2's RelaxNG cannot match an element
|
||||
the ODF schema declares by name class inside a `<text:span>` (the statistic fields), so a
|
||||
formatted page count in a zone read as invalid — LibreOffice writes the same span.
|
||||
The run's own `focus(pos)` is snapped to the next text position: TipTap makes the selection
|
||||
wherever it is told, and Enter on a caret between two list items throws out of prosemirror —
|
||||
a place the view itself never puts one.
|
||||
In Firefox: a page break set inside a text box, which neither format keeps, so the same
|
||||
plugin now sweeps it from a table cell and a note body too; and the run's 1x1 PNG had a
|
||||
truncated IDAT, which only Firefox reports — the same broken bytes were in eleven fixtures.
|
||||
WebKit raises `ResizeObserver loop completed with undelivered notifications` as a page
|
||||
error — the text box refits the wrapper it observes, so the browser defers the rest of the
|
||||
round. That converging loop is the one message `openApp` drops.
|
||||
A comment over a line break rides the `hardBreak` too, which no file carries and the
|
||||
comment's own text runs keep — `normalize` skips the marks of an inline atom, the picture
|
||||
and the formula with it.
|
||||
Then the table run: promoting a cell to a header dropped every attr it had; a replace
|
||||
reaching into a table left the cell it landed in without its column weight, so redo (which
|
||||
replays the recorded step) restored one the edit itself had lost; a list toggle wrapped a
|
||||
table into a list item, where the `.odt` drops it; and a heading in a cell came back from
|
||||
the `.docx` carrying its size as direct formatting. A list's `listStyleName` is no longer
|
||||
compared: direct formatting on a marker makes the file keep the resolved automatic clone
|
||||
instead, and `corpus.test.ts` holds the plain round trip. Its marker char goes the same
|
||||
way — a list that lost its name carries the char the style drew, so both sides compare the
|
||||
drawn one, as the kind already did.
|
||||
Still open: a run set to the body's own font, in a columns section, kept its family on the
|
||||
held side once in four runs of the same seed chain — the flow rewrites such a document
|
||||
between frames, which is why that leg leaves the history unchecked too.
|
||||
Both header/footer zones go first, before the body ops can cut the document to one page.
|
||||
The run draws the two variant flags and the zone distances from the ribbon's Insert tab
|
||||
(Options) — with them on, a double-click on pages 1 to 3 in turn is one session per
|
||||
variant — and edits each with `MONKEY_HF_OPS` ops of the zone's own schema: runs, breaks,
|
||||
the insert bar's three fields, inline images, left by Done. `16-hf-variants` brings the
|
||||
same six zones in through the import path instead. Then one flag is flipped **while a
|
||||
zone is open**, which ends the edit (`App.svelte` drops `hfActive` with it): the text
|
||||
typed before the flip has to be in the zones all the same. A zone is no part of the
|
||||
document, so the saved file is the only thing that reports on it: the round trip holds
|
||||
the zones the app's storage keeps against the ones the importer gives back, per section
|
||||
and per variant in play. Undo is not among the zone's keys — a pass falls into one
|
||||
history group, so a single Mod+Z would empty the zone and the round trip would compare
|
||||
nothing. Every run opens its document through `input.file-input[accept*=".odt"]`: the
|
||||
Insert tab carries a second `.file-input` for pictures, and a bare class selector picks
|
||||
whichever the ribbon happens to be showing.
|
||||
|
||||
`BROWSER=chromium|firefox|webkit` picks the engine (Chromium by default; the others via
|
||||
`npx playwright-core install firefox webkit`). CI runs both on all three, one per matrix
|
||||
|
||||
+4
-1
@@ -58,7 +58,10 @@ export async function openApp(port) {
|
||||
console.log(`engine: ${name} ${browser.version()}`);
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, locale: 'en-US' });
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (err) => pageErrors.push(String(err)));
|
||||
// The text box's ResizeObserver refits the wrapper it observes, so the browser defers
|
||||
// the rest of the round to the next frame and reports it — a converging loop, not an error.
|
||||
const benign = /ResizeObserver loop/;
|
||||
page.on('pageerror', (err) => { if (!benign.test(String(err))) pageErrors.push(String(err)); });
|
||||
page.on('dialog', (d) => d.accept());
|
||||
return { browser, page, pageErrors };
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+30
-3
@@ -31,7 +31,7 @@ try {
|
||||
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.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/05-breaks.odt'));
|
||||
await page.waitForFunction(() => (document.querySelector('.tiptap')?.textContent ?? '').length > 200,
|
||||
null, { timeout: 30_000 });
|
||||
const opened = await settled();
|
||||
@@ -95,7 +95,7 @@ try {
|
||||
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.setInputFiles('input.file-input[accept*=".odt"]', 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);
|
||||
@@ -111,7 +111,7 @@ try {
|
||||
|
||||
// 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.setInputFiles('input.file-input[accept*=".odt"]', 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);
|
||||
@@ -131,6 +131,33 @@ try {
|
||||
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)`);
|
||||
|
||||
// The header/footer switches live in the ribbon's Insert tab. A ticked "different
|
||||
// first page" makes page 1 a second zone, so what is typed there lands beside the
|
||||
// running one — and the distance travels to the app's own storage.
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('.ribbon-tab', { hasText: 'Insert' }).first().click();
|
||||
const hfOptions = page.locator('button.rb', { hasText: 'Options' }).first();
|
||||
await hfOptions.click();
|
||||
await page.locator('.ribbon-menu .check-row input').first().check();
|
||||
const dist = page.locator('.ribbon-menu .num-row input').first();
|
||||
await dist.fill('1.8');
|
||||
await dist.dispatchEvent('change');
|
||||
await hfOptions.click();
|
||||
const zone = page.locator('.hf-zone.hf-header').first();
|
||||
await zone.dblclick({ timeout: 5000 }).catch(() => zone.dispatchEvent('dblclick'));
|
||||
await page.waitForSelector('.hf-active .tiptap', { timeout: 5000 });
|
||||
await page.waitForFunction(() => document.activeElement?.closest?.('.hf-active'), null, { timeout: 5000 });
|
||||
await page.keyboard.type('Titelseite');
|
||||
await page.locator('.hf-bar-done').click();
|
||||
const hf = await page.evaluate(() => ({
|
||||
first: localStorage.getItem('edentext-hf-different-first'),
|
||||
running: localStorage.getItem('edentext-header'),
|
||||
firstPage: localStorage.getItem('edentext-header-first') ?? '',
|
||||
dist: localStorage.getItem('edentext-hf-distances') ?? '',
|
||||
}));
|
||||
check(hf.first === 'true' && !hf.running && hf.firstPage.includes('Titelseite') && /"header":1.8/.test(hf.dist),
|
||||
`the ribbon's header/footer switches reach the document (first page: ${hf.first}, running zone: ${hf.running}, distances: ${hf.dist})`);
|
||||
} catch (err) {
|
||||
check(false, `dom run threw: ${err.message ?? err}`);
|
||||
} finally {
|
||||
|
||||
+102
-73
@@ -107,105 +107,114 @@
|
||||
]
|
||||
},
|
||||
"docs/showcase/book.odt": {
|
||||
"pages": 95,
|
||||
"lines": 3739,
|
||||
"ms": 800,
|
||||
"pages": 104,
|
||||
"lines": 3757,
|
||||
"ms": 1300,
|
||||
"starts": [
|
||||
"Alice’s Adventures in Wonderland",
|
||||
"Contents",
|
||||
"Chapter I",
|
||||
"fortunately was just in time to see it p",
|
||||
"wonder what Latitude or Longitude I’ve g",
|
||||
"down it. There was not a moment to be lo",
|
||||
"at any rate a book of rules for shutting",
|
||||
"said Alice to herself, “in my going out ",
|
||||
"ained the same size: to be sure, this ge",
|
||||
"ice to say it over) “—yes, that’s about ",
|
||||
"Alice was not a bit hurt, and she jumped",
|
||||
"things had happened lately, that Alice h",
|
||||
"And so it was indeed: she was now only t",
|
||||
"it,” said Alice, “and if it makes me gro",
|
||||
"",
|
||||
"Chapter II",
|
||||
"Just then her head struck against the ro",
|
||||
"“I’m sure I’m not Ada,” she said, “for h",
|
||||
"while she was talking. “How",
|
||||
"“Would it be of any use, now,” thought A",
|
||||
"“We indeed!” cried the Mouse, who was tr",
|
||||
"As she said this she looked down at her ",
|
||||
"Just then she heard something splashing ",
|
||||
"pool, “and she sits purring so nicely by",
|
||||
"Dodo, a",
|
||||
"",
|
||||
"Chapter III",
|
||||
"soon submitted to by the English, who wa",
|
||||
"“What I was going to say,” said the Dodo",
|
||||
"“Of course,” the Dodo replied very grave",
|
||||
"‘I’ll be judge, I’ll be jury,’ Said cunn",
|
||||
"This speech caused a remarkable sensatio",
|
||||
"‘William the Conqueror, whose cause was",
|
||||
"either!” And the Eaglet bent down its he",
|
||||
"not got into it), and handed them round ",
|
||||
"“Fury said to a mouse, That he met in th",
|
||||
"“And who is Dinah, if I might venture to",
|
||||
"Chapter IV",
|
||||
"bright brass plate with the name “W. RAB",
|
||||
"her head. Still she went on growing, and",
|
||||
"Presently the Rabbit came up to the door",
|
||||
"voices all talking together: she made ou",
|
||||
"“We must burn the house down!” said the ",
|
||||
"how to set about it; and while she was p",
|
||||
"The great question certainly was, what? ",
|
||||
"this, she came upon a neat little house,",
|
||||
"there was not even room for this, and sh",
|
||||
"“Mary Ann! Mary Ann!” said the voice. “F",
|
||||
"were",
|
||||
"Last came a little feeble, squeaking voi",
|
||||
"“The first thing I’ve got to do,” said A",
|
||||
"This seemed to Alice a good opportunity ",
|
||||
"Chapter V",
|
||||
"then after that into a butterfly, I shou",
|
||||
"“Repeat, ‘",
|
||||
"The Caterpillar was the first to speak.",
|
||||
"at last she stretched her arms round it ",
|
||||
"but the tops of the trees under which sh",
|
||||
"use denying it. I suppose you’ll be tell",
|
||||
"why, I should frighten them out of their",
|
||||
"“Well, I’ve tried to say “How doth the l",
|
||||
"“Not",
|
||||
"“Of the mushroom,” said the Caterpillar,",
|
||||
"you?” She was moving them about as she s",
|
||||
"“But I’m",
|
||||
"It was so long since she had been anythi",
|
||||
"Chapter VI",
|
||||
"Alice went timidly up to the door, and k",
|
||||
"The Footman seemed to think this a good ",
|
||||
"Alice did not at all like the tone of th",
|
||||
"“Wow! wow! wow!”",
|
||||
"not like the look of the thing at all. “",
|
||||
"“Oh, you’re sure to do that,” said the C",
|
||||
"“It turned into a pig,” Alice quietly sa",
|
||||
"Fish-Footman was gone, and the other was",
|
||||
"It was, no doubt: only Alice did not lik",
|
||||
"“They all can,” said the Duchess; “and m",
|
||||
"of lullaby to it as she did so, and givi",
|
||||
"grunted in reply (it had left off sneezi",
|
||||
"“Cheshire Puss,” she began, rather timid",
|
||||
"“Call it what you like,” said the Cat. “",
|
||||
"ause the chimneys were shaped like ears ",
|
||||
"",
|
||||
"Chapter VII",
|
||||
"“Your hair wants cutting,” said the Hatt",
|
||||
"“Two days wrong!” sighed the Hatter. “I ",
|
||||
"Alice sighed wearily. “I think you might",
|
||||
"‘Up above the world you fly, Like a tea-",
|
||||
"“And be quick about it,” added the Hatte",
|
||||
"“There’s no such thing!” Alice was begin",
|
||||
"“They were learning to draw,” the Dormou",
|
||||
"led into the garden. Then she went to wo",
|
||||
"Alice considered a little, and then said",
|
||||
"“Nor I,” said the March Hare.",
|
||||
"“It goes on, you know,” the Hatter conti",
|
||||
"“Tell us a story!” said the March Hare.",
|
||||
"The Dormouse again took a minute or two ",
|
||||
"This answer so confused poor Alice, that",
|
||||
"Once more she found herself in the long ",
|
||||
"",
|
||||
"Chapter VIII",
|
||||
"“Would you tell me,” said Alice, a littl",
|
||||
"When the procession came opposite to Ali",
|
||||
"“May it please your Majesty,” said Two, ",
|
||||
"“She boxed the Queen’s ears—” the Rabbit",
|
||||
"minute, “and then,” thought she, “what w",
|
||||
"“It’s a friend of mine—a Cheshire Cat,” ",
|
||||
"this side of the ground.” So she tucked ",
|
||||
"watching them, and he checked himself su",
|
||||
"upon their faces, so that they couldn’t ",
|
||||
"“Leave off that!” screamed the Queen. “Y",
|
||||
"“What for?” said Alice.",
|
||||
"short time the Queen was in a furious pa",
|
||||
"“How do you like the Queen?” said the Ca",
|
||||
"The hedgehog was engaged in a fight with",
|
||||
"Alice could think of nothing else to say",
|
||||
"",
|
||||
"Chapter IX",
|
||||
"“Tut, tut, child!” said the Duchess. “Ev",
|
||||
"“Of course it is,” said the Duchess, who",
|
||||
"Queen in front of them, with her arms fo",
|
||||
"that’s",
|
||||
"So they sat down, and nobody spoke for s",
|
||||
"“",
|
||||
"Seaography",
|
||||
"“Perhaps it hasn’t one,” Alice ventured ",
|
||||
"“Right, as usual,” said the Duchess: “wh",
|
||||
"“Just about as much right,” said the Duc",
|
||||
"“It’s the thing Mock Turtle Soup is made",
|
||||
"So they went up to the Mock Turtle, who ",
|
||||
"“Yes, we went to school in the sea, thou",
|
||||
"“Yes,” said Alice doubtfully: “it means—",
|
||||
"This was quite a new idea to Alice, and ",
|
||||
"",
|
||||
"Chapter X",
|
||||
"“Of course,” the Mock Turtle said: “adva",
|
||||
"you, won’t you, will you join the dance?",
|
||||
"a long way. So they got their tails fast",
|
||||
"“I could tell you my adventures—beginnin",
|
||||
"[later editions continued as follows Whe",
|
||||
"“Yes, I think you’d better leave off,” s",
|
||||
"",
|
||||
"“The reason is,” said the",
|
||||
"“Of course not,” said the Mock Turtle: “",
|
||||
"of the Lobster Quadrille, that she hardl",
|
||||
"[later editions continued as follows The",
|
||||
"“Chorus again!” cried the",
|
||||
"Chapter XI",
|
||||
"“And that’s the jury-box,” thought Alice",
|
||||
"“The Queen of Hearts, she made some tart",
|
||||
"This did not seem to encourage the witne",
|
||||
"“Of course twinkling begins with a T!” s",
|
||||
"“I can’t go no lower,” said the Hatter: ",
|
||||
"For some minutes the whole court was in ",
|
||||
"On this the White Rabbit blew three blas",
|
||||
"Here the Queen put on her spectacles, an",
|
||||
"“I’m a poor man, your Majesty,” the Hatt",
|
||||
"which tied up at the mouth with strings:",
|
||||
"“Well, if I must, I must,” the King said",
|
||||
"",
|
||||
"Chapter XII",
|
||||
"As soon as the jury had a little recover",
|
||||
"“Well, I shan’t go, at any rate,” said A",
|
||||
"“That",
|
||||
"“If there’s no meaning in it,” said the ",
|
||||
"“I won’t!” said Alice.",
|
||||
"The long grass rustled at her feet as th"
|
||||
"said to herself; “I should think it woul",
|
||||
"“You are,” said the King.",
|
||||
"“If you didn’t sign it,” said the King, ",
|
||||
"him,) “I’ll give him sixpence.",
|
||||
"“It’s a pun!” the King added in an offen",
|
||||
"First, she dreamed of little Alice herse",
|
||||
"her childhood: and how she would gather "
|
||||
]
|
||||
},
|
||||
"docs/showcase/newsletter.docx": {
|
||||
@@ -640,6 +649,26 @@
|
||||
"Appendix"
|
||||
]
|
||||
},
|
||||
"tests/corpus/16-hf-variants.docx": {
|
||||
"pages": 3,
|
||||
"lines": 1528,
|
||||
"ms": 300,
|
||||
"starts": [
|
||||
"1.",
|
||||
"18.",
|
||||
"35."
|
||||
]
|
||||
},
|
||||
"tests/corpus/16-hf-variants.odt": {
|
||||
"pages": 3,
|
||||
"lines": 1688,
|
||||
"ms": 100,
|
||||
"starts": [
|
||||
"1.",
|
||||
"18.",
|
||||
"35."
|
||||
]
|
||||
},
|
||||
"tests/corpus/word/01-flow.docx": {
|
||||
"pages": 2,
|
||||
"lines": 1008,
|
||||
|
||||
@@ -179,7 +179,7 @@ try {
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('.tiptap', { timeout: 30_000 });
|
||||
await page.evaluate(() => { window.__loadAt = performance.now(); });
|
||||
await page.setInputFiles('input.file-input', file);
|
||||
await page.setInputFiles('input.file-input[accept*=".odt"]', file);
|
||||
// The document's name says the file is in; a document may hold nothing to wait for.
|
||||
await page.waitForFunction((n) => document.querySelector('.doc-name-input')?.value.startsWith(n), basename(file, extname(file)), { timeout: 60_000 });
|
||||
await settle(page, true);
|
||||
|
||||
+256
-39
@@ -16,8 +16,9 @@ const PORT = +(process.env.MONKEY_PORT ?? 4189);
|
||||
const SEED = +(process.env.MONKEY_SEED ?? 1);
|
||||
const RUNS = +(process.env.MONKEY_RUNS ?? 3);
|
||||
const OPS = +(process.env.MONKEY_OPS ?? 60);
|
||||
const DOCS = process.env.MONKEY_DOC ? [process.env.MONKEY_DOC] : ['02-blocks.odt', '04-table.odt', '08-lists.odt', '12-notes.odt', '15-chapters.odt'];
|
||||
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
const HF_OPS = +(process.env.MONKEY_HF_OPS ?? 8);
|
||||
const DOCS = process.env.MONKEY_DOC ? [process.env.MONKEY_DOC] : ['02-blocks.odt', '16-hf-variants.odt', '04-table.odt', '08-lists.odt', '12-notes.odt', '15-chapters.odt'];
|
||||
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNwaDgAAAKEAYEml6crAAAAAElFTkSuQmCC';
|
||||
const { check, failures } = checker();
|
||||
if (!hasXmllint) console.log('xmllint missing: the saved files are not validated against the schemas');
|
||||
|
||||
@@ -86,6 +87,30 @@ const drawOp = (r, size) => {
|
||||
return { key: 'ArrowRight' };
|
||||
};
|
||||
|
||||
// The zone's own schema — one paragraph of runs, breaks, fields and inline images — so it
|
||||
// draws from a table of its own. No Escape (that leaves), no undo: a pass falls into one
|
||||
// history group, so one Mod+Z would empty the zone before the round trip ever sees it.
|
||||
const HF_KEYS = ['Enter', 'Backspace', 'Backspace', 'Delete', 'Tab', 'Home', 'End',
|
||||
'ArrowLeft', 'ArrowRight', 'Shift+ArrowLeft', 'Shift+ArrowRight', `${MOD}+b`, `${MOD}+i`, `${MOD}+u`];
|
||||
const HF_OPS_TABLE = [
|
||||
[14, (r) => ({ type: pick(r, WORDS) })],
|
||||
[12, (r) => ({ key: pick(r, HF_KEYS) })],
|
||||
// The insert bar's three field buttons, in its order: page number, count, chapter.
|
||||
[5, (r) => ({ field: int(r, 0, 2) })],
|
||||
[9, (r) => pick(r, [
|
||||
{ cmd: 'toggleBold' }, { cmd: 'toggleItalic' }, { cmd: 'toggleUnderline' }, { cmd: 'toggleStrike' },
|
||||
{ cmd: 'unsetAllMarks' }, { cmd: 'setTextAlign', args: [pick(r, ['left', 'center', 'right'])] },
|
||||
{ cmd: 'setFontSize', args: ['10pt'] }, { cmd: 'setColor', args: ['#c00000'] },
|
||||
{ cmd: 'setImage', args: [{ src: PNG, width: 24, height: 18 }] },
|
||||
])],
|
||||
];
|
||||
const HF_TOTAL = HF_OPS_TABLE.reduce((n, [w]) => n + w, 0);
|
||||
const drawHfOp = (r) => {
|
||||
let x = r() * HF_TOTAL;
|
||||
for (const [w, draw] of HF_OPS_TABLE) { if ((x -= w) < 0) return draw(r); }
|
||||
return { key: 'ArrowRight' };
|
||||
};
|
||||
|
||||
const server = await devServer(PORT);
|
||||
const { browser, page, pageErrors } = await openApp(PORT);
|
||||
const consoleErrors = [];
|
||||
@@ -93,20 +118,29 @@ page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text(
|
||||
|
||||
const ed = (fn, arg) => page.evaluate(fn, arg);
|
||||
const focused = async () => {
|
||||
const err = await ed(() => { try { document.querySelector('.tiptap').editor.commands.focus(); return null; } catch (e) { return String(e); } });
|
||||
await page.waitForFunction(() => document.activeElement === document.querySelector('.tiptap'), null, { timeout: 2000 }).catch(() => {});
|
||||
const err = await ed(() => { try { document.querySelector('.tiptap-host .tiptap').editor.commands.focus(); return null; } catch (e) { return String(e); } });
|
||||
await page.waitForFunction(() => document.activeElement === document.querySelector('.tiptap-host .tiptap'), null, { timeout: 2000 }).catch(() => {});
|
||||
return err;
|
||||
};
|
||||
const runCmd = (op) => ed(({ cmd, args }) => {
|
||||
const editor = document.querySelector('.tiptap').editor;
|
||||
const editor = document.querySelector('.tiptap-host .tiptap').editor;
|
||||
// A caret only ever sits in a text block — the view puts it nowhere else — but
|
||||
// focus(pos) makes the selection wherever it is told, and Enter on one between two
|
||||
// list items throws out of prosemirror. Snap to the next text position instead.
|
||||
if (cmd === 'focus' && typeof args?.[0] === 'number') {
|
||||
const doc = editor.state.doc;
|
||||
let at = Math.max(0, Math.min(args[0], doc.content.size));
|
||||
for (let n = at; n <= doc.content.size; n++) if (doc.resolve(n).parent.isTextblock) { at = n; break; }
|
||||
args = [at];
|
||||
}
|
||||
try { return { ok: editor.commands[cmd](...(args ?? [])) }; } catch (e) { return { threw: String(e?.stack ?? e) }; }
|
||||
}, op);
|
||||
// The schema's own check of the document after every op — a command may leave a node
|
||||
// with content its spec rejects, which nothing else reports.
|
||||
const docCheck = () => ed(() => {
|
||||
try { document.querySelector('.tiptap').editor.state.doc.check(); return null; } catch (e) { return String(e); }
|
||||
try { document.querySelector('.tiptap-host .tiptap').editor.state.doc.check(); return null; } catch (e) { return String(e); }
|
||||
});
|
||||
const docJson = () => ed(() => JSON.stringify(document.querySelector('.tiptap').editor.getJSON()));
|
||||
const docJson = () => ed(() => JSON.stringify(document.querySelector('.tiptap-host .tiptap').editor.getJSON()));
|
||||
// The key the history is held against, and the count of the columns flow's own rewrites:
|
||||
// the flow changes the document outside the history (`FLOW_TX`, columnsFlow.ts), so undo
|
||||
// rebases over it — ponytail: such a run has its history left unchecked.
|
||||
@@ -123,8 +157,8 @@ const watchFlow = () => ed(async () => {
|
||||
return n;
|
||||
};
|
||||
window.__docKey = () => JSON.stringify(
|
||||
mergeJoinedParagraphsJson(strip(document.querySelector('.tiptap').editor.getJSON()).content ?? []));
|
||||
const view = document.querySelector('.tiptap').editor.view;
|
||||
mergeJoinedParagraphsJson(strip(document.querySelector('.tiptap-host .tiptap').editor.getJSON()).content ?? []));
|
||||
const view = document.querySelector('.tiptap-host .tiptap').editor.view;
|
||||
window.__flowTx = 0;
|
||||
if (view.__flowWatched) return;
|
||||
view.__flowWatched = true;
|
||||
@@ -135,25 +169,132 @@ const watchFlow = () => ed(async () => {
|
||||
};
|
||||
});
|
||||
const docKey = () => ed(() => window.__docKey());
|
||||
const docSize = () => ed(() => document.querySelector('.tiptap').editor.state.doc.content.size);
|
||||
const docSize = () => ed(() => document.querySelector('.tiptap-host .tiptap').editor.state.doc.content.size);
|
||||
|
||||
const saveAs = async (ext) => {
|
||||
const HF_LIVE = '.hf-active .tiptap';
|
||||
const hfCmd = (op) => ed(({ cmd, args }) => {
|
||||
const editor = document.querySelector('.hf-active .tiptap')?.editor;
|
||||
if (!editor) return { threw: 'the zone editor is gone' };
|
||||
try { return { ok: editor.commands[cmd](...(args ?? [])) }; } catch (e) { return { threw: String(e?.stack ?? e) }; }
|
||||
}, op);
|
||||
const hfCheck = () => ed(() => {
|
||||
const editor = document.querySelector('.hf-active .tiptap')?.editor;
|
||||
if (!editor) return 'the zone editor is gone';
|
||||
try { editor.state.doc.check(); return null; } catch (e) { return String(e); }
|
||||
});
|
||||
|
||||
// A floating toolbar of the body's — a selected frame's, say — can sit over the zone
|
||||
// and its bar; the handler behind the element is what this run is after, not hit-testing.
|
||||
const clickish = (at, how = 'click') => at[how]({ timeout: 4000 })
|
||||
.catch(() => at.dispatchEvent(how, {}, { timeout: 4000 }))
|
||||
.catch(() => false); // gone already (an op closed the zone): the checks after it still speak
|
||||
|
||||
// A checkbox in the ribbon's own menu: the menu re-renders under the click (the flag
|
||||
// ends an open zone edit), so a real click can find the element detached.
|
||||
const setBox = (at, on) => at.setChecked(on, { timeout: 4000 })
|
||||
.catch(() => at.evaluate((el, v) => { if (el.checked !== v) el.click(); }, on));
|
||||
|
||||
// The two variant flags and the zone distances, from the ribbon's Insert tab. Setting
|
||||
// them here is what puts the first-page and even-page zones in play on a document that
|
||||
// carries none of its own.
|
||||
async function setHfOptions(r) {
|
||||
await page.locator('.ribbon-tab', { hasText: 'Insert' }).first().click();
|
||||
const options = page.locator('button.rb', { hasText: 'Options' }).first();
|
||||
await clickish(options);
|
||||
await page.waitForSelector('.ribbon-menu .check-row input', { timeout: 5000 });
|
||||
const boxes = page.locator('.ribbon-menu .check-row input');
|
||||
for (const i of [0, 1]) await setBox(boxes.nth(i), r() < 0.6);
|
||||
if (r() < 0.4) {
|
||||
const dist = page.locator('.ribbon-menu .num-row input').nth(int(r, 0, 1));
|
||||
await dist.fill(String(pick(r, [0.6, 1, 1.8])));
|
||||
await dist.dispatchEvent('change');
|
||||
}
|
||||
await clickish(options);
|
||||
}
|
||||
|
||||
// Flipping a flag ends an open zone edit (App.svelte drops hfActive with it), which
|
||||
// tears the live editor down mid-session — what was typed must be in the zones anyway.
|
||||
async function flipUnderEdit(mark) {
|
||||
const zone = page.locator('.hf-zone.hf-footer').first();
|
||||
if (!(await zone.count())) return null;
|
||||
await clickish(zone, 'dblclick');
|
||||
await page.waitForSelector(HF_LIVE, { timeout: 5000 });
|
||||
await page.waitForFunction(() => document.activeElement?.closest?.('.hf-active'), null, { timeout: 2000 }).catch(() => {});
|
||||
await page.keyboard.type(mark);
|
||||
await page.locator('.ribbon-tab', { hasText: 'Insert' }).first().click();
|
||||
const options = page.locator('button.rb', { hasText: 'Options' }).first();
|
||||
await clickish(options);
|
||||
const box = page.locator('.ribbon-menu .check-row input').nth(1);
|
||||
await setBox(box, !(await box.isChecked()));
|
||||
await clickish(options);
|
||||
const gone = await page.waitForSelector('.hf-active', { state: 'detached', timeout: 5000 }).then(() => true, () => false);
|
||||
const kept = await ed((m) => ['edentext-header', 'edentext-footer', 'edentext-header-first',
|
||||
'edentext-footer-first', 'edentext-header-even', 'edentext-footer-even', 'edentext-hf-sections']
|
||||
.some((k) => (localStorage.getItem(k) ?? '').includes(m)), mark);
|
||||
if (!gone) return 'the flag left the zone editor open';
|
||||
return kept ? null : 'the text typed before the flag flipped is in no zone';
|
||||
}
|
||||
|
||||
// A zone edited the way a user reaches it: double-click in, edit, leave by Done. Pages
|
||||
// 1 to 3 in turn — with the two variant flags on that is one session per variant, the
|
||||
// title page's, an even page's and a running one's. Only the round trip reports on it.
|
||||
async function editZone(r, zone) {
|
||||
const zones = page.locator(`.hf-zone.hf-${zone}`);
|
||||
const n = await zones.count();
|
||||
if (!n) return { ops: [], broken: `no ${zone} zone on the page` };
|
||||
const ops = [];
|
||||
let broken = null;
|
||||
for (let p = 0; p < Math.min(3, n) && !broken; p++) {
|
||||
await clickish(zones.nth(p), 'dblclick');
|
||||
await page.waitForSelector(HF_LIVE, { timeout: 5000 });
|
||||
// The mount focuses itself a tick later; typing before that loses its first keys.
|
||||
await page.waitForFunction(() => document.activeElement?.closest?.('.hf-active'), null, { timeout: 2000 }).catch(() => {});
|
||||
for (let i = 0; i < HF_OPS && !broken; i++) {
|
||||
const op = { page: p + 1, ...drawHfOp(r) };
|
||||
ops.push(op);
|
||||
if (op.type) await page.keyboard.type(op.type);
|
||||
else if (op.key) await page.keyboard.press(op.key);
|
||||
else if (op.field !== undefined) await clickish(page.locator('.hf-bar-btn').nth(op.field));
|
||||
else {
|
||||
const res = await hfCmd(op);
|
||||
if (res.threw) broken = `${op.cmd} threw: ${res.threw}`;
|
||||
}
|
||||
const invalid = broken ? null : await hfCheck();
|
||||
if (invalid) broken = `schema-invalid ${zone} on page ${p + 1}: ${invalid}`;
|
||||
if (pageErrors.length) broken = `uncaught: ${pageErrors.join(' | ')}`;
|
||||
}
|
||||
await clickish(page.locator('.hf-bar-done'));
|
||||
await page.waitForSelector('.hf-active', { state: 'detached', timeout: 5000 }).catch(() => {});
|
||||
}
|
||||
return { ops, broken };
|
||||
}
|
||||
|
||||
const saveAs = async (ext, retry = true) => {
|
||||
await page.click('.ribbon-tab-file');
|
||||
const [dl] = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 60_000 }),
|
||||
page.locator('.ribbon-menu button', { hasText: `(.${ext})` }).first().click(),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
return new Uint8Array(await readFile(await dl.path()));
|
||||
try {
|
||||
const [dl] = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 30_000 }),
|
||||
page.locator('.ribbon-menu button', { hasText: `(.${ext})` }).first().click(),
|
||||
]);
|
||||
await page.keyboard.press('Escape');
|
||||
return new Uint8Array(await readFile(await dl.path()));
|
||||
} catch (err) {
|
||||
// The File menu can miss a click while the app is still laying the document out, and
|
||||
// no download follows. One more go; a second miss is a finding.
|
||||
if (!retry) throw err;
|
||||
await page.keyboard.press('Escape');
|
||||
return saveAs(ext, false);
|
||||
}
|
||||
};
|
||||
// The file the app saved, read back by the app's importer against the editor's document
|
||||
// (its paragraphs joined across a column or page merged, as the export merges them).
|
||||
const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => {
|
||||
const [{ importOdt }, { importDocx }, { mergeJoinedParagraphsJson }, { normalize, firstDiff, stripFontHoist }] = await Promise.all([
|
||||
import('/src/lib/import/odt.ts'), import('/src/lib/import/docx.ts'), import('/src/lib/export/odt.ts'), import('/tests/normalize.ts')]);
|
||||
const [{ importOdt }, { importDocx }, { mergeJoinedParagraphsJson }, { normalize, firstDiff, stripFontHoist }, hfStore, { effectiveListLevel, defaultLevelBullet }] = await Promise.all([
|
||||
import('/src/lib/import/odt.ts'), import('/src/lib/import/docx.ts'), import('/src/lib/export/odt.ts'), import('/tests/normalize.ts'),
|
||||
import('/src/lib/storage/headerFooter.ts'), import('/src/lib/styles/listStyles.ts')]);
|
||||
const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const res = ext === 'odt' ? importOdt(bin) : importDocx(bin);
|
||||
const editor = document.querySelector('.tiptap').editor;
|
||||
const editor = document.querySelector('.tiptap-host .tiptap').editor;
|
||||
const json = editor.getJSON();
|
||||
// What the importer suppresses as the style's own: a block attribute equal to what its
|
||||
// named style resolves to, and bold in a header cell.
|
||||
@@ -169,8 +310,9 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => {
|
||||
text = own.text ?? {};
|
||||
}
|
||||
if (header && n.type === 'text' && n.marks) n.marks = n.marks.filter((m) => m.type !== 'bold');
|
||||
// A formula alone in its paragraph is a display formula to both files.
|
||||
if (n.type === 'paragraph' && n.content?.length === 1 && n.content[0].type === 'formula') n.content[0].attrs = { ...n.content[0].attrs, display: true };
|
||||
// A formula alone in its block is a display formula to both files — a note holds its
|
||||
// inline content without a paragraph, so it is a block here too.
|
||||
if ((n.type === 'paragraph' || n.type === 'note') && n.content?.length === 1 && n.content[0].type === 'formula') n.content[0].attrs = { ...n.content[0].attrs, display: true };
|
||||
// A box with no size of its own is saved at the size the exporter falls back to, and
|
||||
// Word's table grid always carries widths, so a merged cell reads back with them.
|
||||
if (n.type === 'textBox') n.attrs = { ...n.attrs, width: n.attrs?.width ?? 280, height: n.attrs?.height ?? 96 };
|
||||
@@ -178,6 +320,10 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => {
|
||||
// Both formats spell a header row as the one that repeats, so it reads back repeating.
|
||||
if (n.type === 'table' && n.content?.[0]?.content?.[0]?.type === 'tableHeader') delete n.attrs?.repeatHeader;
|
||||
if (n.attrs?.styleName === style || n.attrs?.styleName === 'Standard') delete n.attrs.styleName;
|
||||
// Same for bold and italic: a heading is already both where its style says so, and
|
||||
// a mark repeating that is what the importers drop.
|
||||
if (n.type === 'text' && n.marks) n.marks = n.marks.filter((m) =>
|
||||
!((m.type === 'bold' && text.bold) || (m.type === 'italic' && text.italic)));
|
||||
// A run repeating what its own style says is direct formatting both importers
|
||||
// suppress — they cannot tell it from a style that failed to resolve.
|
||||
for (const m of n.type === 'text' ? n.marks ?? [] : []) {
|
||||
@@ -194,7 +340,40 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => {
|
||||
for (const c of n.content ?? []) styled(c, header || n.type === 'tableHeader', inFrame, text);
|
||||
return n;
|
||||
};
|
||||
const held = styled({ ...json, content: mergeJoinedParagraphsJson(json.content ?? []) });
|
||||
// The exporter folds a columns section's page-split chain back into one node and merges
|
||||
// the flow-split paragraphs inside it (replaceColumns, odt.ts); so must the held side.
|
||||
const foldColumns = (blocks) => {
|
||||
const out = [];
|
||||
for (const b of mergeJoinedParagraphsJson(blocks)) {
|
||||
const prev = out[out.length - 1];
|
||||
if (b.type !== 'columns') { out.push(b); continue; }
|
||||
if (prev?.type === 'columns' && JSON.stringify(prev.attrs) === JSON.stringify(b.attrs)) {
|
||||
prev.content = mergeJoinedParagraphsJson([...(prev.content ?? []), ...(b.content ?? [])]);
|
||||
} else out.push({ ...b, content: mergeJoinedParagraphsJson(b.content ?? []) });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
// A list's type is only what its marker draws: where the document's own list style
|
||||
// governs the depth, that level's kind decides it (listStyles.ts) and neither file
|
||||
// carries a node type beside it. Both sides compare the drawn kind.
|
||||
const effListKinds = (n, style = null, depth = 0) => {
|
||||
const list = n.type === 'bulletList' || n.type === 'orderedList';
|
||||
const own = list && depth === 0 ? sheet?.list?.[n.attrs?.listStyleName] ?? null : style;
|
||||
if (list) {
|
||||
const eff = effectiveListLevel(n.attrs ?? {}, n.type === 'orderedList', own, depth + 1);
|
||||
n.type = eff.kind === 'number' ? 'orderedList' : 'bulletList';
|
||||
// The name is not always written: direct formatting on a marker makes the list keep
|
||||
// its resolved automatic clone instead (odt.ts), and a nested list's own name is only
|
||||
// the memory for a later lift out. corpus.test.ts holds the plain round trip.
|
||||
// Its marker then reads back as the direct char the style drew, so both sides carry
|
||||
// the drawn one — the same model the kind above compares by.
|
||||
n.attrs = { ...(n.attrs ?? {}), listStyleName: null,
|
||||
bulletChar: eff.kind === 'bullet' ? eff.bulletChar ?? defaultLevelBullet(depth + 1) : null };
|
||||
}
|
||||
for (const c of n.content ?? []) effListKinds(c, own, depth + (list ? 1 : 0));
|
||||
return n;
|
||||
};
|
||||
const held = effListKinds(styled({ ...json, content: foldColumns(json.content ?? []) }));
|
||||
// Word's list model is flat — an item's further blocks are unnumbered paragraphs at its
|
||||
// indent, and nesting is the numbering's — so the DOCX leg compares a list's content
|
||||
// flat, without its structure or the indent Word writes on a block it does not number.
|
||||
@@ -209,22 +388,43 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => {
|
||||
inList = true;
|
||||
continue;
|
||||
}
|
||||
// A paragraph following the list carries the same indent: it is a continuation of
|
||||
// the item, which is the one shape Word has for a block it does not number.
|
||||
out.push(inList && kid.type === 'paragraph' ? bare(kid) : kid);
|
||||
inList = inList && kid.type === 'paragraph';
|
||||
// A block following the list carries the same indent: it is a continuation of the
|
||||
// item, which is the one shape Word has for a block it does not number.
|
||||
const cont = kid.type === 'paragraph' || kid.type === 'heading';
|
||||
out.push(inList && cont ? bare(kid) : kid);
|
||||
inList = inList && cont;
|
||||
}
|
||||
return { ...n, content: out };
|
||||
};
|
||||
const flat = (n) => stripFontHoist(normalize(ext === 'docx' ? flatLists(n) : n));
|
||||
return { diff: firstDiff(flat(held), flat(styled(res.content))), warnings: res.warnings, held: JSON.stringify(held), read: JSON.stringify(res.content) };
|
||||
// The zones the app holds (its own storage is what it exports from) against the ones
|
||||
// the file gives back — a zone is no part of the document compared above.
|
||||
const { loadHfDoc, loadExtraHfSections, loadDifferentFirstPage, loadDifferentOddEven, hfIsEmpty, EMPTY_HF_SET } = hfStore;
|
||||
const heldSets = [{ ...EMPTY_HF_SET,
|
||||
header: loadHfDoc('header'), footer: loadHfDoc('footer'),
|
||||
headerFirst: loadHfDoc('header', 'first'), footerFirst: loadHfDoc('footer', 'first'),
|
||||
headerEven: loadHfDoc('header', 'even'), footerEven: loadHfDoc('footer', 'even'),
|
||||
differentFirstPage: loadDifferentFirstPage(), differentOddEven: loadDifferentOddEven() }, ...loadExtraHfSections()];
|
||||
const zoneOf = (d) => (hfIsEmpty(d) ? null : stripFontHoist(normalize(structuredClone(d))));
|
||||
// Only the variants a set's flags put in play: the file carries them nowhere else.
|
||||
const zonesOf = (s) => {
|
||||
const out = { header: zoneOf(s.header), footer: zoneOf(s.footer),
|
||||
differentFirstPage: !!s.differentFirstPage, differentOddEven: !!s.differentOddEven };
|
||||
if (out.differentFirstPage) { out.headerFirst = zoneOf(s.headerFirst); out.footerFirst = zoneOf(s.footerFirst); }
|
||||
if (out.differentOddEven) { out.headerEven = zoneOf(s.headerEven); out.footerEven = zoneOf(s.footerEven); }
|
||||
return out;
|
||||
};
|
||||
const hfDiff = firstDiff(heldSets.map(zonesOf), (res.hfSections ?? []).map(zonesOf));
|
||||
return { diff: firstDiff(flat(held), flat(effListKinds(styled(res.content)))), hfDiff, warnings: res.warnings,
|
||||
held: JSON.stringify(held), read: JSON.stringify(res.content),
|
||||
hfHeld: JSON.stringify(heldSets.map(zonesOf)), hfRead: JSON.stringify((res.hfSections ?? []).map(zonesOf)) };
|
||||
}, { b64: Buffer.from(bytes).toString('base64'), ext });
|
||||
|
||||
try {
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
|
||||
await page.evaluate(() => { localStorage.clear(); window.showSaveFilePicker = undefined; });
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('.tiptap', { timeout: 30_000 });
|
||||
await page.waitForSelector('.tiptap-host .tiptap', { timeout: 30_000 });
|
||||
await page.evaluate(() => { window.showSaveFilePicker = undefined; });
|
||||
|
||||
const retried = new Set();
|
||||
@@ -234,13 +434,27 @@ try {
|
||||
const doc = DOCS[run % DOCS.length];
|
||||
const r = mulberry32(seed);
|
||||
const before = await docJson();
|
||||
await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus', doc));
|
||||
await page.waitForFunction((b) => JSON.stringify(document.querySelector('.tiptap').editor.getJSON()) !== b, before, { timeout: 30_000 });
|
||||
await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus', doc));
|
||||
await page.waitForFunction((b) => JSON.stringify(document.querySelector('.tiptap-host .tiptap').editor.getJSON()) !== b, before, { timeout: 30_000 });
|
||||
await page.waitForTimeout(1500);
|
||||
await watchFlow();
|
||||
const initial = await docKey();
|
||||
const log = [];
|
||||
let broken = null;
|
||||
|
||||
// Both zones, before the body ops: they can cut the document to a single page,
|
||||
// and pages 1 to 3 are what the two variant flags spread the six zones over.
|
||||
await setHfOptions(r);
|
||||
for (const zone of ['header', 'footer']) {
|
||||
const pass = await editZone(r, zone);
|
||||
await writeFile(join(tmpdir(), `monkey-${seed}-${zone}.json`), JSON.stringify(pass.ops));
|
||||
check(!pass.broken, `seed ${seed}: ${pass.ops.length} ops in the ${zone}${pass.broken ? ` — ${pass.broken}` : ''}`);
|
||||
if (pass.broken) { broken = pass.broken; break; }
|
||||
}
|
||||
if (broken) break;
|
||||
const flipped = await flipUnderEdit(`zap${seed}`);
|
||||
check(!flipped, `seed ${seed}: a flag flipped under an open zone${flipped ? ` — ${flipped}` : ''}`);
|
||||
if (flipped) { broken = flipped; break; }
|
||||
await watchFlow();
|
||||
const initial = await docKey();
|
||||
for (let i = 0; i < OPS && !broken; i++) {
|
||||
const op = drawOp(r, await docSize());
|
||||
log.push(op);
|
||||
@@ -262,7 +476,7 @@ try {
|
||||
// Undo until the opened document is back, redo as often, and the edited one is back.
|
||||
const undone = broken ? -1 : await ed(async (initial) => {
|
||||
const { firstDiff } = await import('/tests/normalize.ts');
|
||||
const editor = document.querySelector('.tiptap').editor;
|
||||
const editor = document.querySelector('.tiptap-host .tiptap').editor;
|
||||
for (let n = 0; n < 500; n++) {
|
||||
if (window.__docKey() === initial) return n;
|
||||
if (!editor.can().undo()) return `undo stops at ${firstDiff({ type: 'doc', content: JSON.parse(initial) }, { type: 'doc', content: JSON.parse(window.__docKey()) })}`;
|
||||
@@ -271,7 +485,7 @@ try {
|
||||
return -1;
|
||||
}, initial);
|
||||
const redone = typeof undone !== 'number' || undone < 0 ? null : await ed((n) => {
|
||||
const editor = document.querySelector('.tiptap').editor;
|
||||
const editor = document.querySelector('.tiptap-host .tiptap').editor;
|
||||
for (let i = 0; i < n; i++) editor.commands.redo();
|
||||
return window.__docKey();
|
||||
}, undone);
|
||||
@@ -289,25 +503,28 @@ try {
|
||||
const schema = hasXmllint ? (ext === 'odt' ? validateOdt : validateDocx)(unzipSync(bytes)) : [];
|
||||
const rt = await roundTrip(bytes, ext).catch((err) => ({ diff: null, warnings: [], threw: String(err.message ?? err).split('\n')[0] }));
|
||||
const bad = rt.threw ? `import threw: ${rt.threw}` : schema.length ? `schema: ${schema[0].slice(0, 300)}`
|
||||
: rt.diff ? `round trip: ${rt.diff}` : rt.warnings.length ? `warnings: ${rt.warnings.join(' | ')}` : null;
|
||||
: rt.diff ? `round trip: ${rt.diff}` : rt.hfDiff ? `header/footer: ${rt.hfDiff}`
|
||||
: rt.warnings.length ? `warnings: ${rt.warnings.join(' | ')}` : null;
|
||||
check(!bad, `seed ${seed}: the saved .${ext} validates and reads back${bad ? ` — ${bad}` : ''}`);
|
||||
if (bad) {
|
||||
// The file and both documents, for the repro.
|
||||
const stem = join(tmpdir(), `monkey-${seed}`);
|
||||
await writeFile(`${stem}.${ext}`, bytes);
|
||||
if (rt.held) await Promise.all([writeFile(`${stem}-${ext}-held.json`, rt.held), writeFile(`${stem}-${ext}-read.json`, rt.read)]);
|
||||
if (rt.hfDiff) await Promise.all([writeFile(`${stem}-${ext}-hf-held.json`, rt.hfHeld), writeFile(`${stem}-${ext}-hf-read.json`, rt.hfRead)]);
|
||||
console.log(` kept: ${stem}.${ext}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// The app reloading under the run (its own recovery prompt, a settings write)
|
||||
// tears the page context down mid-op; one more go, then it is a finding.
|
||||
if (!/Execution context was destroyed/.test(err.message) || retried.has(run)) throw err;
|
||||
// tears the page context down mid-op — either message says so; one more go, then
|
||||
// it is a finding.
|
||||
if (!/Execution context was destroyed|promise was garbage collected/.test(err.message) || retried.has(run)) throw err;
|
||||
retried.add(run);
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
|
||||
await page.evaluate(() => { localStorage.clear(); window.showSaveFilePicker = undefined; });
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('.tiptap', { timeout: 30_000 });
|
||||
await page.waitForSelector('.tiptap-host .tiptap', { timeout: 30_000 });
|
||||
run--;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-4
@@ -17,6 +17,9 @@ const ORDERED_DEFAULTS: Record<string, unknown> = {
|
||||
maxLevel: MAX_HEADING_LEVEL, leader: '.', citationStyle: 'key',
|
||||
};
|
||||
|
||||
// Inline atoms that carry no mark into either file (see normalize below).
|
||||
const MARKLESS_ATOMS = new Set(['formula', 'image', 'hardBreak']);
|
||||
|
||||
// Mark-attr defaults dropped like ORDERED_DEFAULTS is for node attrs.
|
||||
const MARK_DEFAULTS: Record<string, unknown> = {
|
||||
plain: false, // link.ts default; the DOCX importer writes it out explicitly
|
||||
@@ -59,14 +62,16 @@ export function normalize(node: N): N {
|
||||
if (node.type === 'doc') canonNoteIds(node);
|
||||
const out: N = { type: node.type };
|
||||
if (node.text != null) out.text = node.text;
|
||||
// A formula adopts the caret's marks for its font; neither file carries them.
|
||||
if (node.marks?.length && node.type !== 'formula') {
|
||||
// An inline atom wears the marks of the run around it — a formula's font, a picture's
|
||||
// or a line break's inherited comment. None of them paints, and no file carries them.
|
||||
if (node.marks?.length && !MARKLESS_ATOMS.has(node.type)) {
|
||||
const marks = node.marks
|
||||
.map((m: N) => {
|
||||
const mm: N = { type: m.type };
|
||||
// An empty string is the picker's "unset" (a font family, say) — no value at all.
|
||||
// An empty string is the picker's "unset" (a font family, say) — no value at all,
|
||||
// and an empty list (a comment with no replies) is the same nothing.
|
||||
const attrs = Object.fromEntries(Object.entries(m.attrs ?? {})
|
||||
.filter(([k, v]) => v != null && v !== '' && MARK_DEFAULTS[k] !== v)
|
||||
.filter(([k, v]) => v != null && v !== '' && !(Array.isArray(v) && !v.length) && MARK_DEFAULTS[k] !== v)
|
||||
.map(([k, v]) => [k, typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v) ? v.toUpperCase() : v]));
|
||||
// ODF keeps the authored (naive local) comment/revision date, DOCX re-serializes
|
||||
// the same instant as UTC — compare the instant.
|
||||
|
||||
@@ -339,6 +339,25 @@ await write('15-chapters.docx', [{
|
||||
},
|
||||
}, 'chapter-numbering');
|
||||
|
||||
// 16. All six header/footer zones at once: a title page of its own and separate even
|
||||
// pages, both riding on a running zone. Four pages, so each variant renders at least once.
|
||||
await write('16-hf-variants.docx', [{
|
||||
properties: { page, titlePage: true },
|
||||
headers: {
|
||||
default: new Header({ children: [new Paragraph({ alignment: AlignmentType.RIGHT, children: [new TextRun('Odd Header')] })] }),
|
||||
first: new Header({ children: [new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun('Title Page Header')] })] }),
|
||||
even: new Header({ children: [new Paragraph({ alignment: AlignmentType.LEFT, children: [new TextRun('Even Header')] })] }),
|
||||
},
|
||||
footers: {
|
||||
default: new Footer({ children: [new Paragraph({ alignment: AlignmentType.RIGHT,
|
||||
children: [new TextRun({ children: ['Odd page ', PageNumber.CURRENT] })] })] }),
|
||||
first: new Footer({ children: [new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun('Title Page Footer')] })] }),
|
||||
even: new Footer({ children: [new Paragraph({ alignment: AlignmentType.LEFT,
|
||||
children: [new TextRun({ children: ['Even page ', PageNumber.CURRENT] })] })] }),
|
||||
},
|
||||
children: Array.from({ length: 40 }, (_, i) => para(`${i + 1}. ${LOREM}`)),
|
||||
}], undefined, { evenAndOddHeaderAndFooters: true });
|
||||
|
||||
// ODT twins, written by LibreOffice itself — the dominant ODT producer, so they carry
|
||||
// its own conventions (percentage font sizes, Text Body, list styles) and exercise the
|
||||
// foreign-document path our own exporter never produces.
|
||||
|
||||
@@ -84,7 +84,7 @@ async function editorRender(browser, file) {
|
||||
});
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('.tiptap');
|
||||
await page.setInputFiles('input.file-input', file);
|
||||
await page.setInputFiles('input.file-input[accept*=".odt"]', file);
|
||||
await settle(page);
|
||||
const result = await page.evaluate(extractLayout);
|
||||
await page.close();
|
||||
|
||||
@@ -114,12 +114,19 @@ function validate(schema: string, kind: 'xsd' | 'rng', parts: Part[]): string[]
|
||||
// Every XML part of an unzipped .odt against the ODF schema (foreign namespaces
|
||||
// stripped, as extended conformance allows). Formula sub-documents root math:math,
|
||||
// which the schema's start pattern excludes; mimetype and Pictures/ are not XML.
|
||||
// libxml2's RelaxNG cannot match an element the schema declares by name *class* inside a
|
||||
// <text:span> — the ODF statistic fields share one <rng:element><rng:choice>, and probed,
|
||||
// text:creator (its own name=) in the same span validates. LibreOffice writes the span
|
||||
// too, so the field is unwrapped for the check rather than avoided in the export.
|
||||
const STAT_FIELD_SPAN =
|
||||
/<text:span\b[^>]*>(<text:(?:page|paragraph|word|character|table|image|object)-count\b[^>]*(?:\/>|>[^<]*<\/text:[a-z-]+>))<\/text:span>/g;
|
||||
|
||||
export function validateOdt(files: Record<string, Uint8Array>): string[] {
|
||||
const keepOdf = (ns: string | null) => ns == null || ODF_NS.has(ns);
|
||||
const docs: Part[] = [];
|
||||
const manifests: Part[] = [];
|
||||
for (const [name, bytes] of Object.entries(files)) {
|
||||
const part = { label: `odt ${name}`, xml: stripForeign(strFromU8(bytes), keepOdf) };
|
||||
const part = { label: `odt ${name}`, xml: stripForeign(strFromU8(bytes), keepOdf).replace(STAT_FIELD_SPAN, '$1') };
|
||||
if (/^(content|styles|meta|settings)\.xml$/.test(name)) docs.push(part);
|
||||
else if (name === 'META-INF/manifest.xml') manifests.push(part);
|
||||
}
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ try {
|
||||
check(true, 'autosave survives a reload');
|
||||
|
||||
// A corpus document imports, renders its table and reports a page count.
|
||||
await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/04-table.odt'));
|
||||
await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/04-table.odt'));
|
||||
await page.waitForFunction(() => document.querySelector('.tiptap table td')?.textContent.trim(),
|
||||
null, { timeout: 30_000 });
|
||||
const pages = await page.evaluate(() => document.querySelector('.statusbar')?.textContent ?? '');
|
||||
|
||||
Reference in new issue
Block a user