plans/hybrid-more-ui-harness.md
Goal: migrate every e2e spec whose behavior the hybrid vitest harness can drive through the real trigger UI, keeping the harness's deep assertions (LLM payloads, db rows, files, git log). Two groups:
Delivery model: land this as one large consolidated PR, not lots of little PRs. Use reviewable sections or commits inside that single PR, but do not open phase-by-phase, family-by-family, or spec-by-spec PRs. Keep Phase 0/1/2/3 progress on the same branch and keep updating the same PR as additional families migrate. Each migrated e2e spec is deleted or slimmed in the same PR as its replacement integration coverage.
Beyond-ChatPanel flows, grouped by the surface that must become mountable:
| Family | Specs | New surface(s) |
|---|---|---|
| Title-bar / app-list / dialogs | copy_app, delete_app, rename_app, switch_apps, new_chat | TitleBar + /app-details page (+ ChatList / home-lite) |
| Settings / dialog pages | telemetry, max_tool_call_steps, smart_context_options, turbo_edits_options, dyad_pro_key_validation, supabase_migrations (toggle) | settings page sections / standalone settings components + /settings/providers/$provider |
| Publish / connector panels | github, github-import, git_collaboration, neon_branch, neon_migration, media_library | GitHub/Neon connectors (via /app-details), ImportAppDialog, DatabaseSection/MigrationPanel, /media page |
These drive surfaces the harness already mounts (chat input, message cards, banners, queue header, aux menus). Same migration discipline as everything else: keep the expect-by-expect migration checklist, delete each e2e spec in the same consolidated PR as its replacement, and group the work into reviewable commits/sections instead of many small PRs. Where a spec has a case outside ChatPanel, the case that stays e2e is named here explicitly.
| Spec | Drive / assert | Stays e2e |
|---|---|---|
chat_input.spec.ts | Send enable/disable vs approve/reject proposal buttons | — |
context_limit_banner.spec.ts | context-limit banner + summarize button; seed custom-model config via settings instead of the settings UI | — |
default_chat_mode.spec.ts | chat-mode selector default text after seeding defaultChatMode setting | settings-page dropdown case |
local_agent_basic.spec.ts | questionnaire/blueprint approval cards + file/db effects | name-conflict dialog / title-bar case |
local_agent_consent.spec.ts | consent banner, Always/Once/Decline buttons + message snapshots | — |
local_agent_grep.spec.ts | dyad-grep card content/clicks; convert aria snapshots to text assertions | — |
notification_banner.spec.ts | in-chat notification-tip banner dismiss/enable + settings delta | native-notification guide dialog case |
pause_queue.spec.ts | queue header pause/resume/paused state (queue processor mounts in the harness as of the step-limit migration) | — |
plan_mode.spec.ts | accept-plan buttons, questionnaire, mode selector, plan file on disk | plan annotations / View-Plan PreviewPanel cases |
streaming_renderer.spec.ts | streamed dyad-write block rendering + pending indicators | — |
theme_selection.spec.ts | chat-input themes menu, selection + settings persistence | home-page variant |
voice_to_text.spec.ts | mic button pro-gating/enabled states in chat input | real getUserMedia recording case |
Deliverables: 12 integration tests; 6 e2e specs deleted outright, 6 slimmed to their named remaining case(s).
Each row names the blocking extension and where it lives in this plan. Three of the five extensions are already scoped by Phase 0 (0.1/0.3/0.4); only 0.6 and 0.7 are new work items.
| Spec | Blocking extension |
|---|---|
mcp.spec.ts, mcp_auto_consent.spec.ts, mcp_out_of_order.spec.ts, local_agent_advanced.spec.ts (MCP cases) | covered by harness.mcp helpers that seed stdio/http fake MCP servers through the real mcp:* IPC handlers and drive consent/tool cards through real ChatPanel UI |
local_agent_large_attachment.spec.ts, queued_message.spec.ts (attachment cases) | covered by setChatAttachments bridge-level injection into the real ChatInput attachment state; queued_message also uses pressEnterInChat for queue-while-streaming |
chat_image_generation.spec.ts | covered by the existing /chat mount and auxiliary-actions dropdown drivers |
version_search.spec.ts | covered by the existing /chat mount: ChatHeader opens the real VersionPane, so no new surface was needed |
supabase_stale_ui.spec.ts | covered by the /app-details?provider=supabase surface plus the test-build fake Supabase connect path |
local_agent_explore_code.spec.ts | defer — needs the code-explorer/tsc worker backend, which isn't available under vitest (worker bundling); migrate as node-harness payload test or keep e2e until solved |
Implemented as harness.mcp in
src/testing/hybrid_chat_harness.tsx. It creates/deletes MCP server rows
through the real mcp:* IPC handlers, waits for live tool discovery through
mcp:list-tools, uses the same testing/fake-stdio-mcp-server.mjs stdio server
the e2e suite used, and spawns testing/fake-http-mcp-server.mjs on an
ephemeral loopback port for HTTP transport coverage. HTTP child processes are
tracked and killed during harness teardown.
The fake LLM server's MCP auto-consent classifier shortcut now exists on both responses and chat-completions paths, so local-agent classifier tests exercise the same safe/destructive decisions regardless of which fake model route the harness-selected engine model uses.
A harness helper that seeds attachments the way the real file-picker handoff does (bridge-level injection into the chat input's attachment state), so attachment-carrying prompts can be sent through the real submit path. Unlocks 2 specs.
Implemented as harness.setChatAttachments([{ name, content, mimeType, type }])
in src/testing/hybrid_chat_harness.tsx. It writes browser File objects into
attachmentsAtom, leaving submit, queueing, FileReader conversion, IPC stream
params, and .dyad/media persistence on the real production path. The harness
also exposes setSelectedComponents for queue edit/restore assertions that only
need ChatInput state; real iframe component picking stays in E2E.
Migrated CP2 coverage so far: mcp.spec.ts,
mcp_auto_consent.spec.ts, mcp_out_of_order.spec.ts, and the MCP cases from
local_agent_advanced.spec.ts are covered in
src/ipc/handlers/__tests__/mcp.integration.test.tsx, which seeds stdio and
HTTP MCP servers via production IPC, drives build-mode MCP consent/tool cards,
asserts merged out-of-order parallel tool cards, exercises local-agent MCP
auto-consent safe/destructive/manual/pending-classifier paths, and renders MCP
tool-search/schema cards in forced search mode. The fully migrated MCP e2e specs
and their snapshots are deleted; local_agent_advanced.spec.ts is slimmed to
its remaining non-MCP cases. chat_image_generation.spec.ts is covered in
src/ipc/handlers/__tests__/chat_image_generation.integration.test.tsx, which
drives the chat auxiliary-actions dropdown -> real ImageGeneratorDialog ->
real generate-image IPC handler against the fake engine image endpoint, then
asserts the generated media file, enabled send button with no text input,
composer thumbnail dismissal, rendered sent-message image attachment, and DB
message attachment tag. version_search.spec.ts is covered in
src/ipc/handlers/__tests__/version_search.integration.test.tsx, which drives
ChatHeader -> VersionPane through the existing /chat mount and asserts
version-number/message/note search, empty results, clear, favorite-only
filtering, note persistence, favorite persistence, and close/reopen reset
behavior. local_agent_large_attachment.spec.ts is covered in
src/ipc/handlers/__tests__/local_agent_large_attachment.integration.test.tsx,
which seeds a large text attachment into the real ChatInput attachment atom,
sends the prompt through the real local-agent submit path, asserts the rendered
MustardScript card reads attachments:large-log.txt, and verifies the stored
attachment manifest entry. The attachment restore case from
queued_message.spec.ts is covered in
src/ipc/handlers/__tests__/queued_message.integration.test.tsx, which queues a
message during a real stream, restores its seeded attachment and selected
component while editing, clears both on submit, and lets the queue drain through
the real processor. supabase_stale_ui.spec.ts is covered in
src/ipc/handlers/__tests__/supabase_stale_ui.integration.test.tsx, which
connects Supabase for one app through the real app-details connector, navigates
the same mounted route to a second app, and asserts the first app's connected
project card does not leak into the second app's setup UI.
e2e-tests/chat_image_generation.spec.ts,
e2e-tests/version_search.spec.ts,
e2e-tests/local_agent_large_attachment.spec.ts, and
e2e-tests/supabase_stale_ui.spec.ts are deleted on the same consolidated
migration PR. e2e-tests/queued_message.spec.ts is slimmed to the remaining
non-attachment queue and real-preview component-selection coverage.
setupHybridChatHarness (src/testing/hybrid_chat_harness.tsx) already runs
every main-process IPC handler (registerIpcHandlers() — the same call
main.ts makes), a real sqlite db, a real git checkout, the real fake-LLM
server, and a real window.electron bridge. The only thing that is
chat-specific is mount(): it builds a private route tree with a single
/chat route rendering <ChatPanel> inside QueryClient + Jotai + Theme +
Toaster scaffolding (lines ~335–400).
So the work is almost entirely renderer-side: mount more surfaces over the
already-real IPC stack. copy-app, delete-app, rename-app, create-chat,
github:*, neon:*, media:*, set-user-settings, validate-provider-api-key
handlers are all registered today; the buttons that call them just aren't
rendered.
Verified constraints to design around (from the component mapping):
select-app-location folder move, ImportAppDialog's
"Local Folder" tab, selectAppFolder) are outside or severable from these
flows. openExternalUrl appears in several places but is fire-and-forget.E2E_TEST_BUILD / FAKE_LLM_PORT are not set by the harness today
(verified: no reference in src/testing/*). GitHub handlers
(src/ipc/handlers/github_handlers.ts ~L67–80) and the Neon mock client
(src/neon_admin/neon_management_client.ts getNeonClient() L98) both gate
on IS_TEST_BUILD (= E2E_TEST_BUILD === "true", read at import time)
and route to http://localhost:${FAKE_LLM_PORT}/github/... / an in-process
mock. The harness's fake server already serves the /github/* routes
(testing/fake-llm-server/githubHandler.ts).src/testing/hybrid.setup.ts:
t: (key, fallback) => typeof fallback === "string" ? fallback : key).
Several target components select by translated text in e2e
(e.g. "Copy app with history", ai.maxToolCallSteps combobox name).ChatPage
instead of ChatPanel hangs on Monaco/iframe (HYBRID_HARNESS.md §7).
PublishPanel lives in src/components/preview_panel/ — same risk class.
Every new surface needs an import audit before it's blessed.Design principles (carried over from the existing harness):
IS_TEST_BUILD mock Neon,
neon:fake-connect). We do not stub ipc.* client methods.data-testid/aria-label selectors; where a spec's identity depends
on user-visible text, fix the i18n mock (Phase 0.4) instead of weakening the
selector.mountSurface() + a multi-route private treeExtend mount()'s scaffolding into a reusable mountSurface(opts):
harness.mountSurface({
route: "/app-details", // one of the registered test routes
search: { appId: harness.appId }, // validated by the REAL route schema
withTitleBar: true, // wrap in a layout that renders <TitleBar/>
});
validateSearch schema from its route module the way /chat
imports chatSearchSchema (and, like chatSearchSchema, split schemas out
of route files whose static imports drag in preview/Monaco — audit first):
/ (home-lite — see 1.5), /chat (existing), /app-details
(AppDetailsPage from src/pages/app-details.tsx), /settings
(src/pages/settings.tsx), /settings/providers/$provider
(ProviderSettingsPage), /media (src/pages/media.tsx).<TitleBar/> above <Outlet/> so
flows that start with title-bar-app-name-button → navigate → app-details
run the true entry path (TitleBar needs selectedAppIdAtom + list-apps,
both already live).mount() as a thin alias for
mountSurface({ route: "/chat" }) — zero churn for existing tests.harness.router / harness.currentLocation() so tests can assert
post-action navigation (delete-app → /, copy-app "Open in Chat" → /chat)
against the memory history.SidebarProvider
(TelemetryBanner calls useSidebar()), TooltipProvider, and the DeepLink
provider (useDeepLink — required by NeonConnector).testBuild: true option (GitHub/Neon fakes)New harness option that, after the fake server has bound its ephemeral
port and before the dynamic await import("@/ipc/ipc_host"):
process.env.E2E_TEST_BUILD = "true" and
process.env.FAKE_LLM_PORT = <fake server port>;dispose() (same pattern as
DYAD_SKIP_MANAGED_PNPM_INSTALL).This is sufficient because github_handlers / neon_management_client read
the flags at module import, and ipc_host is only imported dynamically inside
setup. It unlocks: fake GitHub device flow + repo/push routes, test-only
endpoints (/github/api/test/push-events, /clear-push-events,
/reset-repos — expose small harness.github.* fetch helpers over
fakeLlmUrl), the in-process mock Neon client, and the neon:fake-connect
handler. Renderer modules must be audited for their own import-time reads of
these flags (none known today; the guard smoke test in 0.5 catches drift).
happy-dom needs the same coaxing for Radix/Base UI popups that
selectFromBaseUiSelect already implements. Add:
openPopover(trigger) / clickMenuItem(name) — pointerDown/Up + click +
keyboard fallback, used for the app-details more-options popover, media
thumbnail action menus, branch-actions menus.confirmDialog(matcher) / findDialog(matcher) — wait for
role="dialog"/"alertdialog", click a named action, wait for close.setSwitch(labelOrTestId, on) — Radix Switch driver (supabase migrations
toggle, github auto-sync).Each helper lands with the first test that needs it, in the harness file, with a cookbook entry in HYBRID_HARNESS.md.
Replace the passthrough t in src/testing/hybrid.setup.ts with one that
synchronously loads the real src/i18n/locales/en/*.json resources (key
lookup + {{var}} interpolation; fall back to key). This makes user-visible
text selectors ("Copy app with history", "Write SQL migration files",
"Max Tool Calls (Agent)") work identically to production and removes a whole
class of silent selector drift. Existing tests keep passing (keys they relied
on either resolve to the same string or they used testids).
For each new surface, a tiny *.integration.test.tsx that mounts it and
asserts (a) it renders its landmark testid, (b) no missing-channel failures at
dispose (already enforced), (c) the Monaco/iframe modules were not loaded
(same class of guard as hybrid_chat_harness.guard.integration.test.tsx).
This is the cheap tripwire for import-graph regressions.
Exit criteria for Phase 0: guard tests green for /app-details and
/settings; mount() alias keeps all 16 existing hybrid tests byte-identical
green; testBuild: true smoke proves a github:start-flow round trip against
the fake server.
All five flows confirmed native-dialog-free. Entry: mountSurface({ route: "/app-details", search: { appId }, withTitleBar: true }).
| Spec | Drive | Assert |
|---|---|---|
copy_app | more-options popover → "Copy app" dialog → with/without history | copy-app handler effects: new app dir + files, versions; check-app-name warning renders for dupes; "Open in Chat" navigates to /chat (router assert) |
delete_app | more-options → Delete dialog → "Delete App" | db row gone, app dir removed, navigation to / |
rename_app | app-details-rename-app-button → rename dialog → confirm dialog ("Rename app and folder" / "app only") | db name, folder renamed (or not), list-apps refresh reflected in TitleBar data-app-name |
new_chat | mount ChatList (sidebar list, the lighter of the two new-chat-buttons) or ChatHeader inside /chat; click | new chats row, navigation to /chat?id=<new>, messages list empty |
switch_apps | see 1.5 | second app created; TitleBar data-app-name switches; chats scoped per app |
switch_apps / home-liteThe real flow runs through home.tsx + HomeChatInput (heaviest mount:
streaming hooks + Lexical + several home atoms). Two-step approach:
switch_apps by creating the second app through the real
create-app handler + useSelectChat-equivalent seeding, then assert the
UI switch via TitleBar/app-details mounts (this covers the spec's
actual assertions, which are app-identity, not home-page rendering).home_chat_existing_app etc.) justify a HomeChatInput surface; that is
explicitly out of scope here.Deliverables: copy_app, delete_app, rename_app, new_chat,
switch_apps as *.integration.test.tsx; delete the five e2e specs. Covered
in src/ipc/handlers/__tests__/app_details_actions.integration.test.tsx on the
single consolidated migration PR.
All six flows funnel through set-user-settings; assert on the harness's real
settings file plus the rendered control state. Mount targets are deliberately
narrow — a standalone component where the spec never leaves it, the settings
page only where section context matters.
| Spec | Mount | Notes |
|---|---|---|
telemetry | PrivacyBanner (src/components/TelemetryBanner.tsx) standalone, inside SidebarProvider | seed telemetryConsent: "unset"; Accept/Reject → settings delta; "Later" → no delta, hideBannerAtom hides banner |
max_tool_call_steps | /settings page (AI section) or MaxToolCallStepsSelector standalone | drive with selectFromBaseUiSelect; assert maxToolCallSteps in settings file (Default → undefined) |
smart_context_options | ProModeSelector standalone (it's a Popover off the chat input, so it can also be driven from the existing /chat mount) | seed pro key + enableDyadPro: true or buttons are disabled; assert enableProSmartFilesContextMode / proSmartContextOption |
turbo_edits_options | same ProModeSelector mount | assert enableProLazyEditsMode / proLazyEditsMode (v1/v2) |
dyad_pro_key_validation | /settings/providers/$provider route with provider: "auto" | no IPC mocking: pass engine: true (existing option) so the real validate-provider-api-key handler streams against the fake server, which 401s keys matching /invalid/i → real "API key rejected" AlertDialog; happy path with testdyadkey. The ApiKeyConfiguration "Paste" button (navigator.clipboard) is not exercised |
supabase_migrations (toggle) | SupabaseIntegration standalone or /settings Integrations section | toggle only renders when isSupabaseConnected(settings) — seed connected supabase settings the way supabase_branch.integration.test.ts seeds them; assert enableSupabaseWriteSqlMigration delta. The migration-files-on-disk half is covered by a hybrid /chat test that also asserts the native-git clean regression. |
Deliverables: six integration tests; delete telemetry,
max_tool_call_steps, smart_context_options, turbo_edits_options,
dyad_pro_key_validation, and supabase_migrations e2e specs. Covered in
src/ipc/handlers/__tests__/settings_actions.integration.test.tsx and
src/ipc/handlers/__tests__/supabase_migrations.integration.test.tsx on the
single consolidated migration PR.
Requires testBuild: true (0.2). Mount connectors via /app-details (they
render there in production) — not via PublishPanel until its
preview_panel import graph passes the 0.5 guard; DatabaseSection /
MigrationPanelBody are imported directly to sidestep that initially.
| Spec | Drive | Assert / notes |
|---|---|---|
github | /app-details → GitHubConnector: device flow (fake server issues FAKE-CODE; flow events arrive through the real bridge ipc.events.github.*), create-repo / connect-existing, sync | covered in src/ipc/handlers/__tests__/github_actions.integration.test.tsx: push events via harness.github.pushEvents() (fake server test endpoint); app repo rows; settings delta on disconnect/reconnect. Reset with /reset-repos + /clear-push-events between tests |
github-import | ImportAppDialog mounted through the harness-only /import-app surface | covered in src/ipc/handlers/__tests__/github_import.integration.test.tsx: drive the "GitHub URL" tab with custom app name/advanced commands and the authenticated "Your GitHub Repos" tab against the fake GitHub git base; assert imported package.json/vite.config.ts, app DB rows, persisted commands, and default component-tagger upgrade. e2e-tests/github-import.spec.ts is deleted on the same consolidated migration PR |
git_collaboration | GithubBranchManager + GithubCollaboratorManager via /app-details | covered in src/ipc/handlers/__tests__/git_collaboration.integration.test.tsx: create/switch/rename/merge/delete branches against the harness's real git checkout, pull through the branch-actions menu, invite/remove collaborators through fake GitHub routes, and seed direct-git merge conflicts for the AI-resolution and cancel-sync UI paths. e2e-tests/git_collaboration.spec.ts is deleted on the same consolidated migration PR |
neon_branch | NeonConnector via /app-details (DeepLink provider from 0.1) | covered in src/ipc/handlers/__tests__/neon_branch.integration.test.tsx: seed the connected Neon account state, then drive project/branch selects via the real connector UI; assert .env.local DATABASE_URL/POSTGRES_URL/NEON_AUTH_BASE_URL, per-branch auth cookie secret persistence, and app DB branch rows. e2e-tests/neon_branch.spec.ts is deleted on the same consolidated migration PR |
neon_migration | DatabaseSection + MigrationPanelBody mounted through the harness-only /database surface, app row seeded with neonProjectId/branch ids the mock client serves (test-main-branch-id, test-development-branch-id) | covered in src/ipc/handlers/__tests__/neon_migration.integration.test.tsx: "Migrate to Production" → review-SQL dialog (destructive warnings, "I understand…") → real migration:migrate; assert persisted deploy-branch choice, success state, production-branch skip state, and production DATABASE_URL. e2e-tests/neon_migration.spec.ts is deleted on the same consolidated migration PR |
media_library | /media route | covered in src/ipc/handlers/__tests__/media_library.integration.test.tsx: seed files under <app>/.dyad/media with fs, drive thumbnail action menu → rename/move/delete dialogs, drive move's AppSearchSelect popover, and assert filesystem + app rows. e2e-tests/media_library.spec.ts is slimmed to the named "Start New Chat With Image" attachment/navigation remnant |
Deliverables: six integration tests; github is covered in
src/ipc/handlers/__tests__/github_actions.integration.test.tsx,
github-import is covered in
src/ipc/handlers/__tests__/github_import.integration.test.tsx,
git_collaboration is covered in
src/ipc/handlers/__tests__/git_collaboration.integration.test.tsx,
neon_branch is covered in
src/ipc/handlers/__tests__/neon_branch.integration.test.tsx,
neon_migration is covered in
src/ipc/handlers/__tests__/neon_migration.integration.test.tsx, and
e2e-tests/github.spec.ts, e2e-tests/github-import.spec.ts,
e2e-tests/git_collaboration.spec.ts, e2e-tests/neon_branch.spec.ts, and
e2e-tests/neon_migration.spec.ts are deleted on the same consolidated
migration PR. media_library leaves one explicitly-named test in a slimmed e2e
spec (attachment strip); every other e2e file in this family is deleted.
| Risk | Mitigation |
|---|---|
Import graph of a new surface drags Monaco/preview/iframe (the ChatPage failure mode) | 0.5 guard test per surface before any migration lands; import route-schemas not route-files; mount DatabaseSection not PublishPanel |
E2E_TEST_BUILD leaks between files/workers | env snapshot/restore in dispose (existing pattern); forks-pool isolation means per-file anyway |
| Radix popovers/menus dead under happy-dom | 0.3 drivers extend the proven selectFromBaseUiSelect recipe; each driver validated by the guard test of the first surface using it |
| i18n mock change breaks existing 16 hybrid tests | 0.4 keeps key-fallback behavior; run full integration project before merging |
| Device-flow polling timers stall fake-timer-less tests | GitHub flow is real-timer polling against a local server (fast); cap with waitForEvent("github:flow-success")-style helpers, generous timeouts as in existing harness |
| Per-file collect time grows as route tree grows | lazy per-mount route registration (0.1); surfaces a test doesn't request are never imported |
| Migrated test silently asserts less than the e2e original | migration checklist: enumerate every expect/snapshot in the spec, map each to the new test or to an explicit keep/drop note in the PR description (lesson from #3801) |
Native folder pickers (selectAppFolder, select-app-location), real
openExternalUrl browser handoff, preview-iframe behaviors, aria/visual
snapshots of full-app layout, Lexical keystroke fidelity, and the home-page
HomeChatInput surface (revisit only with a dedicated plan).
Ship this as one consolidated PR, not a series of many small PRs. Keep the phases as the internal implementation and review order, with commits/sections that make the large diff navigable. Phase checkpoints may be pushed for review, but they should update the same PR instead of creating new PRs:
testBuild
dependency except pro-key validation, which needs only the existing
engine: true).github first (proves device flow + fake routes),
then git_collaboration (reuses it), then neon pair, media_library,
github-import last (dialog-direct mount is independent).Before requesting final review on the consolidated PR, run the full relevant verification once across the final combined diff: existing hybrid tests, new guard tests, and every migrated integration spec. The PR description should include the migration checklist mapping each removed e2e assertion to its new integration assertion or named keep/drop note.
Beyond-ChatPanel end state: 16 of the 17 specs fully deleted from
e2e-tests/, media_library slimmed to its single named remnant, and every
migrated flow driven through the same clicks a user makes — with
payload/db/git/file assertions the Playwright versions never had.