From ea6eed8d1e8273e1779339244573f9e004a43f54 Mon Sep 17 00:00:00 2001 From: Nick Gomez <122398915+nick-inkeep@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:00:14 -0700 Subject: [PATCH] Dispose 13 dormant e2e suites: wire 8, retire 2, keep 3 (#2824) * Dispose 13 dormant e2e suites: wire 8, retire 2, keep 3 13 tests/stress/*.e2e.ts suites existed on disk but were never in the packages/app test:e2e file list, so they never ran in any CI tier. Full disposition: - Promote 8 into test:e2e: slash-command-auto-open, graph-panel-surfaces, clipboard-relative-url-source-fallback (already green) plus asset-embed-real-fidelity, prop-upload, rename-content-preservation, rename-consolidation, outline-navigation (drift repaired to current behavior: file-attachment wiki-embed shape, doc-relative src normalization, kind-bearing rename payload, RFC 9457 success envelopes, primed-layout outline scroll). - Retire edit-with-ai and timeline-diff-sidepane (superseded designs); delete the dead EditWithAiPopover component plus its two orphaned dom tests, and regenerate the Lingui catalogs (5 now-unused strings). - Keep 3 excluded with updated blockers: frontmatter-edit (needs a malformed-frontmatter disk fixture), list-keymap (pins #2817/#2818), okignore-settings (pins #2816; its 4 nav-drift tests repaired, the 2 Hide tests stay red as executable specs of the bug). Reconcile both gated registries (EXCLUDED_FROM_PR_TIER in check-e2e-list-membership.test.mjs and E2E_CI_EXCLUSIONS in e2e-ci-ledger.ts) to the same 3 entries. * Address review: dedup primeFullLayout, fix stale comment and mock - Extract the duplicated primeFullLayout helper into _helpers/editor-state.ts (the established e2e shared-helper barrel) and import it from both outline-navigation and outline-toolbar-occlusion; drops a 43-line verbatim copy. - prop-upload: update the header comment that still claimed the file is NOT in the CI test:e2e list (this change wires it in). - rename-content-preservation: drop the stale ok:true from the lagging /api/pages mock so it matches the flat RFC 9457 success envelope. * Fix stale timeline coverage comment and migrate its dom test to vitest The retired timeline-diff-sidepane.e2e.ts was still cited as the browser coverage for the expand/diff/restore flow. State the truth instead: the flow was redesigned into the TimelineDiffPane overlay in #2607 and has no browser-tier coverage yet. Touching the file also brings it under the no-new-bun-test-imports guard, so its mock.module and bun:test imports move to vi.mock and vitest. 4/4 green under test:dom. GitOrigin-RevId: 3cbed03802150b7a14e10b0b3a46981ac762ecb9 --- packages/app/package.json | 2 +- .../src/components/TimelinePanel.dom.test.tsx | 20 +- .../handoff/EditWithAiPanel.dom.test.tsx | 132 ---- .../handoff/EditWithAiPopover.dom.test.tsx | 194 ------ .../components/handoff/EditWithAiPopover.tsx | 186 ------ .../components/handoff/OpenInAgentMenu.tsx | 4 +- packages/app/src/locales/en/messages.json | 5 - packages/app/src/locales/en/messages.po | 22 - packages/app/src/locales/pseudo/messages.json | 5 - packages/app/src/locales/pseudo/messages.po | 22 - .../app/tests/stress/_helpers/editor-state.ts | 51 +- packages/app/tests/stress/_helpers/index.ts | 1 + .../stress/asset-embed-real-fidelity.e2e.ts | 16 +- packages/app/tests/stress/e2e-ci-ledger.ts | 75 +-- packages/app/tests/stress/edit-with-ai.e2e.ts | 569 ---------------- .../app/tests/stress/okignore-settings.e2e.ts | 17 +- .../tests/stress/outline-navigation.e2e.ts | 36 +- .../stress/outline-toolbar-occlusion.e2e.ts | 50 +- packages/app/tests/stress/prop-upload.e2e.ts | 37 +- .../tests/stress/rename-consolidation.e2e.ts | 7 +- .../stress/rename-content-preservation.e2e.ts | 16 +- .../stress/timeline-diff-sidepane.e2e.ts | 619 ------------------ 22 files changed, 168 insertions(+), 1918 deletions(-) delete mode 100644 packages/app/src/components/handoff/EditWithAiPanel.dom.test.tsx delete mode 100644 packages/app/src/components/handoff/EditWithAiPopover.dom.test.tsx delete mode 100644 packages/app/src/components/handoff/EditWithAiPopover.tsx delete mode 100644 packages/app/tests/stress/edit-with-ai.e2e.ts delete mode 100644 packages/app/tests/stress/timeline-diff-sidepane.e2e.ts diff --git a/packages/app/package.json b/packages/app/package.json index f8d2217a8..8fbcda49b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -26,7 +26,7 @@ "measure:fuzz": "bash scripts/measure-fuzz.sh", "measure:stress": "bash scripts/measure-stress.sh", "perf:compare": "bash scripts/perf-compare.sh", - "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/lint-config-toggle.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts tests/stress/command-palette-parity.e2e.ts tests/stress/settings-search.e2e.ts tests/stress/chip-popover-positioning.e2e.ts tests/stress/docs-open-scroll-restore.e2e.ts tests/stress/file-tree-decoration-chip-strip-repro.e2e.ts tests/stress/grip-click-nodeselect.e2e.ts tests/stress/jsx-halo-semantics.e2e.ts tests/stress/jsx-image-body-click.e2e.ts tests/stress/jsx-prop-panel-placeholder.e2e.ts tests/stress/jsx-range-encompass-halo.e2e.ts tests/stress/jsx-wildcard-convert.e2e.ts tests/stress/markdown-lint.e2e.ts tests/stress/mid-type-recovery.e2e.ts tests/stress/observer-a-multi-client.e2e.ts tests/stress/outline-toolbar-occlusion.e2e.ts tests/stress/quiet-tree-hidden-doc.e2e.ts tests/stress/raw-mdx-fallback-onblur-upgrade.e2e.ts tests/stress/rename-noext-probe.e2e.ts tests/stress/show-ok-folders.e2e.ts tests/stress/source-polish.e2e.ts tests/stress/warm-skeleton-scroll-restore.e2e.ts tests/stress/yjs-no-dual-import.e2e.ts", + "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/lint-config-toggle.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts tests/stress/command-palette-parity.e2e.ts tests/stress/settings-search.e2e.ts tests/stress/chip-popover-positioning.e2e.ts tests/stress/docs-open-scroll-restore.e2e.ts tests/stress/file-tree-decoration-chip-strip-repro.e2e.ts tests/stress/grip-click-nodeselect.e2e.ts tests/stress/jsx-halo-semantics.e2e.ts tests/stress/jsx-image-body-click.e2e.ts tests/stress/jsx-prop-panel-placeholder.e2e.ts tests/stress/jsx-range-encompass-halo.e2e.ts tests/stress/jsx-wildcard-convert.e2e.ts tests/stress/markdown-lint.e2e.ts tests/stress/mid-type-recovery.e2e.ts tests/stress/observer-a-multi-client.e2e.ts tests/stress/outline-toolbar-occlusion.e2e.ts tests/stress/quiet-tree-hidden-doc.e2e.ts tests/stress/raw-mdx-fallback-onblur-upgrade.e2e.ts tests/stress/rename-noext-probe.e2e.ts tests/stress/show-ok-folders.e2e.ts tests/stress/source-polish.e2e.ts tests/stress/warm-skeleton-scroll-restore.e2e.ts tests/stress/yjs-no-dual-import.e2e.ts tests/stress/slash-command-auto-open.e2e.ts tests/stress/graph-panel-surfaces.e2e.ts tests/stress/clipboard-relative-url-source-fallback.e2e.ts tests/stress/asset-embed-real-fidelity.e2e.ts tests/stress/prop-upload.e2e.ts tests/stress/rename-content-preservation.e2e.ts tests/stress/rename-consolidation.e2e.ts tests/stress/outline-navigation.e2e.ts", "test:e2e:install-browsers": "playwright install chromium webkit firefox", "test:visual": "playwright test --config playwright.visual.config.ts", "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", diff --git a/packages/app/src/components/TimelinePanel.dom.test.tsx b/packages/app/src/components/TimelinePanel.dom.test.tsx index d1b60b4d8..d4039f27e 100644 --- a/packages/app/src/components/TimelinePanel.dom.test.tsx +++ b/packages/app/src/components/TimelinePanel.dom.test.tsx @@ -4,17 +4,21 @@ * Pins two user-visible contracts from the "timeline shows only auto-commits" * change: (1) checkpoint rows are filtered out of the panel — they are * background-cleanup artifacts now, not user history; and (2) there is no - * Save Version control in the panel header. The expand/diff/restore flow is - * exercised by the Playwright suite (timeline-diff-sidepane.e2e.ts); this - * mount test locks the filtering + header contract without a browser (and - * without a shadow repo, which the e2e fixture requires). + * Save Version control in the panel header. This mount test locks the + * filtering + header contract without a browser (and without a shadow repo). + * The diff/restore flow (redesigned into the TimelineDiffPane overlay in #2607) + * has no browser-tier coverage yet. * - * Invocation: `bun run test:dom` from `packages/app/`. + * Invocation: `pnpm run test:dom` from `packages/app/`. */ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; + import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -mock.module('next-themes', () => ({ +// `vi.mock` is hoisted above every import, so `next-themes` resolves to this +// stub before the SUT (imported dynamically below) pulls in its transitive +// `useTheme`. +vi.mock('next-themes', () => ({ useTheme: () => ({ resolvedTheme: 'light' }), })); @@ -76,7 +80,7 @@ afterEach(() => { }); function mockHistory(entries: TimelineEntry[]) { - globalThis.fetch = mock((input: RequestInfo | URL) => { + globalThis.fetch = vi.fn((input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/api/history')) { return Promise.resolve( diff --git a/packages/app/src/components/handoff/EditWithAiPanel.dom.test.tsx b/packages/app/src/components/handoff/EditWithAiPanel.dom.test.tsx deleted file mode 100644 index 385d38c32..000000000 --- a/packages/app/src/components/handoff/EditWithAiPanel.dom.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/** - * RTL behavior tests for `EditWithAiPanel` — the "Edit with AI" popover - * body (the instruction prompt box + installed-agent list). The panel is pure - * (install state + pick handler injected), so it renders without the dispatch / - * install-probe hooks. - * - * Invocation: `bun run test:dom` from `packages/app/`. - */ - -import { afterEach, describe, expect, test } from 'bun:test'; -import type { HandoffTarget, InstallState } from '@inkeep/open-knowledge-core'; -import { cleanup, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { EditWithAiPanel } from './EditWithAiPopover'; - -afterEach(() => { - cleanup(); -}); - -/** - * Build a `states` map from a per-target install flag. Targets not named - * default to `null` (pre-probe), matching a fresh `useInstalledAgents` mount. - */ -function installStates( - flags: Partial>, -): Record { - const at = (id: HandoffTarget): InstallState => ({ installed: flags[id] ?? null }); - return { - 'claude-cowork': at('claude-cowork'), - 'claude-code': at('claude-code'), - codex: at('codex'), - cursor: at('cursor'), - }; -} - -describe('EditWithAiPanel', () => { - test('renders the instruction input and one button per installed agent', () => { - render( - {}} - />, - ); - expect(screen.getByTestId('edit-with-ai-instruction')).toBeTruthy(); - expect(screen.getByTestId('edit-with-ai-target-claude-code')).toBeTruthy(); - expect(screen.getByTestId('edit-with-ai-target-codex')).toBeTruthy(); - }); - - test('hides agents that are not installed or still being probed', () => { - render( - {}} - />, - ); - expect(screen.getByTestId('edit-with-ai-target-claude-code')).toBeTruthy(); - expect(screen.queryByTestId('edit-with-ai-target-codex')).toBeNull(); - expect(screen.queryByTestId('edit-with-ai-target-cursor')).toBeNull(); - }); - - test('never renders the cowork target even when it is installed', () => { - render( - {}} - />, - ); - expect(screen.queryByTestId('edit-with-ai-target-claude-cowork')).toBeNull(); - }); - - test('renders no claude.ai web-fallback row', () => { - render( - {}} - />, - ); - expect(screen.queryByText(/claude\.ai/i)).toBeNull(); - }); - - test('picking an agent reports the target and the typed instruction', async () => { - const user = userEvent.setup(); - const picks: Array<{ id: string; instruction: string }> = []; - render( - picks.push({ id: target.id, instruction })} - />, - ); - await user.type(screen.getByTestId('edit-with-ai-instruction'), 'tighten the prose'); - await user.click(screen.getByTestId('edit-with-ai-target-codex')); - expect(picks).toEqual([{ id: 'codex', instruction: 'tighten the prose' }]); - }); - - test('allows a pick with an empty instruction', async () => { - const user = userEvent.setup(); - const picks: Array<{ id: string; instruction: string }> = []; - render( - picks.push({ id: target.id, instruction })} - />, - ); - await user.click(screen.getByTestId('edit-with-ai-target-claude-code')); - expect(picks).toEqual([{ id: 'claude-code', instruction: '' }]); - }); - - test('shows an empty state when no agents are installed', () => { - render( - {}} - />, - ); - const empty = screen.getByTestId('edit-with-ai-empty'); - expect(empty).toBeTruthy(); - expect(screen.queryByTestId('edit-with-ai-target-claude-code')).toBeNull(); - }); - - test('the install-state region is an aria-live region so its async transition is announced', () => { - // The text flips from "Checking for installed agents" to "No installed - // agents found" once the probe resolves. Without aria-live a screen reader - // never hears the transition. - render( - {}} - />, - ); - expect(screen.getByTestId('edit-with-ai-empty').getAttribute('aria-live')).toBe('polite'); - }); -}); diff --git a/packages/app/src/components/handoff/EditWithAiPopover.dom.test.tsx b/packages/app/src/components/handoff/EditWithAiPopover.dom.test.tsx deleted file mode 100644 index 1bf98d34e..000000000 --- a/packages/app/src/components/handoff/EditWithAiPopover.dom.test.tsx +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Tier-3 RTL behavior tests for `EditWithAiPopover` — the controlled popover - * shell that anchors the instruction panel to the trigger, refreshes install - * state on open, and routes a target pick through - * `buildSelectionOrDocHandoffInput` -> `useHandoffDispatch().dispatch` against - * the caller-supplied snapshot. - * - * `useInstalledAgents` + `useHandoffDispatch` are mocked so the test controls - * install state and observes the dispatch wiring without the real probe / - * URL-dispatch graph. The pure panel rendering is covered separately by - * `EditWithAiPanel.dom.test.tsx`. - * - * Invocation: `bun run test:dom` from `packages/app/`. - */ - -import { afterEach, describe, expect, mock, test } from 'bun:test'; -import { cleanup, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { Button } from '@/components/ui/button'; -import type { EditWithAiSelectionSnapshot } from './EditWithAiPopover'; - -// Radix UI's Popover uses `react-focus-scope` + Floating UI, which touch DOM -// globals the shared jsdom preload deliberately omits. Hoist what this popover -// needs locally — copied byte-for-byte from `SettingsDialogShell.dom.test.tsx` -// so the regression surface across Radix-layer mounts stays uniform. -type WindowGlobals = { - MutationObserver?: typeof MutationObserver; - NodeFilter?: typeof NodeFilter; -}; -type GlobalWithDomShims = typeof globalThis & - WindowGlobals & { - window?: WindowGlobals; - ResizeObserver?: unknown; - }; -const globalWithDomShims = globalThis as GlobalWithDomShims; -if ( - globalWithDomShims.MutationObserver === undefined && - globalWithDomShims.window?.MutationObserver !== undefined -) { - globalWithDomShims.MutationObserver = globalWithDomShims.window.MutationObserver; -} -if ( - globalWithDomShims.NodeFilter === undefined && - globalWithDomShims.window?.NodeFilter !== undefined -) { - globalWithDomShims.NodeFilter = globalWithDomShims.window.NodeFilter; -} -if (globalWithDomShims.ResizeObserver === undefined) { - class NoopResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - } - globalWithDomShims.ResizeObserver = NoopResizeObserver; -} - -const dispatchCalls: Array<{ target: string; input: unknown }> = []; -const buildArgs: unknown[] = []; -const toastErrors: string[] = []; -let refreshCount = 0; - -mock.module('sonner', () => ({ - toast: { - error: (message: string) => { - toastErrors.push(message); - }, - success: () => {}, - }, -})); - -mock.module('./useInstalledAgents', () => ({ - useInstalledAgents: () => ({ - states: { - 'claude-cowork': { installed: false }, - 'claude-code': { installed: true }, - codex: { installed: false }, - cursor: { installed: false }, - }, - refresh: () => { - refreshCount += 1; - return Promise.resolve(); - }, - }), -})); - -mock.module('./useHandoffDispatch', () => ({ - useHandoffDispatch: () => ({ - dispatch: (target: string, input: unknown) => { - dispatchCalls.push({ target, input }); - return Promise.resolve({ ok: true }); - }, - reinstallCoworkSkill: () => Promise.resolve({ kind: 'already-installed' }), - }), - // Records its args and returns a recognizable non-null input so the popover - // proceeds to dispatch. The real builder is unit-tested in - // `useHandoffDispatch.test.ts`. Mirrors the real builder's null-return on an - // empty selection so the popover's null-branch toast can be exercised. - buildSelectionOrDocHandoffInput: (args: { selectionMarkdown: string }) => { - buildArgs.push(args); - return args.selectionMarkdown === '' ? null : { __built: true }; - }, -})); - -const { EditWithAiPopover } = await import('./EditWithAiPopover'); - -const SNAPSHOT: EditWithAiSelectionSnapshot = { - docName: 'specs/foo/SPEC', - workspace: { contentDir: '/Users/x/notes', pathSeparator: '/' }, - selectionMarkdown: 'The selected passage.', -}; - -function renderPopover({ - open, - onOpenChange = () => {}, - snapshot, -}: { - open: boolean; - onOpenChange?: (next: boolean) => void; - snapshot: EditWithAiSelectionSnapshot | null; -}) { - return render( - - - , - ); -} - -afterEach(() => { - cleanup(); - dispatchCalls.length = 0; - buildArgs.length = 0; - toastErrors.length = 0; - refreshCount = 0; -}); - -describe('EditWithAiPopover', () => { - test('renders the instruction panel and refreshes install state when open', () => { - renderPopover({ open: true, snapshot: SNAPSHOT }); - - expect(screen.getByTestId('edit-with-ai-instruction')).toBeTruthy(); - expect(refreshCount).toBe(1); - }); - - test('stays closed and does not refresh when open is false', () => { - renderPopover({ open: false, snapshot: null }); - - expect(screen.queryByTestId('edit-with-ai-instruction')).toBeNull(); - expect(refreshCount).toBe(0); - }); - - test('dispatches a selection-scoped handoff carrying the snapshot and typed instruction', async () => { - const user = userEvent.setup(); - const onOpenChange = mock(() => {}); - renderPopover({ open: true, onOpenChange, snapshot: SNAPSHOT }); - - await user.type(screen.getByTestId('edit-with-ai-instruction'), 'make it concise'); - await user.click(screen.getByTestId('edit-with-ai-target-claude-code')); - - expect(buildArgs).toEqual([ - { - docName: 'specs/foo/SPEC', - workspace: { contentDir: '/Users/x/notes', pathSeparator: '/' }, - instruction: 'make it concise', - selectionMarkdown: 'The selected passage.', - }, - ]); - expect(dispatchCalls).toEqual([{ target: 'claude-code', input: { __built: true } }]); - expect(onOpenChange).toHaveBeenCalledWith(false); - }); - - test('allows a pick with an empty instruction', async () => { - const user = userEvent.setup(); - renderPopover({ open: true, snapshot: SNAPSHOT }); - - await user.click(screen.getByTestId('edit-with-ai-target-claude-code')); - - expect((buildArgs[0] as { instruction: string }).instruction).toBe(''); - expect(dispatchCalls).toEqual([{ target: 'claude-code', input: { __built: true } }]); - }); - - test('null input from the builder surfaces a toast and skips dispatch', async () => { - // The builder returns null when docName / workspace are unresolved or the - // selection serialized to empty. Without the popover's null-branch toast, - // the dispatch would silently no-op while the popover closed — invisible to - // the user. This pin asserts the toast fires + dispatch is skipped. - const user = userEvent.setup(); - renderPopover({ open: true, snapshot: { ...SNAPSHOT, selectionMarkdown: '' } }); - - await user.click(screen.getByTestId('edit-with-ai-target-claude-code')); - - expect(dispatchCalls).toEqual([]); - expect(toastErrors).toHaveLength(1); - }); -}); diff --git a/packages/app/src/components/handoff/EditWithAiPopover.tsx b/packages/app/src/components/handoff/EditWithAiPopover.tsx deleted file mode 100644 index 2cb53d35c..000000000 --- a/packages/app/src/components/handoff/EditWithAiPopover.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/** - * "Edit with AI" popover — the selection-scoped handoff affordance, mounted by - * the WYSIWYG bubble-menu button. Unlike the header's `OpenInAgentMenu` - * dropdown, it hosts an instruction input ("What should the AI do?") above the - * installed-agent list: a text field cannot live inside a Radix dropdown menu - * (the menu's typeahead steals keystrokes and arrow keys move menu focus), so - * the prompt box requires the popover surface. - * - * Mirrors `OpenInAgentMenu`'s target rules: install state comes from - * `useInstalledAgents`, the list is `VISIBLE_TARGETS`, and only installed - * targets render. Selection content must not egress to the cloud, so selection - * scope dispatches to locally installed agents only. - * - * Open state and the selection snapshot are owned by the caller (the bubble - * button) and threaded in as `open` / `onOpenChange` / `snapshot`, so the - * Cmd+Shift+I shortcut and a trigger click open the same controlled popover - * against the passage captured for that interaction. - */ - -import type { HandoffTarget, InstallState, TargetData } from '@inkeep/open-knowledge-core'; -import { Trans, useLingui } from '@lingui/react/macro'; -import { type ReactNode, useEffect, useEffectEvent, useState } from 'react'; -import { toast } from 'sonner'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { VISIBLE_TARGETS } from '@/lib/handoff/targets'; -import type { Workspace } from '@/lib/workspace-paths'; -import { TargetIcon } from './OpenInAgentMenuItem'; -import { buildSelectionOrDocHandoffInput, useHandoffDispatch } from './useHandoffDispatch'; -import { useInstalledAgents } from './useInstalledAgents'; - -/** - * Selection state frozen when the popover opens. The bubble-menu button - * captures this snapshot: `selectionMarkdown` from the editor-specific - * serializer, `docName` + `workspace` from document context. Threaded in so a - * selection change between open and dispatch cannot alter what is sent. - */ -export interface EditWithAiSelectionSnapshot { - readonly docName: string | null; - readonly workspace: Workspace | null; - readonly selectionMarkdown: string; -} - -interface EditWithAiPanelProps { - readonly installStates: Record; - /** Fired when the user picks a target; carries the typed instruction — - * the empty string when the user dispatched without typing one. */ - readonly onPick: (target: TargetData, instruction: string) => void; -} - -/** - * Popover body — the instruction input plus the installed-agent list. Pure: - * install state and the pick handler are injected, so it renders - * deterministically in tests without the dispatch / install-probe hooks. - */ -export function EditWithAiPanel({ installStates, onPick }: EditWithAiPanelProps): ReactNode { - const { t } = useLingui(); - const [instruction, setInstruction] = useState(''); - - const installedTargets = VISIBLE_TARGETS.filter( - (target) => installStates[target.id]?.installed === true, - ); - const probePending = VISIBLE_TARGETS.some( - (target) => installStates[target.id]?.installed == null, - ); - - return ( -
-
- Edit with AI -
- setInstruction(event.target.value)} - placeholder={t`What should the AI do? (optional)`} - aria-label={t`Instruction for the AI`} - data-testid="edit-with-ai-instruction" - /> - {installedTargets.length > 0 ? ( - <> -
- Send to -
-
- {installedTargets.map((target) => ( - - ))} -
- - ) : ( -

- {probePending ? ( - Checking for installed agents - ) : ( - No installed agents found - )} -

- )} -
- ); -} - -interface EditWithAiPopoverProps { - /** Controlled open state, owned by the bubble button so the Cmd+Shift+I - * shortcut and a trigger click share one popover. */ - readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; - /** Selection snapshot captured by the caller when the popover opened. Null - * while closed; a non-null snapshot is required to dispatch. */ - readonly snapshot: EditWithAiSelectionSnapshot | null; - /** The trigger element (a button). Rendered via `PopoverTrigger asChild`. */ - readonly children: ReactNode; -} - -/** - * Popover shell: anchors the panel to the trigger, refreshes install state on - * open, and routes a target pick through `buildSelectionOrDocHandoffInput` -> - * `useHandoffDispatch().dispatch` against the caller-supplied snapshot. - */ -export function EditWithAiPopover({ - open, - onOpenChange, - snapshot, - children, -}: EditWithAiPopoverProps): ReactNode { - const { t } = useLingui(); - const { states, refresh } = useInstalledAgents(); - const { dispatch } = useHandoffDispatch(); - - // Refresh install state whenever the popover opens — regardless of whether - // the open came from a trigger click or the Cmd+Shift+I shortcut (which sets - // `open` directly, bypassing `onOpenChange`). `useEffectEvent` keeps - // `refresh` out of the dependency array so the effect fires on the open edge - // only. The probe coordinator handles throttle + dedup, so re-firing is safe. - const refreshOnOpen = useEffectEvent(() => { - void refresh(); - }); - useEffect(() => { - if (open) refreshOnOpen(); - }, [open]); - - const handlePick = (target: TargetData, instruction: string): void => { - if (snapshot !== null) { - const input = buildSelectionOrDocHandoffInput({ - docName: snapshot.docName, - workspace: snapshot.workspace, - instruction, - selectionMarkdown: snapshot.selectionMarkdown, - }); - if (input !== null) { - void dispatch(target.id, input); - } else { - // `buildSelectionOrDocHandoffInput` returns null when docName/workspace - // are unresolved (workspace loads asynchronously on web; the doc may - // not be active). Surfacing a toast keeps the dispatch from being a - // silent no-op while the popover closes. - toast.error(t`Couldn't send the selection — please try again.`); - } - } - onOpenChange(false); - }; - - return ( - - {children} - - - - - ); -} diff --git a/packages/app/src/components/handoff/OpenInAgentMenu.tsx b/packages/app/src/components/handoff/OpenInAgentMenu.tsx index bc66d4d40..aab4f017f 100644 --- a/packages/app/src/components/handoff/OpenInAgentMenu.tsx +++ b/packages/app/src/components/handoff/OpenInAgentMenu.tsx @@ -318,8 +318,8 @@ export function OpenInAgentMenu({ input, open, onOpenChange }: OpenInAgentMenuPr // programmatic open both bypass Radix's own onOpenChange). `useEffectEvent` // keeps `refresh` out of the dependency array so the effect fires on the open // edge only. The probe coordinator handles throttle + dedup, so re-firing is - // safe. Mirrors `EditWithAiPopover`. Declared before the embedded early-return - // so the hook order stays stable across renders (rules of hooks). + // safe. Declared before the embedded early-return so the hook order stays + // stable across renders (rules of hooks). const refreshOnOpen = useEffectEvent(() => { void refresh(); }); diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index 27fb69d91..e107041d7 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -599,7 +599,6 @@ "By-7Pn": ["Toggle a bullet list."], "BymzO4": ["Document outline"], "C0hoX6": ["Always included"], - "C37Zkt": ["Send to"], "C62P6b": ["Editor selection"], "C7XjNB": ["Global Skill"], "C9TBvF": ["No Outgoing"], @@ -718,7 +717,6 @@ "EOjU5V": ["Inline Math Properties"], "EPwuTC": [["usedLabel"], " / ", ["sizeLabel"], " tokens used"], "ETyvvg": ["No language match."], - "EVnzPs": ["Edit with AI"], "EWPtMO": ["Code"], "EWX92Z": ["Collapse Ask AI"], "EZVxVz": ["Replace the active match from the replace field."], @@ -735,7 +733,6 @@ "EbZT7P": ["Toggle the left file sidebar."], "EbjWsS": ["Current commit"], "EcM3k7": ["Failed to clean up pending ", ["kind"]], - "EcR0fl": ["Checking for installed agents"], "EdQY6l": ["None"], "EeUucb": ["Rule editing is available for the project's root markdownlint config"], "Ef7StM": ["Unknown"], @@ -1772,7 +1769,6 @@ "_SDGzH": ["Continue in browser"], "_TDESN": ["Network error - ", ["kind"], " \"", ["createdPath"], "\" still exists on disk"], "_TF8ep": ["Failed to render PDF"], - "_VRtd7": ["Couldn't send the selection — please try again."], "_W-elG": ["(inline)"], "_WPP7e": [ "Read-only copy of a MirrorSource block from another doc. Edit at the source and it updates live." @@ -2296,7 +2292,6 @@ "l3s5ri": ["Import"], "l3zD7w": ["Starting the agent…"], "l46MqT": ["Next tab"], - "l6OXqI": ["No installed agents found"], "l6kpzR": [ "This ", ["targetNoun"], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 8e1a63bdb..4a178a30c 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -1548,10 +1548,6 @@ msgstr "Checkbox" msgid "Checking for agents" msgstr "Checking for agents" -#: src/components/handoff/EditWithAiPopover.tsx -msgid "Checking for installed agents" -msgstr "Checking for installed agents" - #: src/components/share-receive-miss-content.tsx #: src/components/ShareBranchSwitchDialog.tsx msgid "Checking for updates on GitHub" @@ -2543,10 +2539,6 @@ msgstr "Couldn't save the resolution for {filePath}." msgid "Couldn't send the report" msgstr "Couldn't send the report" -#: src/components/handoff/EditWithAiPopover.tsx -msgid "Couldn't send the selection — please try again." -msgstr "Couldn't send the selection — please try again." - #: src/components/BottomComposer.tsx msgid "Couldn't send your prompt — please try again." msgstr "Couldn't send your prompt — please try again." @@ -3267,10 +3259,6 @@ msgstr "Edit this HTML — the preview updates live." msgid "Edit wiki link" msgstr "Edit wiki link" -#: src/components/handoff/EditWithAiPopover.tsx -msgid "Edit with AI" -msgstr "Edit with AI" - #: src/components/TimelinePanel.tsx msgid "Edited" msgstr "Edited" @@ -4677,7 +4665,6 @@ msgstr "Installing" msgid "Installing {agentName}…" msgstr "Installing {agentName}…" -#: src/components/handoff/EditWithAiPopover.tsx #: src/components/handoff/OpenInAgentMenu.tsx msgid "Instruction for the AI" msgstr "Instruction for the AI" @@ -5596,10 +5583,6 @@ msgstr "No hub pages yet. Hubs appear once pages accumulate inbound graph links. msgid "No Incoming" msgstr "No Incoming" -#: src/components/handoff/EditWithAiPopover.tsx -msgid "No installed agents found" -msgstr "No installed agents found" - #: src/editor/extensions/CodeBlockView.tsx msgid "No language match." msgstr "No language match." @@ -7626,10 +7609,6 @@ msgstr "Send" msgid "Send report" msgstr "Send report" -#: src/components/handoff/EditWithAiPopover.tsx -msgid "Send to" -msgstr "Send to" - #: src/components/ReportBugDialogBody.tsx msgid "Send your report by email" msgstr "Send your report by email" @@ -9520,7 +9499,6 @@ msgstr "What is a starter pack?" msgid "What is this knowledge base for?" msgstr "What is this knowledge base for?" -#: src/components/handoff/EditWithAiPopover.tsx #: src/components/handoff/OpenInAgentMenu.tsx msgid "What should the AI do? (optional)" msgstr "What should the AI do? (optional)" diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index 4190b68da..60625ecf8 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -599,7 +599,6 @@ "By-7Pn": ["Ţōĝĝĺē à ƀũĺĺēţ ĺĩśţ."], "BymzO4": ["Ďōćũḿēńţ ōũţĺĩńē"], "C0hoX6": ["Àĺŵàŷś ĩńćĺũďēď"], - "C37Zkt": ["Śēńď ţō"], "C62P6b": ["Ēďĩţōŕ śēĺēćţĩōń"], "C7XjNB": ["Ĝĺōƀàĺ Śķĩĺĺ"], "C9TBvF": ["Ńō Ōũţĝōĩńĝ"], @@ -718,7 +717,6 @@ "EOjU5V": ["Ĩńĺĩńē Ḿàţĥ Ƥŕōƥēŕţĩēś"], "EPwuTC": [["usedLabel"], " / ", ["sizeLabel"], " ţōķēńś ũśēď"], "ETyvvg": ["Ńō ĺàńĝũàĝē ḿàţćĥ."], - "EVnzPs": ["Ēďĩţ ŵĩţĥ ÀĨ"], "EWPtMO": ["Ćōďē"], "EWX92Z": ["Ćōĺĺàƥśē Àśķ ÀĨ"], "EZVxVz": ["Ŕēƥĺàćē ţĥē àćţĩvē ḿàţćĥ ƒŕōḿ ţĥē ŕēƥĺàćē ƒĩēĺď."], @@ -735,7 +733,6 @@ "EbZT7P": ["Ţōĝĝĺē ţĥē ĺēƒţ ƒĩĺē śĩďēƀàŕ."], "EbjWsS": ["Ćũŕŕēńţ ćōḿḿĩţ"], "EcM3k7": ["Ƒàĩĺēď ţō ćĺēàń ũƥ ƥēńďĩńĝ ", ["kind"]], - "EcR0fl": ["Ćĥēćķĩńĝ ƒōŕ ĩńśţàĺĺēď àĝēńţś"], "EdQY6l": ["Ńōńē"], "EeUucb": ["Ŕũĺē ēďĩţĩńĝ ĩś àvàĩĺàƀĺē ƒōŕ ţĥē ƥŕōĴēćţ'ś ŕōōţ ḿàŕķďōŵńĺĩńţ ćōńƒĩĝ"], "Ef7StM": ["Ũńķńōŵń"], @@ -1772,7 +1769,6 @@ "_SDGzH": ["Ćōńţĩńũē ĩń ƀŕōŵśēŕ"], "_TDESN": ["Ńēţŵōŕķ ēŕŕōŕ - ", ["kind"], " \"", ["createdPath"], "\" śţĩĺĺ ēxĩśţś ōń ďĩśķ"], "_TF8ep": ["Ƒàĩĺēď ţō ŕēńďēŕ ƤĎƑ"], - "_VRtd7": ["Ćōũĺďń'ţ śēńď ţĥē śēĺēćţĩōń — ƥĺēàśē ţŕŷ àĝàĩń."], "_W-elG": ["(ĩńĺĩńē)"], "_WPP7e": [ "Ŕēàď-ōńĺŷ ćōƥŷ ōƒ à ḾĩŕŕōŕŚōũŕćē ƀĺōćķ ƒŕōḿ àńōţĥēŕ ďōć. Ēďĩţ àţ ţĥē śōũŕćē àńď ĩţ ũƥďàţēś ĺĩvē." @@ -2296,7 +2292,6 @@ "l3s5ri": ["Ĩḿƥōŕţ"], "l3zD7w": ["Śţàŕţĩńĝ ţĥē àĝēńţ…"], "l46MqT": ["Ńēxţ ţàƀ"], - "l6OXqI": ["Ńō ĩńśţàĺĺēď àĝēńţś ƒōũńď"], "l6kpzR": [ "Ţĥĩś ", ["targetNoun"], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 89ae813db..9c3c89b60 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -1544,10 +1544,6 @@ msgstr "" msgid "Checking for agents" msgstr "" -#: src/components/handoff/EditWithAiPopover.tsx -msgid "Checking for installed agents" -msgstr "" - #: src/components/share-receive-miss-content.tsx #: src/components/ShareBranchSwitchDialog.tsx msgid "Checking for updates on GitHub" @@ -2539,10 +2535,6 @@ msgstr "" msgid "Couldn't send the report" msgstr "" -#: src/components/handoff/EditWithAiPopover.tsx -msgid "Couldn't send the selection — please try again." -msgstr "" - #: src/components/BottomComposer.tsx msgid "Couldn't send your prompt — please try again." msgstr "" @@ -3263,10 +3255,6 @@ msgstr "" msgid "Edit wiki link" msgstr "" -#: src/components/handoff/EditWithAiPopover.tsx -msgid "Edit with AI" -msgstr "" - #: src/components/TimelinePanel.tsx msgid "Edited" msgstr "" @@ -4673,7 +4661,6 @@ msgstr "" msgid "Installing {agentName}…" msgstr "" -#: src/components/handoff/EditWithAiPopover.tsx #: src/components/handoff/OpenInAgentMenu.tsx msgid "Instruction for the AI" msgstr "" @@ -5592,10 +5579,6 @@ msgstr "" msgid "No Incoming" msgstr "" -#: src/components/handoff/EditWithAiPopover.tsx -msgid "No installed agents found" -msgstr "" - #: src/editor/extensions/CodeBlockView.tsx msgid "No language match." msgstr "" @@ -7622,10 +7605,6 @@ msgstr "" msgid "Send report" msgstr "" -#: src/components/handoff/EditWithAiPopover.tsx -msgid "Send to" -msgstr "" - #: src/components/ReportBugDialogBody.tsx msgid "Send your report by email" msgstr "" @@ -9516,7 +9495,6 @@ msgstr "" msgid "What is this knowledge base for?" msgstr "" -#: src/components/handoff/EditWithAiPopover.tsx #: src/components/handoff/OpenInAgentMenu.tsx msgid "What should the AI do? (optional)" msgstr "" diff --git a/packages/app/tests/stress/_helpers/editor-state.ts b/packages/app/tests/stress/_helpers/editor-state.ts index 11a8e6cd8..02f024b66 100644 --- a/packages/app/tests/stress/_helpers/editor-state.ts +++ b/packages/app/tests/stress/_helpers/editor-state.ts @@ -11,7 +11,7 @@ * touch the server URL. */ -import type { Page } from '@playwright/test'; +import { expect, type Page } from '@playwright/test'; /** * Press the platform select-all chord in the focused editor view and yield @@ -227,3 +227,52 @@ export async function waitForPmSelectionInNode( { timeout: timeoutMs }, ); } + +/** + * Force-render the full doc, then reset scroll to the top. ProseMirror skips + * layout work for off-screen content; without this prime step, the smooth- + * scroll fired by an outline click computes its destination against an + * incomplete layout, then content materializes mid-animation and the target + * overshoots off-viewport. Scrolling to the end first materializes every + * paragraph, after which the scroll math is stable. Keyed on + * `[data-testid="editor-scroll-container"]`. + */ +export async function primeFullLayout(page: Page): Promise { + // Scroll to the bottom and poll until `scrollHeight` stops growing — each + // ProseMirror lazy-render pass extends it, and two consecutive equal reads + // (separated by the poll interval, i.e. ≥1 layout cycle) means the full doc + // has materialized. Condition-based wait per the E2E STOP rule (no + // `page.waitForTimeout`). + let lastHeight = -1; + await expect + .poll( + async () => { + const h = await page.evaluate(() => { + const s = document.querySelector('[data-testid="editor-scroll-container"]'); + if (!(s instanceof HTMLElement)) return -1; + s.scrollTop = s.scrollHeight; + return s.scrollHeight; + }); + const stable = h > 0 && h === lastHeight; + lastHeight = h; + return stable; + }, + { timeout: 6_000, intervals: [150, 250, 350] }, + ) + .toBe(true); + + // Reset to the top and poll until `scrollTop` is parked at 0 — scroll + // anchoring can re-nudge it after the large content materialization above. + await expect + .poll( + () => + page.evaluate(() => { + const s = document.querySelector('[data-testid="editor-scroll-container"]'); + if (!(s instanceof HTMLElement)) return -1; + if (s.scrollTop !== 0) s.scrollTop = 0; + return s.scrollTop; + }), + { timeout: 3_000, intervals: [100, 200] }, + ) + .toBe(0); +} diff --git a/packages/app/tests/stress/_helpers/index.ts b/packages/app/tests/stress/_helpers/index.ts index 6826ec679..4a6411df9 100644 --- a/packages/app/tests/stress/_helpers/index.ts +++ b/packages/app/tests/stress/_helpers/index.ts @@ -13,6 +13,7 @@ export { simulateCopyAndRead, simulateCutAndRead } from './clipboard.ts'; export { resetContentToFixtureBaseline } from './content-reset.ts'; export { focusEditor, + primeFullLayout, selectAllAndWaitForSelection, selectText, waitForPmSelectionInNode, diff --git a/packages/app/tests/stress/asset-embed-real-fidelity.e2e.ts b/packages/app/tests/stress/asset-embed-real-fidelity.e2e.ts index e25c966d4..0f5e553a1 100644 --- a/packages/app/tests/stress/asset-embed-real-fidelity.e2e.ts +++ b/packages/app/tests/stress/asset-embed-real-fidelity.e2e.ts @@ -186,7 +186,7 @@ test.describe('asset-embed — real-fidelity byte-identity (QA-001/002/003/004/0 expect(sha256(onDisk)).toBe(expectedSha); }); - test('QA-004: real ZIP → [archive.zip](archive.zip) markdown-link (opaque)', async ({ + test('QA-004: real ZIP → ![[archive.zip]] wiki-embed (File row) + byte-exact on-disk', async ({ page, workerServer, }) => { @@ -197,9 +197,11 @@ test.describe('asset-embed — real-fidelity byte-identity (QA-001/002/003/004/0 .poll(async () => await getSourceText(page), { timeout: 10_000 }) .toContain('archive.zip'); const text = await getSourceText(page); - // Opaque-emit rule: NOT wiki-embed shape for zip. - expect(text).not.toContain('![[archive.zip]]'); - expect(text).toContain('[archive.zip](archive.zip)'); + // zip is a FILE_ATTACHMENT_EXTENSIONS member: drops route through + // pickInsertShape 'jsx-file' -> WikiEmbedFile, whose serialize emits the + // wiki-embed source bytes so the file persists as a File row, not an + // opaque markdown link. + expect(text).toContain('![[archive.zip]]'); const onDisk = await waitForDiskFile(workerServer.contentDir, 'archive.zip'); expect(sha256(onDisk)).toBe(expectedSha); }); @@ -256,7 +258,7 @@ test.describe('asset-embed — real-fidelity byte-identity (QA-001/002/003/004/0 await expect.poll(() => alertFired, { timeout: 500 }).toBe(false); }); - test('QA-006: real CSV → [data.csv](data.csv) markdown-link (D-M accept-all)', async ({ + test('QA-006: real CSV → ![[data.csv]] wiki-embed (File row) + byte-exact on-disk', async ({ page, workerServer, }) => { @@ -267,7 +269,9 @@ test.describe('asset-embed — real-fidelity byte-identity (QA-001/002/003/004/0 .poll(async () => await getSourceText(page), { timeout: 10_000 }) .toContain('data.csv'); const text = await getSourceText(page); - expect(text).not.toContain('![[data.csv]]'); + // csv is a FILE_ATTACHMENT_EXTENSIONS member, so the drop serializes to the + // wiki-embed source bytes rather than a fall-through markdown link. + expect(text).toContain('![[data.csv]]'); const onDisk = await waitForDiskFile(workerServer.contentDir, 'data.csv'); expect(sha256(onDisk)).toBe(expectedSha); expect(onDisk.toString('utf-8')).toBe('name,age,city\nAlice,30,NYC\nBob,25,LA\n'); diff --git a/packages/app/tests/stress/e2e-ci-ledger.ts b/packages/app/tests/stress/e2e-ci-ledger.ts index 68c55af58..38db31748 100644 --- a/packages/app/tests/stress/e2e-ci-ledger.ts +++ b/packages/app/tests/stress/e2e-ci-ledger.ts @@ -16,7 +16,7 @@ */ export interface E2eCiLedgerEntry { - /** Bare filename under tests/stress/, e.g. 'edit-with-ai.e2e.ts'. */ + /** Bare filename under tests/stress/, e.g. 'okignore-settings.e2e.ts'. */ file: string; /** Why the file is excluded from the CI `test:e2e` list. */ reason: string; @@ -25,84 +25,25 @@ export interface E2eCiLedgerEntry { } export const E2E_CI_EXCLUSIONS: readonly E2eCiLedgerEntry[] = [ - { - file: 'asset-embed-real-fidelity.e2e.ts', - reason: - 'fails-locally: QA-004 (real ZIP) and QA-006 (real CSV) byte-identity assertions fail — on-disk sha256/content does not match the uploaded bytes', - evidence: 'same 2 tests failed in 2/2 local runs (2026-07-20); other 5 tests green', - }, - { - file: 'clipboard-relative-url-source-fallback.e2e.ts', - reason: - 'fails-locally: QA-005 inline image in a paragraph emits chunk-wrapper HTML instead of the inline markdown source-fallback; deterministic, fix open in PR #2505', - evidence: 'QA-005 failed deterministically in local run (2026-07-20); remaining tests green', - }, - { - file: 'edit-with-ai.e2e.ts', - reason: - 'fails-locally: all 13 tests fail toBeVisible — Edit-with-AI rows are desktop-gated (window.okDesktop) and never render in the browser e2e harness without a forced-gate init script', - evidence: '13/18 failed, uniform toBeVisible timeouts, local run (2026-07-20)', - }, { file: 'frontmatter-edit.e2e.ts', reason: - 'fails-locally: 5 PropertyPanel assertions fail (rename keeps position, drag reorder, keyboard drag, duplicate-name marker, malformed-YAML banner)', - evidence: 'same 5 tests failed in 2/2 local runs (2026-07-20)', - }, - { - file: 'graph-panel-surfaces.e2e.ts', - reason: - 'fails-locally + ci-budget: fullscreen-graph interaction tests hit the 120s per-test timeout one after another even solo at low machine load; a solo run exceeds 15 minutes', + 'needs-fixture: FR6 (duplicate-key marker) and FR9 (malformed-YAML banner) seed malformed frontmatter through /api/agent-write-md, which now by design refuses to introduce malformed frontmatter (400 urn:ok:error:frontmatter-malformed). They need a disk-write fixture that loads a pre-malformed doc from the inheritor path — M effort, not in this PR. (The other former failures — the virtual "tags" placeholder row and the banner copy — are stale selectors that a repair would fix in the same pass.)', evidence: - '11 failed under batch run; solo re-run at low load kept failing each test at the 120s cap and was abandoned after ~15m (2026-07-20)', + 'agent-write-md returns 400 for a duplicate-title / ": : : invalid" frontmatter body; the duplicate-marker and yaml-error-banner surfaces still exist in src (FrontmatterRow / PropertyPanel), so the suite is repairable once the fixture lands', }, { file: 'list-keymap.e2e.ts', reason: - 'fails-locally: Tab/Shift-Tab list-depth tests receive a flat list (Tab no longer indents) and ordered-item Enter assertion fails', - evidence: 'same 3 tests failed in 2/2 local runs (2026-07-20); other 12 green, 1 skipped', + 'pins live bugs inkeep/agents-private#2817 (WYSIWYG Tab/Shift-Tab list indent/outdent mutates the ProseMirror fragment but never mirrors to Y.Text — normalizeBridge step 7c strips leading indent so Observer A gates the drain as already-in-sync) and #2818 (ordered-list Enter replays the inherited sourceOrdinal, emitting "1." instead of the documented position-based "2."). The two indent-mirror tests and the ordinal test are correct executable specs of those bugs. Promote in the fix PR.', + evidence: + 'Tab/Shift-Tab leave Y.Text byte-identical to the flat seed after the sink/lift renders in the DOM; ordered Enter settles to "1. sf\\n1. "; a 4th failure is an e2e caret-placement race whose app logic is proven at the unit tier (list-boundary-merge.test.ts)', }, { file: 'okignore-settings.e2e.ts', reason: - 'fails-locally: 6 tests fail (US-010 advanced-textarea group + US-013 hide-file/folder patterns); also expensive at ~2.9m solo', - evidence: '6 failed / 13 passed solo at low load, 2.9m wall (2026-07-20)', - }, - { - file: 'outline-navigation.e2e.ts', - reason: - 'fails-locally: outline-click landing assertions fail (WYSIWYG scroll landing, source-mode cursor line, code-fence # disambiguation)', - evidence: '4/5 failed in re-run at low load (2026-07-20)', - }, - { - file: 'prop-upload.e2e.ts', - reason: - 'fails-locally: 5 assertions expect src "initial.png" but receive "/initial.png" (leading-slash drift in upload src handling)', - evidence: 'same 5 tests failed in 2/2 local runs (2026-07-20)', - }, - { - file: 'rename-consolidation.e2e.ts', - reason: - 'fails-locally: 3 browser-fidelity assertions fail (rename response contract returns undefined where true expected; /api/history reachability)', + 'pins live regression inkeep/agents-private#2816: right-click "Hide this file"/"Hide folder" commits the .okignore pattern and shows its toast, but the sidebar row never disappears — the tree fetches a showAll disk walk whose client-side filter has no okignore awareness, so no rebuild/CC1/refetch can remove the row. The 2 US-013 tests are correct RED specs of that bug. (The 4 US-010 settings-navigation drift tests were repaired in this PR.) Promote in the fix PR.', evidence: - 'same 3 tests failed in 2/2 local runs (2026-07-20); tmpdir content dir is not a git repo, rename falls back to fs rename', - }, - { - file: 'rename-content-preservation.e2e.ts', - reason: - 'fails-locally: "renaming the active doc keeps navigation on the renamed doc" fails toBeGreaterThan(0) on 0', - evidence: 'same single test failed in 2/2 local runs (2026-07-20); other 3 green', - }, - { - file: 'slash-command-auto-open.e2e.ts', - reason: - 'fails-locally: SLASH-AUTOOPEN-IMG-MULTI expects 2 [data-jsx-component] nodes after inserting a second Image, receives 1', - evidence: 'same single test failed in 2/2 local runs (2026-07-20); other 21 tests green', - }, - { - file: 'timeline-diff-sidepane.e2e.ts', - reason: - 'fails-locally: all 14 side-pane tests fail element-not-found / toBeVisible — the timeline diff side pane never appears in the harness', - evidence: '14/16 failed in 2/2 local runs (2026-07-20); also expensive (>4m per run)', + 'after the US-010 repair, 17 pass / 2 fail locally; the 2 Hide tests fail deterministically (row visible for the full 10s window) while the success toast fires and the pattern lands in Settings', }, ]; diff --git a/packages/app/tests/stress/edit-with-ai.e2e.ts b/packages/app/tests/stress/edit-with-ai.e2e.ts deleted file mode 100644 index 4546bc572..000000000 --- a/packages/app/tests/stress/edit-with-ai.e2e.ts +++ /dev/null @@ -1,569 +0,0 @@ -/** - * E2e suite for the "Edit with AI" selection handoff. Live-app verification of - * the affordance + the dispatched deep-link URL — closing the mock-gap that the - * *.dom.test.tsx files leave (they mock useHandoffDispatch / useInstalledAgents - * and drive a fake editor, so they never prove the affordance appears on a real - * selection nor that a real, well-formed, in-budget URL is dispatched). - * - * Reuses the handoff-mocks harness: web host with `/api/installed-agents` - * stubbed to the install map, `/api/handoff` intercepted (returns 200, never - * launches an OS app) and the POSTed `{target,url}` body captured into - * `window.__handoffMocks__.handoffApiCalls`. The captured `url` is the proof - * artifact for the deep-link assertions. - * - * Host is darwin so `navigator.platform` is `MacIntel` → `isMacOS()` true and - * the affordance renders by default. The non-macOS negative spoofs - * `navigator.platform` before mount. - */ - -import type { Page } from '@playwright/test'; -import { expect, test, waitForActiveProviderSynced } from './_helpers'; -import { - type HandoffMockConfig, - installHandoffMocks, - readCapturedHandoff, -} from './fixtures/handoff-mocks'; - -const PROSE = 'The quick brown fox jumps over the lazy dog and keeps on running.'; - -function webCfg(workerServer: { baseURL: string; contentDir: string }): HandoffMockConfig { - return { - host: 'web', - install: { claude: true, codex: true, cursor: true }, - workerBaseURL: workerServer.baseURL, - workerContentDir: workerServer.contentDir, - }; -} - -async function seedAndOpenWysiwyg( - page: Page, - api: { seedDocs: (docs: Array<{ name: string; markdown: string }>) => Promise }, - docName: string, - markdown: string, -): Promise { - await api.seedDocs([{ name: docName, markdown }]); - await page.goto(`/#/${docName}`); - await waitForActiveProviderSynced(page); - await page.waitForSelector('.ProseMirror:not(.composer-prosemirror)'); -} - -/** - * Select the text of the first paragraph in the live ProseMirror editor by - * placing an actual DOM Range over its text node + firing the selection events - * TipTap listens for. Returns once the bubble menu has had a chance to appear. - */ -async function selectFirstParagraph(page: Page): Promise { - await page.evaluate(() => { - const pm = document.querySelector('.ProseMirror:not(.composer-prosemirror)'); - if (!pm) throw new Error('no ProseMirror'); - const p = pm.querySelector('p'); - if (!p?.firstChild) throw new Error('no paragraph text node'); - const range = document.createRange(); - range.selectNodeContents(p); - const sel = window.getSelection(); - sel?.removeAllRanges(); - sel?.addRange(range); - document.dispatchEvent(new Event('selectionchange')); - p.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); - }); -} - -async function openSelectionAskAiMenu(page: Page) { - await page.getByTestId('edit-with-ai-bubble-button').click(); - const popover = page.getByTestId('edit-with-ai-popover'); - await expect(popover).toBeVisible(); - return popover; -} - -// Source mode has no selection toolbar; its only "Edit with AI" affordance is -// the Cmd+Shift+I shortcut, which routes through the shared header -// `OpenInAgentMenu` dropdown (not the WYSIWYG popover). -async function openSourceSelectionAskAiMenu(page: Page, shortcut: string) { - await page.getByRole('radio', { name: /Markdown source/i }).click(); - const source = page.locator('.cm-content').first(); - await expect(source).toBeVisible({ timeout: 10_000 }); - await source.focus(); - await page.keyboard.press('ControlOrMeta+A'); - await page.keyboard.press(shortcut); - const menu = page.getByTestId('open-in-agent-menu'); - await expect(menu).toBeVisible(); - return menu; -} - -async function pickAskAiTarget(page: Page, target: 'claude-code' | 'codex' | 'cursor') { - await page.getByTestId(`edit-with-ai-target-${target}`).click(); -} - -// Source mode dispatches through the shared header dropdown, whose rows carry -// the `open-in-agent-item-*` testids (not the popover's `edit-with-ai-target-*`). -async function pickSourceAskAiTarget(page: Page, target: 'claude-code' | 'codex' | 'cursor') { - await page.getByTestId(`open-in-agent-item-${target}`).click(); -} - -test.describe('Edit with AI — affordance + deep-link dispatch (live app)', () => { - test('QA-001/004/026: WYSIWYG bubble shows Edit-with-AI button on a text selection, after the link control, and dispatches a well-formed deep-link', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-wysiwyg', `# Heading\n\n${PROSE}`); - - await selectFirstParagraph(page); - - // button reachable in the bubble menu's text branch. - const btn = page.getByTestId('edit-with-ai-bubble-button'); - await expect(btn).toBeVisible({ timeout: 10_000 }); - - // placement — the button sits after the link control inside the - // bubble bar (DOM order), preceded by a separator, with the sparkle label. - const placement = await page.evaluate(() => { - const bar = document.querySelector('[data-testid="bubble-menu-bar"]'); - const ai = bar?.querySelector('[data-testid="edit-with-ai-bubble-button"]'); - const link = bar?.querySelector('[aria-label="Insert link"]'); - // DOCUMENT_POSITION_PRECEDING on `link` means link precedes ai → ai is after link. - const linkPrecedesAi = - ai && link - ? Boolean(ai.compareDocumentPosition(link) & Node.DOCUMENT_POSITION_PRECEDING) - : false; - return { - label: ai?.textContent?.trim() ?? '', - hasSparkle: Boolean(ai?.querySelector('svg')), - aiAfterLink: linkPrecedesAi, - linkPresent: Boolean(link), - }; - }); - expect(placement.label).toContain('Edit with AI'); - expect(placement.hasSparkle).toBe(true); - expect(placement.linkPresent).toBe(true); - expect(placement.aiAfterLink).toBe(true); - - // open the selection popover, pick Claude → capture URL. - await openSelectionAskAiMenu(page); - await pickAskAiTarget(page, 'claude-code'); - - await expect - .poll(async () => (await readCapturedHandoff(page)).handoffApiCalls.length, { - timeout: 5_000, - }) - .toBe(1); - const call = (await readCapturedHandoff(page)).handoffApiCalls[0]; - expect(call?.target).toBe('claude-code'); - const url = call?.url ?? ''; - expect(url.length).toBeLessThanOrEqual(4096); - const q = new URL(url).searchParams.get('q') ?? ''; - expect(q).toContain('@qa-ewai-wysiwyg.md'); - expect(q).toContain('quick brown fox'); // the selected passage, inlined - expect(q).toContain('```'); // fenced inline block - }); - - test('source mode Cmd+Shift+I opens the shared Ask AI menu and dispatches the selected source', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-source-shortcut', `# Heading\n\n${PROSE}`); - - await openSourceSelectionAskAiMenu(page, 'Meta+Shift+I'); - await pickSourceAskAiTarget(page, 'claude-code'); - - await expect - .poll(async () => (await readCapturedHandoff(page)).handoffApiCalls.length, { - timeout: 5_000, - }) - .toBe(1); - const call = (await readCapturedHandoff(page)).handoffApiCalls[0]; - expect(call?.target).toBe('claude-code'); - const q = new URL(call?.url ?? '').searchParams.get('q') ?? ''; - expect(q).toContain('@qa-ewai-source-shortcut.md'); - expect(q).toContain('# Heading'); - expect(q).toContain('quick brown fox'); - }); - - test('WYSIWYG Cmd+Shift+I opens the selection popover for the selected passage', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-wysiwyg-shortcut', `# Heading\n\n${PROSE}`); - await selectFirstParagraph(page); - - await page.keyboard.press('ControlOrMeta+Shift+I'); - await expect(page.getByTestId('edit-with-ai-popover')).toBeVisible(); - await pickAskAiTarget(page, 'claude-code'); - - await expect - .poll(async () => (await readCapturedHandoff(page)).handoffApiCalls.length, { - timeout: 5_000, - }) - .toBe(1); - const call = (await readCapturedHandoff(page)).handoffApiCalls[0]; - expect(call?.target).toBe('claude-code'); - const q = new URL(call?.url ?? '').searchParams.get('q') ?? ''; - expect(q).toContain('@qa-ewai-wysiwyg-shortcut.md'); - expect(q).toContain('quick brown fox'); - }); - - // no selection → no affordance. - test('QA-009: no bubble button when there is no selection', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-nosel', `# H\n\n${PROSE}`); - // Collapse selection at doc start; bubble must not show. - await page.evaluate(() => { - const sel = window.getSelection(); - sel?.removeAllRanges(); - }); - await expect(page.getByTestId('edit-with-ai-bubble-button')).toHaveCount(0); - }); - - // non-macOS host → the button is present (it opens the cross-platform - // composer). Only the Ctrl+Shift+I keyboard shortcut stays macOS-only, since - // that chord is the browser DevTools shortcut off macOS. - test('QA-010: button present on a non-macOS host (platform spoofed to Linux)', async ({ - page, - api, - workerServer, - }) => { - await page.addInitScript(() => { - Object.defineProperty(navigator, 'platform', { - get: () => 'Linux x86_64', - configurable: true, - }); - }); - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-linux', `# H\n\n${PROSE}`); - await selectFirstParagraph(page); - await expect(page.getByTestId('bubble-menu-bar')).toBeVisible({ timeout: 10_000 }); - await expect(page.getByTestId('edit-with-ai-bubble-button')).toBeVisible(); - }); - - // image / file node selection → no Edit-with-AI button. - test('QA-011: no Edit-with-AI button on an image node selection (node controls instead)', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg( - page, - api, - 'qa-ewai-image', - `# H\n\n![alt text](https://example.com/x.png)\n\n${PROSE}`, - ); - await page.waitForFunction(() => Boolean(window.__activeEditor), null, { timeout: 5_000 }); - // NodeSelect the image via the editor's own API (the content image renders - // as a jsx-component, not a plain , so a DOM click is unreliable). - const nodeSelected = await page.evaluate(() => { - const ed = window.__activeEditor; - if (!ed) return { ok: false, name: null as string | null }; - let imgPos = -1; - let imgName: string | null = null; - ed.state.doc.descendants( - ( - node: { isAtom?: boolean; type: { name: string }; attrs?: { componentName?: string } }, - pos: number, - ) => { - if (imgPos !== -1) return false; - const n = node.type.name; - // Markdown images render as a `jsxComponent` whose attrs.componentName - // is 'img' (see ImageAlignButtons.isImageNodeSelected). - const isImage = - n === 'jsxComponent' && - (node.attrs?.componentName === 'img' || - node.attrs?.componentName === 'CommonMarkImage' || - node.attrs?.componentName === 'Embed'); - if (isImage) { - imgPos = pos; - imgName = `${n}:${node.attrs?.componentName}`; - return false; - } - return true; - }, - ); - if (imgPos === -1) return { ok: false, name: null }; - ed.chain().focus().setNodeSelection(imgPos).run(); - return { ok: true, name: imgName }; - }); - expect(nodeSelected.ok).toBe(true); - // Bubble shows node controls; the Edit-with-AI button (text branch only) - // must be absent on a node selection. - await expect(page.getByTestId('edit-with-ai-bubble-button')).toHaveCount(0); - }); - - // selection popover lists installed agents only, no claude.ai web row. - test('QA-012: selection popover shows installed agents and no claude.ai/web fallback row', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-agents', `# H\n\n${PROSE}`); - await selectFirstParagraph(page); - const popover = await openSelectionAskAiMenu(page); - - await expect(page.getByTestId('edit-with-ai-target-claude-code')).toBeVisible(); - await expect(page.getByTestId('edit-with-ai-target-codex')).toBeVisible(); - await expect(page.getByTestId('edit-with-ai-target-cursor')).toBeVisible(); - // claude-cowork excluded from VISIBLE_TARGETS; no web fallback row in the - // selection popover (selection content must not egress to the cloud). - await expect(page.getByTestId('edit-with-ai-target-claude-cowork')).toHaveCount(0); - const bodyText = await popover.innerText(); - expect(bodyText.toLowerCase()).not.toContain('claude.ai'); - }); - - // typed instruction is threaded into the dispatched prompt. - test('QA-013: typed instruction appears in the dispatched prompt', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-instr', `# H\n\n${PROSE}`); - await selectFirstParagraph(page); - await openSelectionAskAiMenu(page); - await page.getByTestId('edit-with-ai-instruction').fill('tighten the prose'); - await pickAskAiTarget(page, 'codex'); - - await expect - .poll(async () => (await readCapturedHandoff(page)).handoffApiCalls.length, { - timeout: 5_000, - }) - .toBe(1); - const call = (await readCapturedHandoff(page)).handoffApiCalls[0]; - expect(call?.target).toBe('codex'); - const prompt = new URL(call?.url ?? '').searchParams.get('prompt') ?? ''; - expect(prompt).toContain('@qa-ewai-instr.md'); - expect(prompt).toContain('quick brown fox'); - expect(prompt).toContain('Instruction:'); - expect(prompt).toContain('tighten the prose'); - }); - - // empty instruction still dispatches; prompt has no instruction line. - test('QA-013b: dispatch allowed with empty instruction (no instruction text in prompt)', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-noinstr', `# H\n\n${PROSE}`); - await selectFirstParagraph(page); - await openSelectionAskAiMenu(page); - await pickAskAiTarget(page, 'codex'); - - await expect - .poll(async () => (await readCapturedHandoff(page)).handoffApiCalls.length, { - timeout: 5_000, - }) - .toBe(1); - const call = (await readCapturedHandoff(page)).handoffApiCalls[0]; - expect(call?.target).toBe('codex'); - const prompt = new URL(call?.url ?? '').searchParams.get('prompt') ?? ''; - expect(prompt).toContain('@qa-ewai-noinstr.md'); - expect(prompt).toContain('quick brown fox'); - // Lead + passage but no instruction paragraph. The composer's lead and the - // "Here is the passage:" line are present; there is no separate instruction. - expect(prompt).toContain('Here is the passage:'); - expect(prompt).not.toContain('Instruction:'); - }); - - // selection popover loading / empty install states. - test('QA-024: selection popover shows pending then empty-install copy when no agents installed', async ({ - page, - api, - workerServer, - }) => { - // No installed agents. - const cfg: HandoffMockConfig = { - host: 'web', - install: { claude: false, codex: false, cursor: false }, - workerBaseURL: workerServer.baseURL, - workerContentDir: workerServer.contentDir, - }; - await installHandoffMocks(page, cfg); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-empty', `# H\n\n${PROSE}`); - await selectFirstParagraph(page); - await openSelectionAskAiMenu(page); - // The empty/pending placeholder renders (never a clickable agent row). - const empty = page.getByTestId('edit-with-ai-empty'); - await expect(empty).toBeVisible(); - const txt = await empty.innerText(); - expect(/Checking for installed agents|No installed agents found/.test(txt)).toBe(true); - await expect(page.getByTestId('edit-with-ai-target-claude-code')).toHaveCount(0); - }); - - // a11y — popover + keyboard-operable trigger. - test('QA-027: selection popover opens from keyboard activation', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-a11y', `# H\n\n${PROSE}`); - await selectFirstParagraph(page); - const btn = page.getByTestId('edit-with-ai-bubble-button'); - await expect(btn).toBeVisible({ timeout: 10_000 }); - // Keyboard activation: focus the trigger and press Enter. - await btn.focus(); - await page.keyboard.press('Enter'); - await expect(page.getByTestId('edit-with-ai-popover')).toBeVisible(); - await expect(page.getByTestId('edit-with-ai-target-claude-code')).toBeVisible(); - }); - - // dismiss without dispatch is a clean no-op. - test('QA-028: Escape closes the selection popover without dispatching and with no error toast', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-dismiss', `# H\n\n${PROSE}`); - await selectFirstParagraph(page); - await openSelectionAskAiMenu(page); - await page.keyboard.press('Escape'); - await expect(page.getByTestId('edit-with-ai-popover')).toHaveCount(0); - // No dispatch fired, no error toast. - const captured = await readCapturedHandoff(page); - expect(captured.handoffApiCalls).toEqual([]); - await expect(page.getByText(/Couldn't reach/)).toHaveCount(0); - }); - - // selection snapshot at menu-open survives a later selection change. - test('QA-014: dispatched passage is the snapshot taken at menu-open, not the live selection', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg( - page, - api, - 'qa-ewai-snapshot', - `# H\n\n${PROSE}\n\nA completely different second paragraph here.`, - ); - await page.waitForFunction(() => Boolean(window.__activeEditor), null, { timeout: 5_000 }); - await selectFirstParagraph(page); - await openSelectionAskAiMenu(page); - // Now move the editor selection to the second paragraph via the editor API - // (the menu request already snapshotted the first paragraph on open). Using the - // editor command rather than a DOM range avoids destabilizing the open - // menu during the selectionchange storm. - await page.evaluate(() => { - const ed = window.__activeEditor; - if (!ed) return; - const size = ed.state.doc.content.size; - // Select the tail of the doc (second paragraph) without focusing the - // editor (focusing would close the menu). - ed.chain() - .setTextSelection({ from: Math.max(1, size - 20), to: size - 1 }) - .run(); - }); - // Pick an agent — must dispatch the ORIGINAL (first paragraph) passage. - await pickAskAiTarget(page, 'claude-code'); - await expect - .poll(async () => (await readCapturedHandoff(page)).handoffApiCalls.length, { - timeout: 5_000, - }) - .toBe(1); - const q = - new URL((await readCapturedHandoff(page)).handoffApiCalls[0]?.url ?? '').searchParams.get( - 'q', - ) ?? ''; - expect(q).toContain('quick brown fox'); - expect(q).not.toContain('completely different second paragraph'); - }); - - // web host writes no telemetry line (no okDesktop bridge). - test('QA-020: web host records no telemetry line for a selection dispatch', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, webCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-webtel', `# H\n\n${PROSE}`); - await selectFirstParagraph(page); - await openSelectionAskAiMenu(page); - await pickAskAiTarget(page, 'claude-code'); - await expect - .poll(async () => (await readCapturedHandoff(page)).handoffApiCalls.length, { - timeout: 5_000, - }) - .toBe(1); - // recordHandoffCalls is the Electron-bridge capture; on web it is never - // populated (no window.okDesktop), so the IPC append never happens. - const captured = await readCapturedHandoff(page); - expect(captured.recordHandoffCalls).toEqual([]); - // Host classifier should be web (no okDesktop). - const isWeb = await page.evaluate(() => (window as { okDesktop?: unknown }).okDesktop == null); - expect(isWeb).toBe(true); - }); -}); - -test.describe('Edit with AI — Electron telemetry + retry (live app)', () => { - function electronCfg(workerServer: { baseURL: string; contentDir: string }): HandoffMockConfig { - return { - host: 'electron', - install: { claude: true, codex: true, cursor: true }, - workerBaseURL: workerServer.baseURL, - workerContentDir: workerServer.contentDir, - }; - } - - // Electron host tags the dispatch scope:'selection'. - test("QA-019: Electron selection dispatch records scope:'selection'", async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, electronCfg(workerServer)); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-eltel', `# H\n\n${PROSE}`); - await selectFirstParagraph(page); - await openSelectionAskAiMenu(page); - await pickAskAiTarget(page, 'claude-code'); - - await expect - .poll(async () => (await readCapturedHandoff(page)).recordHandoffCalls.length, { - timeout: 5_000, - }) - .toBe(1); - const line = (await readCapturedHandoff(page)).recordHandoffCalls[0]; - expect(line?.host).toBe('electron'); - expect(line?.scope).toBe('selection'); - expect(line?.outcome).toBe('ok'); - }); - - // failed dispatch → error toast with Retry. - test('QA-022: failed selection dispatch surfaces an error toast with a Retry action', async ({ - page, - api, - workerServer, - }) => { - await installHandoffMocks(page, electronCfg(workerServer)); - // Force /api/handoff to fail. - await page.unroute('**/api/handoff'); - await page.route('**/api/handoff', async (route) => { - await route.fulfill({ - status: 422, - contentType: 'application/problem+json', - body: JSON.stringify({ status: 422, title: 'not installed' }), - }); - }); - await seedAndOpenWysiwyg(page, api, 'qa-ewai-retry', `# H\n\n${PROSE}`); - await selectFirstParagraph(page); - await openSelectionAskAiMenu(page); - await pickAskAiTarget(page, 'claude-code'); - - await expect(page.getByText("Couldn't reach Claude — try again?")).toBeVisible({ - timeout: 5_000, - }); - // Scope to the exact-name Retry button (sonner toast action) — `{ exact: true }` - // disambiguates from editor-tab buttons whose accessible names contain the - // doc slug + "Close" verbs that fuzzy-match "Retry". - await expect(page.getByRole('button', { name: 'Retry', exact: true })).toBeVisible(); - }); -}); diff --git a/packages/app/tests/stress/okignore-settings.e2e.ts b/packages/app/tests/stress/okignore-settings.e2e.ts index efef500d0..35213c316 100644 --- a/packages/app/tests/stress/okignore-settings.e2e.ts +++ b/packages/app/tests/stress/okignore-settings.e2e.ts @@ -265,7 +265,10 @@ test.describe('Settings — Ignore patterns section (US-007 / US-008 / US-009 / test('US-010: Show advanced toggle reveals a textarea bound to the same Y.Text', async ({ page, }) => { - await page.goto('/#settings'); + // Deep-link to the section so the later page.reload() reopens here — a bare + // `#settings` hash reopens on the default Preferences section (the active + // section is not persisted across reopen). + await page.goto('/#settings/okignore'); await page.getByTestId('settings-sidebar-item-okignore').click(); await expect(page.getByTestId('settings-okignore-section')).toBeVisible({ timeout: 10_000 }); @@ -312,7 +315,9 @@ test.describe('Settings — Ignore patterns section (US-007 / US-008 / US-009 / test('US-010: editing in textarea persists and the list view reflects the new patterns', async ({ page, }) => { - await page.goto('/#settings'); + // Deep-link to the section so the later page.reload() reopens here (a bare + // `#settings` hash reopens on the default Preferences section). + await page.goto('/#settings/okignore'); await page.getByTestId('settings-sidebar-item-okignore').click(); await expect(page.getByTestId('settings-okignore-section')).toBeVisible({ timeout: 10_000 }); @@ -358,7 +363,9 @@ test.describe('Settings — Ignore patterns section (US-007 / US-008 / US-009 / }); test('US-010: round-trip preserves comments and blank lines byte-for-byte', async ({ page }) => { - await page.goto('/#settings'); + // Deep-link to the section so the later page.reload() reopens here (a bare + // `#settings` hash reopens on the default Preferences section). + await page.goto('/#settings/okignore'); await page.getByTestId('settings-sidebar-item-okignore').click(); await expect(page.getByTestId('settings-okignore-section')).toBeVisible({ timeout: 10_000 }); @@ -393,7 +400,9 @@ test.describe('Settings — Ignore patterns section (US-007 / US-008 / US-009 / }); test('US-010: toggle state persists in localStorage across page reloads', async ({ page }) => { - await page.goto('/#settings'); + // Deep-link to the section so the page.reload() below reopens here (a bare + // `#settings` hash reopens on the default Preferences section). + await page.goto('/#settings/okignore'); await page.getByTestId('settings-sidebar-item-okignore').click(); await expect(page.getByTestId('settings-okignore-section')).toBeVisible({ timeout: 10_000 }); diff --git a/packages/app/tests/stress/outline-navigation.e2e.ts b/packages/app/tests/stress/outline-navigation.e2e.ts index e8f59e4e9..7df9ae99a 100644 --- a/packages/app/tests/stress/outline-navigation.e2e.ts +++ b/packages/app/tests/stress/outline-navigation.e2e.ts @@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto'; import type { Page } from '@playwright/test'; -import { type ApiHelpers, expect, test } from './_helpers'; +import { type ApiHelpers, expect, primeFullLayout, test } from './_helpers'; const sourceToggle = (page: Page) => page.getByRole('radio', { name: 'Markdown source' }); @@ -51,6 +51,14 @@ const DOC = [ async function seedDoc(api: ApiHelpers, page: Page, baseURL: string): Promise { const docName = `outline-${randomUUID().slice(0, 8)}`; await api.createPage(`${docName}.md`); + // Disable ACP follow-file so the agent-write seed below doesn't auto-scroll + // the WYSIWYG editor mid-test — the scroll-landing assertions need a stable + // starting viewport. Mirrors outline-toolbar-occlusion.e2e.ts's primed layout. + await page.addInitScript(() => { + try { + localStorage.setItem('ok-acp-follow-file-v1', '0'); + } catch {} + }); await page.goto(`/#/${docName}`); await page.waitForFunction(() => Boolean(window.__activeProvider?.isSynced), null, { timeout: 15_000, @@ -67,8 +75,10 @@ async function seedDoc(api: ApiHelpers, page: Page, baseURL: string): Promise { const r = await fetch(`${baseURL}/api/page-headings?docName=${docName}`); if (!r.ok) return 0; - const d = (await r.json()) as { ok: boolean; headings?: unknown[] }; - return d.ok ? (d.headings?.length ?? 0) : 0; + // PageHeadingsSuccessSchema is emitted flat under RFC 9457 — no `ok` + // field. HTTP r.ok above is the success gate; read headings directly. + const d = (await r.json()) as { headings?: unknown[] }; + return d.headings?.length ?? 0; }, { timeout: 10_000, intervals: [200, 500, 1000] }, ) @@ -92,13 +102,14 @@ test('outline click scrolls to the matching heading in WYSIWYG mode', async ({ baseURL, }) => { await seedDoc(api, page, baseURL ?? ''); + await primeFullLayout(page); const outlinePanel = page.locator('#panel-outline'); await expect(outlinePanel.getByRole('button', { name: 'Third Heading' })).toBeVisible(); - // "Third Heading" lives below a lot of filler, so before the click the - // editor scroll container should still be near the top. - const scroller = page.locator('.subtle-scrollbar').first(); + // primeFullLayout parks the scroll at the top; the click must move the + // viewport for the scroll-to-heading assertion to be meaningful. + const scroller = page.locator('[data-testid="editor-scroll-container"]').first(); const scrollBefore = await scroller.evaluate((el) => el.scrollTop); expect(scrollBefore).toBeLessThan(50); @@ -213,6 +224,12 @@ const DOC_WITH_FENCED_HASH_COMMENT = [ async function seedFencedDoc(api: ApiHelpers, page: Page, baseURL: string): Promise { const docName = `outline-fence-${randomUUID().slice(0, 8)}`; await api.createPage(`${docName}.md`); + // See seedDoc: keep the agent-write seed from auto-scrolling the editor. + await page.addInitScript(() => { + try { + localStorage.setItem('ok-acp-follow-file-v1', '0'); + } catch {} + }); await page.goto(`/#/${docName}`); await page.waitForFunction(() => Boolean(window.__activeProvider?.isSynced), null, { timeout: 15_000, @@ -228,8 +245,10 @@ async function seedFencedDoc(api: ApiHelpers, page: Page, baseURL: string): Prom async () => { const r = await fetch(`${baseURL}/api/page-headings?docName=${docName}`); if (!r.ok) return 0; - const d = (await r.json()) as { ok: boolean; headings?: unknown[] }; - return d.ok ? (d.headings?.length ?? 0) : 0; + // PageHeadingsSuccessSchema is emitted flat under RFC 9457 — no `ok` + // field. HTTP r.ok above is the success gate; read headings directly. + const d = (await r.json()) as { headings?: unknown[] }; + return d.headings?.length ?? 0; }, { timeout: 10_000, intervals: [200, 500, 1000] }, ) @@ -250,6 +269,7 @@ test('outline click lands on the correct heading when `#` appears inside a code baseURL, }) => { await seedFencedDoc(api, page, baseURL ?? ''); + await primeFullLayout(page); const outlinePanel = page.locator('#panel-outline'); // Before the fix, clicking "Target Section" would scroll to a phantom heading diff --git a/packages/app/tests/stress/outline-toolbar-occlusion.e2e.ts b/packages/app/tests/stress/outline-toolbar-occlusion.e2e.ts index 73c0c3e1a..f21fb625d 100644 --- a/packages/app/tests/stress/outline-toolbar-occlusion.e2e.ts +++ b/packages/app/tests/stress/outline-toolbar-occlusion.e2e.ts @@ -57,7 +57,7 @@ import { randomUUID } from 'node:crypto'; import type { Page } from '@playwright/test'; -import { type ApiHelpers, expect, test } from './_helpers'; +import { type ApiHelpers, expect, primeFullLayout, test } from './_helpers'; const FILLER = 'Filler paragraph to force scrollable content. '.repeat(10); @@ -130,54 +130,6 @@ async function seedDoc(api: ApiHelpers, page: Page): Promise { return docName; } -/** - * Force-render the full doc, then reset scroll to the top. ProseMirror skips - * layout work for off-screen content; without this prime step, the - * smooth-scroll fired by the outline click computes its destination against - * an incomplete layout, then content materializes mid-animation and the - * target overshoots off-viewport. Scrolling to the end first materializes - * every paragraph, after which the scroll math is stable. - */ -async function primeFullLayout(page: Page): Promise { - // Scroll to the bottom and poll until `scrollHeight` stops growing — each - // ProseMirror lazy-render pass extends it, and two consecutive equal reads - // (separated by the poll interval, i.e. ≥1 layout cycle) means the full doc - // has materialized. Condition-based wait per the E2E STOP rule (no - // `page.waitForTimeout`). - let lastHeight = -1; - await expect - .poll( - async () => { - const h = await page.evaluate(() => { - const s = document.querySelector('[data-testid="editor-scroll-container"]'); - if (!(s instanceof HTMLElement)) return -1; - s.scrollTop = s.scrollHeight; - return s.scrollHeight; - }); - const stable = h > 0 && h === lastHeight; - lastHeight = h; - return stable; - }, - { timeout: 6_000, intervals: [150, 250, 350] }, - ) - .toBe(true); - - // Reset to the top and poll until `scrollTop` is parked at 0 — scroll - // anchoring can re-nudge it after the large content materialization above. - await expect - .poll( - () => - page.evaluate(() => { - const s = document.querySelector('[data-testid="editor-scroll-container"]'); - if (!(s instanceof HTMLElement)) return -1; - if (s.scrollTop !== 0) s.scrollTop = 0; - return s.scrollTop; - }), - { timeout: 3_000, intervals: [100, 200] }, - ) - .toBe(0); -} - /** * Wait for the scroll container's `scrollTop` to stop moving for two * consecutive polls separated by ~150 ms. Smooth scrolling has end-state diff --git a/packages/app/tests/stress/prop-upload.e2e.ts b/packages/app/tests/stress/prop-upload.e2e.ts index 54746d057..d74811e33 100644 --- a/packages/app/tests/stress/prop-upload.e2e.ts +++ b/packages/app/tests/stress/prop-upload.e2e.ts @@ -34,10 +34,9 @@ * unified `/api/upload` endpoint is accept-all and the prior * magic-byte-mismatch rejection no longer applies. * - * This file is NOT in the CI `test:e2e` file list - * (`packages/app/package.json` dispatches a fixed subset for PR-tier runs); - * generic `bunx playwright test` invocations run it for pre-push coverage, - * and the nightly E2E stability surveillance picks up flakes. + * This file is in the CI `test:e2e` file list (`packages/app/package.json`), + * so it runs on every PR; the nightly E2E stability surveillance also picks up + * flakes. */ import { randomUUID } from 'node:crypto'; @@ -183,8 +182,15 @@ for (const kind of ['img', 'video', 'audio'] as const) { await page.waitForSelector('.ProseMirror:not(.composer-prosemirror)'); await waitForActiveProviderSynced(page); - // Initial src is the seeded placeholder. - expect(await readSrc(page, c.tag)).toBe(c.initialSrc); + // Initial src is the seeded placeholder, rendered as a server-absolute + // URL: raw JSX media src passes through normalizeDocRelativeMediaRenderProps + // -> normalizeDocRelativeAssetUrl, which roots a doc-relative src at the + // contentDir. These docs live at root, so `initial.ext` -> `/initial.ext`. + // This normalized value is also the baseline waitForSrcChange must compare + // against; passing the raw `initial.ext` would make it short-circuit on the + // raw-vs-normalized delta before the upload replaces the src. + const normalizedInitialSrc = `/${c.initialSrc}`; + expect(await readSrc(page, c.tag)).toBe(normalizedInitialSrc); const panel = await openPropPanel(page); const fileInput = panel.locator('[data-prop-upload-input]').first(); @@ -196,7 +202,7 @@ for (const kind of ['img', 'video', 'audio'] as const) { mimeType: c.payloads[0].mimeType, buffer: c.payloads[0].buffer, }); - const srcAfterFirst = await waitForSrcChange(page, c.tag, c.initialSrc); + const srcAfterFirst = await waitForSrcChange(page, c.tag, normalizedInitialSrc); expect(srcAfterFirst).not.toBe(c.initialSrc); // Server returns the contentDir-relative `path` (POSIX-normalized, no // leading slash from `relative()`); the upload-file.ts client helper @@ -270,7 +276,13 @@ test('UPLOAD-IMG-SUBDIR-01: subdir-doc upload renders that fetches the ass await page.waitForSelector('.ProseMirror:not(.composer-prosemirror)'); await waitForActiveProviderSynced(page); - expect(await readSrc(page, 'img')).toBe(cases.img.initialSrc); + // The doc lives under `sidebar-folder/`, so the doc-relative `initial.png` + // normalizes to the server-absolute `/sidebar-folder/initial.png` at render + // time (normalizeDocRelativeAssetUrl joins the doc's directory). This is also + // the waitForSrcChange baseline (see UPLOAD-*-01 for why the raw src would + // short-circuit the wait). + const normalizedInitialSrc = `/sidebar-folder/${cases.img.initialSrc}`; + expect(await readSrc(page, 'img')).toBe(normalizedInitialSrc); const panel = await openPropPanel(page); const fileInput = panel.locator('[data-prop-upload-input]').first(); @@ -282,7 +294,7 @@ test('UPLOAD-IMG-SUBDIR-01: subdir-doc upload renders that fetches the ass buffer: cases.img.payloads[0].buffer, }); - const newSrc = await waitForSrcChange(page, 'img', cases.img.initialSrc); + const newSrc = await waitForSrcChange(page, 'img', normalizedInitialSrc); expect(newSrc).not.toBe(cases.img.initialSrc); // Subdir is threaded through `path` (contentDir-relative, no leading @@ -332,7 +344,10 @@ test('UPLOAD-IMG-ERR: 0-byte upload → 400 No file received → toast.error → await page.waitForSelector('.ProseMirror:not(.composer-prosemirror)'); await waitForActiveProviderSynced(page); - expect(await readSrc(page, 'img')).toBe(cases.img.initialSrc); + // Root doc: the seeded `initial.png` renders as the server-absolute + // `/initial.png` (doc-relative normalization). + const normalizedInitialSrc = `/${cases.img.initialSrc}`; + expect(await readSrc(page, 'img')).toBe(normalizedInitialSrc); const panel = await openPropPanel(page); const fileInput = panel.locator('[data-prop-upload-input]').first(); @@ -356,5 +371,5 @@ test('UPLOAD-IMG-ERR: 0-byte upload → 400 No file received → toast.error → // src must NOT have changed — the rejection short-circuited before // `onUploaded(url)` could run. - expect(await readSrc(page, 'img')).toBe(cases.img.initialSrc); + expect(await readSrc(page, 'img')).toBe(normalizedInitialSrc); }); diff --git a/packages/app/tests/stress/rename-consolidation.e2e.ts b/packages/app/tests/stress/rename-consolidation.e2e.ts index 136478aad..f060a2490 100644 --- a/packages/app/tests/stress/rename-consolidation.e2e.ts +++ b/packages/app/tests/stress/rename-consolidation.e2e.ts @@ -56,7 +56,8 @@ test.describe('rename-consolidation — browser-fidelity outcomes', () => { }, baseURL); expect(renameRes.status).toBe(200); - expect(renameRes.body.ok).toBe(true); + // The RFC 9457 success envelope carries no `ok` field; HTTP 200 is the + // success signal and the schema body is emitted flat. expect(renameRes.body.renamed).toEqual([{ fromDocName: 'auth', toDocName: 'sso' }]); expect(Array.isArray(renameRes.body.rewrittenDocs)).toBe(true); expect(renameRes.body.rewrittenDocs.length).toBeGreaterThan(0); @@ -127,7 +128,6 @@ test.describe('rename-consolidation — browser-fidelity outcomes', () => { }, baseURL); expect(renameRes.status).toBe(200); - expect(renameRes.body.ok).toBe(true); expect(renameRes.body.renamed).toHaveLength(3); const renamedFromPaths = renameRes.body.renamed .map((r: { fromDocName: string }) => r.fromDocName) @@ -184,7 +184,6 @@ test.describe('rename-consolidation — browser-fidelity outcomes', () => { return { status: r.status, body: await r.json() }; }, baseURL); expect(renameRes.status).toBe(200); - expect(renameRes.body.ok).toBe(true); expect(renameRes.body.renamed).toEqual([{ fromDocName: 'auth-doc', toDocName: 'sso-doc' }]); // Tree updates: 'sso-doc.md' visible. @@ -203,7 +202,7 @@ test.describe('rename-consolidation — browser-fidelity outcomes', () => { }, baseURL); expect([200, 400]).toContain(historyRes.status); if (historyRes.status === 200) { - expect(historyRes.body?.ok).toBe(true); + // HistorySuccessSchema is emitted flat under RFC 9457 — no `ok` field. expect(Array.isArray(historyRes.body?.entries)).toBe(true); } }); diff --git a/packages/app/tests/stress/rename-content-preservation.e2e.ts b/packages/app/tests/stress/rename-content-preservation.e2e.ts index 6aeecb964..86e7e2745 100644 --- a/packages/app/tests/stress/rename-content-preservation.e2e.ts +++ b/packages/app/tests/stress/rename-content-preservation.e2e.ts @@ -91,7 +91,6 @@ test.describe('FileTree sidebar rename — content preservation', () => { status: 200, contentType: 'application/json', body: JSON.stringify({ - ok: true, pages: [ { docName: 'foo/index', @@ -106,9 +105,20 @@ test.describe('FileTree sidebar rename — content preservation', () => { }); await page.route('**/api/rename-path', async (route) => { - const body = route.request().postDataJSON() as { fromPath?: string; toPath?: string } | null; + const body = route.request().postDataJSON() as { + kind?: string; + fromPath?: string; + toPath?: string; + } | null; const response = await route.fetch(); - if (body?.fromPath === 'bar/hello' && body?.toPath === 'bar/something') { + // The file-tree rename payload is kind-bearing and carries the + // destination tree path with its extension: fromPath is the extension- + // less docName, toPath keeps the `.md` suffix (normalizeTreePathForKind). + if ( + body?.kind === 'file' && + body?.fromPath === 'bar/hello' && + body?.toPath === 'bar/something.md' + ) { serveLaggingPages = true; } await route.fulfill({ response }); diff --git a/packages/app/tests/stress/timeline-diff-sidepane.e2e.ts b/packages/app/tests/stress/timeline-diff-sidepane.e2e.ts deleted file mode 100644 index c450942a4..000000000 --- a/packages/app/tests/stress/timeline-diff-sidepane.e2e.ts +++ /dev/null @@ -1,619 +0,0 @@ -/** - * E2E coverage for the Timeline inline-diff side-pane. - * - * Each test creates its own uniquely-named doc via the `api` fixture — - * STOP rule: never hardcode 'test-doc'; workers share a dev-server instance - * and parallel tests would corrupt shared CRDT state. - * - * **Test infrastructure dependency.** These tests require the worker's - * Hocuspocus instance to have a working shadow-repo so `/api/history` - * returns Timeline entries. The per-worker fixture in `_helpers/fixtures.ts` - * satisfies this by spawning the dev server with `OK_TEST_GIT_ENABLED=1`, - * which opts the isolated `contentDir` into shadow-repo mode (git-init + - * `gitEnabled: true` in `createServer`). `/api/save-version` and - * `/api/history` work correctly in the worker environment. - * - * Reference patterns: - * - agent-activity-panel.e2e.ts (per-test seeding + writeAsAgent) - * - outline-navigation.e2e.ts (DocPanel tab panel selectors) - */ -import { randomUUID } from 'node:crypto'; -import type { Page } from '@playwright/test'; -import type { ApiHelpers } from './_helpers'; -import { expect, test } from './_helpers'; - -function uid() { - return `tl-${randomUUID().slice(0, 8)}`; -} - -const AGENT = { - agentId: `agent-${randomUUID().slice(0, 8)}`, - agentName: 'Claude', - clientName: 'claude' as const, -}; - -// ── Shared helpers ───────────────────────────────────────────────────────────── - -/** - * Best-effort `/api/save-version` call. The checkpoint it writes is NOT shown - * in the Timeline (the panel filters checkpoint rows — they're cleanup-job - * artifacts now). We still call it between writes because save-version resets - * the per-writer WIP refs, so each subsequent write lands as its own distinct - * WIP commit — and those WIP rows ARE what the Timeline surfaces. Network - * errors and 400 (no shadow repo in the test fixture) are silently dropped; - * any other unexpected HTTP status throws so failures don't disappear. - */ -async function saveVersion(baseURL: string): Promise { - let res: Response; - try { - res = await fetch(`${baseURL}/api/save-version`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - agentId: AGENT.agentId, - agentName: AGENT.agentName, - clientName: AGENT.clientName, - }), - }); - } catch { - return; // network error — best-effort - } - if (!res.ok && res.status !== 400) { - throw new Error(`save-version unexpected HTTP ${res.status}`); - } -} - -/** - * Seed a doc with N versions. Each version lands as a distinct WIP commit - * (the save-version call between writes resets the WIP refs so they don't - * coalesce). The Timeline surfaces those WIP rows as a flat list; the - * checkpoints save-version produces are filtered out of the panel. - */ -async function seedTimelineDoc( - api: ApiHelpers, - baseURL: string, - docName: string, - versions: string[], -): Promise { - if (versions.length === 0) throw new Error('seedTimelineDoc: versions must be non-empty'); - const [first, ...rest] = versions; - await api.seedDocs([{ name: docName, markdown: first }]); - await saveVersion(baseURL); - for (const v of rest) { - await api.writeAsAgent(docName, v, AGENT); - await saveVersion(baseURL); - } -} - -async function navigateToDoc(page: Page, docName: string) { - await page.goto(`/#/${docName}`); - await expect(page.locator('.ProseMirror:not(.composer-prosemirror)')).toBeVisible({ - timeout: 15_000, - }); -} - -/** Click the Timeline tab in DocPanel and wait for the loading skeleton to settle. */ -async function openTimelineTab(page: Page) { - await page.getByRole('tab', { name: 'Timeline' }).click(); - await expect(page.locator('#panel-timeline')).toBeVisible(); - await expect(page.locator('[aria-label="Loading timeline history"]')).toBeHidden({ - timeout: 15_000, - }); -} - -/** All EntryRow expand-zone buttons (div[role="button"]) in the Timeline panel. */ -function expandButtons(page: Page) { - return page.locator('[data-testid="timeline-entry-expand"]'); -} - -/** All open diff content panels (present in DOM only while a row is expanded). */ -function diffPanels(page: Page) { - return page.locator('[data-testid="timeline-entry-diff"]'); -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -test.describe('Timeline inline diff — side pane', () => { - // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - test('AC-D1: click entry expands inline diff; click again collapses; main editor untouched', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, [ - '# v1\n\nInitial content.', - '# v2\n\nSecond version.', - ]); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).first()).toBeVisible({ timeout: 15_000 }); - const row = expandButtons(page).first(); - - await expect(row).toHaveAttribute('aria-expanded', 'false'); - await expect(diffPanels(page)).toHaveCount(0); - - await row.click(); - await expect(row).toHaveAttribute('aria-expanded', 'true'); - await expect(diffPanels(page)).toHaveCount(1); - - // Main editor is still visible — no hijack - await expect(page.locator('.ProseMirror:not(.composer-prosemirror)')).toBeVisible(); - - await row.click(); - await expect(row).toHaveAttribute('aria-expanded', 'false'); - await expect(diffPanels(page)).toHaveCount(0); - }); - - // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - test('AC-D2: multiple entries can be open simultaneously (multi-expand)', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, [ - '# v1\n\nInitial.', - '# v2\n\nVersion 2.', - '# v3\n\nVersion 3.', - ]); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).nth(2)).toBeVisible({ timeout: 15_000 }); - - await expandButtons(page).nth(0).click(); - await expandButtons(page).nth(1).click(); - await expandButtons(page).nth(2).click(); - - await expect(expandButtons(page).nth(0)).toHaveAttribute('aria-expanded', 'true'); - await expect(expandButtons(page).nth(1)).toHaveAttribute('aria-expanded', 'true'); - await expect(expandButtons(page).nth(2)).toHaveAttribute('aria-expanded', 'true'); - await expect(diffPanels(page)).toHaveCount(3); - }); - - // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - test('AC-D3: second expand of same entry uses cache — no second GET /api/history/{sha}', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, ['# v1\n\nInitial.', '# v2\n\nSecond version.']); - - let historyShaCalls = 0; - page.on('request', (req) => { - if (/\/api\/history\/\w+/.test(req.url())) historyShaCalls++; - }); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).first()).toBeVisible({ timeout: 15_000 }); - const row = expandButtons(page).first(); - - // First expand — triggers the SHA fetch - await row.click(); - await page.waitForResponse(/\/api\/history\/\w+/, { timeout: 10_000 }); - await expect(page.getByText('Loading diff…')).toBeHidden({ timeout: 10_000 }); - expect(historyShaCalls).toBe(1); - - await row.click(); - await expect(diffPanels(page)).toHaveCount(0); - - // Re-expand — cache hit, no new request - await row.click(); - await expect(diffPanels(page)).toHaveCount(1); - await expect(page.getByText('Loading diff…')).toBeHidden({ timeout: 5_000 }); - expect(historyShaCalls).toBe(1); - }); - - // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - test('AC-D5: Restore icon (aria-label + tooltip) is visible on every row — collapsed and expanded', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, ['# v1', '# v2', '# v3']); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).nth(2)).toBeVisible({ timeout: 15_000 }); - - const rowCount = await expandButtons(page).count(); - const restoreButtons = page.locator('[data-testid="timeline-entry-restore"]'); - - await expect(restoreButtons).toHaveCount(rowCount); - for (let i = 0; i < rowCount; i++) { - // Every row is an actor/system commit now, so the affordance reads - // "Restore to this point". Assert it starts with "Restore" so the test - // survives copy refinement without false negatives. - const label = await restoreButtons.nth(i).getAttribute('aria-label'); - expect(label).toMatch(/^Restore/); - } - - // After expanding row 0, all Restore buttons remain (no count change) - await expandButtons(page).first().click(); - await expect(restoreButtons).toHaveCount(rowCount); - await expect(restoreButtons.first()).toBeVisible(); - }); - - // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - test('AC-D6: Restore icon click does NOT expand the row (stopPropagation)', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, ['# v1', '# v2']); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).first()).toBeVisible({ timeout: 15_000 }); - const row = expandButtons(page).first(); - - await expect(row).toHaveAttribute('aria-expanded', 'false'); - - await page.locator('[data-testid="timeline-entry-restore"]').first().click(); - - await expect(row).toHaveAttribute('aria-expanded', 'false'); - await expect(diffPanels(page)).toHaveCount(0); - - await expect(page.locator('[role="dialog"]')).toBeVisible(); - await expect(page.locator('[role="alertdialog"]')).toHaveCount(0); - }); - - // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - test('AC-D7: Dialog confirm fires POST /api/rollback with correct body; dialog closes on success', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, ['# v1\n\nInitial.', '# v2\n\nUpdated.']); - - let capturedBody: Record | null = null; - await page.route('/api/rollback', async (route) => { - capturedBody = JSON.parse(route.request().postData() ?? '{}') as Record; - await route.fulfill({ status: 200, body: JSON.stringify({ ok: true }) }); - }); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).first()).toBeVisible({ timeout: 15_000 }); - - await page.locator('[data-testid="timeline-entry-restore"]').first().click(); - - const dialog = page.locator('[role="dialog"]'); - await expect(dialog).toBeVisible(); - await expect(page.locator('[role="alertdialog"]')).toHaveCount(0); - - await expect(dialog.getByTestId('timeline-entry-restore-confirm')).toBeVisible(); - await expect(dialog.getByTestId('timeline-entry-restore-cancel')).toBeVisible(); - - await dialog.getByTestId('timeline-entry-restore-confirm').click(); - - await expect(async () => { - expect(capturedBody).not.toBeNull(); - expect(capturedBody?.docName).toBe(doc); - expect(typeof capturedBody?.commitSha).toBe('string'); - expect((capturedBody?.commitSha as string).length).toBeGreaterThan(0); - }).toPass({ timeout: 5_000 }); - - await expect(dialog).toBeHidden(); - }); - - // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - test('AC-D8: split/unified toggle in EditorHeader re-renders expanded diff layout', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, [ - '# v1\n\nInitial content here.', - '# v2\n\nUpdated content here.', - ]); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).first()).toBeVisible({ timeout: 15_000 }); - - await expandButtons(page).first().click(); - await expect(diffPanels(page)).toHaveCount(1); - await expect(page.getByText('Loading diff…')).toBeHidden({ timeout: 10_000 }); - - const layoutGroup = page.getByRole('group', { name: 'Diff layout' }); - await expect(layoutGroup).toBeVisible(); - - // The diff renderer (react-diff-view) emits a single root with - // class `diff diff-{viewType}` — we assert against the rendered structure - // so a future regression that drops the prop chain (e.g. hardcoding - // `viewType="unified"` on ActivityPanelDiffView) fails this test. - const diffRoot = diffPanels(page).first().locator('table.diff').first(); - - await layoutGroup.getByRole('button', { name: 'Split diff' }).click(); - await expect(layoutGroup.getByRole('button', { name: 'Split diff' })).toHaveAttribute( - 'data-state', - 'on', - ); - await expect(diffRoot).toHaveClass(/diff-split/); - - await expect(diffPanels(page)).toHaveCount(1); - - await layoutGroup.getByRole('button', { name: 'Unified diff' }).click(); - await expect(layoutGroup.getByRole('button', { name: 'Unified diff' })).toHaveAttribute( - 'data-state', - 'on', - ); - await expect(diffRoot).toHaveClass(/diff-unified/); - }); - - // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - test('AC-D9: navigating to a different doc resets per-entry expanded state', async ({ - page, - api, - baseURL, - }) => { - const docA = uid(); - const docB = uid(); - - await seedTimelineDoc(api, baseURL ?? '', docA, ['# Doc A v1', '# Doc A v2']); - - // Seed docB without resetting the workspace (use createPage + replaceDoc + saveVersion). - await api.createPage(`${docB}.md`); - await api.replaceDoc(docB, '# Doc B v1'); - await saveVersion(baseURL ?? ''); - await api.writeAsAgent(docB, '# Doc B v2', AGENT); - await saveVersion(baseURL ?? ''); - - await navigateToDoc(page, docA); - await openTimelineTab(page); - await expect(expandButtons(page).first()).toBeVisible({ timeout: 15_000 }); - await expandButtons(page).first().click(); - await expect(diffPanels(page)).toHaveCount(1); - - // Navigate to docB — Timeline tab stays active (activeTab state lives in EditorPane) - await page.goto(`/#/${docB}`); - await expect(page.locator('.ProseMirror:not(.composer-prosemirror)')).toBeVisible({ - timeout: 15_000, - }); - await expect(page.locator('[aria-label="Loading timeline history"]')).toBeHidden({ - timeout: 15_000, - }); - await expect(expandButtons(page).first()).toBeVisible({ timeout: 15_000 }); - - // No diff panels — docB's entries are all collapsed (per-row expanded state reset) - await expect(diffPanels(page)).toHaveCount(0); - const count = await expandButtons(page).count(); - for (let i = 0; i < count; i++) { - await expect(expandButtons(page).nth(i)).toHaveAttribute('aria-expanded', 'false'); - } - }); - - // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - test('AC-D10: main editor is never replaced; no sticky "Viewing:" bar appears', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, ['# v1', '# v2']); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).first()).toBeVisible({ timeout: 15_000 }); - - await expect(page.locator('.ProseMirror:not(.composer-prosemirror)')).toBeVisible(); - - await expandButtons(page).first().click(); - await page.locator('[data-testid="timeline-entry-restore"]').first().click(); - - await expect(page.locator('.ProseMirror:not(.composer-prosemirror)')).toBeVisible(); - await expect(page.getByText(/^Viewing:/)).toHaveCount(0); - }); - - // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - test('AC-D11: expanded entry has no Close button; only the Restore icon action button', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, ['# v1', '# v2']); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).first()).toBeVisible({ timeout: 15_000 }); - await expandButtons(page).first().click(); - await expect(diffPanels(page)).toHaveCount(1); - - await expect( - page.locator('#panel-timeline').getByRole('button', { name: /close/i }), - ).toHaveCount(0); - - await expect(page.locator('[data-testid="timeline-entry-restore"]').first()).toBeVisible(); - }); - - // ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - test('FR-D17: all expanded entries collapse after a successful restore', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, [ - '# v1\n\nInitial.', - '# v2\n\nVersion 2.', - '# v3\n\nVersion 3.', - ]); - - await page.route('/api/rollback', (route) => - route.fulfill({ status: 200, body: JSON.stringify({ ok: true }) }), - ); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).nth(2)).toBeVisible({ timeout: 15_000 }); - - await expandButtons(page).nth(0).click(); - await expandButtons(page).nth(1).click(); - await expect(diffPanels(page)).toHaveCount(2); - - await page.locator('[data-testid="timeline-entry-restore"]').first().click(); - await page.getByTestId('timeline-entry-restore-confirm').click(); - - await expect(diffPanels(page)).toHaveCount(0, { timeout: 5_000 }); - await expect(expandButtons(page).nth(0)).toHaveAttribute('aria-expanded', 'false'); - await expect(expandButtons(page).nth(1)).toHaveAttribute('aria-expanded', 'false'); - }); - - // ── Restore failure ───────────────────────────────────────────────────────── - test('restore failure: toast appears, dialog stays open, both confirm + outer Restore re-enable', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, ['# v1', '# v2']); - - await page.route('/api/rollback', (route) => - route.fulfill({ status: 500, body: 'Internal Server Error' }), - ); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).first()).toBeVisible({ timeout: 15_000 }); - - await page.locator('[data-testid="timeline-entry-restore"]').first().click(); - const dialog = page.locator('[role="dialog"]'); - await expect(dialog).toBeVisible(); - await dialog.getByTestId('timeline-entry-restore-confirm').click(); - - await expect(page.getByText('Restore failed — document unchanged')).toBeVisible({ - timeout: 5_000, - }); - - await expect(dialog).toBeVisible(); - - await expect(dialog.getByTestId('timeline-entry-restore-confirm')).not.toBeDisabled({ - timeout: 5_000, - }); - - await expect(page.locator('[data-testid="timeline-entry-restore"]').first()).not.toBeDisabled({ - timeout: 5_000, - }); - }); - - // ── Cancel aborts in-flight restore ──────────────────────────────────────── - test('Cancel during a slow rollback aborts the request — no side effects fire on late response', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - await seedTimelineDoc(api, baseURL ?? '', doc, ['# v1', '# v2', '# v3']); - - let release: () => void = () => {}; - const released = new Promise((resolve) => { - release = resolve; - }); - let rollbackHits = 0; - await page.route('/api/rollback', async (route) => { - rollbackHits++; - await released; - await route.fulfill({ status: 200, body: JSON.stringify({ ok: true }) }); - }); - - await navigateToDoc(page, doc); - await openTimelineTab(page); - - await expect(expandButtons(page).nth(1)).toBeVisible({ timeout: 15_000 }); - - await expandButtons(page).nth(1).click(); - await expect(diffPanels(page)).toHaveCount(1); - - await page.locator('[data-testid="timeline-entry-restore"]').first().click(); - const dialog = page.locator('[role="dialog"]'); - await dialog.getByTestId('timeline-entry-restore-confirm').click(); - - await expect.poll(() => rollbackHits, { timeout: 5_000 }).toBe(1); - - await dialog.getByTestId('timeline-entry-restore-cancel').click(); - await expect(dialog).toBeHidden(); - - // Verify the late response fires no side-effects. Auto-retrying assertions - // can't catch absence-of-change, so wait for the response to actually arrive - // and yield two frames to let React run (or correctly skip) handlers first. - const lateResponse = page.waitForResponse('/api/rollback'); - release(); - await lateResponse; - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }), - ); - - await expect(diffPanels(page)).toHaveCount(1); - await expect(expandButtons(page).nth(1)).toHaveAttribute('aria-expanded', 'true'); - - await expect(page.locator('[data-testid="timeline-entry-restore"]').first()).not.toBeDisabled({ - timeout: 5_000, - }); - }); - - // ── Real (un-mocked) rollback round-trip ─────────────────────────────────── - test('un-mocked rollback round-trip: confirming Restore actually replaces editor content with the historical version', async ({ - page, - api, - baseURL, - }) => { - const doc = uid(); - const v1 = '# Doc\n\nVersion-one body that we will restore to.'; - const v2 = '# Doc\n\nVersion-two body that we are throwing away.'; - await seedTimelineDoc(api, baseURL ?? '', doc, [v1, v2]); - - await navigateToDoc(page, doc); - - // Editor shows v2 currently - await expect(page.locator('.ProseMirror:not(.composer-prosemirror)')).toContainText( - 'Version-two body', - ); - - await openTimelineTab(page); - await expect(expandButtons(page).first()).toBeVisible({ timeout: 15_000 }); - - // The OLDEST entry (last in the rendered list) is the v1 WIP commit - // (createPage + replaceDoc coalesce into one non-agent WIP row). - const lastRestoreIcon = page.locator('[data-testid="timeline-entry-restore"]').last(); - await lastRestoreIcon.click(); - - const dialog = page.locator('[role="dialog"]'); - await expect(dialog).toBeVisible(); - await dialog.getByTestId('timeline-entry-restore-confirm').click(); - - // Editor content updates via the CRDT bridge to the v1 body - await expect(page.locator('.ProseMirror:not(.composer-prosemirror)')).toContainText( - 'Version-one body', - { - timeout: 15_000, - }, - ); - await expect(page.locator('.ProseMirror:not(.composer-prosemirror)')).not.toContainText( - 'Version-two body', - ); - }); -});