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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions packages/app/e2e/prompt/prompt-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ test("shell mode runs a command in the project directory", async ({ page, withPr

await gotoSession()
await prompt.click()
await page.keyboard.type("!")
await expect(prompt).toBeFocused()
await prompt.pressSequentially("!")
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
await expect(prompt).toBeFocused()

await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
await prompt.pressSequentially(cmd)
await expect(prompt).toContainText(cmd)

await prompt.press("Enter")

await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })

Expand Down
263 changes: 207 additions & 56 deletions packages/app/e2e/session/session-review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,185 @@ function edit(file: string, prev: string, next: string) {
)
}

async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
await sdk.session.promptAsync({
sessionID,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Your only valid response is one apply_patch tool call.",
`Use this JSON input: ${JSON.stringify({ patchText })}`,
"Do not call any other tools.",
"Do not output plain text.",
].join("\n"),
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
function paths(text: string) {
return [...text.matchAll(/^\*\*\* (?:Add|Update) File: (.+)$/gm)].map((x) => x[1])
}

function issue(err: unknown) {
if (!err || typeof err !== "object") return undefined
if (!("name" in err) || !("data" in err)) return undefined
const data = err.data
if (!data || typeof data !== "object") return undefined
return {
name: typeof err.name === "string" ? err.name : undefined,
message: "message" in data && typeof data.message === "string" ? data.message : undefined,
status: "statusCode" in data && typeof data.statusCode === "number" ? data.statusCode : undefined,
}
}

async function snap(sdk: ReturnType<typeof createSdk>, sessionID: string, want: string[]) {
const [status, diff, items] = await Promise.all([
sdk.session
.status()
.then((x) => x.data?.[sessionID]?.type ?? "idle")
.catch((err) => `error:${err instanceof Error ? err.name : String(err)}`),
sdk.session
.diff({ sessionID })
.then((x) => x.data ?? [])
.catch(() => []),
sdk.session
.messages({ sessionID, limit: 50 })
.then((x) => x.data ?? [])
.catch(() => []),
])

const seen = diff.filter((x) => want.includes(x.file)).map((x) => x.file)
const msg = items.findLast((x) => x.info.role === "assistant" && !!x.info.error)
const err = msg?.info.role === "assistant" ? issue(msg.info.error) : undefined

return {
status,
diff: {
count: diff.length,
files: diff.slice(0, 10).map((x) => x.file),
seen,
missing: want.filter((x) => !seen.includes(x)),
},
error: err,
}
}

function brief(list: Array<Record<string, unknown>>) {
return list
.map((x) => {
const data = "data" in x && x.data && typeof x.data === "object" ? x.data : undefined
const diff = data && "diff" in data && data.diff && typeof data.diff === "object" ? data.diff : undefined
const err = data && "error" in data && data.error && typeof data.error === "object" ? data.error : undefined
return [
`attempt=${x.attempt}`,
`busy=${x.busy}`,
`idle=${x.idle}`,
`first=${x.first}`,
`late=${x.late}`,
`next=${x.next}`,
`diff=${diff && "count" in diff ? diff.count : "?"}`,
`seen=${diff && "seen" in diff && Array.isArray(diff.seen) ? diff.seen.join(",") : ""}`,
`err=${err && "name" in err && typeof err.name === "string" ? err.name : "none"}`,
].join(" ")
})
.join("\n")
}

async function waitSessionBusy(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 5_000) {
await expect
.poll(
() =>
sdk.session
.status()
.then((x) => x.data?.[sessionID]?.type ?? "idle")
.catch(() => "idle"),
{ timeout },
)
.not.toBe("idle")
}

async function waitProbe(probe: () => Promise<boolean | undefined>, timeout = 5_000) {
return expect
.poll(
() =>
probe()
.then((x) => Boolean(x))
.catch(() => false),
{ timeout },
)
.toBe(true)
.then(() => true)
.catch(() => false)
}

async function patch(
sdk: ReturnType<typeof createSdk>,
sessionID: string,
patchText: string,
probe: () => Promise<boolean | undefined>,
) {
const want = paths(patchText)
const list: Array<Record<string, unknown>> = []

for (let i = 0; i < 3; i++) {
const step: Record<string, unknown> = {
attempt: i + 1,
abort: Boolean(i),
}
list.push(step)

if (i) {
await sdk.session.abort({ sessionID }).catch(() => undefined)
step.drain = await waitSessionIdle(sdk, sessionID, 30_000)
.then(() => true)
.catch(() => false)
}

await sdk.session.promptAsync({
sessionID,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Your only valid response is one apply_patch tool call.",
`Use this JSON input: ${JSON.stringify({ patchText })}`,
"Do not call any other tools.",
"Do not output plain text.",
].join("\n"),
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})

const first = await probe().catch(() => undefined)
step.first = Boolean(first)

const busy = await waitSessionBusy(sdk, sessionID)
.then(() => true)
.catch(() => false)
step.busy = busy

if (!busy) {
if (first) return

const late = await waitProbe(probe)
step.late = late
step.data = await snap(sdk, sessionID, want)
if (late) return
continue
}

const idle = await waitSessionIdle(sdk, sessionID, 45_000)
.then(() => true)
.catch(() => false)
step.idle = idle

if (!idle) continue

const next = await waitProbe(probe, 10_000)
step.next = next
step.data = await snap(sdk, sessionID, want)

if (next) return
}

const body = JSON.stringify(
{
sessionID,
want,
attempts: list,
},
null,
2,
)
await test.info().attach("seed-trace", {
body,
contentType: "application/json",
})

await waitSessionIdle(sdk, sessionID, 120_000)
throw new Error(["Timed out seeding patch", brief(list)].join("\n"))
}

async function show(page: Parameters<typeof test>[0]["page"]) {
Expand Down Expand Up @@ -246,17 +410,14 @@ test("review applies inline comment clicks without horizontal overflow", async (
const sdk = createSdk(project.directory)

await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))

await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
await patch(sdk, session.id, seed([{ file, mark: tag }]), async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.some(
(item) => item.file === file && typeof item.after === "string" && item.after.includes(`mark ${tag}`),
)
.toBe(1)
? true
: undefined
})

await project.gotoSession(session.id)
await show(page)
Expand Down Expand Up @@ -295,17 +456,14 @@ test("review file comments submit on click without clipping actions", async ({ p
const sdk = createSdk(project.directory)

await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))

await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
await patch(sdk, session.id, seed([{ file, mark: tag }]), async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.some(
(item) => item.file === file && typeof item.after === "string" && item.after.includes(`mark ${tag}`),
)
.toBe(1)
? true
: undefined
})

await project.gotoSession(session.id)
await show(page)
Expand Down Expand Up @@ -347,7 +505,17 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
const sdk = createSdk(project.directory)

await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await patch(sdk, session.id, seed(list), async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return list.every((item) =>
diff.some(
(entry) =>
entry.file === item.file && typeof entry.after === "string" && entry.after.includes(`mark ${item.mark}`),
),
)
? true
: undefined
})

await expect
.poll(
Expand All @@ -359,16 +527,6 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
)
.toBe(list.length)

await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(list.length)

await project.gotoSession(session.id)
await show(page)

Expand Down Expand Up @@ -396,18 +554,11 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`)

await patch(sdk, session.id, edit(hit.file, hit.mark, next))

await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : ""
},
{ timeout: 60_000 },
)
.toContain(`mark ${next}`)
await patch(sdk, session.id, edit(hit.file, hit.mark, next), async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" && item.after.includes(`mark ${next}`) ? true : undefined
})

await waitMark(page, hit.file, next)

Expand Down
Loading