test(activation): address code-review findings on E2E flows, mock, CI

Review fixes (REQUEST_CHANGES round 1):

1. HIGH activation-negative-401.yaml: after dismissing the "Connection
   Failed" alert the app stays on the Add-Connection modal
   (handleQuickConnect's failure branch never calls router.back()), so the
   old `text: "No Connection"` assertion (Sessions-tab empty state) could
   never pass. Now asserts connect-submit-button is still visible instead.

2. MEDIUM mock-opencode-server.ts: prompt_async now parses the request
   body, persists the USER's message, and broadcasts it (message.updated +
   message.part.updated) BEFORE the canned assistant reply — matching real
   server behavior. Without this, the app's handleEvent strips the
   optimistic temp- user message when the assistant's message.updated
   arrives and the sent message vanishes from the transcript.
   activation-positive.yaml now also asserts chat-bubble-user and the
   user's message text are visible after the reply lands, so that
   regression class is actually covered.

3. MEDIUM activation-e2e.yml: timeout-minutes 15 -> 60. The job runs the
   same npm install + prebuild + assembleRelease + emulator pipeline that
   cua-smoke.yml budgets 60 min for (emulator-boot-timeout alone is 10 min).

4. MEDIUM activation-e2e.yml: replicated cua-smoke.yml's "Purge stale
   generated sources" step — the Gradle cache key/restore-keys are shared
   with that workflow, so the stale-autolinking-tree failure mode
   (compileReleaseJavaWithJavac against the old package id) applies here too.

Verified locally: tsc --noEmit clean; npm test 81/81 pass; all three
touched YAML files parse valid; mock server exercised standalone —
full prompt cycle confirms GET /session/:id/message returns BOTH user
and assistant messages, SSE order is message.updated(user) ->
message.part.updated(user) -> busy -> message.updated(assistant) ->
message.part.updated(assistant) -> idle, user events carry the
sessionID/messageID fields handleEvent filters on, and --fail-auth
mode returns 401. Still no emulator run in this environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
This commit is contained in:
engineer
2026-07-16 17:19:46 -07:00
parent 01dd0191b3
commit 7ca5d2eb19
4 changed files with 77 additions and 5 deletions

View File

@@ -16,6 +16,14 @@
// returns immediately; the actual reply is delivered as
// `message.updated` + `message.part.updated` + `session.status` (idle)
// events on the SSE stream (src/stores/events.ts).
// - The real server ALSO persists and broadcasts the USER's message. The
// app relies on this: any `message.updated` event strips optimistic
// `temp-` messages (src/stores/sessions.ts handleEvent), so if the mock
// only broadcast the assistant reply, the user's sent message would
// vanish from the transcript. The mock therefore stores the user message
// from the prompt_async body and broadcasts it (message.updated +
// message.part.updated) before the canned assistant reply, and returns
// it from GET /session/:id/message.
//
// Two modes:
// - Normal mode: implements the endpoints above so the app can connect,
@@ -110,6 +118,39 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
})
}
// Persist the user's message (parsed from the prompt_async body) and
// broadcast it over SSE, mirroring the real server. This is what lets the
// app replace its optimistic `temp-` user message with the real one instead
// of losing it when the assistant's message.updated arrives.
function storeUserMessage(sessionID: string, promptParts: Array<{ type?: string; text?: string }>) {
const list = messagesBySession.get(sessionID)
if (!list) return
const now = Date.now()
const messageID = randomUUID()
const info: StoredMessageInfo = {
id: messageID,
sessionID,
role: "user",
time: { created: now, completed: now },
}
const parts: StoredPart[] = promptParts
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => ({
id: randomUUID(),
sessionID,
messageID,
type: "text",
text: p.text,
}))
list.push({ info, parts })
broadcast("message.updated", { info })
for (const part of parts) {
broadcast("message.part.updated", { part })
}
}
function scheduleReply(sessionID: string) {
const list = messagesBySession.get(sessionID)
if (!list) return
@@ -262,7 +303,15 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
if (!sessions.has(sid)) {
return json(res, 404, { error: `unknown session ${sid}` })
}
let promptParts: Array<{ type?: string; text?: string }> = []
try {
const parsed = JSON.parse(body || "{}")
if (Array.isArray(parsed.parts)) promptParts = parsed.parts
} catch {
// malformed body — still ack like a fire-and-forget endpoint would
}
json(res, 200, { ok: true })
storeUserMessage(sid, promptParts)
scheduleReply(sid)
})
return