Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 49 additions & 15 deletions e2e/tests/header.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,22 +163,56 @@ test('HDR-5: the "Your diagrams" breadcrumb returns to the hub', async ({
});

// ──────────────────────────────────────────────────────────────────────────────
// HDR-2: title edit Escape cancels / reverts.
// HDR-2: title edit Escape cancels / reverts (#826).
//
// NOT IMPLEMENTED (verified): the header title is a controlled <input> bound to
// setTitle on every keystroke (AppHeader.tsx → TextInput; editorStore.setTitle). There
// is no draft snapshot and no Escape/onKeyDown handler, so Escape cannot revert — a
// live probe typing "TempEscapeTitle" then Escape leaves the field unchanged. Asserting
// a revert would be asserting behavior the product does not have. Marked fixme until a
// revert-on-Escape affordance is added (a draft buffer committed on Enter/blur and
// discarded on Escape).
test.fixme(
'HDR-2: pressing Escape reverts an in-progress title edit',
async () => {
// Intentionally empty: the revert behavior does not exist yet (see comment above).
// Implement once the title field gains a draft buffer + Escape→revert handler.
},
);
// The title field now edits a local DRAFT buffer (AppHeader.tsx): keystrokes update the
// draft only; Enter/blur COMMIT it via onTitleChange (= editorStore.setTitle); Escape
// DISCARDS the draft back to the last committed value WITHOUT committing. So:
// - type a new title, press Escape ⇒ the field reverts to the prior committed value;
// - then type again and press Enter ⇒ the field commits the new value.
// Both halves are asserted so this is a real revert-AND-commit check, not a half-test.
// ──────────────────────────────────────────────────────────────────────────────
test('HDR-2: pressing Escape reverts an in-progress title edit; Enter commits', async ({
page,
}) => {
await gotoFresh(page);

const title = titleInput(page);
await expect(title).toBeVisible();
// Starter title is "Untitled" — the committed baseline we expect Escape to restore.
await expect(title).toHaveValue('Untitled');

// Type a throwaway title (draft only — not yet committed).
await setTitle(page, 'TempEscapeTitle');
await expect(title).toHaveValue('TempEscapeTitle');

// Escape reverts the draft to the last committed value — no commit happened.
await page.keyboard.press('Escape');
await expect(title).toHaveValue('Untitled');

// And Enter still commits a real edit: type a new value, Enter, value persists.
const COMMITTED = 'EnterCommitsTitle';
await setTitle(page, COMMITTED);
await page.keyboard.press('Enter');
await expect(title).toHaveValue(COMMITTED);

// Prove Enter committed into the actual item (not just the field): save + open the hub
// and read the persisted card title — the COMMITTED value rode through, the reverted
// TempEscapeTitle did not.
await page.locator('[data-testid="header-menu"]').click();
await page.locator('[data-testid="header-save"]').click();
const noticeCancel = page.locator('[data-testid="confirm-cancel"]');
await expect(noticeCancel).toBeVisible();
await noticeCancel.click();
await expect(noticeCancel).toBeHidden();

await gotoHome(page);
await expect(page.locator('[data-testid="home-grid"]')).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText(COMMITTED, { exact: true })).toBeVisible();
await expect(page.getByText('TempEscapeTitle', { exact: true })).toHaveCount(0);
});

// ──────────────────────────────────────────────────────────────────────────────
// HDR-3: an unsaved marker appears on edit and clears after Save.
Expand Down
137 changes: 64 additions & 73 deletions e2e/tests/preview.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,81 +256,72 @@ test('PRV-4: console eval runs JS in the preview iframe and echoes the result',
});

// ──────────────────────────────────────────────────────────────────────────────
// PRV-6: With auto-preview OFF, editing the DSL does NOT re-render until a manual
// refresh (toggling auto-preview back ON re-fires the render — the only
// manual re-render trigger in the no-rail UI).
// PRV-6: With auto-preview OFF, editing the DSL does NOT re-render until the manual
// Refresh control fires (#824).
// ──────────────────────────────────────────────────────────────────────────────
// PRODUCT BUG (fixme until wired): this test is a FALSE-GREEN. `settings.autoPreview`
// is never passed to PreviewFrame — the two call sites in AppRoot.tsx (~918 and ~1350)
// omit `autoPreview={settings.autoPreview}`, so PreviewFrame falls back to its default
// `autoPreview=true` (PreviewFrame.tsx:47) and ALWAYS re-renders on the 500ms debounce.
// The Settings toggle is therefore a dead control for rendering: it flips the stored
// setting but never gates the preview. The old assertions happened to pass only because
// they fired inside the debounce window (before the always-on re-render landed) — they
// would pass identically with auto-preview ON, so they prove nothing.
// To enable this test:
// 1. Wire the prop: pass `autoPreview={settings.autoPreview}` to PreviewFrame at
// AppRoot.tsx ~918 and ~1350 so the toggle actually gates rendering.
// 2. Make the staleness assertion debounce-proof: after editing with auto-preview
// OFF, wait > PREVIEW_DEBOUNCE (the 500ms PreviewFrame debounce) BEFORE asserting
// the preview is NOT re-rendered, so a real always-on render would be caught.
test.fixme(
'PRV-6: with auto-preview OFF, editing does not re-render until auto-preview is re-enabled',
async ({ page }) => {
// The render path depends on the iframe's srcdoc evaluating @zenuml/core; assert
// it against the local app (it works the same against any host the app serves,
// but the render timing is most stable locally — keep parity with smoke/dsl specs
// which run on both, so no skip needed here).

// Step 1 — establish a known baseline render with a distinctive participant.
const BASELINE = 'PrvBaseline\nUser\nUser->PrvBaseline: before';
await typeDsl(page, BASELINE);
await expect(mountLocator(page)).toContainText('PrvBaseline', {
timeout: 15_000,
});

// Step 2 — turn Auto-preview OFF via Settings (the Radix Switch toggles to
// unchecked). This stops PreviewFrame's debounced re-render effect.
await openViaHeaderMenu(page, 'header-settings');
await expect(page.locator('[data-testid="settings-modal"]')).toBeVisible();
const autoPreviewSwitch = page.locator(
'[data-testid="setting-autoPreview"]',
);
await expect(autoPreviewSwitch).toHaveAttribute('aria-checked', 'true');
await autoPreviewSwitch.click();
await expect(autoPreviewSwitch).toHaveAttribute('aria-checked', 'false');
await page.keyboard.press('Escape');
await expect(page.locator('[data-testid="settings-modal"]')).toBeHidden();

// Step 3 — edit the DSL to a NEW distinctive participant. With auto-preview off,
// the preview must stay STALE: still showing the baseline, never the new token.
const EDITED = 'PrvUpdated\nUser\nUser->PrvUpdated: after';
await typeDsl(page, EDITED);
await expect(editorLocator(page)).toContainText('PrvUpdated');

// Give the debounce window time to fire IF it were going to (it must not). Then
// assert the preview is unchanged: baseline still rendered, new token absent.
await expect(mountLocator(page)).toContainText('PrvBaseline');
await expect(mountLocator(page)).not.toContainText('PrvUpdated');

// Step 4 — manual refresh: re-enable Auto-preview. The PreviewFrame effect's dep
// array includes `autoPreview`, so flipping it back ON re-posts the render and
// the preview catches up to the edited DSL.
await openViaHeaderMenu(page, 'header-settings');
await expect(page.locator('[data-testid="settings-modal"]')).toBeVisible();
await autoPreviewSwitch.click();
await expect(autoPreviewSwitch).toHaveAttribute('aria-checked', 'true');
await page.keyboard.press('Escape');
await expect(page.locator('[data-testid="settings-modal"]')).toBeHidden();

await expect(mountLocator(page)).toContainText('PrvUpdated', {
timeout: 15_000,
});
await expect(mountLocator(page)).not.toContainText('PrvBaseline', {
timeout: 15_000,
});
},
// WIRED (#824): `autoPreview={settings.autoPreview}` is now passed to PreviewFrame
// (AppRoot.tsx), so the Settings toggle gates the debounced re-render. With it OFF, a
// RendererHeader "Refresh" control (data-testid="renderer-refresh") appears and calls
// PreviewFrame's imperative render() to re-render on demand. This test is DEBOUNCE-PROOF:
// after editing with auto-preview OFF it waits LONGER than PREVIEW_DEBOUNCE (+ margin)
// BEFORE asserting the preview is stale, so a regression to always-on rendering is caught
// (the old false-green fired inside the debounce window and proved nothing).
//
// Gated off the staging gate (PW_BASE_URL): it depends on local render timing, like the
// other render-timing specs (PRV-4 / production-build).
test.skip(
!!process.env.PW_BASE_URL,
'PRV-6 relies on local render timing (debounce window); not run on the staging gate',
);
test('PRV-6: with auto-preview OFF, editing does not re-render until manual Refresh', async ({
page,
}) => {
// Step 1 — establish a known baseline render with a distinctive participant.
const BASELINE = 'PrvBaseline\nUser\nUser->PrvBaseline: before';
await typeDsl(page, BASELINE);
await expect(mountLocator(page)).toContainText('PrvBaseline', {
timeout: 15_000,
});

// Step 2 — turn Auto-preview OFF via Settings (the Radix Switch toggles to
// unchecked). This stops PreviewFrame's debounced re-render effect and reveals the
// RendererHeader Refresh control.
await openViaHeaderMenu(page, 'header-settings');
await expect(page.locator('[data-testid="settings-modal"]')).toBeVisible();
const autoPreviewSwitch = page.locator('[data-testid="setting-autoPreview"]');
await expect(autoPreviewSwitch).toHaveAttribute('aria-checked', 'true');
await autoPreviewSwitch.click();
await expect(autoPreviewSwitch).toHaveAttribute('aria-checked', 'false');
await page.keyboard.press('Escape');
await expect(page.locator('[data-testid="settings-modal"]')).toBeHidden();

// The manual Refresh control is now present (auto-preview OFF ⇒ AppRoot wires onRefresh).
const refresh = page.locator('[data-testid="renderer-refresh"]');
await expect(refresh).toBeVisible();

// Step 3 — edit the DSL to a NEW distinctive participant. With auto-preview off,
// the preview must stay STALE: still showing the baseline, never the new token.
const EDITED = 'PrvUpdated\nUser\nUser->PrvUpdated: after';
await typeDsl(page, EDITED);
await expect(editorLocator(page)).toContainText('PrvUpdated');

// DEBOUNCE-PROOF: wait WELL PAST the 500ms PreviewFrame debounce (+ generous margin)
// so an always-on re-render would have landed by now if the toggle were dead. Then
// assert the preview is unchanged: baseline still rendered, new token absent.
await page.waitForTimeout(2000); // > PREVIEW_DEBOUNCE (500ms) + margin
await expect(mountLocator(page)).toContainText('PrvBaseline');
await expect(mountLocator(page)).not.toContainText('PrvUpdated');

// Step 4 — click the manual Refresh control. PreviewFrame.render() re-posts a render
// of the CURRENT (edited) code, so the preview catches up to the edited DSL.
await refresh.click();
await expect(mountLocator(page)).toContainText('PrvUpdated', {
timeout: 15_000,
});
await expect(mountLocator(page)).not.toContainText('PrvBaseline', {
timeout: 15_000,
});
});

// ──────────────────────────────────────────────────────────────────────────────
// PRV-8: The renderer header shows a 100% zoom indicator.
Expand Down
74 changes: 56 additions & 18 deletions e2e/tests/settings.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,26 +206,64 @@ test('SET-3: font-family change applies live to .cm-content', async ({
});

// ──────────────────────────────────────────────────────────────────────────────
// SET-4: indent-unit change → auto-indent width. DEFERRED.
// SET-4: indent-size change → auto-indent width (#825).
//
// The Indent-size Select renders but is NOT wired to the editor: AppRoot threads
// only theme/fontSize/fontFamily/keymap into <CodeEditor> (AppRoot.tsx ~1268), and
// NOTHING in web/src sets CM6's `indentUnit` facet from settings.indentSize. The DSL
// block body indents a FIXED 2 spaces via delimitedIndent (zenumlLanguage.ts). So
// selecting indent=4 produces no observable change in the editor — there is no honest
// assertion to make today. Marked fixme rather than faked.
// settings.indentSize/indentWith are now threaded into <CodeEditor> (AppRoot.tsx), which
// sets CM6's `indentUnit` facet from them. The DSL grammar's delimitedIndent reads that
// facet (delimitedStrategy = baseIndent + context.unit), so changing indent size produces
// a REAL, observable change in auto-indent width: pressing Enter inside an `A.run() { … }`
// block indents the new body line by exactly one indent unit.
//
// We compute the observable effect from the DOC TEXT, not a CSS/pixel proxy: after Enter
// the cursor sits at the body's indent, so inserting a sentinel char and reading its
// column (leading-whitespace length on that line) yields the applied indent width. At the
// DEFAULT indent=2 the body indents 2; after switching to indent=4 it indents 4.
// ──────────────────────────────────────────────────────────────────────────────
test.fixme(
'SET-4: indent-unit change (4) changes auto-indent width in a block',
async ({ page }) => {
// Wiring gap: settings.indentSize is not connected to the CM6 indentUnit facet,
// so the editor always indents blocks by 2 spaces regardless of this setting.
// Implement once AppRoot passes indentSize/indentWith into <CodeEditor> and the
// editor sets indentUnit from it; then: set indent=4, type `A.run() {`, Enter,
// assert the new line is indented 4 spaces.
await gotoFresh(page);
},
);
test('SET-4: indent-size change (2 → 4) changes auto-indent width in a DSL block', async ({
page,
}) => {
await gotoFresh(page);
const content = dslContent(page);
await expect(content).toBeVisible();

// Reads the leading-space count of the line the cursor lands on after Enter-in-block.
// Type the opener, Enter (auto-indents the new line one unit), then a sentinel; read
// back the doc and measure the sentinel line's indent. Returns the indent width.
async function blockIndentWidth() {
await content.click();
await page.keyboard.press(selectAll);
await page.keyboard.press('Delete');
// `A.run() {` is a StatementBraceBlock opener; closeBrackets auto-inserts the `}`.
await content.pressSequentially('A.run() {');
await page.keyboard.press('Enter'); // auto-indent the body line by one unit
await content.pressSequentially('X'); // sentinel marks the indented column
// Read the indent width straight from the rendered CM6 lines. CM6 renders each
// document line as a `.cm-line` whose textContent includes the leading whitespace,
// so the sentinel line's leading-space count IS the applied auto-indent width.
const indent = await page.evaluate(() => {
const line = Array.from(
document.querySelectorAll('[data-testid="dsl-editor"] .cm-line'),
)
.map((n) => n.textContent || '')
.find((l) => l.includes('X'));
const m = line ? line.match(/^(\s*)X/) : null;
return m ? m[1].length : -1;
});
return indent;
}

// Default indent = 2 → the block body indents 2 columns.
expect(await blockIndentWidth()).toBe(2);

// Switch indent size to 4 (live compartment reconfigure — no reload).
await openSettings(page);
await chooseSetting(page, 'setting-indentSize', '4');
await page.keyboard.press('Escape');
await expect(page.locator('[data-testid="settings-modal"]')).toBeHidden();

// Now the same Enter-in-block indents the body 4 columns — the setting took effect.
expect(await blockIndentWidth()).toBe(4);
});

// ──────────────────────────────────────────────────────────────────────────────
// SET-5: a behavior toggle takes observable effect.
Expand Down
Loading
Loading