Skip to content

test(e2e): streaming-UX prerequisites — controllable mock streams, testids, chat fixtures#2088

Merged
2witstudios merged 14 commits into
masterfrom
pu/e1-e2e-prereqs
Jul 16, 2026
Merged

test(e2e): streaming-UX prerequisites — controllable mock streams, testids, chat fixtures#2088
2witstudios merged 14 commits into
masterfrom
pu/e1-e2e-prereqs

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Prerequisite leaves 7.0a, 7.0b, 7.0c of the "Deterministic Chat Rendering & Send" epic (PR 7 page — board z9d17y0zh0jjqk4f4vsomld4). These are the harness the spec leaves 7.1-7.5 stand on; the spec leaves themselves wait for PR6 and are not in this PR.

Three disjoint file scopes, one PR: apps/e2e/support, apps/e2e fixtures, and data-testid attributes in apps/web.

7.0a — controllable mock OpenRouter streams

The mock wrote one pong chunk + usage + [DONE] synchronously and ended — the stream was over before Playwright could observe anything, making every mid-stream assertion in 7.1-7.5 impossible.

Streams are now controllable by an explicit mode, set with POST /__stream-config { mode } and honoured regardless of which model arrives:

Mode Behavior
instant (default) the original path, untouched — byte-identical to master
slow N chunks paced on a timer (default 40 × 250ms ≈ 10s window)
held first chunk, then held open until POST /__release-stream

The mode — not a pacing model name — is the only pacing control. The original design steered the mock by model name, which cannot work: the app rewrites any model outside its static catalog before the provider call, so e2e/slow-stream never reaches the mock. That is finding 1 under Verification below, and it is what 7.1-7.5 must build on.

Plus GET /__streams{open, held, mode} (so specs expect.poll until a stream is actually live instead of sleeping), POST /__stream-config also for pacing (chunks, intervalMs), and /__reset releases open streams + restores defaults (including mode: 'instant') so a held stream can never leak across specs. Abort-safe: every write is guarded on writableEnded/destroyed, and the connection-level close clears the chunk timer, so Stop (7.4) can't crash the mock or pin the event loop past server.close().

7.0b — data-testid instrumentation (pure additive)

Attributes only, no behavior change, so this can merge ahead of the epic's rendering PRs and its testids survive the cutover. 14 testids, each at exactly one production site.

Two board corrections, made on the 7.0b leaf before implementing (its table was written against worktree wt-9vu2uxbg; this branch is master-based):

  1. The sidebar shares no bubble/list/indicator component with the other surfaces. It renders CompactMessageRenderer (not MessageRenderer) plus its own inline list, "Thinking…" indicator and skeleton. The leaf's own example selector (sidebar-chat-tabchat-message) could never have resolved from the files it listed, so those four sites are tagged too — otherwise 7.1/7.4's per-surface scoping would have been silently broken.
  2. The sidebar gate no longer contains isMessagesLoading. On master it is if (!isInitialized) alone; the term is already gone, replaced by the in-place selectMessagesAreaMode indicator. Tagged the real gate. Consequence for 7.1: the load-bearing no-flash assertion for the sidebar is chat-loading-skeleton absence, not the spinner.

chat-layout-body was tagged — ChatLayout's AnimatePresence wrappers do survive on master. Note its key={conversationId} makes a conversation switch an intentional remount, so 7.1's "same node after send" is only valid within one conversation.

7.0c — chat fixtures

seedChatConversation / seedChatPage / seedConversationMessages (unified messages table) + authedContext(browser, sessionToken, baseURL) mirroring global-setup's exact cookie shape. Reuses the existing createChatMessage/createAgentPage factories rather than new inserts. Chat specs must seed their own user: the shared storageState user is provider openai and would never reach the mock.

Review fixes (79051ad)

Two P2 findings from Codex, both confirmed real against the source and fixed:

  1. seedChatConversation never created the conversations row. listConversations/countConversations LEFT JOIN conversations then filter WHERE conv."userId" = $user OR conv."isShared" = true — with no row the join yields NULL, both predicates are NULL, and the seeded history is invisible, so the page would have opened a fresh empty conversation and every later spec would have silently tested the wrong thing. Now inserts the row with the same shape the app's own createConversation uses (type: 'page', contextId: pageId), with lastMessageAt/updatedAt from the last seeded message rather than now.

    Proved against the repository's own SQL on a live Postgres — 0 visible before, 1 after; both seeded conversations returned newest-first (B then A, B being what the page opens on); a different user still sees none, so the privacy filter is exercised rather than bypassed.

  2. The smoke spec's assistant-bubble locator matched nothing. filter({ has: '[data-role=assistant]' }) scopes to a descendant, but data-role sits on the chat-message element itself, so the slow-stream test would have timed out. Now an attribute selector on the element itself, with a comment recording why — 7.1-7.5 will select bubbles the same way.

Open review threads — fixed, but deliberately left open

Three Codex threads are fixed and replied to, and left open rather than self-resolved so you can verify them yourself: support/db.ts:188 (the conversations row + lastMessageAt, above), tests/15-chat-fixture-smoke.spec.ts (the assistant-bubble locator, above), and fixtures/chat.fixture.ts. Each covers a real bug I shipped believing the code was correct, which is exactly the category a reviewer should confirm rather than take my word for.

Verification — the suite RUNS now, and the smoke spec is green

tests/15-chat-fixture-smoke.spec.ts → 3/3 passing in ~9s, in a real browser against a real production build: the seeded conversation renders, a slow-stream send grows an assistant bubble mid-stream, and a held stream keeps Stop up until released.

Getting there required unblocking D.1 (apps/web now declares pg — one line; see below) and surfaced four real defects that no direct-to-mock test could ever have caught:

  1. Model-name pacing never worked through the app — 7.0a's core premise. resolveProviderModel rewrites any model outside the static catalog to DEFAULT_MODEL before the provider call (ai-providers-config.ts:485-488), so e2e/slow-stream never reached the mock; the app sent openai/gpt-5.3-chat. Proven against a production build: the client POST carries selectedModel: "e2e/held-stream" while the mock records openai/gpt-5.3-chat. Fix: an explicit POST /__stream-config { mode }, honoured regardless of the model that arrives — the mode is the only pacing control (a later /simplify pass deleted the vestigial model-name triggers: they were unreachable from any real send, yet forced a nullable state, a precedence ternary, a test that existed only to pin that precedence, and five separate "don't use this" comments). Default stays instant, /__reset restores it. 7.1-7.5 must set the mode, not seed a pacing model — recorded on the 7.0a leaf.
  2. The cookie-consent banner ate every composer click (fixed bottom-0 z-[100]): Playwright won't click through it, so chat-send never became actionable and the click hung to timeout. authedContext now pre-records a necessary-only consent decision using the real CONSENT_* constants.
  3. A hydration race silently dropped the typed message: ai-chat-view is visible from server-rendered markup before React hydrates the composer, so an early fill() is discarded and Send never enables. sendChatMessage retries fill until Send enables — no sleep.
  4. An odd contents array leaves a dangling user message, which reads as a turn in flight and disables the composer.

Metering regression check — settled by A/B across the WHOLE suite, not by assertion. Specs 09-14 were run against the same app build twice: with this PR's mock, and with origin/master's. Identical results both times — 8 passed / 8 failed, the same tests in each column. So this PR causes no regression. (13-metering-reconcile passes, exercising the mock's /generation path end-to-end. The 8 failures reproduce on master's own mock; filed as D.2, with 09 pulse/cron additionally proven order-dependent — it passes 3/3 in isolation.) The default mock path is separately byte-identical to master across all 8 request/endpoint cases.

Also green: bun run typecheck 16/16, apps/e2e support tests 14/14, and 924 web unit tests post-merge — including the newly-merged AiChatView cutover's own suites.

⚠️ One line needs your judgement: pg in apps/web dependencies

pg is in serverExternalPackages, so the server bundle emits a bare require('pg') resolved from next's own directory; bun's isolated store has no hoisted copy, so the app died at boot and no browser e2e could run at all — in this worktree and the main checkout. Declaring pg in apps/web fixes it and is what makes the whole suite runnable. It is a product manifest change, so it is flagged rather than assumed; the alternatives are listed on D.1.

Reviewer run recipe

Every invocation now works — two separate cwd bugs, both fixed. bun run --filter '@pagespace/e2e' test:e2e (the documented command) previously could not work: global-setup/global-teardown derived their path from process.cwd() while the reader (fixtures/seed-state.ts) was already __dirname-relative, so it wrote to apps/e2e/apps/e2e/ and died on ENOENT before a single test; teardown failed more quietly, taking the "nothing to clean up" branch and silently leaking the seeded user and drive. Fixing that exposed a second one, caught by the /simplify pass: playwright.config.ts passed storageState: './storageState.json', which Playwright hands straight to readFile without resolving against the config dir — so from the repo root every storageState-backed spec (02-08) died on ENOENT. (My own root check missed it: spec 15 opts out of storageState.) Both are now anchored to __dirname, and all three invocations work for all specs — --filter, cd apps/e2e && bun run test:e2e, and the root-relative binary.

Use the workspace playwright binary, not bunx playwright — bunx fetches a second copy and Playwright aborts with "two different versions of @playwright/test".

Three environment traps remain, each of which silently breaks the suite:

  • Production build only (next build + next start): buildCSPPolicy never emits 'unsafe-eval' in any mode, so under next dev the client throws a CSP EvalError, never hydrates, and every page sits at "Loading..." forever. API-only specs (09-14) don't notice; every UI spec dies.
  • WEB_APP_URL must exactly match the browsed originlocalhost127.0.0.1, else origin validation blocks every mutating request with 403 ORIGIN_INVALID.
  • Storage must be configured: a send writes a page version through page-content-store.ts (S3 only, no local mode), so with no AWS_* it 500s with CredentialsProviderError. Point it at a throwaway MinIO, not the real bucket.

Plus: local DATABASE_URL (postgresql://user:password@localhost:5433/pagespace_test — the .env host postgres doesn't resolve from the host machine, and the container drifts stale, so db:migrate it first), OPENROUTER_BASE_URL=http://127.0.0.1:4998/api/v1, OPENROUTER_DEFAULT_API_KEY=sk-e2e, and CRON_SECRET/STRIPE_WEBHOOK_SECRET/CSRF_SECRET shared with the Playwright process. Let Playwright start the mock — a hand-started one left from an older build serves stale code and produces baffling failures.

The mock's support tests now run in CI (230bb49)

apps/e2e gained test + test:coverage (vitest — no DB, no browser; they pass under env -i), the convention every other workspace follows and the script CI's turbo run test:coverage actually invokes. I had first deferred this to 7.6 and that was wrong: 7.6 is wiring the Playwright run, which needs a web app, a database and a browser. These 14 tests guard this PR's critical constraint — that the default mock path stays byte-identical, which metering specs 09-14 depend on — and until this commit they ran nowhere but my terminal, so a later edit to the dispatch could have perturbed the instant path and merged green.

CI is green on this head commit: Lint & TypeScript Check, Unit Tests (now including @pagespace/e2e), and CodeRabbit all pass; branch is CLEAN and 0 commits behind master.

🤖 Generated with Claude Code

https://claude.ai/code/session_017c9ywLBipnmS1TmsVvbdev

2witstudios and others added 4 commits July 16, 2026 09:01
Support-level tests driving the mock over real HTTP: slow-stream pacing,
held-stream hold/release, /__streams introspection, abort safety and
/__reset stream release. Fails until the modes exist.

The default-path tests (instant `pong` + usage, JSON completion) pass
already by design — they are the regression guard proving metering specs
09-14 keep their behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Model-name-triggered pacing, so no app change is needed to drive it — the
seeded user's currentAiModel arrives as body.model:

  e2e/slow-stream → N chunks paced on a timer (default 40 × 250ms ≈ 10s)
  e2e/held-stream → first chunk, then held until POST /__release-stream
  any other model → the original instant path, byte-for-byte untouched

Plus GET /__streams (open/held) so specs wait for a stream to be live
instead of sleeping, POST /__stream-config for pacing, and /__reset now
releases open streams + restores defaults so a held stream can never leak
across specs.

Abort-safe: every write is guarded on writableEnded/destroyed and the
connection-level close clears the chunk timer, so Stop (7.4) cannot crash
the mock or pin the event loop past server.close().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Attributes only — no behavior change, so this can merge ahead of the
epic's rendering PRs and its testids survive the cutover.

Adds the leaf's table (surface roots, list, bubbles + data-role/-message-id,
skeleton, Stop/Send, textarea, error banner, history items) and reserves
history-live-badge for the Durability epic's badge.

Two board corrections, made on the 7.0b leaf before implementing (the
table was written against wt-9vu2uxbg; this branch is master-based):

- The sidebar shares no bubble/list/indicator component with the other
  surfaces — it renders CompactMessageRenderer and its own inline list,
  "Thinking..." indicator and skeleton. The leaf's own example selector
  (sidebar-chat-tab → chat-message) could not have resolved from the
  listed files, so those four sites are tagged too.
- The sidebar gate is already `!isInitialized` alone; the isMessagesLoading
  term is gone (now the in-place selectMessagesAreaMode indicator). Tagged
  the real gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
seedChatConversation / seedChatPage seed chat_messages rows sharing a
conversationId (alternating roles, 1s apart so createdAt ordering is
unambiguous); seedConversationMessages does the same for the unified
messages table behind the global assistant. Reuses the existing
createChatMessage/createAgentPage factories rather than new inserts.

authedContext turns a seedUser() session token into a logged-in browser
context, mirroring global-setup's cookie shape exactly. Chat specs need
their own user: the shared storageState user is provider 'openai' and
would never reach the mock. Two contexts on one token is safe — per-tab
identity is the client-generated X-Browser-Session-Id.

15-chat-fixture-smoke.spec.ts is the harness's own proof: seeded messages
render, a slow-stream send grows a bubble, a held stream keeps Stop up
until released.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a controllable mock OpenRouter stream server, deterministic chat database and browser fixtures, stable chat DOM selectors, and Playwright/Vitest coverage for seeded messages, paced streaming, held streams, cleanup, and abort handling.

Changes

Chat E2E coverage

Layer / File(s) Summary
Controlled mock streaming
apps/e2e/support/mock-openrouter.ts, apps/e2e/support/http.ts, apps/e2e/support/__tests__/*
Adds instant, slow, and held SSE modes, stream lifecycle endpoints, safe cleanup, HTTP helpers, and Vitest coverage for pacing, release, reset, aborts, and metering.
Chat seeding and browser flow
apps/e2e/support/db.ts, apps/e2e/fixtures/chat.fixture.ts, apps/e2e/tests/15-chat-fixture-smoke.spec.ts, apps/web/src/components/ai/..., apps/web/src/components/layout/...
Adds deterministic conversation seeding, authenticated navigation and message helpers, stable chat selectors, and smoke tests for rendered messages and streaming states.
E2E runner consistency
apps/e2e/global-setup.ts, apps/e2e/global-teardown.ts, apps/e2e/package.json, apps/e2e/vitest.config.ts, apps/web/package.json
Uses module-relative E2E state paths and adds support-test tooling plus PostgreSQL runtime support.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SmokeTest
  participant ChatFixture
  participant BrowserChat
  participant MockOpenRouter
  SmokeTest->>ChatFixture: Seed chat page and create authenticated context
  ChatFixture->>BrowserChat: Navigate and send message
  BrowserChat->>MockOpenRouter: Request streaming completion
  MockOpenRouter-->>BrowserChat: Emit assistant SSE chunks
  SmokeTest->>MockOpenRouter: Release held streams
  BrowserChat-->>SmokeTest: Update message and stop controls
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main E2E prerequisite work: controllable mock streams, test selectors, and chat fixtures.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/e1-e2e-prereqs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2111ab92e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/e2e/support/db.ts
const startedAt = opts.startedAt ?? new Date(Date.now() - 60_000);

for (let i = 0; i < contents.length; i++) {
await factories.createChatMessage(pageId, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist seeded page conversations

This helper only inserts chat_messages rows, but the page chat initializer lists conversations through conversationRepository.listConversations, whose privacy filter only returns rows with a matching conversations.userId or isShared=true. As a result, seedChatPage() creates history that the UI's initial GET /conversations?pageSize=1 cannot see, so the new smoke test and later specs open a fresh empty conversation instead of rendering the seeded one. Please create the matching conversations row for each seeded page conversation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 79051ad — thank you, this was a real bug that would have made every later spec silently test the wrong thing.

Verified the diagnosis against the source rather than taking it on faith: countConversations / listConversations (apps/web/src/lib/repositories/conversation-repository.ts:258-300) LEFT JOIN conversations conv and then filter WHERE conv."userId" = $user OR conv."isShared" = true. With no conversations row the join yields NULL, both predicates evaluate to NULL, and the conversation is filtered out — so the page would open a fresh empty conversation exactly as you describe.

Proved it end-to-end on a live Postgres by replaying the repository's own SQL against seeded data:

messages-only (the OLD seeder) visible to UI query: 0   <- the bug
after adding the conversations row (the FIX):       1

seedChatConversation now inserts the row with the same shape the app's own createConversation uses (type: 'page', contextId: pageId, isShared: false), with lastMessageAt/updatedAt derived from the last seeded message rather than now so history ordering is faithful. Re-verified against the real query: both conversations are returned, newest-first (B then A — B is what the page opens on), and a different user still sees none of them, so the privacy filter is exercised rather than bypassed.

Leaving this thread open for your verification.

const assistant = page
.getByTestId('ai-chat-view')
.getByTestId('chat-message')
.filter({ has: page.locator('[data-role="assistant"]') })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select assistant bubbles by the element itself

This filter looks for a descendant matching [data-role="assistant"], but the new data-role is placed on the chat-message element itself, not inside it. When a streamed assistant bubble exists, this locator still resolves to no elements, so the slow-stream smoke test times out at toBeVisible(); select [data-testid="chat-message"][data-role="assistant"] or otherwise filter on the message element itself.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 79051ad — correct on both the mechanism and the consequence.

filter({ has: ... }) scopes to a descendant match, but 7.0b puts data-role on the chat-message element itself (MessageRenderer.tsx:367, CompactMessageRenderer.tsx:360), so the locator resolved to zero elements and the slow-stream test would have hung to timeout at toBeVisible().

Now selects on the element itself, as you suggested:

const assistant = page
  .getByTestId('ai-chat-view')
  .locator('[data-testid="chat-message"][data-role="assistant"]')
  .last();

Added a comment at the call site recording why it must be an attribute selector rather than a has: filter, since this is an easy trap to re-introduce in 7.1-7.5 which will select bubbles the same way.

One caveat worth stating plainly: this spec cannot execute in my environment — the web app does not boot under bun (require('pg') from the Next server bundle; filed as D.1 on the board and documented in the PR body), so the fix is verified by code reading against the emitted attributes rather than by a green run. A reviewer on an environment where the app boots should confirm it.

Leaving this thread open for your verification.

…ement (7.0c)

Two P2 review findings, both confirmed real against the source:

1. seedChatConversation inserted chat_messages but no conversations row.
   listConversations/countConversations LEFT JOIN conversations and filter
   `WHERE conv."userId" = $user OR conv."isShared" = true`, so with no row
   the join yields NULL, both predicates are NULL, and the seeded history
   is invisible — the page would open a fresh empty conversation instead.
   Now inserts the row with the same shape the app's own createConversation
   uses (type 'page', contextId = pageId), with lastMessageAt/updatedAt set
   from the last seeded message rather than "now".

   Proven against the real query on a live DB: a messages-only conversation
   counts 0 to the UI's privacy filter, 1 once the row exists. Both seeded
   conversations are now returned, newest-first (B, then A), and a different
   user still sees none of them.

2. The smoke spec filtered assistant bubbles with `has: [data-role=...]`,
   which matches a DESCENDANT — but data-role sits on the chat-message
   element itself, so it resolved to nothing and the slow-stream test would
   have timed out. Now an attribute selector on the element itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 79051ad09c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/e2e/fixtures/chat.fixture.ts Outdated
baseURL: string,
): Promise<BrowserContext> {
const url = new URL(baseURL);
const context = await browser.newContext();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve baseURL when creating the auth context

When these chat specs create pages through this manual browser.newContext(), the context does not inherit the project use.baseURL. gotoChatPage() later calls page.goto('/dashboard/...'), so every spec using authedContext() will try to navigate to a relative URL without a base URL and fail before reaching the chat UI. Pass baseURL into newContext() (or navigate with an absolute URL) along with adding the cookie.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed — authedContext now passes baseURL into newContext({ baseURL }).

You were exactly right, and the repo already had the precedent: 07-file-uploads.spec.ts:99 does newContext({ baseURL: baseURL ?? 'http://localhost:3000' }) for the same reason. A manually-created context does not inherit the project's use.baseURL, so every relative page.goto('/dashboard/…') would have failed before reaching the chat UI. Comment added at the call site recording why, since 7.1-7.5 all build contexts through this helper.

This one is now proven by execution rather than by reading: the smoke spec passes 3/3 against a real production build. Getting there also surfaced three further defects in the same code path that only running it could reveal — the cookie-consent banner (fixed bottom-0 z-[100]) silently ate every composer click, a hydration race discarded the typed message so Send never enabled, and — most importantly — 7.0a's model-name pacing never worked through the app at all, because resolveProviderModel rewrites unknown model ids to DEFAULT_MODEL before the provider call. All fixed; details in the PR description.

Thank you for these three — every one was real, and the first two were load-bearing for the specs this PR exists to enable.

Leaving open for your verification.

….0c)

The smoke spec now RUNS, and it found four real defects that no amount of
direct-to-mock testing could have surfaced. 3/3 green end-to-end.

1. Model-name pacing never worked through the app (7.0a's core premise).
   resolveProviderModel rewrites any model outside the static catalog to
   DEFAULT_MODEL before the provider call (ai-providers-config.ts:485-488),
   so `e2e/slow-stream` never reached the mock — the app sent
   `openai/gpt-5.3-chat` instead. Verified against a production build: the
   client POST carries selectedModel=e2e/held-stream, the mock records
   openai/gpt-5.3-chat. Fix: an explicit `POST /__stream-config {mode}`,
   applied regardless of the model that arrives. Model-name triggers are
   retained for direct-to-mock callers. Default stays 'instant', so the
   metering specs are untouched; /__reset restores it so a mode can't leak.
   /__streams now reports `mode` — "already finished" and "mode never
   applied" are indistinguishable from open:0 alone.

2. The cookie-consent banner ate every composer click. It is
   `fixed bottom-0 z-[100]` and Playwright refuses to click through it, so
   chat-send never became actionable and the click hung until the test
   timeout. authedContext now pre-records a necessary-only consent decision
   using the real CONSENT_* constants, so a version bump fails loudly.

3. A hydration race silently dropped the typed message. ai-chat-view is
   visible from server-rendered markup before React hydrates the composer; a
   fill() landing in that window is discarded, Send never enables, and the
   click waits forever. sendChatMessage retries fill until Send enables — no
   sleep, proceeds the instant the composer is live.

4. Seeding an odd `contents` leaves a dangling user message, which reads as a
   turn in flight and disables the composer. Seed complete exchanges.

Also adds `pg` to apps/web dependencies — the verified fix for D.1. `pg` is
in serverExternalPackages, so the server bundle emits a bare require('pg')
resolved from next's own directory; bun's isolated store has no hoisted copy,
so the app died at boot and NO browser e2e could run at all. This one line is
what makes the whole suite runnable. Flagged for reviewer judgement: it is a
product manifest change, and the D.1 note lists the alternatives.

Verified: metering specs 09+10 produce IDENTICAL results with this mock and
with origin/master's mock on the same app build (same 5 pre-existing env
failures, same 3 passes) — proving no regression. Default mock path is also
byte-identical to master across all 8 request/endpoint cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

The documented command — `bun run --filter '@pagespace/e2e' test:e2e`, in
apps/e2e/README.metering.md and on the epic board — could never have worked.
It runs with cwd=apps/e2e, and global-setup derived `E2E_DIR` as
`path.join(process.cwd(), 'apps/e2e')`, so it wrote to apps/e2e/apps/e2e/ and
died on ENOENT before a single test.

The writer was cwd-based while the reader (fixtures/seed-state.ts) is already
`__dirname`-relative, so the two only agreed when the runner happened to be
invoked from the repo root. Resolving from the file's own location makes them
agree by construction, from any cwd.

Teardown had the same bug with a quieter failure: from a non-root cwd it took
the "No .seed-state.json — nothing to clean up" branch and silently leaked the
seeded user and drive instead of cleaning them up.

All three invocations now pass 3/3 and clean up after themselves:
  bun run --filter '@pagespace/e2e' test:e2e      (the documented one)
  cd apps/e2e && bun run test:e2e                 (the epic body's recipe)
  ./apps/e2e/node_modules/.bin/playwright test --config apps/e2e/playwright.config.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 475d5db50e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…icit instant

Findings from an adversarial review pass of 475d5db. It refuted several of
its own hypotheses (the instant-path invariant, the consent cookie's encoding
and suppression, sendChatMessage's flakiness, and pg's blast radius all
verified clean) and left four real ones:

- A comment claimed importing CONSENT_VERSION makes a version bump "fail these
  specs loudly". It does the opposite: the bump flows into the fixture's cookie,
  parseConsentCookie keeps accepting it, and the banner stays suppressed
  silently. Claiming a guard the code does not enforce is worse than no comment,
  so the comment now states what is actually true — including what it does NOT
  buy you.
- /__streams reported `mode` specifically so a spec could tell "already
  finished" from "mode never applied", but mockStreams() typed it away, so no
  Playwright spec could reach it. The justification is now delivered.
- An explicit {mode:'instant'} could not override the model-name triggers,
  because 'instant' doubled as "unset". The override is now nullable: null =
  unset (model names apply), any explicit mode wins — including 'instant'.
  Covered by a test that would hold forever under the old logic.
- The poll ceilings summed past the 90s test timeout, so the 30s budgets were
  unreachable fiction: a run needing them died on "Test timeout exceeded"
  instead of the targeted assertion. Budget raised above their sum.

Also updates the module docblock, which still described the model-name-only
design, and moves `pg` into alphabetical position.

Filed as an epic-level D (NOT fixed here — product scope, outside this PR's
diff): the composer silently discards text typed before hydration. That is a
real user-facing bug on a cold load, and sendChatMessage's retry means every
7.1-7.5 spec is now structurally blind to it.

Support tests 15/15, smoke spec 3/3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

2witstudios and others added 2 commits July 16, 2026 11:13
Declared and assigned, never called — `res.on('close', cleanup)` handles
abandonment directly. Dead API surface on a type that 7.1-7.5 will read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chat surfaces mount simultaneously and each renders its own composer, so
an unscoped getByTestId('chat-send') matches TWO elements once the right
sidebar is open and fails Playwright's strict mode. It passed only because
rightSidebarOpen defaults to false — an accidental precondition that would
have broken the first 7.1-7.5 spec to open the sidebar (7.1 asserts on the
sidebar's gate, so that is not hypothetical).

sendChatMessage now takes a Page | Locator scope, and the smoke spec passes
`getByTestId('ai-chat-view')` — the per-surface pattern 7.1-7.5 must follow.

Verified live against a production build, which also completes 7.0b's stated
acceptance ("a snippet can resolve each selector uniquely per surface") for
the surfaces the smoke spec never mounts:

  sidebar-chat-tab                1
  ↳ scoped chat-messages-area     1
  ↳ scoped chat-textarea          1
  ↳ scoped chat-send              1
  history-conversation-item       2  (+ data-conversation-id present)
  unscoped chat-textarea/-send    2  ← why scoping is mandatory

Those scoped sidebar queries would all be ZERO without the sidebar testids
added by this PR's 7.0b board correction — the leaf's own example selector
targeted components the sidebar does not render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/e2e/fixtures/chat.fixture.ts`:
- Around line 1-5: Update the consent constants import in the chat fixture to
use the public package export `@pagespace/lib/consent`, replacing the direct
internal consent-core source path. Keep importing CONSENT_COOKIE_NAME and
CONSENT_VERSION unchanged.

In `@apps/e2e/support/db.ts`:
- Around line 230-250: Update seedConversationMessages so the
conversations.lastMessageAt value matches the timestamp of the final seeded
message, using the same calculation as the last message’s createdAt (startedAt +
(msgs.length - 1) * 1000). Keep the existing deterministic staggered timestamps
and align this behavior with seedChatConversation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a1be913f-8958-4c2a-b81c-cc73c8dfffa5

📥 Commits

Reviewing files that changed from the base of the PR and between ff64f09 and 2b0f6dc.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (23)
  • apps/e2e/fixtures/chat.fixture.ts
  • apps/e2e/global-setup.ts
  • apps/e2e/global-teardown.ts
  • apps/e2e/package.json
  • apps/e2e/support/__tests__/mock-openrouter.test.ts
  • apps/e2e/support/db.ts
  • apps/e2e/support/http.ts
  • apps/e2e/support/mock-openrouter.ts
  • apps/e2e/tests/15-chat-fixture-smoke.spec.ts
  • apps/e2e/vitest.config.ts
  • apps/web/package.json
  • apps/web/src/components/ai/chat/input/ChatTextarea.tsx
  • apps/web/src/components/ai/chat/input/InputActions.tsx
  • apps/web/src/components/ai/chat/layouts/ChatLayout.tsx
  • apps/web/src/components/ai/page-agents/PageAgentHistoryTab.tsx
  • apps/web/src/components/ai/shared/chat/ChatErrorBanner.tsx
  • apps/web/src/components/ai/shared/chat/ChatMessagesArea.tsx
  • apps/web/src/components/ai/shared/chat/CompactMessageRenderer.tsx
  • apps/web/src/components/ai/shared/chat/MessageRenderer.tsx
  • apps/web/src/components/ai/shared/chat/StreamingIndicator.tsx
  • apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx
  • apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx
  • apps/web/src/components/layout/right-sidebar/ai-assistant/SidebarChatTab.tsx

Comment thread apps/e2e/fixtures/chat.fixture.ts Outdated
Comment thread apps/e2e/support/db.ts
…ge export

Two CodeRabbit findings, both confirmed real against the source.

1. seedConversationMessages set lastMessageAt to `startedAt + msgs.length *
   1000` — one second PAST the last message, which is Date.now() however the
   messages were staggered. Every seeded conversation's lastMessageAt therefore
   collapsed to "now", destroying exactly the recency ordering the staggered
   timestamps exist to create, and diverging from seedChatConversation (which
   already sets it to the last message's own timestamp).

   Verified on a real DB: delta is now 0ms (was +1000), and two conversations
   seeded in sequence order deterministically by lastMessageAt.

   Also returns early on an empty msgs array — neither the old code nor the
   suggested fix handled it, and both would have written a lastMessageAt for a
   conversation with no messages (the suggested form: startedAt - 1000).

2. The consent constants reached into packages/lib/src/consent/consent-core.
   packages/lib exposes `./consent`, so this now imports @pagespace/lib/consent
   and declares @pagespace/lib in apps/e2e's devDependencies rather than relying
   on workspace hoisting for an undeclared dep.

   Note this deliberately diverges from the four neighbouring relative-src
   imports in global-setup.ts/support/db.ts: for e2e specifically the package
   export is the better target, because it resolves to the same BUILT module the
   app under test consumes, rather than to source the app never runs.

   Proven at runtime, not just by typecheck: the smoke spec still passes 3/3,
   and its send tests can only pass if the consent cookie still suppresses the
   banner — a broken import or wrong constant would put the banner back over the
   composer and hang the click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 521bfad8dc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@2witstudios

Copy link
Copy Markdown
Owner Author

Cross-PR check against #2089 (the in-flight global-assistant cutover) — clean, testids survive

The epic body warns that 7.0b "should merge FIRST if the epic's rendering PRs are in flight, so its testids survive the cutover". #2089 (pu/e1-global-cutover, PR 5 of this epic) touches all three surfaces this PR instruments — AiChatView.tsx, GlobalAssistantView.tsx, SidebarChatTab.tsx — and branched before this PR, so it carries zero testids of its own.

Tested rather than assumed, via git merge-tree --write-tree HEAD origin/pu/e1-global-cutover:

  • Auto-merges cleanly — exit 0, no textual conflicts in either direction. (merge-tree's "changed in both" lines are different hunks, not conflicts.)
  • Every testid survives the merged result:
File testids in merged tree
AiChatView.tsx ai-chat-view
GlobalAssistantView.tsx global-assistant-view
SidebarChatTab.tsx sidebar-chat-tab, sidebar-chat-spinner, chat-messages-area, chat-loading-skeleton, streaming-indicator

So merge order does not matter between these two, and the epic's "merge 7.0b first" caution is satisfied either way — no coordination needed.

This also closes 7.0b's central claim empirically against a second, independent cutover: the testids already survived #2079 (merged into this branch, verified — render path AiChatView → ChatLayout → chat-layout-body → ChatMessagesArea → MessageRenderer intact), and they survive #2089 too.

One thing for #2089's author to know rather than discover: SidebarChatTab renders CompactMessageRenderer plus its own inline list, "Thinking…" indicator and skeleton — it shares no chat component with the other two surfaces. That is why it needs its own five testids, and why a scoped query (getByTestId('sidebar-chat-tab').getByTestId('chat-message')) is the only way to disambiguate: the surfaces mount simultaneously, so unscoped chat-textarea/chat-send resolve 2 each with the sidebar open.

…y review

A /simplify pass (4 agents: reuse, simplification, efficiency, altitude) on the
diff. Efficiency found nothing worth fixing — correctly judging the test-infra
costs negligible and refuting a suspected closure leak.

**Real bug, in my own earlier fix.** playwright.config.ts passed
`storageState: './storageState.json'`, which Playwright hands straight to
readFile WITHOUT resolving it against the config dir — so it is cwd-relative,
while global-setup (since 2b0f6dc) writes the file next to itself. The two
only agreed when started from apps/e2e. From the repo root — which I had
documented as a supported invocation — every storageState-backed spec (02-08)
died on `ENOENT: ./storageState.json`. My own root-invocation check missed it
because spec 15 opts out of storageState. Proven before/after: spec 02 went
from ENOENT to failing identically from both cwds on an unrelated pre-existing
assertion. Anchored to __dirname; writer and reader now agree from any cwd.

**Deleted the model-name triggers.** Both the simplification and altitude
agents independently recommended it, and my earlier board note claiming they
"cost nothing" was wrong. They cost: the `StreamMode | null` state (null existed
ONLY to mean "defer to the model name"), the precedence ternary, two exported
constants, a test that existed solely to pin the precedence between the two
mechanisms, and the same "this path is unreachable from a real send" warning
restated in five places to steer readers off an unusable door. Their only
consumers were the support tests that tested them. The one theoretical benefit
— per-request pacing — is unreachable by construction, since the app rewrites
the model id. Now one mechanism, one canonical explanation; the support tests
drive `mode` and send the model the app actually sends (openai/gpt-5.3-chat),
so they exercise the same door a real send comes through.

Also from the review:
- consentCookie now BUILDS the decision with the app's own rejectNonEssential +
  serializeConsentState instead of hand-rolling the literal — the comment
  claimed "byte-identical to rejectNonEssential()" and "matches what the app
  writes"; now the code enforces both instead of asserting them.
- http.ts imports StreamMode instead of re-spelling the union three times.
- sendChatMessage takes a Locator, not `Page | Locator` — the doc said "SHOULD
  be a surface root"; now it is a compile error, not prose.
- Smoke spec closes contexts in afterEach: a trailing `context.close()` is
  skipped when an assertion throws, leaking a context on every red run.
- Test title said "slow-stream model" while the comment below explained the
  model is not what paces it.
- Dropped `export` from in-file-only constants; cleanup() no longer resets a
  variable nothing reads.

Kept, with reasons: seedConversationMessages (unused today, but 7.0c's spec
requires it for the GlobalAssistantView specs); the session-cookie duplication
with global-setup (extracting touches shared auth infra all specs depend on —
regression risk outweighs a cosmetic DRY win).

Support tests 14/14, smoke 3/3, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

Two independent review passes flagged the same gap: `test:support` was a
bespoke script name nothing invoked. CI runs `bunx turbo run test:coverage`,
and apps/e2e had neither `test` nor `test:coverage` — so Turbo skipped the
package entirely and the 14 tests guarding this PR's CRITICAL constraint (that
the default mock path stays byte-identical, which metering specs 09-14 depend
on) never executed anywhere but my terminal. A later edit to the dispatch could
have perturbed the instant path and merged green.

Renamed to the convention every other workspace already follows (`test` +
`test:coverage`, the hook CI actually calls). No `--coverage`: there are no
thresholds for harness code and coverage of a test mock is not the point —
executing the guard is.

I had deferred this as 7.6's scope. That was wrong: 7.6 is wiring the
PLAYWRIGHT run, which needs a web app, a database and a browser. These are
plain node HTTP tests — verified to pass with a completely empty environment
(`env -i`), so nothing about CI can starve them.

Verified with CI's own command: `turbo run test:coverage --filter='!@pagespace/processor'`
now includes @pagespace/e2e in its graph, and `--filter='@pagespace/e2e'` runs
14/14 green in 704ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 9e166bf576

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@2witstudios
2witstudios merged commit 65ec384 into master Jul 16, 2026
3 checks passed
@2witstudios
2witstudios deleted the pu/e1-e2e-prereqs branch July 17, 2026 02:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant