Skip to content

Commit 1684a97

Browse files
test(block-editing): share no-reflow e2e helpers across both hosts
Add @quarto/preview-e2e-helpers (ts-packages/) holding the host-agnostic inline-edit no-reflow assertions, parameterized by a Playwright FrameLocator so the same logic runs against both hosts that embed @quarto/preview-renderer: - q2-preview-spa (real q2 preview binary) -> uses assertNoReflowOnActivation (lean: active-region sizing via #q2-active-edit-region + following-sibling top, scroll-invariant). - hub-client (Automerge + Monaco) -> uses measureLayout/expectLayoutStable (broad: all-blocks + document height) + activateBlock. Both specs drop their local copies and import from the shared package; the hub-client zero-reflow tests are behavior-preserving relocations (helpers moved verbatim), keeping their decoration/left-column checks inline. Lockfile: regenerated with npm 10 (the version matching the committed lockfile format) to avoid npm 11.5.1 stripping esbuild optional platform binaries; the diff is additive only (new workspace + devDeps), esbuild intact.
1 parent 673892a commit 1684a97

8 files changed

Lines changed: 252 additions & 149 deletions

File tree

hub-client/e2e/q2-preview-inline-edit.spec.ts

Lines changed: 5 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ import {
4444
getServerUrl,
4545
} from './helpers/projectFactory';
4646
import { waitForPreviewRender } from './helpers/previewExtraction';
47+
import {
48+
activateBlock,
49+
expectLayoutStable,
50+
measureLayout,
51+
} from '@quarto/preview-e2e-helpers';
4752

4853
/** Set up the project, navigate, and wait for Monaco + preview (+ edit affordances) to be ready. */
4954
async function openFile(
@@ -101,77 +106,6 @@ async function assertAutomerge(
101106
}).toPass({ timeout: 10000 });
102107
}
103108

104-
/**
105-
* Layout snapshot for the zero-reflow invariant: the viewport `top` of every
106-
* editable block (keyed by its stable pool id) plus the document's total
107-
* scrollHeight. Activating a block for editing must not move ANY other block
108-
* and must not change the document's total height (no "space crunch").
109-
*/
110-
type Layout = { tops: Record<string, number>; scrollHeight: number };
111-
112-
async function measureLayout(iframe: FrameLocator): Promise<Layout> {
113-
// Settle before reading geometry: wait for web fonts to finish loading
114-
// (font swaps change line heights) and two animation frames so any pending
115-
// reflow has flushed. Without this, parallel workers can snapshot `before`
116-
// mid-font-load and see spurious sub-block shifts.
117-
await iframe.locator('body').evaluate(async () => {
118-
await (document as Document & { fonts?: FontFaceSet }).fonts?.ready;
119-
await new Promise<void>(r =>
120-
requestAnimationFrame(() => requestAnimationFrame(() => r())),
121-
);
122-
});
123-
const tops = await iframe.locator('[data-block-pool-id]').evaluateAll(els =>
124-
Object.fromEntries(
125-
els.map(el => [
126-
el.getAttribute('data-block-pool-id')!,
127-
el.getBoundingClientRect().top,
128-
]),
129-
),
130-
);
131-
const scrollHeight = await iframe
132-
.locator('body')
133-
.evaluate(() => document.documentElement.scrollHeight);
134-
return { tops, scrollHeight };
135-
}
136-
137-
/**
138-
* Assert that going `before → after` (activation of `editedPoolId`) moved no
139-
* surviving block by more than `tol` px and did not change the document height.
140-
* Reports every offending block + delta so a failure shows exactly where the
141-
* crunch is (space above/below a heading, between a paragraph and a list, ...).
142-
*/
143-
function expectLayoutStable(
144-
before: Layout,
145-
after: Layout,
146-
editedPoolId: string,
147-
tol = 1.5,
148-
): void {
149-
const moved: string[] = [];
150-
for (const [poolId, top] of Object.entries(after.tops)) {
151-
if (poolId === editedPoolId) continue;
152-
const delta = Math.abs(top - before.tops[poolId]);
153-
if (delta > tol) moved.push(`block ${poolId} moved ${delta.toFixed(1)}px`);
154-
}
155-
const heightDelta = Math.abs(after.scrollHeight - before.scrollHeight);
156-
expect(
157-
moved,
158-
`blocks shifted on activation:\n ${moved.join('\n ')}`,
159-
).toEqual([]);
160-
expect(
161-
heightDelta,
162-
`document height changed by ${heightDelta.toFixed(1)}px on activation (space crunch)`,
163-
).toBeLessThanOrEqual(tol);
164-
}
165-
166-
/** Click the first block matching `tag`, returning its pool id; waits for the textarea. */
167-
async function activateBlock(iframe: FrameLocator, tag: string): Promise<string> {
168-
const target = iframe.locator(`${tag}[data-block-pool-id]`).first();
169-
const poolId = await target.getAttribute('data-block-pool-id');
170-
await target.click();
171-
await iframe.locator('textarea').first().waitFor({ timeout: 5000 });
172-
return poolId!;
173-
}
174-
175109
test.describe('q2-preview inline editing', () => {
176110
test.setTimeout(120000);
177111

hub-client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@automerge/automerge-repo-storage-nodefs": "^2.5.6",
5656
"@eslint/js": "^9.39.1",
5757
"@playwright/test": "^1.60.0",
58+
"@quarto/preview-e2e-helpers": "*",
5859
"@testing-library/jest-dom": "^6.6.3",
5960
"@testing-library/react": "^16.1.0",
6061
"@types/compression": "^1.8.1",

package-lock.json

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)