From 4b627a740d0702c5759c9df2789b8600db1d47b7 Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:05:18 +0000 Subject: [PATCH 01/10] test(app): isolate component coverage with storybook --- packages/app/.gitignore | 2 + packages/app/component-tests/README.md | 37 ++++++++ packages/app/component-tests/composer.spec.ts | 25 +++++ .../session-timeline-notices.spec.ts | 41 ++++++++ .../session-timeline-reasoning.spec.ts | 53 +++++++++++ .../component-tests/session-timeline.spec.ts | 49 ++++++++++ packages/app/component-tests/story.ts | 15 +++ .../regression/prompt-thinking-level.spec.ts | 81 ---------------- .../session-timeline-accessibility.spec.ts | 22 ----- .../session-timeline-context-state.spec.ts | 31 ------ .../session-timeline-file-projection.spec.ts | 15 --- .../session-timeline-file-state.spec.ts | 53 ----------- .../session-timeline-notices.spec.ts | 51 ---------- ...sion-timeline-reasoning-projection.spec.ts | 94 ------------------- packages/app/e2e/tsconfig.json | 2 +- packages/app/package.json | 2 + packages/app/playwright.components.config.ts | 29 ++++++ .../timeline/context-projection.stories.tsx | 78 +++++++++++++++ .../timeline/reasoning-projection.stories.tsx | 82 ++++++++++++++++ .../src/timeline/terminal-work.stories.tsx | 11 +++ .../src/timeline/timeline-row.stories.tsx | 23 +++++ 21 files changed, 448 insertions(+), 348 deletions(-) create mode 100644 packages/app/component-tests/README.md create mode 100644 packages/app/component-tests/composer.spec.ts create mode 100644 packages/app/component-tests/session-timeline-notices.spec.ts create mode 100644 packages/app/component-tests/session-timeline-reasoning.spec.ts create mode 100644 packages/app/component-tests/session-timeline.spec.ts create mode 100644 packages/app/component-tests/story.ts delete mode 100644 packages/app/e2e/regression/prompt-thinking-level.spec.ts delete mode 100644 packages/app/e2e/regression/session-timeline-accessibility.spec.ts delete mode 100644 packages/app/e2e/regression/session-timeline-context-state.spec.ts delete mode 100644 packages/app/e2e/regression/session-timeline-file-state.spec.ts delete mode 100644 packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts create mode 100644 packages/app/playwright.components.config.ts create mode 100644 packages/session-ui/src/timeline/context-projection.stories.tsx create mode 100644 packages/session-ui/src/timeline/reasoning-projection.stories.tsx diff --git a/packages/app/.gitignore b/packages/app/.gitignore index d699efb38d2f..f461cab8276b 100644 --- a/packages/app/.gitignore +++ b/packages/app/.gitignore @@ -1,3 +1,5 @@ src/assets/theme.css e2e/test-results e2e/playwright-report +component-tests/test-results +component-tests/playwright-report diff --git a/packages/app/component-tests/README.md b/packages/app/component-tests/README.md new file mode 100644 index 000000000000..2d162d2d6b44 --- /dev/null +++ b/packages/app/component-tests/README.md @@ -0,0 +1,37 @@ +# Component browser tests + +These tests exercise production Solid components through their existing Storybook stories. Unlike `e2e/`, they do not boot the app, configure a server, seed browser storage, or navigate through unrelated routes. + +```sh +# Start Storybook automatically and run all component tests. +bun run test:components + +# Run one component spec. +bun run test:components -- component-tests/session-timeline.spec.ts + +# Explore the suite in Playwright's UI. +bun run test:components:ui +``` + +The tests are deliberately separate from `bun run test:e2e`, so CI can run app-wide user journeys without running component appearance and interaction coverage. Set `PLAYWRIGHT_STORYBOOK_URL` to reuse an existing Storybook instance or `PLAYWRIGHT_STORYBOOK_PORT` to choose its port. + +## Adding a test + +Keep scenarios next to the production component in a `*.stories.tsx` file. A story owns its fixtures, providers, state, and callbacks; the test owns user-visible interactions and assertions. + +```ts +import { expect, story } from "./story" + +story("preserves collapsed state while a tool completes", async ({ mount }) => { + const component = await mount("current-session-context-projection--collapsed-during-status-updates") + const trigger = component.locator('[data-slot="collapsible-trigger"]') + + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await component.getByRole("button", { name: "Complete read" }).click() + await expect(trigger).toHaveAttribute("aria-expanded", "false") +}) +``` + +The story ID is the Storybook component ID followed by `--` and the kebab-cased story export. Open the same story in Storybook to inspect and interact with exactly the scenario the browser test covers. + +Keep cross-route navigation, remote-server ownership, persistent session state, and workflows spanning independent surfaces in `e2e/`. diff --git a/packages/app/component-tests/composer.spec.ts b/packages/app/component-tests/composer.spec.ts new file mode 100644 index 000000000000..b6b18532a611 --- /dev/null +++ b/packages/app/component-tests/composer.spec.ts @@ -0,0 +1,25 @@ +import { expect, story } from "./story" + +story("shows the thinking level control while relevant", async ({ mount, page }) => { + const component = await mount("opencode-composer-flow--model-and-variant") + const composer = component.locator('[data-component="composer"]') + const input = composer.locator('[data-component="composer-editor"]') + const control = composer.getByRole("button", { name: "Choose model variant" }) + + await page.mouse.move(0, 0) + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) + await expect(control).toBeVisible() + + await control.click() + const high = page.getByRole("menuitemradio", { name: "high" }) + await expect(high).toBeVisible() + await page.mouse.move(0, 0) + await expect(control).toBeVisible() + await expect(high).toBeVisible() + await high.click() + + await input.focus() + await expect(control).toBeVisible() + await input.blur() + await expect(control).toBeVisible() +}) diff --git a/packages/app/component-tests/session-timeline-notices.spec.ts b/packages/app/component-tests/session-timeline-notices.spec.ts new file mode 100644 index 000000000000..9648eb7094dd --- /dev/null +++ b/packages/app/component-tests/session-timeline-notices.spec.ts @@ -0,0 +1,41 @@ +import { expect, story } from "./story" + +story("renders the moved location notice in its compact timeline style", async ({ mount, page }) => { + const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session` + await page.setViewportSize({ width: 480, height: 720 }) + const timeline = await mount("current-session-timeline-rows--moved-location") + const notice = timeline.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]') + const label = notice.locator('[data-slot="session-timeline-notice-label"]') + const value = notice.locator('[data-slot="session-timeline-notice-value"]') + const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]') + + await expect(label).toHaveText("Moved to") + await expect(value).toHaveText(directory) + await expect(notice).not.toContainText("·") + await expect(notice.locator("svg")).toHaveCount(0) + await expect(notice).toHaveCSS("height", "28px") + await expect(notice).toHaveCSS("gap", "8px") + await expect(notice).toHaveCSS("padding-top", "4px") + await expect(notice).toHaveCSS("padding-bottom", "4px") + await expect(label).toHaveCSS("font-size", "13px") + await expect(label).toHaveCSS("font-weight", "530") + await expect(label).toHaveCSS("line-height", "16px") + await expect(label).toHaveCSS("color", "rgb(128, 128, 128)") + await expect(value).toHaveCSS("font-size", "13px") + await expect(value).toHaveCSS("font-weight", "440") + await expect(value).toHaveCSS("line-height", "16px") + await expect(value).toHaveCSS("color", "rgb(128, 128, 128)") + await expect(value).toHaveCSS("text-overflow", "ellipsis") + await expect(value).toHaveCSS("white-space", "nowrap") + await expect(value).toHaveAttribute("dir", "ltr") + await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true) + + const tooltip = page.getByText("Session working directory changed", { exact: true }) + await label.hover() + await expect(tooltip).toBeVisible() + await page.mouse.move(0, 0) + await expect(tooltip).toBeHidden() + await tooltipTrigger.focus() + await expect(tooltipTrigger).toBeFocused() + await expect(tooltip).toBeVisible() +}) diff --git a/packages/app/component-tests/session-timeline-reasoning.spec.ts b/packages/app/component-tests/session-timeline-reasoning.spec.ts new file mode 100644 index 000000000000..fcb9e237f060 --- /dev/null +++ b/packages/app/component-tests/session-timeline-reasoning.spec.ts @@ -0,0 +1,53 @@ +import { expect, story } from "./story" + +const profiles = [ + { name: "summaries off no reasoning", id: "summaries-off-no-reasoning", thinking: true, body: false }, + { + name: "summaries off reasoning heading", + id: "summaries-off-reasoning-heading", + thinking: true, + body: false, + heading: true, + }, + { + name: "summaries off with visible tool", + id: "summaries-off-with-visible-tool", + thinking: true, + body: false, + heading: true, + }, + { name: "summaries on no content", id: "summaries-on-no-content", thinking: true, body: false }, + { name: "summaries on blank reasoning", id: "summaries-on-blank-reasoning", thinking: true, body: false }, + { + name: "summaries on visible reasoning", + id: "summaries-on-visible-reasoning", + thinking: false, + body: true, + }, + { + name: "summaries on visible tool no reasoning", + id: "summaries-on-visible-tool-no-reasoning", + thinking: false, + body: false, + }, +] as const + +for (const profile of profiles) { + story(`projects busy reasoning profile ${profile.name}`, async ({ mount }) => { + const timeline = await mount(`current-session-reasoning-projection--${profile.id}`) + await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) + await expect(timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')).toHaveCount( + profile.body ? 1 : 0, + ) + if ("heading" in profile) { + await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible() + } + }) +} + +story("does not infer reasoning visibility from provider identity", async ({ mount }) => { + const timeline = await mount("current-session-reasoning-projection--provider-without-reasoning") + await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(timeline.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) + await expect(timeline.getByText("No reasoning payload", { exact: true })).toBeVisible() +}) diff --git a/packages/app/component-tests/session-timeline.spec.ts b/packages/app/component-tests/session-timeline.spec.ts new file mode 100644 index 000000000000..7f53e40119bf --- /dev/null +++ b/packages/app/component-tests/session-timeline.spec.ts @@ -0,0 +1,49 @@ +import { expect, story } from "./story" + +story("renders streamed reasoning without starting the app", async ({ mount }) => { + const timeline = await mount("current-session-timeline-rows--streaming-reasoning-and-text") + await expect(timeline.locator('[data-component="session-timeline"]')).toBeVisible() + await expect(timeline.getByText("Checking the current contract", { exact: true })).toBeVisible() +}) + +story("preserves a collapsed context group through count and status updates", async ({ mount }) => { + const timeline = await mount("current-session-context-projection--collapsed-during-status-updates") + const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]') + const trigger = group.locator('[data-slot="collapsible-trigger"]') + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await timeline.getByRole("button", { name: "Complete read" }).click() + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await timeline.getByRole("button", { name: "Complete glob" }).click() + await expect(trigger).toHaveAttribute("aria-expanded", "false") +}) + +story("space activates a focused timeline button instead of scrolling", async ({ mount, page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }) + const timeline = await mount("current-session-terminal-work--collapsed-shell") + const trigger = timeline.locator('[data-timeline-part-id="tool_terminal_passed"] [data-slot="collapsible-trigger"]') + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await trigger.focus() + const before = await page.evaluate(() => window.scrollY) + await trigger.press("Space") + await expect(trigger).toHaveAttribute("aria-expanded", "true") + expect(await page.evaluate(() => window.scrollY)).toBe(before) +}) + +story("renders a completed write through the production file component", async ({ mount }) => { + const timeline = await mount("current-session-file-changes--created-a-new-file") + await expect(timeline.locator('[data-component="write-content"]')).toBeVisible() +}) + +story("keeps patch file disclosures independent", async ({ mount }) => { + const timeline = await mount("current-session-file-changes--patched-two-files") + const files = timeline.locator('[data-scope="apply-patch"] button') + await expect(files).toHaveCount(2) + await expect(files.nth(0)).toHaveAttribute("aria-expanded", "false") + await expect(files.nth(1)).toHaveAttribute("aria-expanded", "false") + await files.nth(0).click() + await expect(files.nth(0)).toHaveAttribute("aria-expanded", "true") + await expect(files.nth(1)).toHaveAttribute("aria-expanded", "false") + await files.nth(1).click() + await expect(files.nth(0)).toHaveAttribute("aria-expanded", "true") + await expect(files.nth(1)).toHaveAttribute("aria-expanded", "true") +}) diff --git a/packages/app/component-tests/story.ts b/packages/app/component-tests/story.ts new file mode 100644 index 000000000000..3b83d3d22e22 --- /dev/null +++ b/packages/app/component-tests/story.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test" +import type { Locator } from "@playwright/test" + +export { expect } + +export const story = test.extend<{ mount: (id: string) => Promise }>({ + mount: async ({ page }, use) => { + await use(async (id) => { + await page.goto(`/iframe.html?id=${encodeURIComponent(id)}&viewMode=story`) + const root = page.locator("#storybook-root") + await expect(root).toBeVisible({ timeout: 30_000 }) + return root + }) + }, +}) diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts deleted file mode 100644 index 7dd640a50c0e..000000000000 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { expect, test, type Page } from "@playwright/test" -import { base64Encode } from "@opencode-ai/util/encode" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectAppVisible } from "../utils/waits" - -const directory = "C:/OpenCode/PromptThinkingLevelRegression" -const projectID = "proj_prompt_thinking_level_regression" -const sessionID = "ses_prompt_thinking_level_regression" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - -test("shows the thinking level control while relevant", async ({ page }) => { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "prompt-thinking-level-regression", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { - "thinking-model": { - id: "thinking-model", - name: "Thinking Model", - limit: { context: 200_000 }, - variants: { high: {} }, - }, - }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "thinking-model" }, - }, - sessions: [ - { - id: sessionID, - slug: "prompt-thinking-level-regression", - projectID, - directory, - title: "Prompt thinking level regression", - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - pageMessages: () => ({ items: [] }), - }) - await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) - const composer = page.locator('[data-component="composer"]') - const input = composer.locator('[data-component="composer-editor"]') - const control = composer.getByRole("button", { name: "Choose model variant" }) - await expectAppVisible(composer) - - await idleComposer(page) - await expect(control).toBeVisible() - - await control.click() - const high = page.getByRole("menuitemradio", { name: "high" }) - await expect(high).toBeVisible() - await page.mouse.move(0, 0) - await expect(control).toBeVisible() - await expect(high).toBeVisible() - await high.click() - - await idleComposer(page) - await input.focus() - await expect(control).toBeVisible() - - await idleComposer(page) - await expect(control).toBeVisible() -}) - -async function idleComposer(page: Page) { - await page.mouse.move(0, 0) - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) -} diff --git a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts deleted file mode 100644 index 598763c02253..000000000000 --- a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect, test } from "@playwright/test" -import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture" - -test("space activates a focused timeline button instead of scrolling", async ({ page }) => { - const shellID = "prt_space_button_shell" - await setupTimeline(page, { - messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])], - settings: { shellToolPartsExpanded: false }, - reducedMotion: true, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`) - await trigger.focus() - const before = await scroller.evaluate((element) => element.scrollTop) - await trigger.press("Space") - await expect(trigger).toHaveAttribute("aria-expanded", "true") - expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before) -}) - -function lines(count: number) { - return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") -} diff --git a/packages/app/e2e/regression/session-timeline-context-state.spec.ts b/packages/app/e2e/regression/session-timeline-context-state.spec.ts deleted file mode 100644 index a4878dd9f398..000000000000 --- a/packages/app/e2e/regression/session-timeline-context-state.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - partUpdated, - setupTimeline, - toolPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -test("preserves a collapsed context group through count and status updates", async ({ page }) => { - const ids = ["prt_closed_01_read", "prt_closed_02_glob"] - const inputs = { - read: { path: "src/a.ts", offset: 0, limit: 120 }, - glob: { path: ".", pattern: "**/*.ts" }, - } - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage( - [toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)], - { completed: false }, - ), - ], - }) - const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) - const trigger = group.locator('[data-slot="collapsible-trigger"]') - await expect(trigger).toHaveAttribute("aria-expanded", "false") - await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100) - await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300) - await expect(trigger).toHaveAttribute("aria-expanded", "false") -}) diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts index df0d8c77c98c..06cde4a20de8 100644 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -8,21 +8,6 @@ import { userText, } from "../performance/timeline-stability/fixture" -test("renders completed write content", async ({ page }) => { - const id = "prt_file_projection_write" - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }), - ]), - ], - settings: { editToolPartsExpanded: true }, - }) - - await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible() -}) - test("renders a completed single-file patch", async ({ page }) => { const id = "prt_file_projection_single_patch" await setupTimeline(page, { diff --git a/packages/app/e2e/regression/session-timeline-file-state.spec.ts b/packages/app/e2e/regression/session-timeline-file-state.spec.ts deleted file mode 100644 index 8318a685a605..000000000000 --- a/packages/app/e2e/regression/session-timeline-file-state.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect, test } from "@playwright/test" -import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" -import { createTwoFilesPatch } from "diff" - -test("keeps patch file disclosures independent", async ({ page }) => { - const patchID = "prt_nested_patch" - const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")] - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart( - patchID, - "patch", - "completed", - { patchText: "Update three files" }, - { metadata: { files } }, - ), - ]), - ], - settings: { editToolPartsExpanded: true }, - }) - const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`) - const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]') - const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]') - await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3) - await deleted.getByRole("button").click() - await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true") - await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false") - await modified.getByRole("button").click() - await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true") - await deleted.getByRole("button").click() - await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false") - await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true") -}) - -function patchFile(file: string, status: "added" | "modified" | "deleted") { - const before = status === "added" ? "" : source(false) - const after = status === "deleted" ? "" : source(true) - return { - file, - status, - patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after), - additions: status === "deleted" ? 0 : 4, - deletions: status === "added" ? 0 : 3, - } -} - -function source(changed: boolean) { - return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join( - "", - ) -} diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index 45f1ca218be7..de9e01906204 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -185,57 +185,6 @@ test("shows a delegating row while subagent input streams", async ({ page }) => await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) }) -test("renders the moved location notice in its compact timeline style", async ({ page }) => { - const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session` - await page.setViewportSize({ width: 480, height: 720 }) - await setupTimeline(page, { - sessionMessages: [ - user, - { - id: "msg_location", - type: "location-switched", - location: { directory }, - time: { created: 2 }, - }, - ], - }) - - const notice = page.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]') - const label = notice.locator('[data-slot="session-timeline-notice-label"]') - const value = notice.locator('[data-slot="session-timeline-notice-value"]') - const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]') - - await expect(label).toHaveText("Moved to") - await expect(value).toHaveText(directory) - await expect(notice).not.toContainText("·") - await expect(notice.locator("svg")).toHaveCount(0) - await expect(notice).toHaveCSS("height", "28px") - await expect(notice).toHaveCSS("gap", "8px") - await expect(notice).toHaveCSS("padding-top", "4px") - await expect(notice).toHaveCSS("padding-bottom", "4px") - await expect(label).toHaveCSS("font-size", "13px") - await expect(label).toHaveCSS("font-weight", "530") - await expect(label).toHaveCSS("line-height", "16px") - await expect(label).toHaveCSS("color", "rgb(128, 128, 128)") - await expect(value).toHaveCSS("font-size", "13px") - await expect(value).toHaveCSS("font-weight", "440") - await expect(value).toHaveCSS("line-height", "16px") - await expect(value).toHaveCSS("color", "rgb(128, 128, 128)") - await expect(value).toHaveCSS("text-overflow", "ellipsis") - await expect(value).toHaveCSS("white-space", "nowrap") - await expect(value).toHaveAttribute("dir", "ltr") - await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true) - - const tooltip = page.getByText("Session working directory changed", { exact: true }) - await label.hover() - await expect(tooltip).toBeVisible() - await page.mouse.move(0, 0) - await expect(tooltip).toBeHidden() - await tooltipTrigger.focus() - await expect(tooltipTrigger).toBeFocused() - await expect(tooltip).toBeVisible() -}) - test("moves blocking work to the background with Ctrl+B", async ({ page }) => { await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] }) const card = page.locator('[data-component="task-tool-card"]') diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts deleted file mode 100644 index 387b713069e2..000000000000 --- a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantID, - assistantMessage, - reasoningPart, - setupTimeline, - status, - textPart, - toolPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -const profiles = [ - { name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false }, - { - name: "summaries off reasoning heading", - summaries: false, - reasoning: "## Inspecting stability", - other: false, - thinking: true, - body: false, - }, - { - name: "summaries off with visible tool", - summaries: false, - reasoning: "## Inspecting stability", - other: true, - thinking: true, - body: false, - }, - { name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false }, - { - name: "summaries on blank reasoning", - summaries: true, - reasoning: " ", - other: false, - thinking: true, - body: false, - }, - { - name: "summaries on visible reasoning", - summaries: true, - reasoning: "## Inspecting stability", - other: false, - thinking: false, - body: true, - }, - { - name: "summaries on visible tool no reasoning", - summaries: true, - reasoning: "", - other: true, - thinking: false, - body: false, - }, -] as const - -for (const profile of profiles) { - test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => { - const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}` - const parts = [ - ...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []), - ...(profile.other - ? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })] - : []), - ] - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage(parts, { completed: false })], - settings: { showReasoningSummaries: profile.summaries }, - }) - await timeline.send(status("busy"), 150) - - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) - await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0) - if (!profile.summaries && profile.reasoning.trim()) { - await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() - } - }) -} - -test("does not infer reasoning visibility from provider identity", async ({ page }) => { - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }), - ], - settings: { showReasoningSummaries: true }, - }) - await timeline.send(status("busy"), 150) - - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) - await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toBeVisible() -}) diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index defce2fbb61f..908124f1e829 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -7,5 +7,5 @@ "rootDir": "..", "types": ["node", "bun"] }, - "include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"] + "include": ["./**/*.ts", "./**/*.tsx", "../component-tests/**/*.ts", "../src/types.ts"] } diff --git a/packages/app/package.json b/packages/app/package.json index 276b3d2c5266..372364b050d8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -26,6 +26,8 @@ "test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", + "test:components": "playwright test --config playwright.components.config.ts", + "test:components:ui": "playwright test --config playwright.components.config.ts --ui", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report e2e/playwright-report", "test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts", diff --git a/packages/app/playwright.components.config.ts b/packages/app/playwright.components.config.ts new file mode 100644 index 000000000000..c0619f777983 --- /dev/null +++ b/packages/app/playwright.components.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test" + +const port = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT ?? 6006) +const baseURL = process.env.PLAYWRIGHT_STORYBOOK_URL ?? `http://127.0.0.1:${port}` + +export default defineConfig({ + testDir: "./component-tests", + outputDir: "./component-tests/test-results", + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: [["html", { outputFolder: "component-tests/playwright-report", open: "never" }], ["line"]], + webServer: { + command: `bun --bun run --cwd ../storybook storybook -- --port ${port} --ci --no-open`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [{ name: "components", use: { ...devices["Desktop Chrome"] } }], +}) diff --git a/packages/session-ui/src/timeline/context-projection.stories.tsx b/packages/session-ui/src/timeline/context-projection.stories.tsx new file mode 100644 index 000000000000..02a710535a1d --- /dev/null +++ b/packages/session-ui/src/timeline/context-projection.stories.tsx @@ -0,0 +1,78 @@ +import type { SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise" +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import type { SessionDocument } from "../document" +import { CurrentSessionProviders } from "../storybook/current-session-story" +import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "../storybook/current-session-fixtures" +import { SessionTimeline } from "./session-timeline" + +export default { + title: "OpenCode/Conversation/Context projection", + id: "current-session-context-projection", + component: SessionTimeline, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: "Interactive context-group state transitions through the real production timeline.", + }, + }, + }, +} + +function ContextStatusStory() { + const [state, setState] = createStore({ read: false, glob: false }) + const tool = (name: "read" | "glob", completed: boolean) => { + const input = name === "read" ? { path: "src/a.ts", offset: 0, limit: 120 } : { path: ".", pattern: "**/*.ts" } + return { + type: "tool", + id: `tool_context_${name}`, + name, + state: completed + ? { status: "completed", input, content: [{ type: "text", text: "Complete" }], metadata: {} } + : { status: "running", input, metadata: {} }, + time: { + created: STORY_TIME, + ran: STORY_TIME + 100, + ...(completed ? { completed: STORY_TIME + 200 } : {}), + }, + } satisfies SessionMessageAssistantTool + } + const document = createMemo( + () => + ({ + sessionID: CURRENT_SESSION_ID, + messages: [ + ...thinkingDocument.messages, + { + id: "msg_context_projection_assistant", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content: [tool("read", state.read), tool("glob", state.glob)], + time: { created: STORY_TIME }, + } satisfies SessionMessageAssistant, + ], + status: { type: "busy" }, + diffs: [], + }) satisfies SessionDocument, + ) + + return ( +
+
+ + +
+ + + +
+ ) +} + +export const CollapsedDuringStatusUpdates = { render: () => } diff --git a/packages/session-ui/src/timeline/reasoning-projection.stories.tsx b/packages/session-ui/src/timeline/reasoning-projection.stories.tsx new file mode 100644 index 000000000000..4aa1f2fdca39 --- /dev/null +++ b/packages/session-ui/src/timeline/reasoning-projection.stories.tsx @@ -0,0 +1,82 @@ +import type { SessionMessageAssistant } from "@opencode-ai/client/promise" +import type { SessionDocument } from "../document" +import { CurrentSessionProviders } from "../storybook/current-session-story" +import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "../storybook/current-session-fixtures" +import { SessionTimeline } from "./session-timeline" + +export default { + title: "OpenCode/Conversation/Reasoning projection", + id: "current-session-reasoning-projection", + component: SessionTimeline, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: "Busy reasoning, thinking, and tool visibility rendered directly by the production timeline.", + }, + }, + }, +} + +function ReasoningProjection(props: { summaries: boolean; reasoning?: string; tool?: boolean; text?: string }) { + const content = [ + ...(props.reasoning === undefined + ? [] + : [ + { + type: "reasoning" as const, + text: props.reasoning, + time: { created: STORY_TIME + 100 }, + }, + ]), + ...(props.tool + ? [ + { + type: "tool" as const, + id: "tool_reasoning_projection_skill", + name: "skill", + state: { status: "running" as const, input: { name: "inspect" }, metadata: {} }, + time: { created: STORY_TIME + 200, ran: STORY_TIME + 250 }, + }, + ] + : []), + ...(props.text === undefined ? [] : [{ type: "text" as const, text: props.text }]), + ] satisfies SessionMessageAssistant["content"] + const assistant = { + id: "msg_projection_assistant", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content, + time: { created: STORY_TIME }, + } satisfies SessionMessageAssistant + const document = { + sessionID: CURRENT_SESSION_ID, + messages: [...thinkingDocument.messages, assistant], + status: { type: "busy" }, + diffs: [], + } satisfies SessionDocument + + return ( +
+ + + +
+ ) +} + +export const SummariesOffNoReasoning = { render: () => } +export const SummariesOffReasoningHeading = { + render: () => , +} +export const SummariesOffWithVisibleTool = { + render: () => , +} +export const SummariesOnNoContent = { render: () => } +export const SummariesOnBlankReasoning = { render: () => } +export const SummariesOnVisibleReasoning = { + render: () => , +} +export const SummariesOnVisibleToolNoReasoning = { render: () => } +export const ProviderWithoutReasoning = { render: () => } diff --git a/packages/session-ui/src/timeline/terminal-work.stories.tsx b/packages/session-ui/src/timeline/terminal-work.stories.tsx index 2d0f8b879f11..bbc87193515f 100644 --- a/packages/session-ui/src/timeline/terminal-work.stories.tsx +++ b/packages/session-ui/src/timeline/terminal-work.stories.tsx @@ -62,6 +62,17 @@ export const UserCommandCompleted = { ), } +export const CollapsedShell = { + render: () => ( + + ), +} + export const TestsPassed = { render: () => ( ( + + ), +} + export const InstructionsUpdatedSingle = { render: () => ( Date: Wed, 26 Aug 2026 03:10:59 +0000 Subject: [PATCH 02/10] test(app): trace migrated component test origins --- packages/app/component-tests/composer.spec.ts | 1 + packages/app/component-tests/session-timeline-notices.spec.ts | 1 + .../app/component-tests/session-timeline-reasoning.spec.ts | 2 ++ packages/app/component-tests/session-timeline.spec.ts | 4 ++++ 4 files changed, 8 insertions(+) diff --git a/packages/app/component-tests/composer.spec.ts b/packages/app/component-tests/composer.spec.ts index b6b18532a611..e9c657174088 100644 --- a/packages/app/component-tests/composer.spec.ts +++ b/packages/app/component-tests/composer.spec.ts @@ -1,5 +1,6 @@ import { expect, story } from "./story" +// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts story("shows the thinking level control while relevant", async ({ mount, page }) => { const component = await mount("opencode-composer-flow--model-and-variant") const composer = component.locator('[data-component="composer"]') diff --git a/packages/app/component-tests/session-timeline-notices.spec.ts b/packages/app/component-tests/session-timeline-notices.spec.ts index 9648eb7094dd..afc1aa5c7c05 100644 --- a/packages/app/component-tests/session-timeline-notices.spec.ts +++ b/packages/app/component-tests/session-timeline-notices.spec.ts @@ -1,5 +1,6 @@ import { expect, story } from "./story" +// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts story("renders the moved location notice in its compact timeline style", async ({ mount, page }) => { const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session` await page.setViewportSize({ width: 480, height: 720 }) diff --git a/packages/app/component-tests/session-timeline-reasoning.spec.ts b/packages/app/component-tests/session-timeline-reasoning.spec.ts index fcb9e237f060..a7d0210aafdd 100644 --- a/packages/app/component-tests/session-timeline-reasoning.spec.ts +++ b/packages/app/component-tests/session-timeline-reasoning.spec.ts @@ -33,6 +33,7 @@ const profiles = [ ] as const for (const profile of profiles) { + // Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts story(`projects busy reasoning profile ${profile.name}`, async ({ mount }) => { const timeline = await mount(`current-session-reasoning-projection--${profile.id}`) await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) @@ -45,6 +46,7 @@ for (const profile of profiles) { }) } +// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts story("does not infer reasoning visibility from provider identity", async ({ mount }) => { const timeline = await mount("current-session-reasoning-projection--provider-without-reasoning") await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) diff --git a/packages/app/component-tests/session-timeline.spec.ts b/packages/app/component-tests/session-timeline.spec.ts index 7f53e40119bf..81fdd730a6c9 100644 --- a/packages/app/component-tests/session-timeline.spec.ts +++ b/packages/app/component-tests/session-timeline.spec.ts @@ -6,6 +6,7 @@ story("renders streamed reasoning without starting the app", async ({ mount }) = await expect(timeline.getByText("Checking the current contract", { exact: true })).toBeVisible() }) +// Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts story("preserves a collapsed context group through count and status updates", async ({ mount }) => { const timeline = await mount("current-session-context-projection--collapsed-during-status-updates") const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]') @@ -17,6 +18,7 @@ story("preserves a collapsed context group through count and status updates", as await expect(trigger).toHaveAttribute("aria-expanded", "false") }) +// Moved from packages/app/e2e/regression/session-timeline-accessibility.spec.ts story("space activates a focused timeline button instead of scrolling", async ({ mount, page }) => { await page.emulateMedia({ reducedMotion: "reduce" }) const timeline = await mount("current-session-terminal-work--collapsed-shell") @@ -29,11 +31,13 @@ story("space activates a focused timeline button instead of scrolling", async ({ expect(await page.evaluate(() => window.scrollY)).toBe(before) }) +// Moved from packages/app/e2e/regression/session-timeline-file-projection.spec.ts story("renders a completed write through the production file component", async ({ mount }) => { const timeline = await mount("current-session-file-changes--created-a-new-file") await expect(timeline.locator('[data-component="write-content"]')).toBeVisible() }) +// Moved from packages/app/e2e/regression/session-timeline-file-state.spec.ts story("keeps patch file disclosures independent", async ({ mount }) => { const timeline = await mount("current-session-file-changes--patched-two-files") const files = timeline.locator('[data-scope="apply-patch"] button') From e60ebb56456af1c2068cda2f6a59eb95a9638f73 Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:19:46 +0000 Subject: [PATCH 03/10] test(app): migrate more storybook component cases --- .../component-tests/session-lifecycle.spec.ts | 84 ++++ .../session-review-comments.spec.ts | 51 +++ .../session-tool-projection.spec.ts | 177 ++++++++ .../src/components/session-review.stories.tsx | 35 +- .../timeline/context-projection.stories.tsx | 9 +- .../src/timeline/tool-projection.stories.tsx | 380 ++++++++++++++++++ packages/storybook/.storybook/preview.tsx | 7 +- 7 files changed, 739 insertions(+), 4 deletions(-) create mode 100644 packages/app/component-tests/session-lifecycle.spec.ts create mode 100644 packages/app/component-tests/session-review-comments.spec.ts create mode 100644 packages/app/component-tests/session-tool-projection.spec.ts create mode 100644 packages/session-ui/src/timeline/tool-projection.stories.tsx diff --git a/packages/app/component-tests/session-lifecycle.spec.ts b/packages/app/component-tests/session-lifecycle.spec.ts new file mode 100644 index 000000000000..183955a63d2f --- /dev/null +++ b/packages/app/component-tests/session-lifecycle.spec.ts @@ -0,0 +1,84 @@ +import { expect, story } from "./story" + +for (const expanded of [false, true]) { + // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts + story(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ mount }) => { + const timeline = await mount( + `current-session-tool-projection--${expanded ? "expanded-shell-updates" : "collapsed-shell-updates"}`, + ) + const trigger = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]') + await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) + await timeline.getByRole("button", { name: "Update output" }).click() + await expect(timeline.getByText("Sibling content", { exact: true })).toBeVisible() + await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) + await timeline.getByRole("button", { name: "Run command" }).click() + await timeline.getByRole("button", { name: "Complete command" }).click() + await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) + }) +} + +// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +story("transitions a streaming shell from writing through command execution", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--streaming-shell-lifecycle") + const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]') + const title = tool.locator('[data-slot="basic-tool-tool-title"]') + const shimmer = title.locator('[data-component="text-shimmer"]') + const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]') + await expect(shimmer).toHaveAttribute("aria-label", "Shell") + await expect(shimmer).toHaveAttribute("data-active", "true") + await expect(subtitle).toHaveText("Writing command...") + await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0) + await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0) + await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px") + await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px") + await expect(title).toHaveCSS("font-size", "13px") + await expect(title).toHaveCSS("font-family", /^Inter,/) + await expect(title).toHaveCSS("font-weight", "530") + await expect(title).toHaveCSS("line-height", "16px") + await expect(title).toHaveCSS("color", "rgb(22, 22, 22)") + await expect(subtitle).toHaveCSS("font-size", "13px") + await expect(subtitle).toHaveCSS("font-family", /^Inter,/) + await expect(subtitle).toHaveCSS("font-weight", "440") + await expect(subtitle).toHaveCSS("line-height", "16px") + await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)") + await timeline.getByRole("button", { name: "Run command" }).click() + await expect(shimmer).toHaveAttribute("data-active", "true") + await expect(subtitle).toHaveText("printf ready") + await expect(tool).not.toContainText("Writing command...") + await timeline.getByRole("button", { name: "Complete command" }).click() + await expect(subtitle).toHaveText("printf ready") +}) + +// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +story("shimmers and expands a running shell command", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--streaming-shell-lifecycle") + await timeline.getByRole("button", { name: "Run command" }).click() + const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]') + const trigger = tool.locator('[data-slot="collapsible-trigger"]') + await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") + await expect(tool).not.toContainText("Writing command...") + await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText("printf ready") + await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0) + await expect(trigger).toHaveCSS("height", "28px") + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running") +}) + +for (const profile of [ + { locale: "de", story: "completed-german", label: "Erkundung abgeschlossen" }, + { locale: "ar", story: "completed-arabic", label: "تم الاستكشاف" }, +] as const) { + // Moved from packages/app/e2e/regression/session-timeline-locale-projection.spec.ts + story(`projects translated context status in ${profile.locale}`, async ({ mount, page }) => { + const timeline = await mount(`current-session-context-projection--${profile.story}`) + await timeline.getByRole("button", { name: "Complete read" }).click() + await timeline.getByRole("button", { name: "Complete glob" }).click() + const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]') + await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label) + await expect(page.locator("html")).toHaveAttribute("lang", profile.locale) + }) +} diff --git a/packages/app/component-tests/session-review-comments.spec.ts b/packages/app/component-tests/session-review-comments.spec.ts new file mode 100644 index 000000000000..b6ccd80d1ec9 --- /dev/null +++ b/packages/app/component-tests/session-review-comments.spec.ts @@ -0,0 +1,51 @@ +import { expect, story } from "./story" + +// Moved from packages/app/e2e/regression/review-line-comment.spec.ts +story("opens the comment editor when code is clicked", async ({ mount }) => { + const root = await mount("components-session-review--interactive-comments") + const review = root.locator('[data-component="session-review"]') + await review.getByText("export const value = 'after'", { exact: true }).click() + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2") +}) + +// Moved from packages/app/e2e/regression/review-line-comment.spec.ts +story("opens the comment editor when a line number is clicked", async ({ mount }) => { + const root = await mount("components-session-review--interactive-comments") + const review = root.locator('[data-component="session-review"]') + await expect(review.getByText("export const first = 1", { exact: true })).toBeVisible() + const numbers = review.locator('[data-column-number="1"]') + await expect(numbers).toHaveCount(2) + const number = numbers.nth(1) + await number.click() + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") +}) + +// Moved from packages/app/e2e/regression/review-line-comment.spec.ts +story("opens the comment editor for a line number range", async ({ mount }) => { + const root = await mount("components-session-review--interactive-comments") + const review = root.locator('[data-component="session-review"]') + const first = review.locator('[data-column-number="1"]') + const last = review.locator('[data-column-number="3"]') + await expect(first).toHaveCount(2) + await expect(last).toHaveCount(2) + await first.nth(1).dragTo(last.nth(1)) + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3") +}) + +// Moved from packages/app/e2e/regression/review-line-comment.spec.ts +story("shows a comment button when a diff line is hovered", async ({ mount }) => { + const root = await mount("components-session-review--interactive-comments") + const review = root.locator('[data-component="session-review"]') + const line = review.getByText("export const first = 1", { exact: true }) + const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true }) + await expect(comment).toHaveCount(1) + await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true }) + await expect(comment).toBeVisible() + await expect(comment).toHaveCSS("pointer-events", "auto") + await comment.dispatchEvent("click") + await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") +}) diff --git a/packages/app/component-tests/session-tool-projection.spec.ts b/packages/app/component-tests/session-tool-projection.spec.ts new file mode 100644 index 000000000000..83e93e891986 --- /dev/null +++ b/packages/app/component-tests/session-tool-projection.spec.ts @@ -0,0 +1,177 @@ +import { expect, story } from "./story" + +// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts +story("renders every admitted tool family and hides timeline-only exclusions", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--every-tool-family") + await expect( + timeline.locator('[data-timeline-part-ids="tool_family_read,tool_family_glob,tool_family_grep,tool_family_list"]'), + ).toBeVisible() + for (const id of [ + "webfetch", + "websearch", + "subagent", + "shell", + "edit", + "write", + "patch", + "question", + "skill", + "custom", + ]) { + await expect(timeline.locator(`[data-timeline-part-id="tool_family_${id}"]`), id).toBeVisible() + } + const patch = timeline.locator('[data-timeline-part-id="tool_family_patch"]') + await expect(patch.getByText("1 file", { exact: true })).toBeVisible() + await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0) + await expect(patch.getByRole("button")).toHaveCount(1) + await expect(patch.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "false") + await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0) + await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0) + const edit = timeline.locator('[data-timeline-part-id="tool_family_edit"]') + await expect(edit.locator('[data-component="apply-patch-tool"]')).toBeVisible() + await expect(edit.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit") + await expect(timeline.locator('[data-timeline-part-id="tool_family_todo"]')).toHaveCount(0) +}) + +// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +story("renders every tool error outcome without leaking hidden tools", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--every-tool-error") + const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"] + await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1) + await expect(timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')).toContainText(/dismissed/i) + await expect(timeline.locator('[data-timeline-part-id="tool_error_todo"]')).toHaveCount(0) + for (const name of names) await expect(timeline.locator(`[data-timeline-part-id="tool_error_${name}"]`)).toBeVisible() +}) + +// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +story("transitions shell and question through running error outcomes", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--running-tool-errors") + const shell = timeline.locator('[data-timeline-part-id="tool_transition_shell"]') + const question = timeline.locator('[data-timeline-part-id="tool_transition_question"]') + await expect(shell).toBeVisible() + await expect(question).toHaveCount(0) + await timeline.getByRole("button", { name: "Fail running tools" }).click() + await expect(shell.locator('[data-kind="tool-error-card"]')).toBeVisible() + await expect(shell).toContainText("Command exited 1") + await expect(question).toContainText(/dismissed/i) +}) + +// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +story("labels all web search provider variants", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--search-providers") + await expect(timeline.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() + await expect(timeline.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() + await expect(timeline.getByRole("button", { name: /^Web Search/ })).toBeVisible() +}) + +// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +story("labels completed searches with result counts", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--context-labels") + const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]') + await group.locator('[data-slot="collapsible-trigger"]').click() + const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]') + await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)") + await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)") +}) + +// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +story("labels read tools from their path input", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--context-labels") + const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]') + await group.locator('[data-slot="collapsible-trigger"]').click() + await expect( + group + .locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]') + .filter({ hasText: "Read" }), + ).toContainText("a.ts") +}) + +// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +story("labels skill tools from IDs and result metadata", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--skill-labels") + for (const [id, name] of [ + ["tool_skill_id", "frontend-design"], + ["tool_skill_name", "OpenCode"], + ] as const) { + const skill = timeline.locator(`[data-timeline-part-id="${id}"]`) + const loaded = skill.locator('[data-component="tool-loaded-item"]') + await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`) + await expect(loaded).toHaveCSS("line-height", "16px") + await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded") + await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill") + await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name) + } +}) + +// Moved from packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +story("groups singleton and separated context operations at correct boundaries", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--context-boundaries") + await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_read"]')).toBeVisible() + await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep"]')).toBeVisible() + await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_list"]')).toBeVisible() + await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5) +}) + +// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts +story("combines adjacent edit calls and repeated files into one group", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--grouped-edits") + const group = timeline.locator('[data-timeline-part-ids="tool_grouped_edit_first,tool_grouped_edit_second"]') + await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit") + await expect(group.getByText("1 file", { exact: true })).toBeVisible() + await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"]) + await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true") +}) + +// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts +story("combines adjacent patch calls and repeated files into one group", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--grouped-patch-updates") + const first = timeline.locator('[data-timeline-part-id="tool_grouped_patch_first"]') + const file = first.locator('[data-scope="apply-patch"] [data-type="update"] button') + await expect(file).toBeVisible() + await file.click() + await expect(file).toHaveAttribute("aria-expanded", "true") + await first.evaluate((element) => { + const row = element.closest("[data-timeline-key]") + if (row) row.dataset.patchRow = "stable" + }) + await timeline.getByRole("button", { name: "Append patch" }).click() + const group = timeline.locator('[data-timeline-part-ids="tool_grouped_patch_first,tool_grouped_patch_second"]') + await expect(group.locator("xpath=ancestor::*[@data-timeline-key]")).toHaveAttribute("data-patch-row", "stable") + await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"]) + await expect(group.locator('[data-type="update"] button')).toHaveAttribute("aria-expanded", "true") + await timeline.getByRole("button", { name: "Complete patch" }).click() + await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts", "second.ts"]) + await expect(group.locator('[data-type="update"] button')).toHaveAttribute("aria-expanded", "true") + await expect(group.locator('[data-type="add"] button')).toHaveAttribute("aria-expanded", "false") + await expect( + timeline.locator( + '[data-timeline-part-id="tool_grouped_patch_first"], [data-timeline-part-id="tool_grouped_patch_second"]', + ), + ).toHaveCount(0) +}) + +// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +story("preserves surviving grouped patch state when its first patch fails", async ({ mount }) => { + const timeline = await mount("current-session-tool-projection--grouped-patch-failure") + const group = timeline.locator('[data-timeline-part-ids="tool_grouped_patch_first,tool_grouped_patch_second"]') + const file = group.locator('[data-scope="apply-patch"] button').filter({ hasText: "surviving.ts" }) + await expect(file).toBeVisible() + await file.click() + await expect(file).toHaveAttribute("aria-expanded", "true") + await group.evaluate((element) => { + const row = element.closest("[data-timeline-key]") + if (row) row.dataset.groupIdentity = "preserved" + }) + await timeline.getByRole("button", { name: "Fail first patch" }).click() + const failed = timeline.locator("[data-timeline-key]", { + has: timeline.locator('[data-timeline-part-id="tool_grouped_patch_first"]'), + }) + const surviving = timeline.locator("[data-timeline-key]", { + has: timeline.locator('[data-timeline-part-id="tool_grouped_patch_second"]'), + }) + await expect(failed).toHaveAttribute("data-timeline-key", /^assistant-part:part:/) + await expect(surviving).toHaveAttribute("data-timeline-key", /^assistant-part:file:/) + await expect(failed.getByText("Patch failed visibly")).toBeVisible() + await expect(surviving).toHaveAttribute("data-group-identity", "preserved") + await expect(surviving.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true") +}) diff --git a/packages/session-ui/src/components/session-review.stories.tsx b/packages/session-ui/src/components/session-review.stories.tsx index cf54eb2b7f8a..d78a8f05af2f 100644 --- a/packages/session-ui/src/components/session-review.stories.tsx +++ b/packages/session-ui/src/components/session-review.stories.tsx @@ -1,6 +1,7 @@ +import { createStore } from "solid-js/store" import { CurrentSessionProviders } from "../storybook/current-session-story" import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures" -import { SessionReview } from "./session-review" +import { SessionReview, type SessionReviewComment } from "./session-review" function ReviewStory(props: { split?: boolean }) { return ( @@ -44,3 +45,35 @@ export const UnifiedDark = { globals: { theme: "dark" }, render: () => , } + +function InteractiveCommentsStory() { + const [state, setState] = createStore({ comments: [] as SessionReviewComment[] }) + const file = "src/review.ts" + const diffs = [ + { + file, + additions: 1, + deletions: 1, + status: "modified" as const, + patch: + "diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n", + }, + ] + return ( + +
+ + setState("comments", (comments) => [...comments, { id: `comment-${comments.length + 1}`, ...comment }]) + } + /> +
+
+ ) +} + +export const InteractiveComments = { render: () => } diff --git a/packages/session-ui/src/timeline/context-projection.stories.tsx b/packages/session-ui/src/timeline/context-projection.stories.tsx index 02a710535a1d..c232d597a171 100644 --- a/packages/session-ui/src/timeline/context-projection.stories.tsx +++ b/packages/session-ui/src/timeline/context-projection.stories.tsx @@ -50,10 +50,13 @@ function ContextStatusStory() { agent: "build", model: STORY_MODEL, content: [tool("read", state.read), tool("glob", state.glob)], - time: { created: STORY_TIME }, + time: { + created: STORY_TIME, + ...(state.read && state.glob ? { completed: STORY_TIME + 300 } : {}), + }, } satisfies SessionMessageAssistant, ], - status: { type: "busy" }, + status: { type: state.read && state.glob ? "idle" : "busy" }, diffs: [], }) satisfies SessionDocument, ) @@ -76,3 +79,5 @@ function ContextStatusStory() { } export const CollapsedDuringStatusUpdates = { render: () => } +export const CompletedGerman = { globals: { locale: "de" }, render: () => } +export const CompletedArabic = { globals: { locale: "ar" }, render: () => } diff --git a/packages/session-ui/src/timeline/tool-projection.stories.tsx b/packages/session-ui/src/timeline/tool-projection.stories.tsx new file mode 100644 index 000000000000..1beff00bc1c0 --- /dev/null +++ b/packages/session-ui/src/timeline/tool-projection.stories.tsx @@ -0,0 +1,380 @@ +import type { JsonValue, SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise" +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import type { SessionDocument } from "../document" +import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "../storybook/current-session-fixtures" +import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story" +import { SessionTimeline } from "./session-timeline" + +export default { + title: "OpenCode/Conversation/Tool projection", + id: "current-session-tool-projection", + component: SessionTimeline, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: "Inspectable tool families, error outcomes, grouping, labels, and reactive lifecycle transitions.", + }, + }, + }, +} + +function tool( + id: string, + name: string, + status: "streaming" | "running" | "completed" | "error", + input: Record, + options: { metadata?: Record; output?: string; error?: string } = {}, +): SessionMessageAssistantTool { + const state = + status === "streaming" + ? { status, input: JSON.stringify(input) } + : status === "running" + ? { status, input, metadata: { ...options.metadata, ...(options.output ? { output: options.output } : {}) } } + : status === "error" + ? { + status, + input, + error: { type: "ToolExecutionError", message: options.error ?? `${name} failed visibly` }, + metadata: options.metadata, + } + : { + status, + input, + content: [{ type: "text" as const, text: options.output ?? "Complete" }], + metadata: options.metadata, + } + return { + type: "tool", + id, + name, + state, + time: { + created: STORY_TIME, + ...(status === "streaming" ? {} : { ran: STORY_TIME + 100 }), + ...(status === "completed" || status === "error" ? { completed: STORY_TIME + 200 } : {}), + }, + } +} + +function document(content: SessionMessageAssistant["content"], busy = false): SessionDocument { + return { + sessionID: CURRENT_SESSION_ID, + messages: [ + ...thinkingDocument.messages, + { + id: "msg_tool_projection_assistant", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content, + time: { created: STORY_TIME, ...(busy ? {} : { completed: STORY_TIME + 300 }) }, + }, + ], + status: { type: busy ? "busy" : "idle" }, + diffs: [], + } +} + +function patchFile(file: string, status: "modified" | "added" = "modified") { + return { + file, + status, + patch: + status === "added" + ? "@@ -0,0 +1 @@\n+export const after = true" + : "@@ -1 +1 @@\n-export const before = true\n+export const after = true", + additions: 1, + deletions: status === "added" ? 0 : 1, + } +} + +const questions = { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } + +export const EveryToolFamily = { + render: () => ( + + ), +} + +export const EveryToolError = { + render: () => { + const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"] + const input = (name: string): Record => { + if (name === "shell") return { command: "exit 1" } + if (name === "edit" || name === "write") return { path: "src/error.ts", content: "" } + if (name === "patch") return { patchText: "Update src/error.ts" } + if (name === "webfetch") return { url: "https://example.com" } + if (name === "websearch") return { query: "failure" } + if (name === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect." } + if (name === "skill") return { name: "failure" } + return { target: "failure" } + } + return ( + tool(`tool_error_${name}`, name, "error", input(name))), + tool("tool_error_question_dismissed", "question", "error", questions, { + error: "The user dismissed this question", + }), + tool("tool_error_question_transport", "question", "error", questions, { error: "Question transport failed" }), + tool("tool_error_todo", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }), + ])} + width="860px" + /> + ) + }, +} + +export const SearchProviders = { + render: () => ( + + ), +} + +export const ContextLabels = { + render: () => ( + + ), +} + +export const SkillLabels = { + render: () => ( + + ), +} + +export const ContextBoundaries = { + render: () => ( + + ), +} + +export const GroupedEdits = { + render: () => ( + + ), +} + +function ShellLifecycleStory(props: { expanded?: boolean; transition?: boolean }) { + const [state, setState] = createStore({ phase: props.transition ? "streaming" : "completed", revision: 0 }) + const current = createMemo(() => { + const phase = state.phase as "streaming" | "running" | "completed" + const command = phase === "streaming" ? "" : "printf ready" + const content: SessionMessageAssistant["content"] = [ + tool("tool_shell_lifecycle", "shell", phase, command ? { command } : {}, { + output: phase === "running" ? "still running" : `line ${state.revision + 1}`, + }), + ...(state.revision ? [{ type: "text" as const, text: "Sibling content" }] : []), + ] + return document(content, phase !== "completed") + }) + return ( +
+
+ + + +
+ + + +
+ ) +} + +export const StreamingShellLifecycle = { render: () => } +export const CollapsedShellUpdates = { render: () => } +export const ExpandedShellUpdates = { render: () => } + +function ErrorTransitionStory() { + const [state, setState] = createStore({ failed: false }) + const current = createMemo(() => + document( + [ + tool( + "tool_transition_shell", + "shell", + state.failed ? "error" : "running", + { command: "exit 1" }, + { + error: "Command exited 1", + }, + ), + tool("tool_transition_question", "question", state.failed ? "error" : "running", questions, { + error: "The user dismissed this question", + }), + ], + !state.failed, + ), + ) + return ( +
+ + + + +
+ ) +} + +export const RunningToolErrors = { render: () => } + +function GroupedPatchStory(props: { failure?: boolean }) { + const [state, setState] = createStore({ phase: "initial" }) + const current = createMemo(() => { + const first = tool( + "tool_grouped_patch_first", + "patch", + state.phase === "failed" ? "error" : "completed", + { patchText: "Update src/first.ts" }, + { metadata: { files: [patchFile("src/first.ts")] }, error: "Patch failed visibly" }, + ) + const include = props.failure || state.phase !== "initial" + const second = tool( + "tool_grouped_patch_second", + "patch", + state.phase === "complete" || state.phase === "failed" ? "completed" : "running", + { patchText: "Update more files" }, + { + metadata: { + files: props.failure + ? [patchFile("src/surviving.ts")] + : state.phase === "complete" + ? [patchFile("src/first.ts"), patchFile("src/second.ts", "added")] + : [], + }, + }, + ) + return document(include ? [first, second] : [first], state.phase !== "complete") + }) + return ( +
+
+ + + +
+ + + +
+ ) +} + +export const GroupedPatchUpdates = { render: () => } +export const GroupedPatchFailure = { render: () => } diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx index b153b291e9f4..111ee6bce285 100644 --- a/packages/storybook/.storybook/preview.tsx +++ b/packages/storybook/.storybook/preview.tsx @@ -69,7 +69,7 @@ const frame = createJSXDecorator((Story, context) => { - + @@ -109,6 +109,11 @@ export default definePreview({ description: "Interface direction", defaultValue: "ltr", }, + locale: { + name: "Locale", + description: "Interface language", + defaultValue: "en", + }, }, parameters: { actions: { From 034fb71c44e8c8eabcf260435ccbcb10704919ea Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:33:23 +0000 Subject: [PATCH 04/10] test(app): checkpoint remaining component migrations --- .../session-notice-projection.spec.ts | 72 +++++++ .../session-review-comments.spec.ts | 11 +- .../session-tool-projection.spec.ts | 54 ----- .../regression/review-line-comment.spec.ts | 48 ----- .../session-timeline-lifecycle-state.spec.ts | 97 --------- .../session-timeline-notices.spec.ts | 169 +-------------- .../session-timeline-projection.spec.ts | 109 ---------- ...ession-timeline-reducer-projection.spec.ts | 19 -- .../session-timeline-tool-projection.spec.ts | 153 -------------- .../session-timeline-transport.spec.ts | 88 +------- packages/client/test/promise.test.ts | 109 ++++++++++ .../timeline/notice-projection.stories.tsx | 194 ++++++++++++++++++ 12 files changed, 385 insertions(+), 738 deletions(-) create mode 100644 packages/app/component-tests/session-notice-projection.spec.ts create mode 100644 packages/session-ui/src/timeline/notice-projection.stories.tsx diff --git a/packages/app/component-tests/session-notice-projection.spec.ts b/packages/app/component-tests/session-notice-projection.spec.ts new file mode 100644 index 000000000000..1a67b5a074c4 --- /dev/null +++ b/packages/app/component-tests/session-notice-projection.spec.ts @@ -0,0 +1,72 @@ +import { expect, story } from "./story" + +// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts +story("renders current protocol notices in CLI order", async ({ mount, page }) => { + const warnings: string[] = [] + page.on("console", (message) => { + if (message.text().includes("computations created outside a `createRoot` or `render`")) + warnings.push(message.text()) + }) + const timeline = await mount("current-session-notice-projection--protocol-notice-order") + const notices = timeline.locator('[data-slot="session-timeline-notice"]') + await expect(notices).toHaveCount(4) + await expect(notices.nth(0)).toContainText("Agent · explore") + await expect(notices.nth(1)).toContainText("explore finished · Search code") + await expect(notices.nth(2)).toContainText("Continuing after restart") + await expect(notices.nth(3)).toContainText("Skill · Review") + await expect(notices).toHaveClass([/text-text-weak/, /text-text-weak/, /text-text-weak/, /text-text-weak/]) + await expect(notices.locator(".text-text-strong")).toHaveCount(0) + expect(warnings).toEqual([]) +}) + +// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts +story("renders a compaction summary while it streams and after completion", async ({ mount }) => { + const timeline = await mount("current-session-notice-projection--compaction-lifecycle") + const compaction = timeline.locator('[data-component="session-compaction-message"]') + await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible() + await timeline.getByRole("button", { name: "Stream summary" }).click() + await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible() + await expect(compaction).toContainText("Streamed implementation details.") + await timeline.getByRole("button", { name: "Complete summary" }).click() + await expect(compaction).toContainText("Final implementation details.") + await expect(compaction).not.toContainText("Streamed implementation details.") +}) + +// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts +story("updates running compactions to failed and cancelled boundaries", async ({ mount }) => { + const timeline = await mount("current-session-notice-projection--compaction-lifecycle") + await timeline.getByRole("button", { name: "Stream summary" }).click() + await timeline.getByRole("button", { name: "Fail compaction" }).click() + const compactions = timeline.locator('[data-component="session-compaction-message"]') + const failed = compactions.filter({ hasText: "The provider rejected the summary." }) + await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible() + await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible() + await expect(failed).not.toContainText("Streamed implementation details.") + await timeline.getByRole("button", { name: "Cancel next compaction" }).click() + await expect(compactions).toHaveCount(2) + const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." }) + await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible() + await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.") +}) + +// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts +story("shows a delegating row while subagent input streams", async ({ mount }) => { + const timeline = await mount("current-session-notice-projection--streaming-delegation") + const delegating = timeline.locator('[data-component="task-tool-delegating"]') + await expect(delegating).toBeVisible() + const shimmer = delegating.locator('[data-component="text-shimmer"]') + await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...") + await expect(shimmer).toHaveCSS("line-height", "16px") + const icon = delegating.locator('[data-slot="icon-svg"]') + await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible() + await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)") + await expect(timeline.locator('[data-component="task-tool-card"]')).toHaveCount(0) + await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) +}) + +// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts +story("waits for completion before labeling requested background work", async ({ mount }) => { + const timeline = await mount("current-session-notice-projection--requested-background-work") + await expect(timeline.locator('[data-component="task-tool-card"]')).toContainText("Inspect code") + await expect(timeline.locator('[data-component="task-tool-card"]')).not.toContainText("(background)") +}) diff --git a/packages/app/component-tests/session-review-comments.spec.ts b/packages/app/component-tests/session-review-comments.spec.ts index b6ccd80d1ec9..0c1dd9391c96 100644 --- a/packages/app/component-tests/session-review-comments.spec.ts +++ b/packages/app/component-tests/session-review-comments.spec.ts @@ -14,9 +14,8 @@ story("opens the comment editor when a line number is clicked", async ({ mount } const root = await mount("components-session-review--interactive-comments") const review = root.locator('[data-component="session-review"]') await expect(review.getByText("export const first = 1", { exact: true })).toBeVisible() - const numbers = review.locator('[data-column-number="1"]') - await expect(numbers).toHaveCount(2) - const number = numbers.nth(1) + const number = review.locator('[data-column-number="1"]') + await expect(number).toHaveCount(1) await number.click() await expect(review.getByRole("textbox")).toBeVisible() await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") @@ -28,9 +27,9 @@ story("opens the comment editor for a line number range", async ({ mount }) => { const review = root.locator('[data-component="session-review"]') const first = review.locator('[data-column-number="1"]') const last = review.locator('[data-column-number="3"]') - await expect(first).toHaveCount(2) - await expect(last).toHaveCount(2) - await first.nth(1).dragTo(last.nth(1)) + await expect(first).toHaveCount(1) + await expect(last).toHaveCount(1) + await first.dragTo(last) await expect(review.getByRole("textbox")).toBeVisible() await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3") }) diff --git a/packages/app/component-tests/session-tool-projection.spec.ts b/packages/app/component-tests/session-tool-projection.spec.ts index 83e93e891986..d91bb26242be 100644 --- a/packages/app/component-tests/session-tool-projection.spec.ts +++ b/packages/app/component-tests/session-tool-projection.spec.ts @@ -121,57 +121,3 @@ story("combines adjacent edit calls and repeated files into one group", async ({ await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"]) await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true") }) - -// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts -story("combines adjacent patch calls and repeated files into one group", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--grouped-patch-updates") - const first = timeline.locator('[data-timeline-part-id="tool_grouped_patch_first"]') - const file = first.locator('[data-scope="apply-patch"] [data-type="update"] button') - await expect(file).toBeVisible() - await file.click() - await expect(file).toHaveAttribute("aria-expanded", "true") - await first.evaluate((element) => { - const row = element.closest("[data-timeline-key]") - if (row) row.dataset.patchRow = "stable" - }) - await timeline.getByRole("button", { name: "Append patch" }).click() - const group = timeline.locator('[data-timeline-part-ids="tool_grouped_patch_first,tool_grouped_patch_second"]') - await expect(group.locator("xpath=ancestor::*[@data-timeline-key]")).toHaveAttribute("data-patch-row", "stable") - await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"]) - await expect(group.locator('[data-type="update"] button')).toHaveAttribute("aria-expanded", "true") - await timeline.getByRole("button", { name: "Complete patch" }).click() - await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts", "second.ts"]) - await expect(group.locator('[data-type="update"] button')).toHaveAttribute("aria-expanded", "true") - await expect(group.locator('[data-type="add"] button')).toHaveAttribute("aria-expanded", "false") - await expect( - timeline.locator( - '[data-timeline-part-id="tool_grouped_patch_first"], [data-timeline-part-id="tool_grouped_patch_second"]', - ), - ).toHaveCount(0) -}) - -// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts -story("preserves surviving grouped patch state when its first patch fails", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--grouped-patch-failure") - const group = timeline.locator('[data-timeline-part-ids="tool_grouped_patch_first,tool_grouped_patch_second"]') - const file = group.locator('[data-scope="apply-patch"] button').filter({ hasText: "surviving.ts" }) - await expect(file).toBeVisible() - await file.click() - await expect(file).toHaveAttribute("aria-expanded", "true") - await group.evaluate((element) => { - const row = element.closest("[data-timeline-key]") - if (row) row.dataset.groupIdentity = "preserved" - }) - await timeline.getByRole("button", { name: "Fail first patch" }).click() - const failed = timeline.locator("[data-timeline-key]", { - has: timeline.locator('[data-timeline-part-id="tool_grouped_patch_first"]'), - }) - const surviving = timeline.locator("[data-timeline-key]", { - has: timeline.locator('[data-timeline-part-id="tool_grouped_patch_second"]'), - }) - await expect(failed).toHaveAttribute("data-timeline-key", /^assistant-part:part:/) - await expect(surviving).toHaveAttribute("data-timeline-key", /^assistant-part:file:/) - await expect(failed.getByText("Patch failed visibly")).toBeVisible() - await expect(surviving).toHaveAttribute("data-group-identity", "preserved") - await expect(surviving.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true") -}) diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 3749c4e64bfc..a485b79e9380 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -12,54 +12,6 @@ test.beforeEach(async ({ page }) => { await openReview(page) }) -test("opens the comment editor when code is clicked", async ({ page }) => { - const review = page.locator('[data-component="session-review"]') - const line = review.getByText("export const value = 'after'", { exact: true }) - await expectAppVisible(line) - await line.click() - - await expect(review.getByRole("textbox")).toBeVisible() - await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2") -}) - -test("opens the comment editor when a line number is clicked", async ({ page }) => { - const review = page.locator('[data-component="session-review"]') - const lineNumber = review.locator('[data-column-number="1"]').last() - await expectAppVisible(lineNumber) - await lineNumber.click() - - await expect(review.getByRole("textbox")).toBeVisible() - await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") -}) - -test("opens the comment editor for a line number range", async ({ page }) => { - const review = page.locator('[data-component="session-review"]') - const start = review.locator('[data-column-number="1"]').last() - const end = review.locator('[data-column-number="3"]').last() - await expectAppVisible(start) - await expectAppVisible(end) - - await start.dragTo(end) - - await expect(review.getByRole("textbox")).toBeVisible() - await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3") -}) - -test("shows a comment button when a diff line is hovered", async ({ page }) => { - const review = page.locator('[data-component="session-review"]') - const line = review.getByText("export const first = 1", { exact: true }) - await expectAppVisible(line) - - const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true }) - await expect(comment).toHaveCount(1) - await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true }) - await expect(comment).toBeVisible() - await expect(comment).toHaveCSS("pointer-events", "auto") - await comment.dispatchEvent("click") - await expect(review.getByRole("textbox")).toBeVisible() - await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") -}) - test("stages a submitted line comment in the prompt context", async ({ page }) => { page.on("request", (request) => { expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET") diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index f81dd081adc3..53989427b167 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from "@playwright/test" import { - assistantID, assistantMessage, completedAssistantInfo, messageUpdated, @@ -9,104 +8,12 @@ import { renderedPartID, setupTimeline, shell, - sessionID, status, stepStarted, textPart, - toolCalled, - toolInputEnded, - toolInputStarted, userMessage, } from "../performance/timeline-stability/fixture" -for (const expanded of [false, true]) { - test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => { - const id = `prt_shell_default_${expanded}` - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])], - settings: { shellToolPartsExpanded: expanded }, - }) - const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`) - await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) - - await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180) - await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180) - await timeline.send(status("busy"), 100) - await timeline.send(status("idle"), 250) - await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) - }) -} - -test("transitions a streaming shell from writing through command execution", async ({ page }) => { - const id = "prt_shell_streaming_input" - const command = "printf ready" - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([], { completed: false })], - }) - await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" })) - - const tool = page.locator(`[data-timeline-part-id="${id}"]`) - const title = tool.locator('[data-slot="basic-tool-tool-title"]') - const titleShimmer = title.locator('[data-component="text-shimmer"]') - const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]') - await expect(titleShimmer).toHaveAttribute("aria-label", "Shell") - await expect(titleShimmer).toHaveAttribute("data-active", "true") - await expect(subtitle).toHaveText("Writing command...") - await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0) - await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0) - await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px") - await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px") - await expect(title).toHaveCSS("font-size", "13px") - await expect(title).toHaveCSS("font-family", /^Inter,/) - await expect(title).toHaveCSS("font-weight", "530") - await expect(title).toHaveCSS("line-height", "16px") - await expect(title).toHaveCSS("color", "rgb(22, 22, 22)") - await expect(subtitle).toHaveCSS("font-size", "13px") - await expect(subtitle).toHaveCSS("font-family", /^Inter,/) - await expect(subtitle).toHaveCSS("font-weight", "440") - await expect(subtitle).toHaveCSS("line-height", "16px") - await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)") - - const input = JSON.stringify({ command }) - await timeline.send(toolInputEnded({ sessionID, assistantMessageID: assistantID, id, text: input })) - await expect(titleShimmer).toHaveAttribute("data-active", "true") - await expect(subtitle).toHaveText(command) - await expect(tool).not.toContainText("Writing command...") - - await timeline.send( - toolCalled({ - sessionID, - assistantMessageID: assistantID, - id, - input: { command }, - executed: true, - }), - ) - await expect(titleShimmer).toHaveAttribute("data-active", "true") - await expect(subtitle).toHaveText(command) -}) - -test("shimmers and expands a running shell command", async ({ page }) => { - const id = "prt_shell_running_command" - const command = "sleep 10 && echo done" - await setupTimeline(page, { - messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })], - settings: { shellToolPartsExpanded: false }, - }) - - const tool = page.locator(`[data-timeline-part-id="${id}"]`) - await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") - await expect(tool).not.toContainText("Writing command...") - await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command) - await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0) - await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px") - await tool.locator('[data-slot="collapsible-trigger"]').click() - await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") - await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running") -}) - test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { const reasoningID = "prt_reasoning_hidden" const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) @@ -166,7 +73,3 @@ test("moves busy through retry and recovery to final idle content", async ({ pag "Recovered response", ) }) - -function lines(count: number) { - return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") -} diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index de9e01906204..b01469fd0b1a 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -1,24 +1,10 @@ import { expect, test } from "@playwright/test" import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" -import { - compactionDelta, - compactionEnded, - compactionFailed, - compactionStarted, - event, - session, - sessionID, - setupTimeline, -} from "../performance/timeline-stability/fixture" +import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture" const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo -const assistant = ( - completed: boolean, - tool = false, - childID?: string, - background = false, -): SessionMessageAssistant => ({ +const assistant = (completed: boolean, tool = false, childID?: string): SessionMessageAssistant => ({ id: "msg_assistant", type: "assistant", agent: "build", @@ -31,7 +17,7 @@ const assistant = ( name: "subagent", state: { status: "running", - input: { description: "Inspect code", ...(background ? { background: true } : {}) }, + input: { description: "Inspect code" }, metadata: { status: "running", ...(childID ? { sessionID: childID } : {}) }, }, time: { created: 2 }, @@ -41,150 +27,6 @@ const assistant = ( time: { created: 2, ...(completed ? { completed: 3 } : {}) }, }) -test("renders current protocol notices in CLI order", async ({ page }) => { - const ownerWarnings: string[] = [] - page.on("console", (message) => { - if (message.text().includes("computations created outside a `createRoot` or `render`")) - ownerWarnings.push(message.text()) - }) - await setupTimeline(page, { - sessionMessages: [ - user, - { id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } }, - assistant(true), - { - id: "msg_subagent", - type: "synthetic", - text: "done", - description: "Search code", - metadata: { source: "subagent", agent: "explore", state: "completed" }, - time: { created: 4 }, - }, - { - id: "msg_restart", - type: "synthetic", - text: "continue", - description: "Continuing after restart", - time: { created: 5 }, - }, - { id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } }, - ], - }) - - const notices = page.locator('[data-slot="session-timeline-notice"]') - await expect(notices).toHaveCount(4) - await expect(notices.nth(0)).toContainText("Agent · explore") - await expect(notices.nth(1)).toContainText("explore finished · Search code") - await expect(notices.nth(2)).toContainText("Continuing after restart") - await expect(notices.nth(3)).toContainText("Skill · Review") - await expect(notices).toHaveClass([/text-text-weak/, /text-text-weak/, /text-text-weak/, /text-text-weak/]) - await expect(notices.locator(".text-text-strong")).toHaveCount(0) - expect(ownerWarnings).toEqual([]) -}) - -test("renders a compaction summary while it streams and after completion", async ({ page }) => { - const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] }) - - await timeline.send( - compactionStarted({ - sessionID, - reason: "manual", - recent: "", - }), - ) - - const compaction = page.locator('[data-component="session-compaction-message"]') - await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible() - - await timeline.send( - compactionDelta({ - sessionID, - text: "## Checkpoint\n\nStreamed implementation details.", - }), - ) - await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible() - await expect(compaction).toContainText("Streamed implementation details.") - - await timeline.send( - compactionEnded({ - sessionID, - reason: "manual", - text: "## Checkpoint\n\nFinal implementation details.", - recent: "", - }), - ) - await expect(compaction).toContainText("Final implementation details.") - await expect(compaction).not.toContainText("Streamed implementation details.") -}) - -test("updates running compactions to failed and cancelled boundaries", async ({ page }) => { - const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] }) - - await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" })) - await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." })) - await timeline.send( - compactionFailed({ - sessionID, - reason: "auto", - error: { - type: "compaction.failed", - message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}', - }, - }), - ) - - const compactions = page.locator('[data-component="session-compaction-message"]') - const failed = compactions.filter({ hasText: "The provider rejected the summary." }) - await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible() - await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible() - await expect(failed).not.toContainText("Partial summary that should be discarded.") - - await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" })) - await timeline.send( - compactionFailed({ - sessionID, - reason: "manual", - error: { type: "aborted", message: "Cancellation detail should stay hidden." }, - }), - ) - - await expect(compactions).toHaveCount(2) - const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." }) - await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible() - await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.") -}) - -test("shows a delegating row while subagent input streams", async ({ page }) => { - await setupTimeline(page, { - sessionMessages: [ - user, - { - ...assistant(false), - content: [ - { - type: "tool", - id: "call_subagent", - name: "subagent", - state: { status: "streaming", input: "" }, - time: { created: 2 }, - }, - ], - }, - ], - }) - - const delegating = page.locator('[data-component="task-tool-delegating"]') - await expect(delegating).toBeVisible() - const shimmer = delegating.locator('[data-component="text-shimmer"]') - await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...") - await expect(shimmer).toHaveCSS("line-height", "16px") - const icon = delegating.locator('[data-slot="icon-svg"]') - await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible() - await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)") - await expect(page.locator('[data-component="task-tool-card"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) -}) - test("moves blocking work to the background with Ctrl+B", async ({ page }) => { await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] }) const card = page.locator('[data-component="task-tool-card"]') @@ -220,11 +62,6 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => { await request }) -test("waits for completion before labeling requested background work", async ({ page }) => { - await setupTimeline(page, { sessionMessages: [user, assistant(false, true, undefined, true)] }) - await expect(page.locator('[data-component="task-tool-card"]')).not.toContainText("(background)") -}) - test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => { const childID = "ses_running_child" await setupTimeline(page, { diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 15fad5bd0317..9c1ac84f9905 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -11,78 +11,6 @@ import { } from "../performance/timeline-stability/fixture" test.describe("session timeline projection", () => { - test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => { - const parts = [ - toolPart("prt_01_read", "read", "completed", { path: "src/a.ts" }), - toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }), - toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }), - toolPart("prt_04_list", "list", "completed", { path: "src" }), - toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }), - toolPart( - "prt_websearch", - "websearch", - "completed", - { query: "timeline stability" }, - { output: "https://example.com/result" }, - ), - toolPart("prt_task", "subagent", "completed", { - description: "Inspect timeline", - agent: "explore", - prompt: "Inspect the timeline implementation.", - }), - toolPart( - "prt_bash", - "shell", - "completed", - { command: "printf stable" }, - { output: "stable", title: "printf stable" }, - ), - editPart("prt_edit"), - toolPart("prt_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true\n" }), - patchPart("prt_patch"), - toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }), - toolPart( - "prt_question", - "question", - "completed", - { questions: [{ question: "Keep stable?", header: "Stability", options: [] }] }, - { metadata: { answers: [["Yes"]] } }, - ), - toolPart("prt_skill", "skill", "completed", { name: "stability" }), - toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }), - ] - await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) - - await expect( - page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'), - ).toBeVisible() - for (const id of [ - "prt_webfetch", - "prt_websearch", - "prt_task", - "prt_bash", - "prt_edit", - "prt_write", - "prt_patch", - "prt_question", - "prt_skill", - "prt_custom", - ]) { - await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible() - } - const patch = page.locator('[data-timeline-part-id="prt_patch"]') - await expect(patch.getByText("1 file", { exact: true })).toBeVisible() - await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0) - await expect(patch.getByRole("button")).toHaveCount(1) - await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1) - await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0) - await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0) - const edit = page.locator('[data-timeline-part-id="prt_edit"]') - await expect(edit.locator('[data-component="apply-patch-tool"]')).toBeVisible() - await expect(edit.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit") - await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0) - }) - test("combines adjacent patch calls and repeated files into one group", async ({ page }) => { const first = "prt_patch_first" const second = "prt_patch_second" @@ -153,43 +81,6 @@ test.describe("session timeline projection", () => { await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0) }) - test("combines adjacent edit calls and repeated files into one group", async ({ page }) => { - const first = "prt_edit_first" - const second = "prt_edit_second" - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart( - first, - "edit", - "completed", - { path: "src/first.ts", oldString: "one", newString: "two" }, - { - metadata: { files: [patchFile("src/first.ts", "modified")] }, - }, - ), - toolPart( - second, - "edit", - "completed", - { path: "src/first.ts", oldString: "two", newString: "three" }, - { - metadata: { files: [patchFile("src/first.ts", "modified")] }, - }, - ), - ]), - ], - settings: { editToolPartsExpanded: true }, - }) - - const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`) - await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit") - await expect(group.getByText("1 file", { exact: true })).toBeVisible() - await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"]) - await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true") - }) - test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => { const firstUser = userMessage( [ diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index 22c94ae1c65f..a2c7f5808f43 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -6,30 +6,11 @@ import { partUpdated, renderedPartID, setupTimeline, - shell, status, textPart, - toolPart, userMessage, } from "../performance/timeline-stability/fixture" -test("groups singleton and separated context operations at correct boundaries", async ({ page }) => { - const parts = [ - toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }), - textPart("prt_boundary_02_text", "Boundary text"), - toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }), - toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }), - shell("prt_boundary_05_shell", "completed", "done"), - toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }), - ] - await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) - - await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible() - await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible() - await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible() - await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5) -}) - test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { const textID = "prt_event_order_text" const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false }) diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index af21a50b9099..6d3e3e520262 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -7,63 +7,6 @@ import { userMessage, } from "../performance/timeline-stability/fixture" -test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { - const ordinary = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"] - const parts = ordinary.map((tool, index) => - toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), - ) - parts.push( - toolPart("prt_question_dismissed", "question", "error", questionInput(), { - error: "The user dismissed this question", - }), - toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }), - toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }), - ) - await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) - - await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1) - await expect(page.getByText(/dismissed/i)).toBeVisible() - await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0) - for (let index = 0; index < ordinary.length; index++) { - await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible() - } -}) - -test("transitions shell and question through running error outcomes", async ({ page }) => { - const shellID = "prt_transition_error_shell" - const questionID = "prt_transition_error_question" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage( - [ - toolPart(shellID, "shell", "streaming", { command: "exit 1" }), - toolPart(questionID, "question", "streaming", questionInput()), - ], - { completed: false }, - ), - ], - }) - await timeline.waitForPart(shellID) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) - await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120) - await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) - await timeline.send( - partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })), - 180, - ) - await timeline.send( - partUpdated( - toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }), - ), - 250, - ) - - await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible() - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i) -}) - test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => { const failed = "prt_grouped_patch_failed" const surviving = "prt_grouped_patch_surviving" @@ -133,99 +76,3 @@ test("preserves surviving grouped patch state when its first patch fails", async }) .toBeGreaterThanOrEqual(-0.5) }) - -test("labels all web search provider variants", async ({ page }) => { - const parts = [ - toolPart( - "prt_search_parallel", - "websearch", - "completed", - { query: "parallel" }, - { metadata: { provider: "parallel" } }, - ), - toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }), - toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }), - ] - await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) - - await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() - await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() - await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible() -}) - -test("labels completed searches with result counts", async ({ page }) => { - const glob = "prt_glob_count" - const grep = "prt_grep_count" - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(glob, "glob", "completed", { path: ".", pattern: "**/*.ts" }, { metadata: { count: 1 } }), - toolPart(grep, "grep", "completed", { path: ".", pattern: "value" }, { metadata: { matches: 12 } }), - ]), - ], - }) - - const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`) - await group.locator('[data-slot="collapsible-trigger"]').click() - const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]') - await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)") - await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)") -}) - -test("labels read tools from their path input", async ({ page }) => { - const id = "prt_read_path" - await setupTimeline(page, { - messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])], - }) - - const group = page.locator(`[data-timeline-part-ids="${id}"]`) - await group.locator('[data-slot="collapsible-trigger"]').click() - await expect( - group - .locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]') - .filter({ hasText: "Read" }), - ).toContainText("a.ts") -}) - -test("labels skill tools from IDs and result metadata", async ({ page }) => { - const pending = "prt_skill_id" - const completed = "prt_skill_name" - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(pending, "skill", "running", { id: "frontend-design" }), - toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }), - ]), - ], - }) - - for (const [id, name] of [ - [pending, "frontend-design"], - [completed, "OpenCode"], - ] as const) { - const skill = page.locator(`[data-timeline-part-id="${id}"]`) - const loaded = skill.locator('[data-component="tool-loaded-item"]') - await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`) - await expect(loaded).toHaveCSS("line-height", "16px") - await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded") - await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill") - await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name) - } -}) - -function questionInput() { - return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } -} - -function errorInput(tool: string) { - if (tool === "shell") return { command: "exit 1" } - if (["edit", "write"].includes(tool)) return { path: "src/error.ts", content: "" } - if (tool === "patch") return { patchText: "Update src/error.ts" } - if (tool === "webfetch") return { url: "https://example.com" } - if (tool === "websearch") return { query: "failure" } - if (tool === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." } - if (tool === "skill") return { name: "failure" } - return { target: "failure" } -} diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 746b79f7deda..b35d58bf674f 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -1,64 +1,5 @@ -import { expect, test, type Page } from "@playwright/test" -import { partUpdated, renderedPartID, setupTimeline, textPart } from "../performance/timeline-stability/fixture" - -test("keeps one connection open while delivering multiple events", async ({ page }) => { - const timeline = await setupTimeline(page) - - const first = (await timeline.transport.burst(partUpdated(textPart("prt_transport_first", "first event")))).at(-1)! - const second = (await timeline.transport.burst(partUpdated(textPart("prt_transport_second", "second event")))).at(-1)! - - await timeline.waitForPart("prt_transport_first") - await timeline.waitForPart("prt_transport_second") - expect(first.connectionID).toBe(second.connectionID) - await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) - expect(await timeline.transport.acknowledgements()).toHaveLength(4) -}) - -test("delivers a burst from one stream chunk", async ({ page }) => { - const timeline = await setupTimeline(page) - const acknowledgements = await timeline.transport.burst([ - ...partUpdated(textPart("prt_transport_burst_a", "burst a")), - ...partUpdated(textPart("prt_transport_burst_b", "burst b")), - ]) - - await timeline.waitForPart("prt_transport_burst_a") - await timeline.waitForPart("prt_transport_burst_b") - expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1, 1, 1]) - expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(4) -}) - -test("parses split JSON and a split multibyte code point", async ({ page }) => { - const timeline = await setupTimeline(page) - const [started, payload] = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603")) - await timeline.transport.send(started!) - const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`) - const snowman = new TextEncoder().encode("\u2603")[0]! - const multibyte = encoded.indexOf(snowman) - - const acknowledgement = await timeline.transport.split(payload!, [9, multibyte + 1, multibyte + 2]) - - await timeline.waitForPart("prt_transport_split") - await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_transport_split")}"]`)).toContainText( - "split snowman \u2603\u2603\u2603", - ) - expect(acknowledgement.chunkCount).toBe(4) -}) - -test("delivers server heartbeat without mutating the timeline", async ({ page }) => { - const timeline = await setupTimeline(page) - const partID = "prt_transport_heartbeat_sentinel" - const sentinel = (await timeline.transport.burst(partUpdated(textPart(partID, "heartbeat sentinel")))).at(-1)! - await timeline.waitForPart(partID) - await expect( - page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"] [data-component="markdown"]`), - ).toHaveAttribute("data-markdown-ready", "") - const before = await timelineRows(page) - const heartbeat = await timeline.transport.heartbeat() - - await expect.poll(() => timelineRows(page)).toEqual(before) - expect(heartbeat.connectionID).toBe(sentinel.connectionID) - await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) -}) +import { expect, test } from "@playwright/test" +import { partUpdated, setupTimeline, textPart } from "../performance/timeline-stability/fixture" test("reconnects after a clean close", async ({ page }) => { const timeline = await setupTimeline(page) @@ -104,28 +45,3 @@ test("does not request replay when reconnecting the volatile event stream", asyn expect(first.eventID).toBe("timeline-event-7") expect(connection.headers["last-event-id"]).toBeUndefined() }) - -test("passes through non-event fetches", async ({ page }) => { - const timeline = await setupTimeline(page) - - const health = await page.evaluate(async () => { - const response = await fetch("/api/health") - return response.json() - }) - - expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 }) - await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) -}) - -function timelineRows(page: Page) { - return page.locator("[data-timeline-row]").evaluateAll((rows) => - rows.map((row) => ({ - kind: row.getAttribute("data-timeline-row"), - message: row.getAttribute("data-message-id"), - parts: Array.from(row.querySelectorAll("[data-timeline-part-id]"), (part) => - part.getAttribute("data-timeline-part-id"), - ), - text: row.textContent, - })), - ) -} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 3fa6334d8f0f..a0d2f3f62425 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -514,6 +514,115 @@ test("event.subscribe exposes the Promise event stream wire projection", async ( expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000) }) +// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts +test("event.subscribe keeps one request open while delivering multiple events", async () => { + const requests: Request[] = [] + const events = [ + { id: "evt_first", created: 1, type: "server.connected", data: {} }, + { id: "evt_second", created: 2, type: "server.connected", data: {} }, + ] + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + requests.push(input instanceof Request ? input : new Request(input, init)) + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }) + }, + }) + const received = [] + for await (const event of client.event.subscribe()) received.push(event) + expect(received).toEqual(events) + expect(requests).toHaveLength(1) +}) + +// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts +test("event.subscribe delivers every event from one stream chunk", async () => { + const events = Array.from({ length: 4 }, (_, index) => ({ + id: `evt_burst_${index}`, + created: index, + type: "server.connected", + data: {}, + })) + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + new Response(new TextEncoder().encode(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")), { + headers: { "content-type": "text/event-stream" }, + }), + }) + const received = [] + for await (const event of client.event.subscribe()) received.push(event) + expect(received).toEqual(events) + expect(new Set(received.map((event) => event.id)).size).toBe(4) +}) + +// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts +test("event.subscribe parses split JSON and a split multibyte code point", async () => { + const event = { + id: "evt_split", + created: 1, + type: "server.connected", + data: { text: "split snowman \u2603\u2603\u2603" }, + } + const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) + const multibyte = encoded.indexOf(new TextEncoder().encode("\u2603")[0]!) + const boundaries = [9, multibyte + 1, multibyte + 2, encoded.length] + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + new Response( + new ReadableStream({ + start(controller) { + boundaries.forEach((end, index) => + controller.enqueue(encoded.slice(index ? boundaries[index - 1] : 0, end)), + ) + controller.close() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ), + }) + await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event }) +}) + +// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts +test("event.subscribe ignores server heartbeat comments", async () => { + const event = { id: "evt_sentinel", created: 1, type: "server.connected", data: {} } + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + new Response(`: heartbeat\n\ndata: ${JSON.stringify(event)}\n\n: heartbeat\n\n`, { + headers: { "content-type": "text/event-stream" }, + }), + }) + const received = [] + for await (const item of client.event.subscribe()) received.push(item) + expect(received).toEqual([event]) +}) + +// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts +test("event transport passes through ordinary health requests", async () => { + const requests: string[] = [] + const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} } + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(new URL(request.url).pathname) + if (new URL(request.url).pathname === "/api/event") { + return new Response(`data: ${JSON.stringify(event)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }) + } + return Response.json({ healthy: true, version: "2.0.0", pid: 1 }) + }, + }) + await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event }) + await expect(client.health.get()).resolves.toEqual({ healthy: true, version: "2.0.0", pid: 1 }) + expect(requests).toEqual(["/api/event", "/api/health"]) +}) + test("event.subscribe terminates on malformed Promise SSE data", async () => { const client = OpenCode.make({ baseUrl: "http://localhost:3000", diff --git a/packages/session-ui/src/timeline/notice-projection.stories.tsx b/packages/session-ui/src/timeline/notice-projection.stories.tsx new file mode 100644 index 000000000000..992f5a865e88 --- /dev/null +++ b/packages/session-ui/src/timeline/notice-projection.stories.tsx @@ -0,0 +1,194 @@ +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import type { SessionDocument } from "../document" +import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME } from "../storybook/current-session-fixtures" +import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story" +import { SessionTimeline } from "./session-timeline" + +export default { + title: "OpenCode/Conversation/Notice projection", + id: "current-session-notice-projection", + component: SessionTimeline, + parameters: { layout: "fullscreen" }, +} + +const user = { id: "msg_notice_user", type: "user", text: "Run it", time: { created: STORY_TIME } } as const +const assistant = { + id: "msg_notice_assistant", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content: [{ type: "text", text: "Working" }], + time: { created: STORY_TIME + 1, completed: STORY_TIME + 2 }, +} satisfies SessionMessageInfo + +export const ProtocolNoticeOrder = { + render: () => ( + + ), +} + +function CompactionLifecycleStory() { + const [state, setState] = createStore({ phase: "running", summary: "", second: false }) + const current = createMemo(() => { + const failed = state.phase === "failed" + const completed = state.phase === "completed" + const message = { + id: "msg_notice_compaction", + type: "compaction" as const, + status: failed ? ("failed" as const) : completed ? ("completed" as const) : ("running" as const), + reason: "auto" as const, + ...(failed + ? { + error: { + type: "compaction.failed", + message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}', + }, + } + : { summary: state.summary, recent: "" }), + time: { created: STORY_TIME + 10 }, + } + const cancelled = { + id: "msg_notice_compaction_cancelled", + type: "compaction" as const, + status: "failed" as const, + reason: "manual" as const, + error: { type: "aborted", message: "Cancellation detail should stay hidden." }, + time: { created: STORY_TIME + 20 }, + } + return { + sessionID: CURRENT_SESSION_ID, + messages: [user, assistant, message, ...(state.second ? [cancelled] : [])], + status: { type: completed || failed ? "idle" : "busy" }, + diffs: [], + } satisfies SessionDocument + }) + return ( +
+
+ + + + +
+ + + +
+ ) +} + +export const CompactionLifecycle = { render: () => } + +export const StreamingDelegation = { + render: () => ( + + ), +} + +export const RequestedBackgroundWork = { + render: () => ( + + ), +} From 4308b6ba26358c9d056b7250f08bdce32c6eefa3 Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:47:41 +0000 Subject: [PATCH 05/10] test(app): finish isolating component-level regressions --- .../component-tests/session-lifecycle.spec.ts | 33 +++++ .../session-message-projection.spec.ts | 61 ++++++++ .../session-timeline-collapse-state.spec.ts | 130 +----------------- .../session-timeline-lifecycle-state.spec.ts | 75 ---------- ...session-timeline-locale-projection.spec.ts | 25 ---- .../session-timeline-projection.spec.ts | 126 ----------------- .../src/storybook/current-session-story.tsx | 15 +- .../src/timeline/file-changes.stories.tsx | 35 ++++- .../timeline/reasoning-projection.stories.tsx | 123 +++++++++++++++++ .../src/timeline/timeline-row.stories.tsx | 102 ++++++++++++++ 10 files changed, 367 insertions(+), 358 deletions(-) create mode 100644 packages/app/component-tests/session-message-projection.spec.ts delete mode 100644 packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts delete mode 100644 packages/app/e2e/regression/session-timeline-locale-projection.spec.ts diff --git a/packages/app/component-tests/session-lifecycle.spec.ts b/packages/app/component-tests/session-lifecycle.spec.ts index 183955a63d2f..62d2ef496b58 100644 --- a/packages/app/component-tests/session-lifecycle.spec.ts +++ b/packages/app/component-tests/session-lifecycle.spec.ts @@ -68,6 +68,39 @@ story("shimmers and expands a running shell command", async ({ mount }) => { await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running") }) +// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +story("transitions thinking and hidden reasoning through busy to idle", async ({ mount }) => { + const timeline = await mount("current-session-reasoning-projection--hidden-reasoning-lifecycle") + const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]') + await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible() + await expect(reasoning).toHaveCount(0) + await timeline.getByRole("button", { name: "Start shell" }).click() + await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await expect(timeline.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible() + await timeline.getByRole("button", { name: "Finish session" }).click() + await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(reasoning).toHaveCount(0) +}) + +// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +story("moves busy through retry and recovery to final idle content", async ({ mount }) => { + const timeline = await mount("current-session-reasoning-projection--retry-recovery-lifecycle") + await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await expect(timeline.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0) + await timeline.getByRole("button", { name: "Retry request" }).click() + await expect(timeline.locator('[data-timeline-row="Retry"]')).toBeVisible() + await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await timeline.getByRole("button", { name: "Recover request" }).click() + await expect(timeline.locator('[data-timeline-row="Retry"]')).toHaveCount(0) + await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await timeline.getByRole("button", { name: "Finish response" }).click() + await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(timeline.locator('[data-timeline-part-id="msg_retry_recovery_lifecycle:text:0"]')).toContainText( + "Recovered response", + ) +}) + for (const profile of [ { locale: "de", story: "completed-german", label: "Erkundung abgeschlossen" }, { locale: "ar", story: "completed-arabic", label: "تم الاستكشاف" }, diff --git a/packages/app/component-tests/session-message-projection.spec.ts b/packages/app/component-tests/session-message-projection.spec.ts new file mode 100644 index 000000000000..87dd05fb2fa6 --- /dev/null +++ b/packages/app/component-tests/session-message-projection.spec.ts @@ -0,0 +1,61 @@ +import { expect, story } from "./story" + +// Moved from packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +story("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ mount }) => { + const timeline = await mount("current-session-file-changes--edit-with-streamed-sibling") + const tool = timeline.locator('[data-timeline-part-id="tool_edit_status"]') + const trigger = tool.locator('[data-scope="apply-patch"] button') + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await tool.evaluate((element) => ((element as HTMLElement).dataset.regressionMarker = "before-stream")) + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await timeline.getByRole("button", { name: "Stream sibling content" }).click() + await expect(timeline.getByText("Streaming added a later assistant text part.", { exact: true })).toBeVisible() + await expect(tool).toHaveAttribute("data-regression-marker", "before-stream") + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await expect(tool.locator("xpath=ancestor::*[@data-timeline-row]")).toHaveAttribute( + "data-timeline-row", + "AssistantPart", + ) +}) + +// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts +story("renders interruption independently when the turn is not compacted", async ({ mount }) => { + const timeline = await mount("current-session-timeline-rows--interrupted-turn") + await expect(timeline.getByText("Interrupted", { exact: true })).toBeVisible() + await expect(timeline.getByText("Before", { exact: true })).toBeVisible() + await expect(timeline.getByText("After", { exact: true })).toBeVisible() + const rows = await timeline + .locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row"))) + expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"]) +}) + +// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts +story("renders aliased and long custom model notices", async ({ mount, page }) => { + await page.setViewportSize({ width: 420, height: 700 }) + const timeline = await mount("current-session-timeline-rows--aliased-model-notices") + const shortName = "GPT-5.4 nano" + const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts" + const short = timeline.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName }) + const long = timeline.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName }) + await expect(short).toBeVisible() + await expect(short.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible() + await expect(short.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh") + await expect(timeline.getByText("fast-nano", { exact: true })).toHaveCount(0) + await expect(short.locator('[data-component="provider-icon"]')).toBeVisible() + await expect(long).toBeVisible() + await expect(long.locator('[data-component="provider-icon"]')).toBeVisible() + await expect(long.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0) + await expect(long.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`) + await expect.poll(() => long.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true) +}) + +// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts +story("renders user image, file attachment, file reference, and agent reference", async ({ mount }) => { + const timeline = await mount("current-session-timeline-rows--rich-user-attachments") + await expect(timeline.getByAltText("pixel.png")).toBeVisible() + await expect(timeline.getByText("tsconfig.json")).toBeVisible() + await expect(timeline.getByText("@src/a.ts", { exact: true })).toBeVisible() + await expect(timeline.getByText("@explore", { exact: true })).toBeVisible() +}) diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 2e08719b39b4..ea5d80aefbc1 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Locator, type Page } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" @@ -10,7 +10,6 @@ const sessionID = "ses_timeline_state_regression" const userMessageID = "msg_user_regression" const assistantMessageID = "msg_assistant_regression" const editPartID = "prt_0001_edit" -const textPartID = `${assistantMessageID}:text:0` const title = "Timeline collapse state regression" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } @@ -84,37 +83,6 @@ const assistantMessage = { } satisfies SessionMessageInfo test.describe("regression: session timeline local row state", () => { - test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => { - const events: EventPayload[] = [] - await mockServer(page, events) - await configurePage(page) - - await page.goto(sessionHref()) - await expectSessionTitle(page, title) - - const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() - await expectAppVisible(wrapper) - await expectExpanded(wrapper, true) - - await wrapper.evaluate((element) => { - ;(element as HTMLElement).dataset.regressionMarker = "before-stream" - }) - await wrapper.locator('[data-scope="apply-patch"] button').click() - await expectExpanded(wrapper, false) - - events.push(...textEvents()) - - await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({ - timeout: 10_000, - }) - - expect(await readToolState(page)).toEqual({ - expanded: false, - row: "AssistantPart", - streamedTextVisible: true, - }) - }) - test("does not remount an edit diff when a sibling part arrives", async ({ page }) => { const events: EventPayload[] = [] await installDiffProbe(page) @@ -223,38 +191,6 @@ async function configurePage(page: Page) { }) } -async function expectExpanded(locator: Locator, expected: boolean) { - await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected) -} - -async function readToolState(page: Page) { - return page - .locator(`[data-timeline-part-id="${editPartID}"]`) - .first() - .evaluate( - (element, textPartID) => ({ - expanded: (() => { - const trigger = - element.querySelector('[data-scope="apply-patch"] button') ?? - element.querySelector('[data-slot="collapsible-trigger"]') - const aria = trigger?.getAttribute("aria-expanded") - if (aria === "true") return true - if (aria === "false") return false - - const root = element.querySelector('[data-component="collapsible"]') - if (root?.hasAttribute("data-expanded")) return true - if (root?.hasAttribute("data-closed")) return false - - const content = element.querySelector('[data-slot="collapsible-content"]') - return !!content && content.getBoundingClientRect().height > 0 - })(), - row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"), - streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`), - }), - `${assistantMessageID}:text:0`, - ) -} - async function installDiffProbe(page: Page) { await page.addInitScript(() => { let shadowRootCount = 0 @@ -346,54 +282,6 @@ function textEvents(): OpenCodeEvent[] { ] } -function toolEvents(part: typeof editPart): OpenCodeEvent[] { - return [ - eventValue( - "session.tool.input.started", - { - sessionID, - assistantMessageID, - id: part.callID, - name: part.tool, - }, - 1, - ), - eventValue( - "session.tool.input.ended", - { - sessionID, - assistantMessageID, - id: part.callID, - text: JSON.stringify(part.state.input), - }, - 1, - ), - eventValue( - "session.tool.called", - { - sessionID, - assistantMessageID, - id: part.callID, - input: part.state.input, - executed: true, - }, - 1, - ), - eventValue( - "session.tool.success", - { - sessionID, - assistantMessageID, - id: part.callID, - content: [{ type: "text", text: part.state.output }], - metadata: part.state.metadata as Record, - executed: true, - }, - 2, - ), - ] -} - function eventValue( type: Type, data: Extract["data"], @@ -410,22 +298,6 @@ function eventValue( } as unknown as Extract } -function readExpanded(element: Element) { - const trigger = - element.querySelector('[data-scope="apply-patch"] button') ?? - element.querySelector('[data-slot="collapsible-trigger"]') - const aria = trigger?.getAttribute("aria-expanded") - if (aria === "true") return true - if (aria === "false") return false - - const root = element.querySelector('[data-component="collapsible"]') - if (root?.hasAttribute("data-expanded")) return true - if (root?.hasAttribute("data-closed")) return false - - const content = element.querySelector('[data-slot="collapsible-content"]') - return !!content && content.getBoundingClientRect().height > 0 -} - async function mockServer( page: Page, events: EventPayload[], diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts deleted file mode 100644 index 53989427b167..000000000000 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - completedAssistantInfo, - messageUpdated, - partUpdated, - reasoningPart, - renderedPartID, - setupTimeline, - shell, - status, - stepStarted, - textPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { - const reasoningID = "prt_reasoning_hidden" - const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistant], - settings: { showReasoningSummaries: false }, - cpuRate: 4, - }) - await timeline.send(status("busy"), 150) - - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() - await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() - await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) - await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160) - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() - await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180) - await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100) - await timeline.send(status("idle"), 300) - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) -}) - -test("moves busy through retry and recovery to final idle content", async ({ page }) => { - const assistant = assistantMessage([], { completed: false }) - const timeline = await setupTimeline(page, { - messages: [ - userMessage(undefined, { - summary: { - diffs: [ - { - file: "src/retry.ts", - additions: 1, - deletions: 1, - status: "modified", - patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true", - }, - ], - }, - }), - assistant, - ], - }) - await timeline.send(status("busy"), 140) - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() - await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0) - await timeline.send(status("retry"), 180) - await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible() - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await timeline.send(stepStarted(assistant), 180) - await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() - await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140) - await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100) - await timeline.send(status("idle"), 350) - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText( - "Recovered response", - ) -}) diff --git a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts deleted file mode 100644 index 0f6d57f17f93..000000000000 --- a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { expect, test } from "@playwright/test" -import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" - -for (const profile of [ - { locale: "de", label: "Erkundung abgeschlossen" }, - { locale: "ar", label: "تم الاستكشاف" }, -] as const) { - test(`projects translated context status in ${profile.locale}`, async ({ page }) => { - const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`] - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(ids[0]!, "read", "completed", { path: "src/a.ts" }), - toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), - ]), - ], - locale: profile.locale, - }) - - const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) - await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label) - await expect(page.locator("html")).toHaveAttribute("lang", profile.locale) - }) -} diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 9c1ac84f9905..fec89e9eb420 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -7,7 +7,6 @@ import { toolPart, userMessage, userText, - type PartSeed, } from "../performance/timeline-stability/fixture" test.describe("session timeline projection", () => { @@ -121,133 +120,8 @@ test.describe("session timeline projection", () => { await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() }) - - test("renders interruption independently when the turn is not compacted", async ({ page }) => { - const user = userMessage() - const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], { - id: "msg_1001_before", - error: { type: "MessageAbortedError", message: "Stopped" }, - }) - const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], { - id: "msg_1002_after", - created: 1700000003000, - }) - await setupTimeline(page, { messages: [user, before, after] }) - - await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() - const rows = await page - .locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]') - .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row"))) - expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"]) - }) - - test("renders aliased and long custom model notices", async ({ page }) => { - const shortName = "GPT-5.4 nano" - const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts" - await setupTimeline(page, { - viewport: { width: 420, height: 700 }, - sessionMessages: [ - { - id: "msg_model_fast_nano", - type: "model-switched", - time: { created: 1700000000000 }, - model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" }, - }, - { - id: "msg_model_long_context", - type: "model-switched", - time: { created: 1700000001000 }, - model: { providerID: "company-gateway", id: "long-context" }, - }, - userMessage(), - assistantMessage(), - ], - }) - - const shortNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName }) - const longNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName }) - await expect(shortNotice).toBeVisible() - await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible() - await expect(shortNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh") - await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0) - await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible() - await expect(longNotice).toBeVisible() - await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible() - await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0) - await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`) - await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true) - }) - - test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => { - const text = "Use @explore with @src/a.ts and inspect the attachments" - const parts: PartSeed<"user">[] = [ - userText(text, { id: "prt_user_rich" }), - { - id: "prt_user_image", - type: "file", - mime: "image/png", - filename: "pixel.png", - url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", - }, - { - id: "prt_user_attachment", - type: "file", - mime: "application/json", - filename: "tsconfig.json", - url: "data:application/json;base64,e30=", - }, - { - id: "prt_user_reference", - type: "file", - mime: "text/plain", - filename: "a.ts", - url: "src/a.ts", - source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } }, - }, - { - id: "prt_user_agent", - type: "agent", - name: "explore", - source: { value: "@explore", start: 4, end: 12 }, - }, - ] - await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] }) - - await expect(page.getByAltText("pixel.png")).toBeVisible() - await expect(page.getByText("tsconfig.json")).toBeVisible() - await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible() - await expect(page.getByText("@explore", { exact: true })).toBeVisible() - }) }) -function editPart(id: string) { - return toolPart( - id, - "edit", - "completed", - { path: "src/a.ts", oldString: "export const value = 1", newString: "export const value = 2" }, - { - metadata: { - files: [patchFile("src/a.ts", "modified")], - }, - }, - ) -} - -function patchPart(id: string) { - return toolPart( - id, - "patch", - "completed", - { patchText: "Update the projected files" }, - { - metadata: { - files: [patchFile("src/a.ts", "modified")], - }, - }, - ) -} - function patchFile(file: string, status: "added" | "modified" | "deleted") { return { file, diff --git a/packages/session-ui/src/storybook/current-session-story.tsx b/packages/session-ui/src/storybook/current-session-story.tsx index 04ec7e4d64fc..939bc93afad8 100644 --- a/packages/session-ui/src/storybook/current-session-story.tsx +++ b/packages/session-ui/src/storybook/current-session-story.tsx @@ -21,8 +21,19 @@ export function CurrentSessionProviders(props: { document: SessionDocument; chil { name: "test", color: "green" }, ], provider: { - all: new Map([["anthropic", { models: { "claude-sonnet-4": { name: "Claude Sonnet 4" } } }]]), - connected: ["anthropic"], + all: new Map }>([ + ["anthropic", { models: { "claude-sonnet-4": { name: "Claude Sonnet 4" } } }], + [ + "company-gateway", + { + models: { + "fast-nano": { name: "GPT-5.4 nano" }, + "long-context": { name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts" }, + }, + }, + ], + ]), + connected: ["anthropic", "company-gateway"], default: { anthropic: "claude-sonnet-4" }, }, session: [ diff --git a/packages/session-ui/src/timeline/file-changes.stories.tsx b/packages/session-ui/src/timeline/file-changes.stories.tsx index 0d435ae4ca8f..892fd76a8562 100644 --- a/packages/session-ui/src/timeline/file-changes.stories.tsx +++ b/packages/session-ui/src/timeline/file-changes.stories.tsx @@ -1,4 +1,6 @@ -import { CurrentSessionTimelineStory } from "../storybook/current-session-story" +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story" import { editThenTestDocument, fileChangeLoadingDocument, @@ -70,6 +72,37 @@ export const PatchedTwoFiles = { ), } +function EditSiblingUpdateStory() { + const [state, setState] = createStore({ sibling: false }) + const document = createMemo(() => ({ + ...editThenTestDocument, + messages: editThenTestDocument.messages + .filter((message) => message.id === "msg_user_edit" || message.id === "msg_assistant_edit") + .map((message) => { + if (message.type !== "assistant" || !state.sibling) return message + return { + ...message, + content: [ + ...message.content, + { type: "text" as const, text: "Streaming added a later assistant text part." }, + ], + } + }), + })) + return ( +
+ + + + +
+ ) +} + +export const EditWithStreamedSibling = { render: () => } + export const CreatedANewFile = { render: () => ( } export const ProviderWithoutReasoning = { render: () => } + +function HiddenReasoningLifecycleStory() { + const [state, setState] = createStore({ phase: "thinking" }) + const document = createMemo(() => { + const finished = state.phase === "idle" + const running = state.phase === "running" + return { + sessionID: CURRENT_SESSION_ID, + messages: [ + ...thinkingDocument.messages, + { + id: "msg_hidden_reasoning_lifecycle", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content: [ + { + type: "reasoning", + text: "## Inspecting stability", + time: { created: STORY_TIME + 100 }, + }, + ...(running || finished + ? [ + { + type: "tool" as const, + id: "tool_hidden_reasoning_shell", + name: "shell", + state: finished + ? { + status: "completed" as const, + input: { command: "printf done" }, + content: [{ type: "text" as const, text: "done" }], + metadata: {}, + } + : { status: "running" as const, input: { command: "printf done" }, metadata: {} }, + time: { + created: STORY_TIME + 200, + ran: STORY_TIME + 250, + ...(finished ? { completed: STORY_TIME + 300 } : {}), + }, + }, + ] + : []), + ], + time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 400 } : {}) }, + }, + ], + status: { type: finished ? "idle" : "busy" }, + diffs: [], + } satisfies SessionDocument + }) + return ( +
+
+ + +
+ + + +
+ ) +} + +function RetryRecoveryLifecycleStory() { + const [state, setState] = createStore({ phase: "thinking" }) + const document = createMemo(() => { + const retry = state.phase === "retry" + const finished = state.phase === "idle" + return { + sessionID: CURRENT_SESSION_ID, + messages: [ + ...thinkingDocument.messages, + { + id: "msg_retry_recovery_lifecycle", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content: finished ? [{ type: "text" as const, text: "Recovered response" }] : [], + ...(retry + ? { + retry: { + attempt: 2, + at: 1_900_000_000_000, + error: { type: "ProviderRateLimitError", message: "Rate limit reached. Retrying with backoff." }, + }, + } + : {}), + time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 300 } : {}) }, + }, + ], + status: { type: finished ? "idle" : "busy" }, + diffs: [], + } satisfies SessionDocument + }) + return ( +
+
+ + + +
+ + + +
+ ) +} + +export const HiddenReasoningLifecycle = { render: () => } +export const RetryRecoveryLifecycle = { render: () => } diff --git a/packages/session-ui/src/timeline/timeline-row.stories.tsx b/packages/session-ui/src/timeline/timeline-row.stories.tsx index 0d7ab0926be1..a91ffb212d29 100644 --- a/packages/session-ui/src/timeline/timeline-row.stories.tsx +++ b/packages/session-ui/src/timeline/timeline-row.stories.tsx @@ -179,6 +179,108 @@ export const MovedLocation = { ), } +export const InterruptedTurn = { + render: () => ( + + ), +} + +export const AliasedModelNotices = { + render: () => ( + + ), +} + +export const RichUserAttachments = { + render: () => ( + + ), +} + export const InstructionsUpdatedSingle = { render: () => ( Date: Wed, 26 Aug 2026 04:12:25 +0000 Subject: [PATCH 06/10] test(app): colocate component suites with package owners --- bun.lock | 2 + packages/app/component-tests/README.md | 37 ------------- packages/app/component-tests/composer.spec.ts | 2 +- .../terminal-composer-focus.spec.ts | 1 + packages/app/playwright.components.config.ts | 31 +---------- packages/app/tsconfig.json | 8 ++- packages/session-ui/.gitignore | 2 + .../component-tests/session-lifecycle.spec.ts | 2 +- .../session-message-projection.spec.ts | 2 +- .../session-notice-projection.spec.ts | 2 +- .../session-review-comments.spec.ts | 4 +- .../session-timeline-notices.spec.ts | 2 +- .../session-timeline-reasoning.spec.ts | 2 +- .../component-tests/session-timeline.spec.ts | 2 +- .../session-tool-projection.spec.ts | 2 +- packages/session-ui/package.json | 5 +- .../playwright.components.config.ts | 4 ++ packages/storybook/package.json | 1 + packages/storybook/playwright/README.md | 55 +++++++++++++++++++ packages/storybook/playwright/config.ts | 31 +++++++++++ .../playwright}/story.ts | 0 packages/storybook/tsconfig.json | 2 +- turbo.json | 4 ++ 23 files changed, 125 insertions(+), 78 deletions(-) delete mode 100644 packages/app/component-tests/README.md create mode 100644 packages/session-ui/.gitignore rename packages/{app => session-ui}/component-tests/session-lifecycle.spec.ts (99%) rename packages/{app => session-ui}/component-tests/session-message-projection.spec.ts (98%) rename packages/{app => session-ui}/component-tests/session-notice-projection.spec.ts (98%) rename packages/{app => session-ui}/component-tests/session-review-comments.spec.ts (95%) rename packages/{app => session-ui}/component-tests/session-timeline-notices.spec.ts (97%) rename packages/{app => session-ui}/component-tests/session-timeline-reasoning.spec.ts (97%) rename packages/{app => session-ui}/component-tests/session-timeline.spec.ts (97%) rename packages/{app => session-ui}/component-tests/session-tool-projection.spec.ts (99%) create mode 100644 packages/session-ui/playwright.components.config.ts create mode 100644 packages/storybook/playwright/README.md create mode 100644 packages/storybook/playwright/config.ts rename packages/{app/component-tests => storybook/playwright}/story.ts (100%) diff --git a/bun.lock b/bun.lock index 292cb1cfdf5f..661fd9c649e1 100644 --- a/bun.lock +++ b/bun.lock @@ -731,6 +731,7 @@ }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", + "@playwright/test": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/luxon": "catalog:", @@ -836,6 +837,7 @@ "@opencode-ai/client": "workspace:*", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@playwright/test": "catalog:", "@solidjs/meta": "catalog:", "@storybook/addon-a11y": "10.4.4", "@storybook/addon-docs": "10.4.4", diff --git a/packages/app/component-tests/README.md b/packages/app/component-tests/README.md deleted file mode 100644 index 2d162d2d6b44..000000000000 --- a/packages/app/component-tests/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Component browser tests - -These tests exercise production Solid components through their existing Storybook stories. Unlike `e2e/`, they do not boot the app, configure a server, seed browser storage, or navigate through unrelated routes. - -```sh -# Start Storybook automatically and run all component tests. -bun run test:components - -# Run one component spec. -bun run test:components -- component-tests/session-timeline.spec.ts - -# Explore the suite in Playwright's UI. -bun run test:components:ui -``` - -The tests are deliberately separate from `bun run test:e2e`, so CI can run app-wide user journeys without running component appearance and interaction coverage. Set `PLAYWRIGHT_STORYBOOK_URL` to reuse an existing Storybook instance or `PLAYWRIGHT_STORYBOOK_PORT` to choose its port. - -## Adding a test - -Keep scenarios next to the production component in a `*.stories.tsx` file. A story owns its fixtures, providers, state, and callbacks; the test owns user-visible interactions and assertions. - -```ts -import { expect, story } from "./story" - -story("preserves collapsed state while a tool completes", async ({ mount }) => { - const component = await mount("current-session-context-projection--collapsed-during-status-updates") - const trigger = component.locator('[data-slot="collapsible-trigger"]') - - await expect(trigger).toHaveAttribute("aria-expanded", "false") - await component.getByRole("button", { name: "Complete read" }).click() - await expect(trigger).toHaveAttribute("aria-expanded", "false") -}) -``` - -The story ID is the Storybook component ID followed by `--` and the kebab-cased story export. Open the same story in Storybook to inspect and interact with exactly the scenario the browser test covers. - -Keep cross-route navigation, remote-server ownership, persistent session state, and workflows spanning independent surfaces in `e2e/`. diff --git a/packages/app/component-tests/composer.spec.ts b/packages/app/component-tests/composer.spec.ts index e9c657174088..3334e46f685f 100644 --- a/packages/app/component-tests/composer.spec.ts +++ b/packages/app/component-tests/composer.spec.ts @@ -1,4 +1,4 @@ -import { expect, story } from "./story" +import { expect, story } from "../../storybook/playwright/story" // Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts story("shows the thinking level control while relevant", async ({ mount, page }) => { diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index ca9263437b1c..a0325cb335c1 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -87,6 +87,7 @@ test("clears the terminal line with Command+Delete", async ({ page }) => { const terminal = page.locator('[data-component="terminal"]') await page.keyboard.press("Control+Backquote") await expect(terminal.locator("textarea")).toHaveCount(1) + await expect.poll(() => sendPtyOutput).toBeDefined() await page.keyboard.press("Meta+Backspace") diff --git a/packages/app/playwright.components.config.ts b/packages/app/playwright.components.config.ts index c0619f777983..92269c90436c 100644 --- a/packages/app/playwright.components.config.ts +++ b/packages/app/playwright.components.config.ts @@ -1,29 +1,4 @@ -import { defineConfig, devices } from "@playwright/test" +import { fileURLToPath } from "node:url" +import { componentConfig } from "../storybook/playwright/config" -const port = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT ?? 6006) -const baseURL = process.env.PLAYWRIGHT_STORYBOOK_URL ?? `http://127.0.0.1:${port}` - -export default defineConfig({ - testDir: "./component-tests", - outputDir: "./component-tests/test-results", - timeout: 60_000, - expect: { timeout: 10_000 }, - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 2 : undefined, - reporter: [["html", { outputFolder: "component-tests/playwright-report", open: "never" }], ["line"]], - webServer: { - command: `bun --bun run --cwd ../storybook storybook -- --port ${port} --ci --no-open`, - url: baseURL, - reuseExistingServer: !process.env.CI, - timeout: 120_000, - }, - use: { - baseURL, - trace: "on-first-retry", - screenshot: "only-on-failure", - video: "retain-on-failure", - }, - projects: [{ name: "components", use: { ...devices["Desktop Chrome"] } }], -}) +export default componentConfig(fileURLToPath(new URL(".", import.meta.url))) diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index e2a27dd5d8ad..e33268a46ad8 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -21,6 +21,12 @@ "@/*": ["./src/*"] } }, - "include": ["src", "package.json"], + "include": [ + "src", + "component-tests", + "playwright.components.config.ts", + "../storybook/playwright/*.ts", + "package.json" + ], "exclude": ["dist", "ts-dist"] } diff --git a/packages/session-ui/.gitignore b/packages/session-ui/.gitignore new file mode 100644 index 000000000000..9fe1f0a3d3c5 --- /dev/null +++ b/packages/session-ui/.gitignore @@ -0,0 +1,2 @@ +component-tests/test-results +component-tests/playwright-report diff --git a/packages/app/component-tests/session-lifecycle.spec.ts b/packages/session-ui/component-tests/session-lifecycle.spec.ts similarity index 99% rename from packages/app/component-tests/session-lifecycle.spec.ts rename to packages/session-ui/component-tests/session-lifecycle.spec.ts index 62d2ef496b58..a4ed6891aff9 100644 --- a/packages/app/component-tests/session-lifecycle.spec.ts +++ b/packages/session-ui/component-tests/session-lifecycle.spec.ts @@ -1,4 +1,4 @@ -import { expect, story } from "./story" +import { expect, story } from "../../storybook/playwright/story" for (const expanded of [false, true]) { // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts diff --git a/packages/app/component-tests/session-message-projection.spec.ts b/packages/session-ui/component-tests/session-message-projection.spec.ts similarity index 98% rename from packages/app/component-tests/session-message-projection.spec.ts rename to packages/session-ui/component-tests/session-message-projection.spec.ts index 87dd05fb2fa6..aae7b60433e9 100644 --- a/packages/app/component-tests/session-message-projection.spec.ts +++ b/packages/session-ui/component-tests/session-message-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, story } from "./story" +import { expect, story } from "../../storybook/playwright/story" // Moved from packages/app/e2e/regression/session-timeline-collapse-state.spec.ts story("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ mount }) => { diff --git a/packages/app/component-tests/session-notice-projection.spec.ts b/packages/session-ui/component-tests/session-notice-projection.spec.ts similarity index 98% rename from packages/app/component-tests/session-notice-projection.spec.ts rename to packages/session-ui/component-tests/session-notice-projection.spec.ts index 1a67b5a074c4..f70fc3cb09f0 100644 --- a/packages/app/component-tests/session-notice-projection.spec.ts +++ b/packages/session-ui/component-tests/session-notice-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, story } from "./story" +import { expect, story } from "../../storybook/playwright/story" // Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts story("renders current protocol notices in CLI order", async ({ mount, page }) => { diff --git a/packages/app/component-tests/session-review-comments.spec.ts b/packages/session-ui/component-tests/session-review-comments.spec.ts similarity index 95% rename from packages/app/component-tests/session-review-comments.spec.ts rename to packages/session-ui/component-tests/session-review-comments.spec.ts index 0c1dd9391c96..0924f1bc958c 100644 --- a/packages/app/component-tests/session-review-comments.spec.ts +++ b/packages/session-ui/component-tests/session-review-comments.spec.ts @@ -1,4 +1,4 @@ -import { expect, story } from "./story" +import { expect, story } from "../../storybook/playwright/story" // Moved from packages/app/e2e/regression/review-line-comment.spec.ts story("opens the comment editor when code is clicked", async ({ mount }) => { @@ -41,7 +41,7 @@ story("shows a comment button when a diff line is hovered", async ({ mount }) => const line = review.getByText("export const first = 1", { exact: true }) const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true }) await expect(comment).toHaveCount(1) - await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true }) + await line.hover() await expect(comment).toBeVisible() await expect(comment).toHaveCSS("pointer-events", "auto") await comment.dispatchEvent("click") diff --git a/packages/app/component-tests/session-timeline-notices.spec.ts b/packages/session-ui/component-tests/session-timeline-notices.spec.ts similarity index 97% rename from packages/app/component-tests/session-timeline-notices.spec.ts rename to packages/session-ui/component-tests/session-timeline-notices.spec.ts index afc1aa5c7c05..c838472b7960 100644 --- a/packages/app/component-tests/session-timeline-notices.spec.ts +++ b/packages/session-ui/component-tests/session-timeline-notices.spec.ts @@ -1,4 +1,4 @@ -import { expect, story } from "./story" +import { expect, story } from "../../storybook/playwright/story" // Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts story("renders the moved location notice in its compact timeline style", async ({ mount, page }) => { diff --git a/packages/app/component-tests/session-timeline-reasoning.spec.ts b/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts similarity index 97% rename from packages/app/component-tests/session-timeline-reasoning.spec.ts rename to packages/session-ui/component-tests/session-timeline-reasoning.spec.ts index a7d0210aafdd..3f14ec353c3c 100644 --- a/packages/app/component-tests/session-timeline-reasoning.spec.ts +++ b/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts @@ -1,4 +1,4 @@ -import { expect, story } from "./story" +import { expect, story } from "../../storybook/playwright/story" const profiles = [ { name: "summaries off no reasoning", id: "summaries-off-no-reasoning", thinking: true, body: false }, diff --git a/packages/app/component-tests/session-timeline.spec.ts b/packages/session-ui/component-tests/session-timeline.spec.ts similarity index 97% rename from packages/app/component-tests/session-timeline.spec.ts rename to packages/session-ui/component-tests/session-timeline.spec.ts index 81fdd730a6c9..690e03dd6ba6 100644 --- a/packages/app/component-tests/session-timeline.spec.ts +++ b/packages/session-ui/component-tests/session-timeline.spec.ts @@ -1,4 +1,4 @@ -import { expect, story } from "./story" +import { expect, story } from "../../storybook/playwright/story" story("renders streamed reasoning without starting the app", async ({ mount }) => { const timeline = await mount("current-session-timeline-rows--streaming-reasoning-and-text") diff --git a/packages/app/component-tests/session-tool-projection.spec.ts b/packages/session-ui/component-tests/session-tool-projection.spec.ts similarity index 99% rename from packages/app/component-tests/session-tool-projection.spec.ts rename to packages/session-ui/component-tests/session-tool-projection.spec.ts index d91bb26242be..72a64326e28c 100644 --- a/packages/app/component-tests/session-tool-projection.spec.ts +++ b/packages/session-ui/component-tests/session-tool-projection.spec.ts @@ -1,4 +1,4 @@ -import { expect, story } from "./story" +import { expect, story } from "../../storybook/playwright/story" // Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts story("renders every admitted tool family and hides timeline-only exclusions", async ({ mount }) => { diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index d04e47d15106..1a6b0f9ee1d7 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -47,10 +47,13 @@ "scripts": { "generate:progress-indicator": "bun script/generate-session-progress-indicator.ts", "typecheck": "tsgo -b", - "test": "bun test src --only-failures" + "test": "bun test src --only-failures", + "test:components": "playwright test --config playwright.components.config.ts", + "test:components:ui": "playwright test --config playwright.components.config.ts --ui" }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", + "@playwright/test": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/luxon": "catalog:", diff --git a/packages/session-ui/playwright.components.config.ts b/packages/session-ui/playwright.components.config.ts new file mode 100644 index 000000000000..92269c90436c --- /dev/null +++ b/packages/session-ui/playwright.components.config.ts @@ -0,0 +1,4 @@ +import { fileURLToPath } from "node:url" +import { componentConfig } from "../storybook/playwright/config" + +export default componentConfig(fileURLToPath(new URL(".", import.meta.url))) diff --git a/packages/storybook/package.json b/packages/storybook/package.json index b895f9997a54..495bfa05b480 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -12,6 +12,7 @@ "@tailwindcss/vite": "catalog:", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@playwright/test": "catalog:", "@solidjs/meta": "catalog:", "@storybook/addon-a11y": "10.4.4", "@storybook/addon-docs": "10.4.4", diff --git a/packages/storybook/playwright/README.md b/packages/storybook/playwright/README.md new file mode 100644 index 000000000000..5e2cd7fd6e11 --- /dev/null +++ b/packages/storybook/playwright/README.md @@ -0,0 +1,55 @@ +# Component browser tests + +Production Solid components are tested through their existing Storybook stories without booting the app, configuring a server, seeding browser storage, or navigating unrelated routes. + +Keep each spec in the package that owns its production component: + +- `packages/session-ui/component-tests/` owns timeline, tool, notice, reasoning, lifecycle, and review coverage. +- `packages/app/component-tests/` owns Composer and other app-only component coverage. +- `packages/storybook/playwright/` owns the shared Storybook startup configuration and `story` mount fixture. + +Run a package's isolated browser suite from that package: + +```sh +# Session UI components. +cd packages/session-ui +bun run test:components +bun run test:components -- component-tests/session-timeline.spec.ts +bun run test:components:ui + +# App-owned components. +cd packages/app +bun run test:components +bun run test:components -- component-tests/composer.spec.ts +``` + +Both suites are separately filterable Turbo tasks: + +```sh +bun turbo test:components --filter=@opencode-ai/session-ui +bun turbo test:components --filter=@opencode-ai/app +``` + +Component browser coverage deliberately remains separate from each package's default `test` script and from `packages/app`'s `test:e2e`, so expensive Storybook checks can be scheduled independently from required unit and full-app journey CI. Set `PLAYWRIGHT_STORYBOOK_URL` to reuse an existing Storybook instance or `PLAYWRIGHT_STORYBOOK_PORT` to choose its port. + +## Adding a test + +Keep inspectable scenarios next to the production component in a `*.stories.tsx` file. A story owns its fixtures, providers, state, and callbacks; its package-local spec owns user-visible interactions and assertions. + +```ts +import { expect, story } from "../../storybook/playwright/story" + +// Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts +story("preserves collapsed state while a tool completes", async ({ mount }) => { + const component = await mount("current-session-context-projection--collapsed-during-status-updates") + const trigger = component.locator('[data-slot="collapsible-trigger"]') + + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await component.getByRole("button", { name: "Complete read" }).click() + await expect(trigger).toHaveAttribute("aria-expanded", "false") +}) +``` + +The story ID is the Storybook component ID followed by `--` and the kebab-cased story export. Open the same story in Storybook to inspect exactly the scenario covered by the browser test. Preserve an original-source-path comment for every migrated E2E case. + +Keep cross-route navigation, remote-server ownership, persistent session state, full-app virtualization, and workflows spanning independent surfaces in `packages/app/e2e/`. diff --git a/packages/storybook/playwright/config.ts b/packages/storybook/playwright/config.ts new file mode 100644 index 000000000000..718246e9bb0a --- /dev/null +++ b/packages/storybook/playwright/config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from "@playwright/test" + +const port = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT ?? 6006) +const baseURL = process.env.PLAYWRIGHT_STORYBOOK_URL ?? `http://127.0.0.1:${port}` + +export function componentConfig(directory: string) { + return defineConfig({ + testDir: `${directory}/component-tests`, + outputDir: `${directory}/component-tests/test-results`, + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: [["html", { outputFolder: `${directory}/component-tests/playwright-report`, open: "never" }], ["line"]], + webServer: { + command: `bun --bun run --cwd ${directory}/../storybook storybook -- --port ${port} --ci --no-open`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [{ name: "components", use: { ...devices["Desktop Chrome"] } }], + }) +} diff --git a/packages/app/component-tests/story.ts b/packages/storybook/playwright/story.ts similarity index 100% rename from packages/app/component-tests/story.ts rename to packages/storybook/playwright/story.ts diff --git a/packages/storybook/tsconfig.json b/packages/storybook/tsconfig.json index 68ae315d2bfe..53f8f873df4c 100644 --- a/packages/storybook/tsconfig.json +++ b/packages/storybook/tsconfig.json @@ -12,5 +12,5 @@ "strict": true, "types": ["vite/client", "node"] }, - "include": [".storybook/**/*.ts", ".storybook/**/*.tsx"] + "include": [".storybook/**/*.ts", ".storybook/**/*.tsx", "playwright/**/*.ts"] } diff --git a/turbo.json b/turbo.json index 89ae3dc0daf0..5e971c372d38 100644 --- a/turbo.json +++ b/turbo.json @@ -11,6 +11,10 @@ "globalPassThroughEnv": ["CI", "OPENCODE_DISABLE_SHARE"], "tasks": { "typecheck": {}, + "test:components": { + "outputs": [], + "cache": false + }, "@opencode-ai/enterprise#typecheck": { "dependsOn": ["@opencode-ai/core#typecheck"] }, From 7800ed86dd3b69def2bd98f39e2e666caa32c5a3 Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:26:36 +0000 Subject: [PATCH 07/10] test(session-ui): consolidate meaningful component stories --- .../app/e2e/regression/session-queue.spec.ts | 1 + .../component-tests/session-lifecycle.spec.ts | 20 +- .../session-notice-projection.spec.ts | 10 +- .../session-timeline-reasoning.spec.ts | 37 +- .../component-tests/session-timeline.spec.ts | 2 +- .../session-tool-projection.spec.ts | 18 +- .../storybook/current-session-scenarios.ts | 75 ++++ .../timeline/context-projection.stories.tsx | 83 ---- .../src/timeline/file-changes.stories.tsx | 31 ++ .../timeline/notice-projection.stories.tsx | 194 --------- .../timeline/reasoning-projection.stories.tsx | 205 ---------- .../timeline/research-and-agents.stories.tsx | 296 +++++++++++++- .../src/timeline/terminal-work.stories.tsx | 44 +- .../src/timeline/timeline-row.stories.tsx | 300 +++++++++++++- .../src/timeline/tool-projection.stories.tsx | 380 ------------------ packages/storybook/playwright/README.md | 2 +- packages/storybook/playwright/story.ts | 28 +- 17 files changed, 823 insertions(+), 903 deletions(-) create mode 100644 packages/session-ui/src/storybook/current-session-scenarios.ts delete mode 100644 packages/session-ui/src/timeline/context-projection.stories.tsx delete mode 100644 packages/session-ui/src/timeline/notice-projection.stories.tsx delete mode 100644 packages/session-ui/src/timeline/reasoning-projection.stories.tsx delete mode 100644 packages/session-ui/src/timeline/tool-projection.stories.tsx diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index f4bc001fc95a..9e96e0b68fb7 100644 --- a/packages/app/e2e/regression/session-queue.spec.ts +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -207,6 +207,7 @@ test("editing restores the existing draft and replaces only the original queue p await original.click() await expect(view.input).toHaveText("tighten the error copy") await view.input.fill("tighten the error copy and add a retry hint") + await expect(view.input).toHaveText("tighten the error copy and add a retry hint") await view.input.press("Enter") await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([ diff --git a/packages/session-ui/component-tests/session-lifecycle.spec.ts b/packages/session-ui/component-tests/session-lifecycle.spec.ts index a4ed6891aff9..f36e4d71afd5 100644 --- a/packages/session-ui/component-tests/session-lifecycle.spec.ts +++ b/packages/session-ui/component-tests/session-lifecycle.spec.ts @@ -3,9 +3,7 @@ import { expect, story } from "../../storybook/playwright/story" for (const expanded of [false, true]) { // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts story(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ mount }) => { - const timeline = await mount( - `current-session-tool-projection--${expanded ? "expanded-shell-updates" : "collapsed-shell-updates"}`, - ) + const timeline = await mount("current-session-terminal-work--run-a-command", { args: { expanded } }) const trigger = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]') await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) await trigger.click() @@ -21,7 +19,7 @@ for (const expanded of [false, true]) { // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts story("transitions a streaming shell from writing through command execution", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--streaming-shell-lifecycle") + const timeline = await mount("current-session-terminal-work--run-a-command", { args: { streaming: true } }) const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]') const title = tool.locator('[data-slot="basic-tool-tool-title"]') const shimmer = title.locator('[data-component="text-shimmer"]') @@ -53,7 +51,7 @@ story("transitions a streaming shell from writing through command execution", as // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts story("shimmers and expands a running shell command", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--streaming-shell-lifecycle") + const timeline = await mount("current-session-terminal-work--run-a-command", { args: { streaming: true } }) await timeline.getByRole("button", { name: "Run command" }).click() const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]') const trigger = tool.locator('[data-slot="collapsible-trigger"]') @@ -70,7 +68,7 @@ story("shimmers and expands a running shell command", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts story("transitions thinking and hidden reasoning through busy to idle", async ({ mount }) => { - const timeline = await mount("current-session-reasoning-projection--hidden-reasoning-lifecycle") + const timeline = await mount("current-session-timeline-rows--working-without-reasoning-details") const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]') await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible() await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible() @@ -85,7 +83,7 @@ story("transitions thinking and hidden reasoning through busy to idle", async ({ // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts story("moves busy through retry and recovery to final idle content", async ({ mount }) => { - const timeline = await mount("current-session-reasoning-projection--retry-recovery-lifecycle") + const timeline = await mount("current-session-timeline-rows--retry-and-recover") await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible() await expect(timeline.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0) await timeline.getByRole("button", { name: "Retry request" }).click() @@ -102,12 +100,14 @@ story("moves busy through retry and recovery to final idle content", async ({ mo }) for (const profile of [ - { locale: "de", story: "completed-german", label: "Erkundung abgeschlossen" }, - { locale: "ar", story: "completed-arabic", label: "تم الاستكشاف" }, + { locale: "de", label: "Erkundung abgeschlossen" }, + { locale: "ar", label: "تم الاستكشاف" }, ] as const) { // Moved from packages/app/e2e/regression/session-timeline-locale-projection.spec.ts story(`projects translated context status in ${profile.locale}`, async ({ mount, page }) => { - const timeline = await mount(`current-session-context-projection--${profile.story}`) + const timeline = await mount("current-session-research-agents--explore-the-codebase", { + globals: { locale: profile.locale }, + }) await timeline.getByRole("button", { name: "Complete read" }).click() await timeline.getByRole("button", { name: "Complete glob" }).click() const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]') diff --git a/packages/session-ui/component-tests/session-notice-projection.spec.ts b/packages/session-ui/component-tests/session-notice-projection.spec.ts index f70fc3cb09f0..87d426c8fc3b 100644 --- a/packages/session-ui/component-tests/session-notice-projection.spec.ts +++ b/packages/session-ui/component-tests/session-notice-projection.spec.ts @@ -7,7 +7,7 @@ story("renders current protocol notices in CLI order", async ({ mount, page }) = if (message.text().includes("computations created outside a `createRoot` or `render`")) warnings.push(message.text()) }) - const timeline = await mount("current-session-notice-projection--protocol-notice-order") + const timeline = await mount("current-session-timeline-rows--agent-activity-notices") const notices = timeline.locator('[data-slot="session-timeline-notice"]') await expect(notices).toHaveCount(4) await expect(notices.nth(0)).toContainText("Agent · explore") @@ -21,7 +21,7 @@ story("renders current protocol notices in CLI order", async ({ mount, page }) = // Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts story("renders a compaction summary while it streams and after completion", async ({ mount }) => { - const timeline = await mount("current-session-notice-projection--compaction-lifecycle") + const timeline = await mount("current-session-timeline-rows--compact-session") const compaction = timeline.locator('[data-component="session-compaction-message"]') await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible() await timeline.getByRole("button", { name: "Stream summary" }).click() @@ -34,7 +34,7 @@ story("renders a compaction summary while it streams and after completion", asyn // Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts story("updates running compactions to failed and cancelled boundaries", async ({ mount }) => { - const timeline = await mount("current-session-notice-projection--compaction-lifecycle") + const timeline = await mount("current-session-timeline-rows--compact-session") await timeline.getByRole("button", { name: "Stream summary" }).click() await timeline.getByRole("button", { name: "Fail compaction" }).click() const compactions = timeline.locator('[data-component="session-compaction-message"]') @@ -51,7 +51,7 @@ story("updates running compactions to failed and cancelled boundaries", async ({ // Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts story("shows a delegating row while subagent input streams", async ({ mount }) => { - const timeline = await mount("current-session-notice-projection--streaming-delegation") + const timeline = await mount("current-session-research-agents--delegating-an-agent") const delegating = timeline.locator('[data-component="task-tool-delegating"]') await expect(delegating).toBeVisible() const shimmer = delegating.locator('[data-component="text-shimmer"]') @@ -66,7 +66,7 @@ story("shows a delegating row while subagent input streams", async ({ mount }) = // Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts story("waits for completion before labeling requested background work", async ({ mount }) => { - const timeline = await mount("current-session-notice-projection--requested-background-work") + const timeline = await mount("current-session-research-agents--starting-background-work") await expect(timeline.locator('[data-component="task-tool-card"]')).toContainText("Inspect code") await expect(timeline.locator('[data-component="task-tool-card"]')).not.toContainText("(background)") }) diff --git a/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts b/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts index 3f14ec353c3c..e4ddd7f30e75 100644 --- a/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts +++ b/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts @@ -1,32 +1,47 @@ import { expect, story } from "../../storybook/playwright/story" const profiles = [ - { name: "summaries off no reasoning", id: "summaries-off-no-reasoning", thinking: true, body: false }, + { name: "summaries off no reasoning", summaries: false, reasoning: "none", tool: false, thinking: true, body: false }, { name: "summaries off reasoning heading", - id: "summaries-off-reasoning-heading", + summaries: false, + reasoning: "heading", + tool: false, thinking: true, body: false, heading: true, }, { name: "summaries off with visible tool", - id: "summaries-off-with-visible-tool", + summaries: false, + reasoning: "heading", + tool: true, thinking: true, body: false, heading: true, }, - { name: "summaries on no content", id: "summaries-on-no-content", thinking: true, body: false }, - { name: "summaries on blank reasoning", id: "summaries-on-blank-reasoning", thinking: true, body: false }, + { name: "summaries on no content", summaries: true, reasoning: "none", tool: false, thinking: true, body: false }, + { + name: "summaries on blank reasoning", + summaries: true, + reasoning: "blank", + tool: false, + thinking: true, + body: false, + }, { name: "summaries on visible reasoning", - id: "summaries-on-visible-reasoning", + summaries: true, + reasoning: "heading", + tool: false, thinking: false, body: true, }, { name: "summaries on visible tool no reasoning", - id: "summaries-on-visible-tool-no-reasoning", + summaries: true, + reasoning: "none", + tool: true, thinking: false, body: false, }, @@ -35,7 +50,9 @@ const profiles = [ for (const profile of profiles) { // Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts story(`projects busy reasoning profile ${profile.name}`, async ({ mount }) => { - const timeline = await mount(`current-session-reasoning-projection--${profile.id}`) + const timeline = await mount("current-session-timeline-rows--agent-reasoning", { + args: { summaries: profile.summaries, reasoning: profile.reasoning, tool: profile.tool }, + }) await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) await expect(timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')).toHaveCount( profile.body ? 1 : 0, @@ -48,7 +65,9 @@ for (const profile of profiles) { // Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts story("does not infer reasoning visibility from provider identity", async ({ mount }) => { - const timeline = await mount("current-session-reasoning-projection--provider-without-reasoning") + const timeline = await mount("current-session-timeline-rows--agent-reasoning", { + args: { reasoning: "none", text: "No reasoning payload" }, + }) await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(timeline.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) await expect(timeline.getByText("No reasoning payload", { exact: true })).toBeVisible() diff --git a/packages/session-ui/component-tests/session-timeline.spec.ts b/packages/session-ui/component-tests/session-timeline.spec.ts index 690e03dd6ba6..996b2061f64f 100644 --- a/packages/session-ui/component-tests/session-timeline.spec.ts +++ b/packages/session-ui/component-tests/session-timeline.spec.ts @@ -8,7 +8,7 @@ story("renders streamed reasoning without starting the app", async ({ mount }) = // Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts story("preserves a collapsed context group through count and status updates", async ({ mount }) => { - const timeline = await mount("current-session-context-projection--collapsed-during-status-updates") + const timeline = await mount("current-session-research-agents--explore-the-codebase") const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]') const trigger = group.locator('[data-slot="collapsible-trigger"]') await expect(trigger).toHaveAttribute("aria-expanded", "false") diff --git a/packages/session-ui/component-tests/session-tool-projection.spec.ts b/packages/session-ui/component-tests/session-tool-projection.spec.ts index 72a64326e28c..bcb11f50b2fc 100644 --- a/packages/session-ui/component-tests/session-tool-projection.spec.ts +++ b/packages/session-ui/component-tests/session-tool-projection.spec.ts @@ -2,7 +2,7 @@ import { expect, story } from "../../storybook/playwright/story" // Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts story("renders every admitted tool family and hides timeline-only exclusions", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--every-tool-family") + const timeline = await mount("current-session-research-agents--complete-agent-workflow") await expect( timeline.locator('[data-timeline-part-ids="tool_family_read,tool_family_glob,tool_family_grep,tool_family_list"]'), ).toBeVisible() @@ -35,7 +35,7 @@ story("renders every admitted tool family and hides timeline-only exclusions", a // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("renders every tool error outcome without leaking hidden tools", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--every-tool-error") + const timeline = await mount("current-session-research-agents--recover-from-tool-failures") const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"] await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1) await expect(timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')).toContainText(/dismissed/i) @@ -45,7 +45,7 @@ story("renders every tool error outcome without leaking hidden tools", async ({ // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("transitions shell and question through running error outcomes", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--running-tool-errors") + const timeline = await mount("current-session-research-agents--failed-command-and-question") const shell = timeline.locator('[data-timeline-part-id="tool_transition_shell"]') const question = timeline.locator('[data-timeline-part-id="tool_transition_question"]') await expect(shell).toBeVisible() @@ -58,7 +58,7 @@ story("transitions shell and question through running error outcomes", async ({ // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("labels all web search provider variants", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--search-providers") + const timeline = await mount("current-session-research-agents--compare-search-providers") await expect(timeline.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() await expect(timeline.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() await expect(timeline.getByRole("button", { name: /^Web Search/ })).toBeVisible() @@ -66,7 +66,7 @@ story("labels all web search provider variants", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("labels completed searches with result counts", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--context-labels") + const timeline = await mount("current-session-research-agents--search-results-and-files") const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]') await group.locator('[data-slot="collapsible-trigger"]').click() const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]') @@ -76,7 +76,7 @@ story("labels completed searches with result counts", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("labels read tools from their path input", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--context-labels") + const timeline = await mount("current-session-research-agents--search-results-and-files") const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]') await group.locator('[data-slot="collapsible-trigger"]').click() await expect( @@ -88,7 +88,7 @@ story("labels read tools from their path input", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("labels skill tools from IDs and result metadata", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--skill-labels") + const timeline = await mount("current-session-research-agents--loading-specialized-skills") for (const [id, name] of [ ["tool_skill_id", "frontend-design"], ["tool_skill_name", "OpenCode"], @@ -105,7 +105,7 @@ story("labels skill tools from IDs and result metadata", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts story("groups singleton and separated context operations at correct boundaries", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--context-boundaries") + const timeline = await mount("current-session-research-agents--research-across-steps") await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_read"]')).toBeVisible() await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep"]')).toBeVisible() await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_list"]')).toBeVisible() @@ -114,7 +114,7 @@ story("groups singleton and separated context operations at correct boundaries", // Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts story("combines adjacent edit calls and repeated files into one group", async ({ mount }) => { - const timeline = await mount("current-session-tool-projection--grouped-edits") + const timeline = await mount("current-session-file-changes--repeated-edits") const group = timeline.locator('[data-timeline-part-ids="tool_grouped_edit_first,tool_grouped_edit_second"]') await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit") await expect(group.getByText("1 file", { exact: true })).toBeVisible() diff --git a/packages/session-ui/src/storybook/current-session-scenarios.ts b/packages/session-ui/src/storybook/current-session-scenarios.ts new file mode 100644 index 000000000000..46aafaedaed9 --- /dev/null +++ b/packages/session-ui/src/storybook/current-session-scenarios.ts @@ -0,0 +1,75 @@ +import type { JsonValue, SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise" +import type { SessionDocument } from "../document" +import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "./current-session-fixtures" + +export function storyTool( + id: string, + name: string, + status: "streaming" | "running" | "completed" | "error", + input: Record, + options: { metadata?: Record; output?: string; error?: string } = {}, +): SessionMessageAssistantTool { + const state = + status === "streaming" + ? { status, input: JSON.stringify(input) } + : status === "running" + ? { status, input, metadata: { ...options.metadata, ...(options.output ? { output: options.output } : {}) } } + : status === "error" + ? { + status, + input, + error: { type: "ToolExecutionError", message: options.error ?? `${name} failed visibly` }, + metadata: options.metadata, + } + : { + status, + input, + content: [{ type: "text" as const, text: options.output ?? "Complete" }] as [ + { type: "text"; text: string }, + ], + metadata: options.metadata, + } + return { + type: "tool", + id, + name, + state, + time: { + created: STORY_TIME, + ...(status === "streaming" ? {} : { ran: STORY_TIME + 100 }), + ...(status === "completed" || status === "error" ? { completed: STORY_TIME + 200 } : {}), + }, + } +} + +export function storyDocument(content: SessionMessageAssistant["content"], busy = false): SessionDocument { + return { + sessionID: CURRENT_SESSION_ID, + messages: [ + ...thinkingDocument.messages, + { + id: "msg_tool_projection_assistant", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content, + time: { created: STORY_TIME, ...(busy ? {} : { completed: STORY_TIME + 300 }) }, + }, + ], + status: { type: busy ? "busy" : "idle" }, + diffs: [], + } +} + +export function storyPatchFile(file: string, status: "modified" | "added" = "modified") { + return { + file, + status, + patch: + status === "added" + ? "@@ -0,0 +1 @@\n+export const after = true" + : "@@ -1 +1 @@\n-export const before = true\n+export const after = true", + additions: 1, + deletions: status === "added" ? 0 : 1, + } +} diff --git a/packages/session-ui/src/timeline/context-projection.stories.tsx b/packages/session-ui/src/timeline/context-projection.stories.tsx deleted file mode 100644 index c232d597a171..000000000000 --- a/packages/session-ui/src/timeline/context-projection.stories.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import type { SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise" -import { createMemo } from "solid-js" -import { createStore } from "solid-js/store" -import type { SessionDocument } from "../document" -import { CurrentSessionProviders } from "../storybook/current-session-story" -import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "../storybook/current-session-fixtures" -import { SessionTimeline } from "./session-timeline" - -export default { - title: "OpenCode/Conversation/Context projection", - id: "current-session-context-projection", - component: SessionTimeline, - parameters: { - layout: "fullscreen", - docs: { - description: { - component: "Interactive context-group state transitions through the real production timeline.", - }, - }, - }, -} - -function ContextStatusStory() { - const [state, setState] = createStore({ read: false, glob: false }) - const tool = (name: "read" | "glob", completed: boolean) => { - const input = name === "read" ? { path: "src/a.ts", offset: 0, limit: 120 } : { path: ".", pattern: "**/*.ts" } - return { - type: "tool", - id: `tool_context_${name}`, - name, - state: completed - ? { status: "completed", input, content: [{ type: "text", text: "Complete" }], metadata: {} } - : { status: "running", input, metadata: {} }, - time: { - created: STORY_TIME, - ran: STORY_TIME + 100, - ...(completed ? { completed: STORY_TIME + 200 } : {}), - }, - } satisfies SessionMessageAssistantTool - } - const document = createMemo( - () => - ({ - sessionID: CURRENT_SESSION_ID, - messages: [ - ...thinkingDocument.messages, - { - id: "msg_context_projection_assistant", - type: "assistant", - agent: "build", - model: STORY_MODEL, - content: [tool("read", state.read), tool("glob", state.glob)], - time: { - created: STORY_TIME, - ...(state.read && state.glob ? { completed: STORY_TIME + 300 } : {}), - }, - } satisfies SessionMessageAssistant, - ], - status: { type: state.read && state.glob ? "idle" : "busy" }, - diffs: [], - }) satisfies SessionDocument, - ) - - return ( -
-
- - -
- - - -
- ) -} - -export const CollapsedDuringStatusUpdates = { render: () => } -export const CompletedGerman = { globals: { locale: "de" }, render: () => } -export const CompletedArabic = { globals: { locale: "ar" }, render: () => } diff --git a/packages/session-ui/src/timeline/file-changes.stories.tsx b/packages/session-ui/src/timeline/file-changes.stories.tsx index 892fd76a8562..f86897df1c98 100644 --- a/packages/session-ui/src/timeline/file-changes.stories.tsx +++ b/packages/session-ui/src/timeline/file-changes.stories.tsx @@ -8,6 +8,7 @@ import { multiFilePatchDocument, writeFileDocument, } from "../storybook/current-session-fixtures" +import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios" import { SessionTimeline } from "./session-timeline" export default { @@ -72,6 +73,36 @@ export const PatchedTwoFiles = { ), } +export const RepeatedEdits = { + render: () => ( + + ), +} + function EditSiblingUpdateStory() { const [state, setState] = createStore({ sibling: false }) const document = createMemo(() => ({ diff --git a/packages/session-ui/src/timeline/notice-projection.stories.tsx b/packages/session-ui/src/timeline/notice-projection.stories.tsx deleted file mode 100644 index 992f5a865e88..000000000000 --- a/packages/session-ui/src/timeline/notice-projection.stories.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import type { SessionMessageInfo } from "@opencode-ai/client/promise" -import { createMemo } from "solid-js" -import { createStore } from "solid-js/store" -import type { SessionDocument } from "../document" -import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME } from "../storybook/current-session-fixtures" -import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story" -import { SessionTimeline } from "./session-timeline" - -export default { - title: "OpenCode/Conversation/Notice projection", - id: "current-session-notice-projection", - component: SessionTimeline, - parameters: { layout: "fullscreen" }, -} - -const user = { id: "msg_notice_user", type: "user", text: "Run it", time: { created: STORY_TIME } } as const -const assistant = { - id: "msg_notice_assistant", - type: "assistant", - agent: "build", - model: STORY_MODEL, - content: [{ type: "text", text: "Working" }], - time: { created: STORY_TIME + 1, completed: STORY_TIME + 2 }, -} satisfies SessionMessageInfo - -export const ProtocolNoticeOrder = { - render: () => ( - - ), -} - -function CompactionLifecycleStory() { - const [state, setState] = createStore({ phase: "running", summary: "", second: false }) - const current = createMemo(() => { - const failed = state.phase === "failed" - const completed = state.phase === "completed" - const message = { - id: "msg_notice_compaction", - type: "compaction" as const, - status: failed ? ("failed" as const) : completed ? ("completed" as const) : ("running" as const), - reason: "auto" as const, - ...(failed - ? { - error: { - type: "compaction.failed", - message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}', - }, - } - : { summary: state.summary, recent: "" }), - time: { created: STORY_TIME + 10 }, - } - const cancelled = { - id: "msg_notice_compaction_cancelled", - type: "compaction" as const, - status: "failed" as const, - reason: "manual" as const, - error: { type: "aborted", message: "Cancellation detail should stay hidden." }, - time: { created: STORY_TIME + 20 }, - } - return { - sessionID: CURRENT_SESSION_ID, - messages: [user, assistant, message, ...(state.second ? [cancelled] : [])], - status: { type: completed || failed ? "idle" : "busy" }, - diffs: [], - } satisfies SessionDocument - }) - return ( -
-
- - - - -
- - - -
- ) -} - -export const CompactionLifecycle = { render: () => } - -export const StreamingDelegation = { - render: () => ( - - ), -} - -export const RequestedBackgroundWork = { - render: () => ( - - ), -} diff --git a/packages/session-ui/src/timeline/reasoning-projection.stories.tsx b/packages/session-ui/src/timeline/reasoning-projection.stories.tsx deleted file mode 100644 index 4526b614e162..000000000000 --- a/packages/session-ui/src/timeline/reasoning-projection.stories.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import type { SessionMessageAssistant } from "@opencode-ai/client/promise" -import { createMemo } from "solid-js" -import { createStore } from "solid-js/store" -import type { SessionDocument } from "../document" -import { CurrentSessionProviders } from "../storybook/current-session-story" -import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "../storybook/current-session-fixtures" -import { SessionTimeline } from "./session-timeline" - -export default { - title: "OpenCode/Conversation/Reasoning projection", - id: "current-session-reasoning-projection", - component: SessionTimeline, - parameters: { - layout: "fullscreen", - docs: { - description: { - component: "Busy reasoning, thinking, and tool visibility rendered directly by the production timeline.", - }, - }, - }, -} - -function ReasoningProjection(props: { summaries: boolean; reasoning?: string; tool?: boolean; text?: string }) { - const content = [ - ...(props.reasoning === undefined - ? [] - : [ - { - type: "reasoning" as const, - text: props.reasoning, - time: { created: STORY_TIME + 100 }, - }, - ]), - ...(props.tool - ? [ - { - type: "tool" as const, - id: "tool_reasoning_projection_skill", - name: "skill", - state: { status: "running" as const, input: { name: "inspect" }, metadata: {} }, - time: { created: STORY_TIME + 200, ran: STORY_TIME + 250 }, - }, - ] - : []), - ...(props.text === undefined ? [] : [{ type: "text" as const, text: props.text }]), - ] satisfies SessionMessageAssistant["content"] - const assistant = { - id: "msg_projection_assistant", - type: "assistant", - agent: "build", - model: STORY_MODEL, - content, - time: { created: STORY_TIME }, - } satisfies SessionMessageAssistant - const document = { - sessionID: CURRENT_SESSION_ID, - messages: [...thinkingDocument.messages, assistant], - status: { type: "busy" }, - diffs: [], - } satisfies SessionDocument - - return ( -
- - - -
- ) -} - -export const SummariesOffNoReasoning = { render: () => } -export const SummariesOffReasoningHeading = { - render: () => , -} -export const SummariesOffWithVisibleTool = { - render: () => , -} -export const SummariesOnNoContent = { render: () => } -export const SummariesOnBlankReasoning = { render: () => } -export const SummariesOnVisibleReasoning = { - render: () => , -} -export const SummariesOnVisibleToolNoReasoning = { render: () => } -export const ProviderWithoutReasoning = { render: () => } - -function HiddenReasoningLifecycleStory() { - const [state, setState] = createStore({ phase: "thinking" }) - const document = createMemo(() => { - const finished = state.phase === "idle" - const running = state.phase === "running" - return { - sessionID: CURRENT_SESSION_ID, - messages: [ - ...thinkingDocument.messages, - { - id: "msg_hidden_reasoning_lifecycle", - type: "assistant", - agent: "build", - model: STORY_MODEL, - content: [ - { - type: "reasoning", - text: "## Inspecting stability", - time: { created: STORY_TIME + 100 }, - }, - ...(running || finished - ? [ - { - type: "tool" as const, - id: "tool_hidden_reasoning_shell", - name: "shell", - state: finished - ? { - status: "completed" as const, - input: { command: "printf done" }, - content: [{ type: "text" as const, text: "done" }], - metadata: {}, - } - : { status: "running" as const, input: { command: "printf done" }, metadata: {} }, - time: { - created: STORY_TIME + 200, - ran: STORY_TIME + 250, - ...(finished ? { completed: STORY_TIME + 300 } : {}), - }, - }, - ] - : []), - ], - time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 400 } : {}) }, - }, - ], - status: { type: finished ? "idle" : "busy" }, - diffs: [], - } satisfies SessionDocument - }) - return ( -
-
- - -
- - - -
- ) -} - -function RetryRecoveryLifecycleStory() { - const [state, setState] = createStore({ phase: "thinking" }) - const document = createMemo(() => { - const retry = state.phase === "retry" - const finished = state.phase === "idle" - return { - sessionID: CURRENT_SESSION_ID, - messages: [ - ...thinkingDocument.messages, - { - id: "msg_retry_recovery_lifecycle", - type: "assistant", - agent: "build", - model: STORY_MODEL, - content: finished ? [{ type: "text" as const, text: "Recovered response" }] : [], - ...(retry - ? { - retry: { - attempt: 2, - at: 1_900_000_000_000, - error: { type: "ProviderRateLimitError", message: "Rate limit reached. Retrying with backoff." }, - }, - } - : {}), - time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 300 } : {}) }, - }, - ], - status: { type: finished ? "idle" : "busy" }, - diffs: [], - } satisfies SessionDocument - }) - return ( -
-
- - - -
- - - -
- ) -} - -export const HiddenReasoningLifecycle = { render: () => } -export const RetryRecoveryLifecycle = { render: () => } diff --git a/packages/session-ui/src/timeline/research-and-agents.stories.tsx b/packages/session-ui/src/timeline/research-and-agents.stories.tsx index ec968aa9e3ec..2c3c36a816b1 100644 --- a/packages/session-ui/src/timeline/research-and-agents.stories.tsx +++ b/packages/session-ui/src/timeline/research-and-agents.stories.tsx @@ -1,10 +1,19 @@ -import { CurrentSessionTimelineStory } from "../storybook/current-session-story" +import type { JsonValue, SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise" +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import type { SessionDocument } from "../document" +import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story" import { + CURRENT_SESSION_ID, + STORY_MODEL, + STORY_TIME, inspectAndExplainDocument, loadedResourcesDocument, subagentDocument, + thinkingDocument, webResearchDocument, } from "../storybook/current-session-fixtures" +import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios" import { SessionTimeline } from "./session-timeline" export default { @@ -65,3 +74,288 @@ export const DelegateFocusedTasks = { /> ), } + +function CodebaseExplorationStory() { + const [state, setState] = createStore({ read: false, glob: false }) + const tool = (name: "read" | "glob", completed: boolean) => { + const input = name === "read" ? { path: "src/a.ts", offset: 0, limit: 120 } : { path: ".", pattern: "**/*.ts" } + return { + type: "tool", + id: `tool_context_${name}`, + name, + state: completed + ? { status: "completed", input, content: [{ type: "text", text: "Complete" }], metadata: {} } + : { status: "running", input, metadata: {} }, + time: { + created: STORY_TIME, + ran: STORY_TIME + 100, + ...(completed ? { completed: STORY_TIME + 200 } : {}), + }, + } satisfies SessionMessageAssistantTool + } + const document = createMemo( + () => + ({ + sessionID: CURRENT_SESSION_ID, + messages: [ + ...thinkingDocument.messages, + { + id: "msg_codebase_exploration_assistant", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content: [tool("read", state.read), tool("glob", state.glob)], + time: { created: STORY_TIME, ...(state.read && state.glob ? { completed: STORY_TIME + 300 } : {}) }, + } satisfies SessionMessageAssistant, + ], + status: { type: state.read && state.glob ? "idle" : "busy" }, + diffs: [], + }) satisfies SessionDocument, + ) + return ( +
+
+ + +
+ + + +
+ ) +} + +export const ExploreTheCodebase = { render: () => } + +export const CompareSearchProviders = { + render: () => ( + + ), +} + +export const SearchResultsAndFiles = { + render: () => ( + + ), +} + +export const LoadingSpecializedSkills = { + render: () => ( + + ), +} + +export const ResearchAcrossSteps = { + render: () => ( + + ), +} + +const questions = { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } + +export const CompleteAgentWorkflow = { + render: () => ( + + ), +} + +export const RecoverFromToolFailures = { + render: () => { + const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"] + const input = (name: string): Record => { + if (name === "shell") return { command: "exit 1" } + if (name === "edit" || name === "write") return { path: "src/error.ts", content: "" } + if (name === "patch") return { patchText: "Update src/error.ts" } + if (name === "webfetch") return { url: "https://example.com" } + if (name === "websearch") return { query: "failure" } + if (name === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect." } + if (name === "skill") return { name: "failure" } + return { target: "failure" } + } + return ( + storyTool(`tool_error_${name}`, name, "error", input(name))), + storyTool("tool_error_question_dismissed", "question", "error", questions, { + error: "The user dismissed this question", + }), + storyTool("tool_error_question_transport", "question", "error", questions, { + error: "Question transport failed", + }), + storyTool("tool_error_todo", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }), + ])} + width="860px" + /> + ) + }, +} + +function FailedCommandAndQuestionStory() { + const [state, setState] = createStore({ failed: false }) + const document = createMemo(() => + storyDocument( + [ + storyTool( + "tool_transition_shell", + "shell", + state.failed ? "error" : "running", + { command: "exit 1" }, + { + error: "Command exited 1", + }, + ), + storyTool("tool_transition_question", "question", state.failed ? "error" : "running", questions, { + error: "The user dismissed this question", + }), + ], + !state.failed, + ), + ) + return ( +
+ + + + +
+ ) +} + +export const FailedCommandAndQuestion = { render: () => } + +export const DelegatingAnAgent = { + render: () => ( + + ), +} + +export const StartingBackgroundWork = { + render: () => ( + + ), +} diff --git a/packages/session-ui/src/timeline/terminal-work.stories.tsx b/packages/session-ui/src/timeline/terminal-work.stories.tsx index bbc87193515f..81f64d89f468 100644 --- a/packages/session-ui/src/timeline/terminal-work.stories.tsx +++ b/packages/session-ui/src/timeline/terminal-work.stories.tsx @@ -1,4 +1,7 @@ -import { CurrentSessionTimelineStory } from "../storybook/current-session-story" +import type { SessionMessageAssistant } from "@opencode-ai/client/promise" +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story" import { executeCodeDocument, expandedShellDocument, @@ -9,6 +12,7 @@ import { terminalPassedDocument, terminalRunningDocument, } from "../storybook/current-session-fixtures" +import { storyDocument, storyTool } from "../storybook/current-session-scenarios" import { SessionTimeline } from "./session-timeline" export default { @@ -121,6 +125,44 @@ export const TestFailed = { ), } +function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolean }) { + const [state, setState] = createStore({ phase: props.streaming ? "streaming" : "completed", revision: 0 }) + const document = createMemo(() => { + const phase = state.phase as "streaming" | "running" | "completed" + const command = phase === "streaming" ? "" : "printf ready" + const content: SessionMessageAssistant["content"] = [ + storyTool("tool_shell_lifecycle", "shell", phase, command ? { command } : {}, { + output: phase === "running" ? "still running" : `line ${state.revision + 1}`, + }), + ...(state.revision ? [{ type: "text" as const, text: "Sibling content" }] : []), + ] + return storyDocument(content, phase !== "completed") + }) + return ( +
+
+ + + +
+ + + +
+ ) +} + +export const RunACommand = { + args: { expanded: false, streaming: false }, + render: (args: { expanded: boolean; streaming: boolean }) => , +} + export const FixedAndPassed = { render: () => ( + + + + + ) +} + +export const AgentReasoning = { + args: { summaries: true, reasoning: "heading", tool: false, text: "" }, + argTypes: { reasoning: { control: "select", options: ["none", "blank", "heading"] } }, + render: (args: { summaries: boolean; reasoning: string; tool: boolean; text: string }) => ( + + ), +} + +function HiddenReasoningStory() { + const [state, setState] = createStore({ phase: "thinking" }) + const document = createMemo(() => { + const finished = state.phase === "idle" + const running = state.phase === "running" + return { + sessionID: CURRENT_SESSION_ID, + messages: [ + ...thinkingDocument.messages, + { + id: "msg_hidden_reasoning_lifecycle", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content: [ + { type: "reasoning", text: "## Inspecting stability", time: { created: STORY_TIME + 100 } }, + ...(running || finished + ? [ + { + type: "tool" as const, + id: "tool_hidden_reasoning_shell", + name: "shell", + state: finished + ? { + status: "completed" as const, + input: { command: "printf done" }, + content: [{ type: "text" as const, text: "done" }], + metadata: {}, + } + : { status: "running" as const, input: { command: "printf done" }, metadata: {} }, + time: { + created: STORY_TIME + 200, + ran: STORY_TIME + 250, + ...(finished ? { completed: STORY_TIME + 300 } : {}), + }, + }, + ] + : []), + ], + time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 400 } : {}) }, + }, + ], + status: { type: finished ? "idle" : "busy" }, + diffs: [], + } satisfies SessionDocument + }) + return ( +
+
+ + +
+ + + +
+ ) +} + +export const WorkingWithoutReasoningDetails = { render: () => } + +function RetryAndRecoverStory() { + const [state, setState] = createStore({ phase: "thinking" }) + const document = createMemo(() => { + const retry = state.phase === "retry" + const finished = state.phase === "idle" + return { + sessionID: CURRENT_SESSION_ID, + messages: [ + ...thinkingDocument.messages, + { + id: "msg_retry_recovery_lifecycle", + type: "assistant", + agent: "build", + model: STORY_MODEL, + content: finished ? [{ type: "text" as const, text: "Recovered response" }] : [], + ...(retry + ? { + retry: { + attempt: 2, + at: 1_900_000_000_000, + error: { type: "ProviderRateLimitError", message: "Rate limit reached. Retrying with backoff." }, + }, + } + : {}), + time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 300 } : {}) }, + }, + ], + status: { type: finished ? "idle" : "busy" }, + diffs: [], + } satisfies SessionDocument + }) + return ( +
+
+ + + +
+ + + +
+ ) +} + +export const RetryAndRecover = { render: () => } + export const ProviderRetry = { render: () => ( ( + + ), +} + +function CompactSessionStory() { + const [state, setState] = createStore({ phase: "running", summary: "", second: false }) + const document = createMemo(() => { + const failed = state.phase === "failed" + const completed = state.phase === "completed" + const message = { + id: "msg_notice_compaction", + type: "compaction" as const, + status: failed ? ("failed" as const) : completed ? ("completed" as const) : ("running" as const), + reason: "auto" as const, + ...(failed + ? { + error: { + type: "compaction.failed", + message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}', + }, + } + : { summary: state.summary, recent: "" }), + time: { created: STORY_TIME + 10 }, + } + const cancelled = { + id: "msg_notice_compaction_cancelled", + type: "compaction" as const, + status: "failed" as const, + reason: "manual" as const, + error: { type: "aborted", message: "Cancellation detail should stay hidden." }, + time: { created: STORY_TIME + 20 }, + } + return { + sessionID: CURRENT_SESSION_ID, + messages: [noticeUser, noticeAssistant, message, ...(state.second ? [cancelled] : [])], + status: { type: completed || failed ? "idle" : "busy" }, + diffs: [], + } satisfies SessionDocument + }) + return ( +
+
+ + + + +
+ + + +
+ ) +} + +export const CompactSession = { render: () => } + export const CompactionInProgress = { render: () => ( , - options: { metadata?: Record; output?: string; error?: string } = {}, -): SessionMessageAssistantTool { - const state = - status === "streaming" - ? { status, input: JSON.stringify(input) } - : status === "running" - ? { status, input, metadata: { ...options.metadata, ...(options.output ? { output: options.output } : {}) } } - : status === "error" - ? { - status, - input, - error: { type: "ToolExecutionError", message: options.error ?? `${name} failed visibly` }, - metadata: options.metadata, - } - : { - status, - input, - content: [{ type: "text" as const, text: options.output ?? "Complete" }], - metadata: options.metadata, - } - return { - type: "tool", - id, - name, - state, - time: { - created: STORY_TIME, - ...(status === "streaming" ? {} : { ran: STORY_TIME + 100 }), - ...(status === "completed" || status === "error" ? { completed: STORY_TIME + 200 } : {}), - }, - } -} - -function document(content: SessionMessageAssistant["content"], busy = false): SessionDocument { - return { - sessionID: CURRENT_SESSION_ID, - messages: [ - ...thinkingDocument.messages, - { - id: "msg_tool_projection_assistant", - type: "assistant", - agent: "build", - model: STORY_MODEL, - content, - time: { created: STORY_TIME, ...(busy ? {} : { completed: STORY_TIME + 300 }) }, - }, - ], - status: { type: busy ? "busy" : "idle" }, - diffs: [], - } -} - -function patchFile(file: string, status: "modified" | "added" = "modified") { - return { - file, - status, - patch: - status === "added" - ? "@@ -0,0 +1 @@\n+export const after = true" - : "@@ -1 +1 @@\n-export const before = true\n+export const after = true", - additions: 1, - deletions: status === "added" ? 0 : 1, - } -} - -const questions = { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } - -export const EveryToolFamily = { - render: () => ( - - ), -} - -export const EveryToolError = { - render: () => { - const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"] - const input = (name: string): Record => { - if (name === "shell") return { command: "exit 1" } - if (name === "edit" || name === "write") return { path: "src/error.ts", content: "" } - if (name === "patch") return { patchText: "Update src/error.ts" } - if (name === "webfetch") return { url: "https://example.com" } - if (name === "websearch") return { query: "failure" } - if (name === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect." } - if (name === "skill") return { name: "failure" } - return { target: "failure" } - } - return ( - tool(`tool_error_${name}`, name, "error", input(name))), - tool("tool_error_question_dismissed", "question", "error", questions, { - error: "The user dismissed this question", - }), - tool("tool_error_question_transport", "question", "error", questions, { error: "Question transport failed" }), - tool("tool_error_todo", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }), - ])} - width="860px" - /> - ) - }, -} - -export const SearchProviders = { - render: () => ( - - ), -} - -export const ContextLabels = { - render: () => ( - - ), -} - -export const SkillLabels = { - render: () => ( - - ), -} - -export const ContextBoundaries = { - render: () => ( - - ), -} - -export const GroupedEdits = { - render: () => ( - - ), -} - -function ShellLifecycleStory(props: { expanded?: boolean; transition?: boolean }) { - const [state, setState] = createStore({ phase: props.transition ? "streaming" : "completed", revision: 0 }) - const current = createMemo(() => { - const phase = state.phase as "streaming" | "running" | "completed" - const command = phase === "streaming" ? "" : "printf ready" - const content: SessionMessageAssistant["content"] = [ - tool("tool_shell_lifecycle", "shell", phase, command ? { command } : {}, { - output: phase === "running" ? "still running" : `line ${state.revision + 1}`, - }), - ...(state.revision ? [{ type: "text" as const, text: "Sibling content" }] : []), - ] - return document(content, phase !== "completed") - }) - return ( -
-
- - - -
- - - -
- ) -} - -export const StreamingShellLifecycle = { render: () => } -export const CollapsedShellUpdates = { render: () => } -export const ExpandedShellUpdates = { render: () => } - -function ErrorTransitionStory() { - const [state, setState] = createStore({ failed: false }) - const current = createMemo(() => - document( - [ - tool( - "tool_transition_shell", - "shell", - state.failed ? "error" : "running", - { command: "exit 1" }, - { - error: "Command exited 1", - }, - ), - tool("tool_transition_question", "question", state.failed ? "error" : "running", questions, { - error: "The user dismissed this question", - }), - ], - !state.failed, - ), - ) - return ( -
- - - - -
- ) -} - -export const RunningToolErrors = { render: () => } - -function GroupedPatchStory(props: { failure?: boolean }) { - const [state, setState] = createStore({ phase: "initial" }) - const current = createMemo(() => { - const first = tool( - "tool_grouped_patch_first", - "patch", - state.phase === "failed" ? "error" : "completed", - { patchText: "Update src/first.ts" }, - { metadata: { files: [patchFile("src/first.ts")] }, error: "Patch failed visibly" }, - ) - const include = props.failure || state.phase !== "initial" - const second = tool( - "tool_grouped_patch_second", - "patch", - state.phase === "complete" || state.phase === "failed" ? "completed" : "running", - { patchText: "Update more files" }, - { - metadata: { - files: props.failure - ? [patchFile("src/surviving.ts")] - : state.phase === "complete" - ? [patchFile("src/first.ts"), patchFile("src/second.ts", "added")] - : [], - }, - }, - ) - return document(include ? [first, second] : [first], state.phase !== "complete") - }) - return ( -
-
- - - -
- - - -
- ) -} - -export const GroupedPatchUpdates = { render: () => } -export const GroupedPatchFailure = { render: () => } diff --git a/packages/storybook/playwright/README.md b/packages/storybook/playwright/README.md index 5e2cd7fd6e11..9d3f6601231f 100644 --- a/packages/storybook/playwright/README.md +++ b/packages/storybook/playwright/README.md @@ -41,7 +41,7 @@ import { expect, story } from "../../storybook/playwright/story" // Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts story("preserves collapsed state while a tool completes", async ({ mount }) => { - const component = await mount("current-session-context-projection--collapsed-during-status-updates") + const component = await mount("current-session-research-agents--explore-the-codebase") const trigger = component.locator('[data-slot="collapsible-trigger"]') await expect(trigger).toHaveAttribute("aria-expanded", "false") diff --git a/packages/storybook/playwright/story.ts b/packages/storybook/playwright/story.ts index 3b83d3d22e22..f11b25fa4edc 100644 --- a/packages/storybook/playwright/story.ts +++ b/packages/storybook/playwright/story.ts @@ -3,10 +3,32 @@ import type { Locator } from "@playwright/test" export { expect } -export const story = test.extend<{ mount: (id: string) => Promise }>({ +export const story = test.extend<{ + mount: ( + id: string, + options?: { args?: Record; globals?: Record }, + ) => Promise +}>({ mount: async ({ page }, use) => { - await use(async (id) => { - await page.goto(`/iframe.html?id=${encodeURIComponent(id)}&viewMode=story`) + await use(async (id, options) => { + const query = new URLSearchParams({ id, viewMode: "story" }) + if (options?.args) { + query.set( + "args", + Object.entries(options.args) + .map(([key, value]) => `${key}:${value}`) + .join(";"), + ) + } + if (options?.globals) { + query.set( + "globals", + Object.entries(options.globals) + .map(([key, value]) => `${key}:${value}`) + .join(";"), + ) + } + await page.goto(`/iframe.html?${query}`) const root = page.locator("#storybook-root") await expect(root).toBeVisible({ timeout: 30_000 }) return root From 7d79b9c70cfc3f1cbf5a65c4532f33d5ea68ddcd Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:26:19 +0000 Subject: [PATCH 08/10] test(session-ui): consolidate stories into five product scenarios --- .../component-tests/session-lifecycle.spec.ts | 13 +++--- .../session-message-projection.spec.ts | 8 ++-- .../session-notice-projection.spec.ts | 10 ++--- .../session-timeline-notices.spec.ts | 2 +- .../session-timeline-reasoning.spec.ts | 8 ++-- .../component-tests/session-timeline.spec.ts | 4 +- .../session-tool-projection.spec.ts | 18 ++++---- .../src/timeline/file-changes.stories.tsx | 12 +++++- .../timeline/research-and-agents.stories.tsx | 39 ++++++++++++----- .../src/timeline/terminal-work.stories.tsx | 11 ++++- .../src/timeline/timeline-row.stories.tsx | 42 +++++++++++++++---- packages/storybook/playwright/README.md | 4 +- 12 files changed, 116 insertions(+), 55 deletions(-) diff --git a/packages/session-ui/component-tests/session-lifecycle.spec.ts b/packages/session-ui/component-tests/session-lifecycle.spec.ts index f36e4d71afd5..ef665f16ad31 100644 --- a/packages/session-ui/component-tests/session-lifecycle.spec.ts +++ b/packages/session-ui/component-tests/session-lifecycle.spec.ts @@ -3,7 +3,7 @@ import { expect, story } from "../../storybook/playwright/story" for (const expanded of [false, true]) { // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts story(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ mount }) => { - const timeline = await mount("current-session-terminal-work--run-a-command", { args: { expanded } }) + const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { expanded } }) const trigger = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]') await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) await trigger.click() @@ -19,7 +19,7 @@ for (const expanded of [false, true]) { // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts story("transitions a streaming shell from writing through command execution", async ({ mount }) => { - const timeline = await mount("current-session-terminal-work--run-a-command", { args: { streaming: true } }) + const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { streaming: true } }) const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]') const title = tool.locator('[data-slot="basic-tool-tool-title"]') const shimmer = title.locator('[data-component="text-shimmer"]') @@ -51,7 +51,7 @@ story("transitions a streaming shell from writing through command execution", as // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts story("shimmers and expands a running shell command", async ({ mount }) => { - const timeline = await mount("current-session-terminal-work--run-a-command", { args: { streaming: true } }) + const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { streaming: true } }) await timeline.getByRole("button", { name: "Run command" }).click() const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]') const trigger = tool.locator('[data-slot="collapsible-trigger"]') @@ -68,7 +68,7 @@ story("shimmers and expands a running shell command", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts story("transitions thinking and hidden reasoning through busy to idle", async ({ mount }) => { - const timeline = await mount("current-session-timeline-rows--working-without-reasoning-details") + const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "hidden" } }) const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]') await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible() await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible() @@ -83,7 +83,7 @@ story("transitions thinking and hidden reasoning through busy to idle", async ({ // Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts story("moves busy through retry and recovery to final idle content", async ({ mount }) => { - const timeline = await mount("current-session-timeline-rows--retry-and-recover") + const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "retry" } }) await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible() await expect(timeline.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0) await timeline.getByRole("button", { name: "Retry request" }).click() @@ -105,7 +105,8 @@ for (const profile of [ ] as const) { // Moved from packages/app/e2e/regression/session-timeline-locale-projection.spec.ts story(`projects translated context status in ${profile.locale}`, async ({ mount, page }) => { - const timeline = await mount("current-session-research-agents--explore-the-codebase", { + const timeline = await mount("current-session-research-agents--agent-research", { + args: { scenario: "exploration" }, globals: { locale: profile.locale }, }) await timeline.getByRole("button", { name: "Complete read" }).click() diff --git a/packages/session-ui/component-tests/session-message-projection.spec.ts b/packages/session-ui/component-tests/session-message-projection.spec.ts index aae7b60433e9..a5956f8fdf83 100644 --- a/packages/session-ui/component-tests/session-message-projection.spec.ts +++ b/packages/session-ui/component-tests/session-message-projection.spec.ts @@ -2,7 +2,7 @@ import { expect, story } from "../../storybook/playwright/story" // Moved from packages/app/e2e/regression/session-timeline-collapse-state.spec.ts story("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ mount }) => { - const timeline = await mount("current-session-file-changes--edit-with-streamed-sibling") + const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "streaming" } }) const tool = timeline.locator('[data-timeline-part-id="tool_edit_status"]') const trigger = tool.locator('[data-scope="apply-patch"] button') await expect(trigger).toHaveAttribute("aria-expanded", "true") @@ -21,7 +21,7 @@ story("keeps a manually collapsed tool collapsed when later assistant content st // Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts story("renders interruption independently when the turn is not compacted", async ({ mount }) => { - const timeline = await mount("current-session-timeline-rows--interrupted-turn") + const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "interruption" } }) await expect(timeline.getByText("Interrupted", { exact: true })).toBeVisible() await expect(timeline.getByText("Before", { exact: true })).toBeVisible() await expect(timeline.getByText("After", { exact: true })).toBeVisible() @@ -34,7 +34,7 @@ story("renders interruption independently when the turn is not compacted", async // Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts story("renders aliased and long custom model notices", async ({ mount, page }) => { await page.setViewportSize({ width: 420, height: 700 }) - const timeline = await mount("current-session-timeline-rows--aliased-model-notices") + const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "models" } }) const shortName = "GPT-5.4 nano" const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts" const short = timeline.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName }) @@ -53,7 +53,7 @@ story("renders aliased and long custom model notices", async ({ mount, page }) = // Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts story("renders user image, file attachment, file reference, and agent reference", async ({ mount }) => { - const timeline = await mount("current-session-timeline-rows--rich-user-attachments") + const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "attachments" } }) await expect(timeline.getByAltText("pixel.png")).toBeVisible() await expect(timeline.getByText("tsconfig.json")).toBeVisible() await expect(timeline.getByText("@src/a.ts", { exact: true })).toBeVisible() diff --git a/packages/session-ui/component-tests/session-notice-projection.spec.ts b/packages/session-ui/component-tests/session-notice-projection.spec.ts index 87d426c8fc3b..480de59cf043 100644 --- a/packages/session-ui/component-tests/session-notice-projection.spec.ts +++ b/packages/session-ui/component-tests/session-notice-projection.spec.ts @@ -7,7 +7,7 @@ story("renders current protocol notices in CLI order", async ({ mount, page }) = if (message.text().includes("computations created outside a `createRoot` or `render`")) warnings.push(message.text()) }) - const timeline = await mount("current-session-timeline-rows--agent-activity-notices") + const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "notices" } }) const notices = timeline.locator('[data-slot="session-timeline-notice"]') await expect(notices).toHaveCount(4) await expect(notices.nth(0)).toContainText("Agent · explore") @@ -21,7 +21,7 @@ story("renders current protocol notices in CLI order", async ({ mount, page }) = // Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts story("renders a compaction summary while it streams and after completion", async ({ mount }) => { - const timeline = await mount("current-session-timeline-rows--compact-session") + const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "compaction" } }) const compaction = timeline.locator('[data-component="session-compaction-message"]') await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible() await timeline.getByRole("button", { name: "Stream summary" }).click() @@ -34,7 +34,7 @@ story("renders a compaction summary while it streams and after completion", asyn // Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts story("updates running compactions to failed and cancelled boundaries", async ({ mount }) => { - const timeline = await mount("current-session-timeline-rows--compact-session") + const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "compaction" } }) await timeline.getByRole("button", { name: "Stream summary" }).click() await timeline.getByRole("button", { name: "Fail compaction" }).click() const compactions = timeline.locator('[data-component="session-compaction-message"]') @@ -51,7 +51,7 @@ story("updates running compactions to failed and cancelled boundaries", async ({ // Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts story("shows a delegating row while subagent input streams", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--delegating-an-agent") + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "delegation" } }) const delegating = timeline.locator('[data-component="task-tool-delegating"]') await expect(delegating).toBeVisible() const shimmer = delegating.locator('[data-component="text-shimmer"]') @@ -66,7 +66,7 @@ story("shows a delegating row while subagent input streams", async ({ mount }) = // Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts story("waits for completion before labeling requested background work", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--starting-background-work") + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "background" } }) await expect(timeline.locator('[data-component="task-tool-card"]')).toContainText("Inspect code") await expect(timeline.locator('[data-component="task-tool-card"]')).not.toContainText("(background)") }) diff --git a/packages/session-ui/component-tests/session-timeline-notices.spec.ts b/packages/session-ui/component-tests/session-timeline-notices.spec.ts index c838472b7960..3c18e403c6d1 100644 --- a/packages/session-ui/component-tests/session-timeline-notices.spec.ts +++ b/packages/session-ui/component-tests/session-timeline-notices.spec.ts @@ -4,7 +4,7 @@ import { expect, story } from "../../storybook/playwright/story" story("renders the moved location notice in its compact timeline style", async ({ mount, page }) => { const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session` await page.setViewportSize({ width: 480, height: 720 }) - const timeline = await mount("current-session-timeline-rows--moved-location") + const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "location" } }) const notice = timeline.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]') const label = notice.locator('[data-slot="session-timeline-notice-label"]') const value = notice.locator('[data-slot="session-timeline-notice-value"]') diff --git a/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts b/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts index e4ddd7f30e75..0a375cba2d01 100644 --- a/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts +++ b/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts @@ -50,8 +50,8 @@ const profiles = [ for (const profile of profiles) { // Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts story(`projects busy reasoning profile ${profile.name}`, async ({ mount }) => { - const timeline = await mount("current-session-timeline-rows--agent-reasoning", { - args: { summaries: profile.summaries, reasoning: profile.reasoning, tool: profile.tool }, + const timeline = await mount("current-session-timeline-rows--conversation", { + args: { scenario: "reasoning", summaries: profile.summaries, reasoning: profile.reasoning, tool: profile.tool }, }) await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) await expect(timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')).toHaveCount( @@ -65,8 +65,8 @@ for (const profile of profiles) { // Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts story("does not infer reasoning visibility from provider identity", async ({ mount }) => { - const timeline = await mount("current-session-timeline-rows--agent-reasoning", { - args: { reasoning: "none", text: "No reasoning payload" }, + const timeline = await mount("current-session-timeline-rows--conversation", { + args: { scenario: "reasoning", reasoning: "none", text: "No reasoning payload" }, }) await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(timeline.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) diff --git a/packages/session-ui/component-tests/session-timeline.spec.ts b/packages/session-ui/component-tests/session-timeline.spec.ts index 996b2061f64f..05bc98136f51 100644 --- a/packages/session-ui/component-tests/session-timeline.spec.ts +++ b/packages/session-ui/component-tests/session-timeline.spec.ts @@ -8,7 +8,7 @@ story("renders streamed reasoning without starting the app", async ({ mount }) = // Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts story("preserves a collapsed context group through count and status updates", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--explore-the-codebase") + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "exploration" } }) const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]') const trigger = group.locator('[data-slot="collapsible-trigger"]') await expect(trigger).toHaveAttribute("aria-expanded", "false") @@ -21,7 +21,7 @@ story("preserves a collapsed context group through count and status updates", as // Moved from packages/app/e2e/regression/session-timeline-accessibility.spec.ts story("space activates a focused timeline button instead of scrolling", async ({ mount, page }) => { await page.emulateMedia({ reducedMotion: "reduce" }) - const timeline = await mount("current-session-terminal-work--collapsed-shell") + const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { scenario: "collapsed" } }) const trigger = timeline.locator('[data-timeline-part-id="tool_terminal_passed"] [data-slot="collapsible-trigger"]') await expect(trigger).toHaveAttribute("aria-expanded", "false") await trigger.focus() diff --git a/packages/session-ui/component-tests/session-tool-projection.spec.ts b/packages/session-ui/component-tests/session-tool-projection.spec.ts index bcb11f50b2fc..b66f23966357 100644 --- a/packages/session-ui/component-tests/session-tool-projection.spec.ts +++ b/packages/session-ui/component-tests/session-tool-projection.spec.ts @@ -2,7 +2,7 @@ import { expect, story } from "../../storybook/playwright/story" // Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts story("renders every admitted tool family and hides timeline-only exclusions", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--complete-agent-workflow") + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "workflow" } }) await expect( timeline.locator('[data-timeline-part-ids="tool_family_read,tool_family_glob,tool_family_grep,tool_family_list"]'), ).toBeVisible() @@ -35,7 +35,7 @@ story("renders every admitted tool family and hides timeline-only exclusions", a // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("renders every tool error outcome without leaking hidden tools", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--recover-from-tool-failures") + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "failures" } }) const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"] await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1) await expect(timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')).toContainText(/dismissed/i) @@ -45,7 +45,7 @@ story("renders every tool error outcome without leaking hidden tools", async ({ // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("transitions shell and question through running error outcomes", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--failed-command-and-question") + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "transition" } }) const shell = timeline.locator('[data-timeline-part-id="tool_transition_shell"]') const question = timeline.locator('[data-timeline-part-id="tool_transition_question"]') await expect(shell).toBeVisible() @@ -58,7 +58,7 @@ story("transitions shell and question through running error outcomes", async ({ // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("labels all web search provider variants", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--compare-search-providers") + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "providers" } }) await expect(timeline.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() await expect(timeline.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() await expect(timeline.getByRole("button", { name: /^Web Search/ })).toBeVisible() @@ -66,7 +66,7 @@ story("labels all web search provider variants", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("labels completed searches with result counts", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--search-results-and-files") + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "results" } }) const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]') await group.locator('[data-slot="collapsible-trigger"]').click() const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]') @@ -76,7 +76,7 @@ story("labels completed searches with result counts", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("labels read tools from their path input", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--search-results-and-files") + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "results" } }) const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]') await group.locator('[data-slot="collapsible-trigger"]').click() await expect( @@ -88,7 +88,7 @@ story("labels read tools from their path input", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("labels skill tools from IDs and result metadata", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--loading-specialized-skills") + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "skills" } }) for (const [id, name] of [ ["tool_skill_id", "frontend-design"], ["tool_skill_name", "OpenCode"], @@ -105,7 +105,7 @@ story("labels skill tools from IDs and result metadata", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts story("groups singleton and separated context operations at correct boundaries", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--research-across-steps") + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "steps" } }) await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_read"]')).toBeVisible() await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep"]')).toBeVisible() await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_list"]')).toBeVisible() @@ -114,7 +114,7 @@ story("groups singleton and separated context operations at correct boundaries", // Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts story("combines adjacent edit calls and repeated files into one group", async ({ mount }) => { - const timeline = await mount("current-session-file-changes--repeated-edits") + const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "repeated" } }) const group = timeline.locator('[data-timeline-part-ids="tool_grouped_edit_first,tool_grouped_edit_second"]') await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit") await expect(group.getByText("1 file", { exact: true })).toBeVisible() diff --git a/packages/session-ui/src/timeline/file-changes.stories.tsx b/packages/session-ui/src/timeline/file-changes.stories.tsx index f86897df1c98..02856393c540 100644 --- a/packages/session-ui/src/timeline/file-changes.stories.tsx +++ b/packages/session-ui/src/timeline/file-changes.stories.tsx @@ -73,7 +73,7 @@ export const PatchedTwoFiles = { ), } -export const RepeatedEdits = { +const RepeatedEdits = { render: () => ( } +const EditWithStreamedSibling = { render: () => } + +const fileScenarios = { repeated: RepeatedEdits, streaming: EditWithStreamedSibling } + +export const ChangingFiles = { + args: { scenario: "streaming" }, + argTypes: { scenario: { control: "select", options: Object.keys(fileScenarios) } }, + render: (args: { scenario: string }) => fileScenarios[args.scenario as keyof typeof fileScenarios].render(), +} export const CreatedANewFile = { render: () => ( diff --git a/packages/session-ui/src/timeline/research-and-agents.stories.tsx b/packages/session-ui/src/timeline/research-and-agents.stories.tsx index 2c3c36a816b1..7a2d24fdfe7b 100644 --- a/packages/session-ui/src/timeline/research-and-agents.stories.tsx +++ b/packages/session-ui/src/timeline/research-and-agents.stories.tsx @@ -129,9 +129,9 @@ function CodebaseExplorationStory() { ) } -export const ExploreTheCodebase = { render: () => } +const ExploreTheCodebase = { render: () => } -export const CompareSearchProviders = { +const CompareSearchProviders = { render: () => ( ( ( ( ( { const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"] const input = (name: string): Record => { @@ -325,9 +325,9 @@ function FailedCommandAndQuestionStory() { ) } -export const FailedCommandAndQuestion = { render: () => } +const FailedCommandAndQuestion = { render: () => } -export const DelegatingAnAgent = { +const DelegatingAnAgent = { render: () => ( ( ), } + +const researchScenarios = { + workflow: CompleteAgentWorkflow, + exploration: ExploreTheCodebase, + providers: CompareSearchProviders, + results: SearchResultsAndFiles, + skills: LoadingSpecializedSkills, + steps: ResearchAcrossSteps, + failures: RecoverFromToolFailures, + transition: FailedCommandAndQuestion, + delegation: DelegatingAnAgent, + background: StartingBackgroundWork, +} + +export const AgentResearch = { + args: { scenario: "workflow" }, + argTypes: { scenario: { control: "select", options: Object.keys(researchScenarios) } }, + render: (args: { scenario: string }) => researchScenarios[args.scenario as keyof typeof researchScenarios].render(), +} diff --git a/packages/session-ui/src/timeline/terminal-work.stories.tsx b/packages/session-ui/src/timeline/terminal-work.stories.tsx index 81f64d89f468..d552df877687 100644 --- a/packages/session-ui/src/timeline/terminal-work.stories.tsx +++ b/packages/session-ui/src/timeline/terminal-work.stories.tsx @@ -66,7 +66,7 @@ export const UserCommandCompleted = { ), } -export const CollapsedShell = { +const CollapsedShell = { render: () => ( , } +export const TerminalCommands = { + args: { scenario: "command", expanded: false, streaming: false }, + argTypes: { scenario: { control: "select", options: ["command", "collapsed"] } }, + render: (args: { scenario: string; expanded: boolean; streaming: boolean }) => + args.scenario === "collapsed" ? CollapsedShell.render() : RunACommand.render(args), +} + export const FixedAndPassed = { render: () => ( ( @@ -180,7 +180,7 @@ function HiddenReasoningStory() { ) } -export const WorkingWithoutReasoningDetails = { render: () => } +const WorkingWithoutReasoningDetails = { render: () => } function RetryAndRecoverStory() { const [state, setState] = createStore({ phase: "thinking" }) @@ -233,7 +233,7 @@ function RetryAndRecoverStory() { ) } -export const RetryAndRecover = { render: () => } +const RetryAndRecover = { render: () => } export const ProviderRetry = { render: () => ( @@ -256,7 +256,7 @@ const noticeAssistant = { time: { created: STORY_TIME + 1, completed: STORY_TIME + 2 }, } satisfies SessionMessageInfo -export const AgentActivityNotices = { +const AgentActivityNotices = { render: () => ( } +const CompactSession = { render: () => } export const CompactionInProgress = { render: () => ( @@ -454,7 +454,7 @@ export const MixedDirectionRtl = { ), } -export const MovedLocation = { +const MovedLocation = { render: () => ( ( ( ( { + if (args.scenario === "reasoning") return + return conversationScenarios[args.scenario as Exclude].render() + }, +} + export const InstructionsUpdatedSingle = { render: () => ( { - const component = await mount("current-session-research-agents--explore-the-codebase") + const component = await mount("current-session-research-agents--agent-research", { + args: { scenario: "exploration" }, + }) const trigger = component.locator('[data-slot="collapsible-trigger"]') await expect(trigger).toHaveAttribute("aria-expanded", "false") From 08e60630feea5563831e40593386819f52579919 Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:07:15 +0000 Subject: [PATCH 09/10] test(app): restore assertion and integration coverage --- .../regression/prompt-thinking-level.spec.ts | 81 ++++++++ .../app/e2e/regression/session-queue.spec.ts | 2 + .../session-timeline-accessibility.spec.ts | 49 +++++ .../session-timeline-collapse-state.spec.ts | 130 ++++++++++++- .../session-timeline-context-state.spec.ts | 31 +++ .../session-timeline-lifecycle-state.spec.ts | 176 ++++++++++++++++++ ...session-timeline-locale-projection.spec.ts | 23 +++ .../session-timeline-notices.spec.ts | 138 +++++++++++++- .../session-timeline-projection.spec.ts | 37 ++++ ...sion-timeline-reasoning-projection.spec.ts | 94 ++++++++++ .../session-timeline-tool-projection.spec.ts | 39 ++++ .../session-timeline-transport.spec.ts | 88 ++++++++- .../component-tests/session-lifecycle.spec.ts | 6 + .../component-tests/session-timeline.spec.ts | 38 ++-- .../session-tool-projection.spec.ts | 8 +- .../storybook/current-session-scenarios.ts | 4 +- .../src/timeline/file-changes.stories.tsx | 69 ++++++- .../timeline/research-and-agents.stories.tsx | 19 +- .../src/timeline/terminal-work.stories.tsx | 28 ++- .../src/timeline/timeline-row.stories.tsx | 2 +- packages/storybook/playwright/README.md | 4 + 21 files changed, 1027 insertions(+), 39 deletions(-) create mode 100644 packages/app/e2e/regression/prompt-thinking-level.spec.ts create mode 100644 packages/app/e2e/regression/session-timeline-accessibility.spec.ts create mode 100644 packages/app/e2e/regression/session-timeline-context-state.spec.ts create mode 100644 packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts create mode 100644 packages/app/e2e/regression/session-timeline-locale-projection.spec.ts create mode 100644 packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts new file mode 100644 index 000000000000..7dd640a50c0e --- /dev/null +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -0,0 +1,81 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const directory = "C:/OpenCode/PromptThinkingLevelRegression" +const projectID = "proj_prompt_thinking_level_regression" +const sessionID = "ses_prompt_thinking_level_regression" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test("shows the thinking level control while relevant", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "prompt-thinking-level-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "thinking-model": { + id: "thinking-model", + name: "Thinking Model", + limit: { context: 200_000 }, + variants: { high: {} }, + }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "thinking-model" }, + }, + sessions: [ + { + id: sessionID, + slug: "prompt-thinking-level-regression", + projectID, + directory, + title: "Prompt thinking level regression", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + }) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + const composer = page.locator('[data-component="composer"]') + const input = composer.locator('[data-component="composer-editor"]') + const control = composer.getByRole("button", { name: "Choose model variant" }) + await expectAppVisible(composer) + + await idleComposer(page) + await expect(control).toBeVisible() + + await control.click() + const high = page.getByRole("menuitemradio", { name: "high" }) + await expect(high).toBeVisible() + await page.mouse.move(0, 0) + await expect(control).toBeVisible() + await expect(high).toBeVisible() + await high.click() + + await idleComposer(page) + await input.focus() + await expect(control).toBeVisible() + + await idleComposer(page) + await expect(control).toBeVisible() +}) + +async function idleComposer(page: Page) { + await page.mouse.move(0, 0) + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) +} diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index 9e96e0b68fb7..21f06eac2bf7 100644 --- a/packages/app/e2e/regression/session-queue.spec.ts +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -201,11 +201,13 @@ test("editing restores the existing draft and replaces only the original queue p await view.input.fill("my in-progress draft") await original.click() await expect(view.input).toHaveText("tighten the error copy") + await expect(view.input).toBeFocused() await view.input.press("Escape") await expect(view.input).toHaveText("my in-progress draft") await original.click() await expect(view.input).toHaveText("tighten the error copy") + await expect(view.input).toBeFocused() await view.input.fill("tighten the error copy and add a retry hint") await expect(view.input).toHaveText("tighten the error copy and add a retry hint") await view.input.press("Enter") diff --git a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts new file mode 100644 index 000000000000..3888c4a6e174 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + setupTimeline, + shell, + textPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("space activates a focused timeline button instead of scrolling", async ({ page }) => { + const shellID = "prt_space_button_shell" + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + shell(shellID, "completed", lines(5)), + textPart( + "prt_space_following", + "Following content leaves room to focus the command away from the bottom. ".repeat(40), + ), + ]), + ], + settings: { shellToolPartsExpanded: false }, + reducedMotion: true, + seedHistory: true, + }) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) + const trigger = page.getByRole("button", { name: "Used Shell" }) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight)) + .toBeGreaterThan(300) + await trigger.scrollIntoViewIfNeeded() + await scroller.hover() + await page.mouse.wheel(0, -100) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeGreaterThan(50) + await expect(trigger).toBeInViewport() + await trigger.focus() + await expect(trigger).toBeFocused() + const before = await scroller.evaluate((element) => element.scrollTop) + await trigger.press("Space") + await expect(trigger).toHaveAttribute("aria-expanded", "true") + expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before) +}) + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index ea5d80aefbc1..2e08719b39b4 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test" +import { expect, test, type Locator, type Page } from "@playwright/test" import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" @@ -10,6 +10,7 @@ const sessionID = "ses_timeline_state_regression" const userMessageID = "msg_user_regression" const assistantMessageID = "msg_assistant_regression" const editPartID = "prt_0001_edit" +const textPartID = `${assistantMessageID}:text:0` const title = "Timeline collapse state regression" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } @@ -83,6 +84,37 @@ const assistantMessage = { } satisfies SessionMessageInfo test.describe("regression: session timeline local row state", () => { + test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => { + const events: EventPayload[] = [] + await mockServer(page, events) + await configurePage(page) + + await page.goto(sessionHref()) + await expectSessionTitle(page, title) + + const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() + await expectAppVisible(wrapper) + await expectExpanded(wrapper, true) + + await wrapper.evaluate((element) => { + ;(element as HTMLElement).dataset.regressionMarker = "before-stream" + }) + await wrapper.locator('[data-scope="apply-patch"] button').click() + await expectExpanded(wrapper, false) + + events.push(...textEvents()) + + await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({ + timeout: 10_000, + }) + + expect(await readToolState(page)).toEqual({ + expanded: false, + row: "AssistantPart", + streamedTextVisible: true, + }) + }) + test("does not remount an edit diff when a sibling part arrives", async ({ page }) => { const events: EventPayload[] = [] await installDiffProbe(page) @@ -191,6 +223,38 @@ async function configurePage(page: Page) { }) } +async function expectExpanded(locator: Locator, expected: boolean) { + await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected) +} + +async function readToolState(page: Page) { + return page + .locator(`[data-timeline-part-id="${editPartID}"]`) + .first() + .evaluate( + (element, textPartID) => ({ + expanded: (() => { + const trigger = + element.querySelector('[data-scope="apply-patch"] button') ?? + element.querySelector('[data-slot="collapsible-trigger"]') + const aria = trigger?.getAttribute("aria-expanded") + if (aria === "true") return true + if (aria === "false") return false + + const root = element.querySelector('[data-component="collapsible"]') + if (root?.hasAttribute("data-expanded")) return true + if (root?.hasAttribute("data-closed")) return false + + const content = element.querySelector('[data-slot="collapsible-content"]') + return !!content && content.getBoundingClientRect().height > 0 + })(), + row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"), + streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`), + }), + `${assistantMessageID}:text:0`, + ) +} + async function installDiffProbe(page: Page) { await page.addInitScript(() => { let shadowRootCount = 0 @@ -282,6 +346,54 @@ function textEvents(): OpenCodeEvent[] { ] } +function toolEvents(part: typeof editPart): OpenCodeEvent[] { + return [ + eventValue( + "session.tool.input.started", + { + sessionID, + assistantMessageID, + id: part.callID, + name: part.tool, + }, + 1, + ), + eventValue( + "session.tool.input.ended", + { + sessionID, + assistantMessageID, + id: part.callID, + text: JSON.stringify(part.state.input), + }, + 1, + ), + eventValue( + "session.tool.called", + { + sessionID, + assistantMessageID, + id: part.callID, + input: part.state.input, + executed: true, + }, + 1, + ), + eventValue( + "session.tool.success", + { + sessionID, + assistantMessageID, + id: part.callID, + content: [{ type: "text", text: part.state.output }], + metadata: part.state.metadata as Record, + executed: true, + }, + 2, + ), + ] +} + function eventValue( type: Type, data: Extract["data"], @@ -298,6 +410,22 @@ function eventValue( } as unknown as Extract } +function readExpanded(element: Element) { + const trigger = + element.querySelector('[data-scope="apply-patch"] button') ?? + element.querySelector('[data-slot="collapsible-trigger"]') + const aria = trigger?.getAttribute("aria-expanded") + if (aria === "true") return true + if (aria === "false") return false + + const root = element.querySelector('[data-component="collapsible"]') + if (root?.hasAttribute("data-expanded")) return true + if (root?.hasAttribute("data-closed")) return false + + const content = element.querySelector('[data-slot="collapsible-content"]') + return !!content && content.getBoundingClientRect().height > 0 +} + async function mockServer( page: Page, events: EventPayload[], diff --git a/packages/app/e2e/regression/session-timeline-context-state.spec.ts b/packages/app/e2e/regression/session-timeline-context-state.spec.ts new file mode 100644 index 000000000000..a4878dd9f398 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-context-state.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test" +import { + assistantMessage, + partUpdated, + setupTimeline, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +test("preserves a collapsed context group through count and status updates", async ({ page }) => { + const ids = ["prt_closed_01_read", "prt_closed_02_glob"] + const inputs = { + read: { path: "src/a.ts", offset: 0, limit: 120 }, + glob: { path: ".", pattern: "**/*.ts" }, + } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage( + [toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)], + { completed: false }, + ), + ], + }) + const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) + const trigger = group.locator('[data-slot="collapsible-trigger"]') + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100) + await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300) + await expect(trigger).toHaveAttribute("aria-expanded", "false") +}) diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts new file mode 100644 index 000000000000..71ef8a03a7ef --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -0,0 +1,176 @@ +import { expect, test } from "@playwright/test" +import { + assistantID, + assistantMessage, + completedAssistantInfo, + messageUpdated, + partUpdated, + reasoningPart, + renderedPartID, + setupTimeline, + shell, + sessionID, + status, + stepStarted, + textPart, + toolCalled, + toolInputEnded, + toolInputStarted, + userMessage, +} from "../performance/timeline-stability/fixture" + +for (const expanded of [false, true]) { + test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => { + const id = `prt_shell_default_${expanded}` + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])], + settings: { shellToolPartsExpanded: expanded }, + }) + const trigger = expanded + ? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`) + : page.getByRole("button", { name: "Used Shell" }) + await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) + + await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180) + await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180) + await timeline.send(status("busy"), 100) + await timeline.send(status("idle"), 250) + await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) + }) +} + +test("transitions a streaming shell from writing through command execution", async ({ page }) => { + const id = "prt_shell_streaming_input" + const command = "printf ready" + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([], { completed: false })], + }) + await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" })) + + const tool = page.locator(`[data-timeline-part-id="${id}"]`) + const title = tool.locator('[data-slot="basic-tool-tool-title"]') + const titleShimmer = title.locator('[data-component="text-shimmer"]') + const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]') + await expect(titleShimmer).toHaveAttribute("aria-label", "Shell") + await expect(titleShimmer).toHaveAttribute("data-active", "true") + await expect(subtitle).toHaveText("Writing command...") + await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0) + await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0) + await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px") + await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px") + await expect(title).toHaveCSS("font-size", "13px") + await expect(title).toHaveCSS("font-family", /^Inter,/) + await expect(title).toHaveCSS("font-weight", "530") + await expect(title).toHaveCSS("line-height", "16px") + await expect(title).toHaveCSS("color", "rgb(22, 22, 22)") + await expect(subtitle).toHaveCSS("font-size", "13px") + await expect(subtitle).toHaveCSS("font-family", /^Inter,/) + await expect(subtitle).toHaveCSS("font-weight", "440") + await expect(subtitle).toHaveCSS("line-height", "16px") + await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)") + + const input = JSON.stringify({ command }) + await timeline.send(toolInputEnded({ sessionID, assistantMessageID: assistantID, id, text: input })) + await expect(titleShimmer).toHaveAttribute("data-active", "true") + await expect(subtitle).toHaveText(command) + await expect(tool).not.toContainText("Writing command...") + + await timeline.send( + toolCalled({ + sessionID, + assistantMessageID: assistantID, + id, + input: { command }, + executed: true, + }), + ) + await expect(titleShimmer).toHaveAttribute("data-active", "true") + await expect(subtitle).toHaveText(command) +}) + +test("shimmers and expands a running shell command", async ({ page }) => { + const id = "prt_shell_running_command" + const command = "sleep 10 && echo done" + await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })], + settings: { shellToolPartsExpanded: false }, + }) + + const tool = page.locator(`[data-timeline-part-id="${id}"]`) + await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") + await expect(tool).not.toContainText("Writing command...") + await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command) + await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0) + await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px") + await tool.locator('[data-slot="collapsible-trigger"]').click() + await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") + await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running") +}) + +test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { + const reasoningID = "prt_reasoning_hidden" + const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistant], + settings: { showReasoningSummaries: false }, + cpuRate: 4, + }) + await timeline.send(status("busy"), 150) + + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0) + await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180) + await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100) + await timeline.send(status("idle"), 300) + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0) +}) + +test("moves busy through retry and recovery to final idle content", async ({ page }) => { + const assistant = assistantMessage([], { completed: false }) + const timeline = await setupTimeline(page, { + messages: [ + userMessage(undefined, { + summary: { + diffs: [ + { + file: "src/retry.ts", + additions: 1, + deletions: 1, + status: "modified", + patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true", + }, + ], + }, + }), + assistant, + ], + }) + await timeline.send(status("busy"), 140) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0) + await timeline.send(status("retry"), 180) + await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await timeline.send(stepStarted(assistant), 180) + await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140) + await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100) + await timeline.send(status("idle"), 350) + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText( + "Recovered response", + ) +}) + +function lines(count: number) { + return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") +} diff --git a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts new file mode 100644 index 000000000000..30405aff3568 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from "@playwright/test" +import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" + +for (const locale of ["de", "ar"] as const) { + test(`projects localized tool names with an English fallback in ${locale}`, async ({ page }) => { + const ids = [`prt_locale_${locale}_01_read`, `prt_locale_${locale}_02_glob`] + await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(ids[0]!, "read", "completed", { path: "src/a.ts" }), + toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), + ]), + ], + locale, + }) + + const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) + await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /) + await expect(group.locator('[data-component="tag"]')).toHaveText("2") + await expect(page.locator("html")).toHaveAttribute("lang", locale) + }) +} diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index b01469fd0b1a..d189662ffdd0 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -1,10 +1,24 @@ import { expect, test } from "@playwright/test" import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise" -import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture" +import { + compactionDelta, + compactionEnded, + compactionFailed, + compactionStarted, + event, + session, + sessionID, + setupTimeline, +} from "../performance/timeline-stability/fixture" const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo -const assistant = (completed: boolean, tool = false, childID?: string): SessionMessageAssistant => ({ +const assistant = ( + completed: boolean, + tool = false, + childID?: string, + background = false, +): SessionMessageAssistant => ({ id: "msg_assistant", type: "assistant", agent: "build", @@ -17,7 +31,7 @@ const assistant = (completed: boolean, tool = false, childID?: string): SessionM name: "subagent", state: { status: "running", - input: { description: "Inspect code" }, + input: { description: "Inspect code", ...(background ? { background: true } : {}) }, metadata: { status: "running", ...(childID ? { sessionID: childID } : {}) }, }, time: { created: 2 }, @@ -27,6 +41,124 @@ const assistant = (completed: boolean, tool = false, childID?: string): SessionM time: { created: 2, ...(completed ? { completed: 3 } : {}) }, }) +test("renders current protocol notices in CLI order", async ({ page }) => { + const ownerWarnings: string[] = [] + page.on("console", (message) => { + if (message.text().includes("computations created outside a `createRoot` or `render`")) + ownerWarnings.push(message.text()) + }) + await setupTimeline(page, { + sessionMessages: [ + user, + { id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } }, + assistant(true), + { + id: "msg_subagent", + type: "synthetic", + text: "done", + description: "Search code", + metadata: { source: "subagent", agent: "explore", state: "completed" }, + time: { created: 4 }, + }, + { + id: "msg_restart", + type: "synthetic", + text: "continue", + description: "Continuing after restart", + time: { created: 5 }, + }, + { id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } }, + ], + }) + + const notices = page.locator('[data-slot="session-timeline-notice"]') + await expect(notices).toHaveCount(4) + await expect(notices.nth(0)).toContainText("Agent · explore") + await expect(notices.nth(1)).toContainText("explore finished · Search code") + await expect(notices.nth(2)).toContainText("Continuing after restart") + await expect(notices.nth(3)).toContainText("Skill · Review") + await expect(notices).toHaveClass([/text-text-weak/, /text-text-weak/, /text-text-weak/, /text-text-weak/]) + await expect(notices.locator(".text-text-strong")).toHaveCount(0) + expect(ownerWarnings).toEqual([]) +}) + +test("renders a compaction summary while it streams and after completion", async ({ page }) => { + const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] }) + + await timeline.send( + compactionStarted({ + sessionID, + reason: "manual", + recent: "", + }), + ) + + const compaction = page.locator('[data-component="session-compaction-message"]') + await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible() + + await timeline.send( + compactionDelta({ + sessionID, + text: "## Checkpoint\n\nStreamed implementation details.", + }), + ) + await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible() + await expect(compaction).toContainText("Streamed implementation details.") + + await timeline.send( + compactionEnded({ + sessionID, + reason: "manual", + text: "## Checkpoint\n\nFinal implementation details.", + recent: "", + }), + ) + await expect(compaction).toContainText("Final implementation details.") + await expect(compaction).not.toContainText("Streamed implementation details.") +}) + +test("updates running compactions to failed and cancelled boundaries", async ({ page }) => { + const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] }) + + await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" })) + await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." })) + await expect(page.getByText("Partial summary that should be discarded.", { exact: true })).toBeVisible() + await timeline.send( + compactionFailed({ + sessionID, + reason: "auto", + error: { + type: "compaction.failed", + message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}', + }, + }), + ) + + const compactions = page.locator('[data-component="session-compaction-message"]') + const failed = compactions.filter({ hasText: "The provider rejected the summary." }) + await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible() + await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible() + await expect(failed).not.toContainText("Partial summary that should be discarded.") + + await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" })) + await expect(compactions).toHaveCount(2) + await timeline.send(compactionDelta({ sessionID, text: "Summary before cancellation." })) + await expect(page.getByText("Summary before cancellation.", { exact: true })).toBeVisible() + await timeline.send( + compactionFailed({ + sessionID, + reason: "manual", + error: { type: "aborted", message: "Cancellation detail should stay hidden." }, + }), + ) + + await expect(compactions).toHaveCount(2) + const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." }) + await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible() + await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.") + await expect(cancelled).not.toContainText("Summary before cancellation.") +}) + test("moves blocking work to the background with Ctrl+B", async ({ page }) => { await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] }) const card = page.locator('[data-component="task-tool-card"]') diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index feac35926714..ba0b730f310f 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -121,6 +121,43 @@ test.describe("session timeline projection", () => { await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() }) + + test("renders aliased and long custom model notices", async ({ page }) => { + const shortName = "GPT-5.4 nano" + const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts" + await setupTimeline(page, { + viewport: { width: 420, height: 700 }, + sessionMessages: [ + { + id: "msg_model_fast_nano", + type: "model-switched", + time: { created: 1700000000000 }, + model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" }, + }, + { + id: "msg_model_long_context", + type: "model-switched", + time: { created: 1700000001000 }, + model: { providerID: "company-gateway", id: "long-context" }, + }, + userMessage(), + assistantMessage(), + ], + }) + + const shortNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName }) + const longNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName }) + await expect(shortNotice).toBeVisible() + await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible() + await expect(shortNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh") + await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0) + await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible() + await expect(longNotice).toBeVisible() + await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible() + await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0) + await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`) + await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true) + }) }) function patchFile(file: string, status: "added" | "modified" | "deleted") { diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts new file mode 100644 index 000000000000..387b713069e2 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from "@playwright/test" +import { + assistantID, + assistantMessage, + reasoningPart, + setupTimeline, + status, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +const profiles = [ + { name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false }, + { + name: "summaries off reasoning heading", + summaries: false, + reasoning: "## Inspecting stability", + other: false, + thinking: true, + body: false, + }, + { + name: "summaries off with visible tool", + summaries: false, + reasoning: "## Inspecting stability", + other: true, + thinking: true, + body: false, + }, + { name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false }, + { + name: "summaries on blank reasoning", + summaries: true, + reasoning: " ", + other: false, + thinking: true, + body: false, + }, + { + name: "summaries on visible reasoning", + summaries: true, + reasoning: "## Inspecting stability", + other: false, + thinking: false, + body: true, + }, + { + name: "summaries on visible tool no reasoning", + summaries: true, + reasoning: "", + other: true, + thinking: false, + body: false, + }, +] as const + +for (const profile of profiles) { + test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => { + const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}` + const parts = [ + ...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []), + ...(profile.other + ? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })] + : []), + ] + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage(parts, { completed: false })], + settings: { showReasoningSummaries: profile.summaries }, + }) + await timeline.send(status("busy"), 150) + + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) + await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0) + if (!profile.summaries && profile.reasoning.trim()) { + await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() + } + }) +} + +test("does not infer reasoning visibility from provider identity", async ({ page }) => { + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }), + ], + settings: { showReasoningSummaries: true }, + }) + await timeline.send(status("busy"), 150) + + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toBeVisible() +}) diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index 71d7f77ea2f5..d723c973c424 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -7,6 +7,41 @@ import { userMessage, } from "../performance/timeline-stability/fixture" +test("transitions shell and question through running error outcomes", async ({ page }) => { + const shellID = "prt_transition_error_shell" + const questionID = "prt_transition_error_question" + const timeline = await setupTimeline(page, { + settings: { shellToolPartsExpanded: true }, + messages: [ + userMessage(), + assistantMessage( + [ + toolPart(shellID, "shell", "streaming", { command: "exit 1" }), + toolPart(questionID, "question", "streaming", questionInput()), + ], + { completed: false }, + ), + ], + }) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) + await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120) + await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180) + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) + await timeline.send( + partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })), + 180, + ) + await timeline.send( + partUpdated( + toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }), + ), + 250, + ) + + await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i) +}) + test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => { const failed = "prt_grouped_patch_failed" const surviving = "prt_grouped_patch_surviving" @@ -123,3 +158,7 @@ test("groups only consecutive successful skill tools", async ({ page }) => { await expect(loaded.nth(0)).toHaveAttribute("aria-label", "Loaded ocpr, effect, ui-pr-screenshots skills") await expect(loaded.nth(1)).toHaveAttribute("aria-label", "Loaded opencode skill") }) + +function questionInput() { + return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } +} diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index b35d58bf674f..746b79f7deda 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -1,5 +1,64 @@ -import { expect, test } from "@playwright/test" -import { partUpdated, setupTimeline, textPart } from "../performance/timeline-stability/fixture" +import { expect, test, type Page } from "@playwright/test" +import { partUpdated, renderedPartID, setupTimeline, textPart } from "../performance/timeline-stability/fixture" + +test("keeps one connection open while delivering multiple events", async ({ page }) => { + const timeline = await setupTimeline(page) + + const first = (await timeline.transport.burst(partUpdated(textPart("prt_transport_first", "first event")))).at(-1)! + const second = (await timeline.transport.burst(partUpdated(textPart("prt_transport_second", "second event")))).at(-1)! + + await timeline.waitForPart("prt_transport_first") + await timeline.waitForPart("prt_transport_second") + expect(first.connectionID).toBe(second.connectionID) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) + expect(await timeline.transport.acknowledgements()).toHaveLength(4) +}) + +test("delivers a burst from one stream chunk", async ({ page }) => { + const timeline = await setupTimeline(page) + const acknowledgements = await timeline.transport.burst([ + ...partUpdated(textPart("prt_transport_burst_a", "burst a")), + ...partUpdated(textPart("prt_transport_burst_b", "burst b")), + ]) + + await timeline.waitForPart("prt_transport_burst_a") + await timeline.waitForPart("prt_transport_burst_b") + expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1, 1, 1]) + expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(4) +}) + +test("parses split JSON and a split multibyte code point", async ({ page }) => { + const timeline = await setupTimeline(page) + const [started, payload] = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603")) + await timeline.transport.send(started!) + const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`) + const snowman = new TextEncoder().encode("\u2603")[0]! + const multibyte = encoded.indexOf(snowman) + + const acknowledgement = await timeline.transport.split(payload!, [9, multibyte + 1, multibyte + 2]) + + await timeline.waitForPart("prt_transport_split") + await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_transport_split")}"]`)).toContainText( + "split snowman \u2603\u2603\u2603", + ) + expect(acknowledgement.chunkCount).toBe(4) +}) + +test("delivers server heartbeat without mutating the timeline", async ({ page }) => { + const timeline = await setupTimeline(page) + const partID = "prt_transport_heartbeat_sentinel" + const sentinel = (await timeline.transport.burst(partUpdated(textPart(partID, "heartbeat sentinel")))).at(-1)! + await timeline.waitForPart(partID) + await expect( + page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"] [data-component="markdown"]`), + ).toHaveAttribute("data-markdown-ready", "") + const before = await timelineRows(page) + const heartbeat = await timeline.transport.heartbeat() + + await expect.poll(() => timelineRows(page)).toEqual(before) + expect(heartbeat.connectionID).toBe(sentinel.connectionID) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) +}) test("reconnects after a clean close", async ({ page }) => { const timeline = await setupTimeline(page) @@ -45,3 +104,28 @@ test("does not request replay when reconnecting the volatile event stream", asyn expect(first.eventID).toBe("timeline-event-7") expect(connection.headers["last-event-id"]).toBeUndefined() }) + +test("passes through non-event fetches", async ({ page }) => { + const timeline = await setupTimeline(page) + + const health = await page.evaluate(async () => { + const response = await fetch("/api/health") + return response.json() + }) + + expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 }) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) +}) + +function timelineRows(page: Page) { + return page.locator("[data-timeline-row]").evaluateAll((rows) => + rows.map((row) => ({ + kind: row.getAttribute("data-timeline-row"), + message: row.getAttribute("data-message-id"), + parts: Array.from(row.querySelectorAll("[data-timeline-part-id]"), (part) => + part.getAttribute("data-timeline-part-id"), + ), + text: row.textContent, + })), + ) +} diff --git a/packages/session-ui/component-tests/session-lifecycle.spec.ts b/packages/session-ui/component-tests/session-lifecycle.spec.ts index 8ceddb5562e6..0c57f9064a51 100644 --- a/packages/session-ui/component-tests/session-lifecycle.spec.ts +++ b/packages/session-ui/component-tests/session-lifecycle.spec.ts @@ -11,6 +11,8 @@ for (const expanded of [false, true]) { await trigger.click() await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) await timeline.getByRole("button", { name: "Update output" }).click() + await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) + await timeline.getByRole("button", { name: "Append sibling" }).click() await expect(timeline.getByText("Sibling content", { exact: true })).toBeVisible() await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) await timeline.getByRole("button", { name: "Mark session busy" }).click() @@ -43,6 +45,10 @@ story("transitions a streaming shell from writing through command execution", as await expect(subtitle).toHaveCSS("font-weight", "440") await expect(subtitle).toHaveCSS("line-height", "16px") await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)") + await timeline.getByRole("button", { name: "Complete input" }).click() + await expect(shimmer).toHaveAttribute("data-active", "true") + await expect(subtitle).toHaveText("printf ready") + await expect(tool).not.toContainText("Writing command...") await timeline.getByRole("button", { name: "Run command" }).click() await expect(shimmer).toHaveAttribute("data-active", "true") await expect(subtitle).toHaveText("printf ready") diff --git a/packages/session-ui/component-tests/session-timeline.spec.ts b/packages/session-ui/component-tests/session-timeline.spec.ts index 8e91590c350e..65e287a838ba 100644 --- a/packages/session-ui/component-tests/session-timeline.spec.ts +++ b/packages/session-ui/component-tests/session-timeline.spec.ts @@ -21,7 +21,9 @@ story("preserves a collapsed context group through count and status updates", as // Moved from packages/app/e2e/regression/session-timeline-accessibility.spec.ts story("space activates a focused timeline button instead of scrolling", async ({ mount, page }) => { await page.emulateMedia({ reducedMotion: "reduce" }) + await page.setViewportSize({ width: 800, height: 240 }) const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { scenario: "collapsed" } }) + await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - innerHeight)).toBeGreaterThan(0) const trigger = timeline.getByRole("button", { name: "Used Shell", exact: true }) await expect(trigger).toHaveAttribute("aria-expanded", "false") await trigger.focus() @@ -33,21 +35,31 @@ story("space activates a focused timeline button instead of scrolling", async ({ // Moved from packages/app/e2e/regression/session-timeline-file-projection.spec.ts story("renders a completed write through the production file component", async ({ mount }) => { - const timeline = await mount("current-session-file-changes--created-a-new-file") - await expect(timeline.locator('[data-component="write-content"]')).toBeVisible() + const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "write" } }) + await expect( + timeline.locator('[data-timeline-part-id="prt_file_projection_write"] [data-component="write-content"]'), + ).toBeVisible() }) // Moved from packages/app/e2e/regression/session-timeline-file-state.spec.ts story("keeps patch file disclosures independent", async ({ mount }) => { - const timeline = await mount("current-session-file-changes--patched-two-files") - const files = timeline.locator('[data-scope="apply-patch"] button') - await expect(files).toHaveCount(2) - await expect(files.nth(0)).toHaveAttribute("aria-expanded", "false") - await expect(files.nth(1)).toHaveAttribute("aria-expanded", "false") - await files.nth(0).click() - await expect(files.nth(0)).toHaveAttribute("aria-expanded", "true") - await expect(files.nth(1)).toHaveAttribute("aria-expanded", "false") - await files.nth(1).click() - await expect(files.nth(0)).toHaveAttribute("aria-expanded", "true") - await expect(files.nth(1)).toHaveAttribute("aria-expanded", "true") + const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "patch" } }) + const wrapper = timeline.locator('[data-timeline-part-id="prt_nested_patch"]') + const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"] button') + const added = wrapper.locator('[data-scope="apply-patch"] [data-type="add"] button') + const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"] button') + await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3) + await deleted.click() + await expect(deleted).toHaveAttribute("aria-expanded", "true") + await expect(modified).toHaveAttribute("aria-expanded", "false") + await modified.click() + await expect(modified).toHaveAttribute("aria-expanded", "true") + await deleted.click() + await expect(deleted).toHaveAttribute("aria-expanded", "false") + await expect(modified).toHaveAttribute("aria-expanded", "true") + await expect(added).toHaveAttribute("aria-expanded", "false") + await added.click() + await expect(added).toHaveAttribute("aria-expanded", "true") + await expect(modified).toHaveAttribute("aria-expanded", "true") + await expect(deleted).toHaveAttribute("aria-expanded", "false") }) diff --git a/packages/session-ui/component-tests/session-tool-projection.spec.ts b/packages/session-ui/component-tests/session-tool-projection.spec.ts index ded1bd276510..3b5047ff7771 100644 --- a/packages/session-ui/component-tests/session-tool-projection.spec.ts +++ b/packages/session-ui/component-tests/session-tool-projection.spec.ts @@ -45,7 +45,9 @@ story("renders every tool error outcome without leaking hidden tools", async ({ await expect(group.locator('[data-component="tag"]')).toHaveText(String(names.length)) await group.getByRole("button").click() await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1) - await expect(timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')).toContainText(/dismissed/i) + const dismissed = timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]') + await expect(dismissed.getByText(/dismissed/i)).toBeVisible() + await expect(dismissed).toContainText(/dismissed/i) await expect(timeline.locator('[data-timeline-part-id="tool_error_todo"]')).toHaveCount(0) for (const name of names) await expect(timeline.locator(`[data-timeline-part-id="tool_error_${name}"]`)).toBeVisible() }) @@ -85,8 +87,8 @@ story("labels completed searches with result counts", async ({ mount }) => { // Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts story("labels read tools from their path input", async ({ mount }) => { - const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "results" } }) - const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]') + const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "read" } }) + const group = timeline.locator('[data-timeline-part-ids="prt_read_path"]') await group.locator('[data-slot="collapsible-trigger"]').click() await expect( group diff --git a/packages/session-ui/src/storybook/current-session-scenarios.ts b/packages/session-ui/src/storybook/current-session-scenarios.ts index 46aafaedaed9..71d6e615a52f 100644 --- a/packages/session-ui/src/storybook/current-session-scenarios.ts +++ b/packages/session-ui/src/storybook/current-session-scenarios.ts @@ -7,11 +7,11 @@ export function storyTool( name: string, status: "streaming" | "running" | "completed" | "error", input: Record, - options: { metadata?: Record; output?: string; error?: string } = {}, + options: { metadata?: Record; output?: string; error?: string; raw?: string } = {}, ): SessionMessageAssistantTool { const state = status === "streaming" - ? { status, input: JSON.stringify(input) } + ? { status, input: options.raw ?? JSON.stringify(input) } : status === "running" ? { status, input, metadata: { ...options.metadata, ...(options.output ? { output: options.output } : {}) } } : status === "error" diff --git a/packages/session-ui/src/timeline/file-changes.stories.tsx b/packages/session-ui/src/timeline/file-changes.stories.tsx index 02856393c540..7f3a8f1776df 100644 --- a/packages/session-ui/src/timeline/file-changes.stories.tsx +++ b/packages/session-ui/src/timeline/file-changes.stories.tsx @@ -1,3 +1,4 @@ +import { createTwoFilesPatch } from "diff" import { createMemo } from "solid-js" import { createStore } from "solid-js/store" import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story" @@ -107,15 +108,17 @@ function EditSiblingUpdateStory() { const [state, setState] = createStore({ sibling: false }) const document = createMemo(() => ({ ...editThenTestDocument, + status: { type: "busy" as const }, messages: editThenTestDocument.messages .filter((message) => message.id === "msg_user_edit" || message.id === "msg_assistant_edit") .map((message) => { - if (message.type !== "assistant" || !state.sibling) return message + if (message.type !== "assistant") return message return { ...message, + time: { created: message.time.created }, content: [ ...message.content, - { type: "text" as const, text: "Streaming added a later assistant text part." }, + ...(state.sibling ? [{ type: "text" as const, text: "Streaming added a later assistant text part." }] : []), ], } }), @@ -134,7 +137,67 @@ function EditSiblingUpdateStory() { const EditWithStreamedSibling = { render: () => } -const fileScenarios = { repeated: RepeatedEdits, streaming: EditWithStreamedSibling } +const ThreeFilePatch = { + render: () => { + const source = (changed: boolean) => + Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join("") + const files = [ + { file: "src/a.ts", status: "modified" }, + { file: "src/b.ts", status: "added" }, + { file: "src/old.ts", status: "deleted" }, + ].map(({ file, status }) => ({ + file, + status, + patch: createTwoFilesPatch( + `a/${file}`, + `b/${file}`, + status === "added" ? "" : source(false), + status === "deleted" ? "" : source(true), + ), + additions: status === "deleted" ? 0 : 4, + deletions: status === "added" ? 0 : 3, + })) + return ( + + ) + }, +} + +const WrittenSource = { + render: () => ( + + ), +} + +const fileScenarios = { + repeated: RepeatedEdits, + streaming: EditWithStreamedSibling, + patch: ThreeFilePatch, + write: WrittenSource, +} export const ChangingFiles = { args: { scenario: "streaming" }, diff --git a/packages/session-ui/src/timeline/research-and-agents.stories.tsx b/packages/session-ui/src/timeline/research-and-agents.stories.tsx index 165c4fe4ec60..603543d6b94b 100644 --- a/packages/session-ui/src/timeline/research-and-agents.stories.tsx +++ b/packages/session-ui/src/timeline/research-and-agents.stories.tsx @@ -177,6 +177,16 @@ const SearchResultsAndFiles = { ), } +const ReadOneFile = { + render: () => ( + + ), +} + const LoadingSpecializedSkills = { render: () => ( ), } @@ -365,6 +375,7 @@ const researchScenarios = { exploration: ExploreTheCodebase, providers: CompareSearchProviders, results: SearchResultsAndFiles, + read: ReadOneFile, skills: LoadingSpecializedSkills, steps: ResearchAcrossSteps, failures: RecoverFromToolFailures, diff --git a/packages/session-ui/src/timeline/terminal-work.stories.tsx b/packages/session-ui/src/timeline/terminal-work.stories.tsx index 244f827df27e..c29c6e47bb6d 100644 --- a/packages/session-ui/src/timeline/terminal-work.stories.tsx +++ b/packages/session-ui/src/timeline/terminal-work.stories.tsx @@ -128,32 +128,46 @@ export const TestFailed = { function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolean }) { const [state, setState] = createStore({ phase: props.streaming ? "streaming" : "completed", - revision: 0, + lines: 3, + sibling: false, busy: false, }) const document = createMemo(() => { - const phase = state.phase as "streaming" | "running" | "completed" + const phase = state.phase as "streaming" | "input" | "running" | "completed" const command = phase === "streaming" ? "" : "printf ready" const content: SessionMessageAssistant["content"] = [ - storyTool("tool_shell_lifecycle", "shell", phase, command ? { command } : {}, { - output: phase === "running" ? "still running" : `line ${state.revision + 1}`, + storyTool("tool_shell_lifecycle", "shell", phase === "input" ? "streaming" : phase, command ? { command } : {}, { + output: + phase === "running" + ? "still running" + : Array.from({ length: state.lines }, (_, index) => `line ${index + 1}`).join("\n"), + ...(phase === "streaming" ? { raw: "" } : {}), }), - ...(state.revision ? [{ type: "text" as const, text: "Sibling content" }] : []), + ...(state.sibling ? [{ type: "text" as const, text: "Sibling content" }] : []), ] - return storyDocument(content, phase !== "completed" || state.busy) + return { + ...storyDocument(content, phase !== "completed"), + status: { type: phase !== "completed" || state.busy ? ("busy" as const) : ("idle" as const) }, + } }) return (
+ - + diff --git a/packages/session-ui/src/timeline/timeline-row.stories.tsx b/packages/session-ui/src/timeline/timeline-row.stories.tsx index a8b7f0fb8c04..61f556538711 100644 --- a/packages/session-ui/src/timeline/timeline-row.stories.tsx +++ b/packages/session-ui/src/timeline/timeline-row.stories.tsx @@ -566,7 +566,7 @@ const RichUserAttachments = { data: "", mime: "text/plain", name: "a.ts", - source: { type: "path", path: "src/a.ts" }, + source: { type: "uri", uri: "src/a.ts" }, mention: { text: "@src/a.ts", start: 18, end: 27 }, }, ], diff --git a/packages/storybook/playwright/README.md b/packages/storybook/playwright/README.md index d16c97228cc4..b9578e336417 100644 --- a/packages/storybook/playwright/README.md +++ b/packages/storybook/playwright/README.md @@ -55,3 +55,7 @@ story("preserves collapsed state while a tool completes", async ({ mount }) => { The story ID is the Storybook component ID followed by `--` and the kebab-cased story export. Open the same story in Storybook to inspect exactly the scenario covered by the browser test. Preserve an original-source-path comment for every migrated E2E case. Keep cross-route navigation, remote-server ownership, persistent session state, full-app virtualization, and workflows spanning independent surfaces in `packages/app/e2e/`. + +Component rendering and integration coverage can be complementary. A local story control that installs a completed message does not test event delivery, production reducer cleanup, or a live stream. Keep those original checks in E2E, including stream/chunk identity, compaction and retry events, independent lifecycle transitions, and the real app scroll owner. A provenance comment records the source of a component assertion; it is not evidence that its integration counterpart can be deleted. + +When moving an assertion, preserve its discriminating fixture: file status kinds, empty/single-variant inputs, singleton groups, live message state, and the order of intermediate updates. Verify the actual scroll container overflows before asserting that keyboard activation does not scroll it. From 77fdb42d8c72e20fdef5a4b401812e1912c08557 Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:29:34 +0000 Subject: [PATCH 10/10] test(ci): select component suites by affected dependencies --- .github/workflows/test.yml | 66 +++++++- packages/app/playwright.components.config.ts | 2 +- packages/storybook/.storybook/main.ts | 4 + packages/storybook/playwright/README.md | 21 ++- packages/storybook/playwright/config.ts | 8 +- script/github/browser-suites.test.ts | 162 +++++++++++++++++++ script/github/browser-suites.ts | 91 +++++++++++ 7 files changed, 341 insertions(+), 13 deletions(-) create mode 100644 script/github/browser-suites.test.ts create mode 100644 script/github/browser-suites.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 696adb464575..7a6af02f3a88 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,6 +27,8 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 outputs: app: ${{ steps.packages.outputs.app }} + appComponents: ${{ steps.packages.outputs.appComponents }} + sessionComponents: ${{ steps.packages.outputs.sessionComponents }} steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -39,18 +41,16 @@ jobs: with: bun-version-file: package.json + - name: Test browser suite selection + working-directory: script/github + run: bun test --root . browser-suites.test.ts + - name: Find affected packages id: packages env: TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }} TURBO_SCM_HEAD: ${{ github.sha }} - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "app=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json - bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT" + run: bun script/github/browser-suites.ts unit: name: unit (${{ matrix.settings.name }}) @@ -176,6 +176,58 @@ jobs: working-directory: packages/www run: bun run check:generated + components: + name: components (${{ matrix.package }}) + needs: affected + strategy: + fail-fast: false + matrix: + include: + - package: app + enabled: ${{ needs.affected.outputs.appComponents }} + - package: session-ui + enabled: ${{ needs.affected.outputs.sessionComponents }} + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + # Separate runners own separate Storybook servers; the suites never race for a port. + - name: Checkout repository + if: matrix.enabled == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + if: matrix.enabled == 'true' + uses: ./.github/actions/setup-bun + + - name: Setup Node for Playwright + if: matrix.enabled == 'true' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24.15" + + - name: Install Chromium + if: matrix.enabled == 'true' + working-directory: packages/${{ matrix.package }} + run: bunx playwright install --with-deps chromium + + - name: Run component tests + if: matrix.enabled == 'true' + working-directory: packages/${{ matrix.package }} + run: bun run test:components + timeout-minutes: 15 + env: + CI: true + + - name: Upload component artifacts + if: always() && matrix.enabled == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: components-${{ matrix.package }}-${{ github.run_attempt }} + if-no-files-found: ignore + retention-days: 7 + path: | + packages/${{ matrix.package }}/component-tests/test-results + packages/${{ matrix.package }}/component-tests/playwright-report + e2e: name: e2e (${{ matrix.settings.name }}) needs: affected diff --git a/packages/app/playwright.components.config.ts b/packages/app/playwright.components.config.ts index 92269c90436c..2a2f2970dd96 100644 --- a/packages/app/playwright.components.config.ts +++ b/packages/app/playwright.components.config.ts @@ -1,4 +1,4 @@ import { fileURLToPath } from "node:url" import { componentConfig } from "../storybook/playwright/config" -export default componentConfig(fileURLToPath(new URL(".", import.meta.url))) +export default componentConfig(fileURLToPath(new URL(".", import.meta.url)), 6007) diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts index 5d5daa7a014d..19a7d888565a 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -31,6 +31,10 @@ export default defineMain({ async viteFinal(config) { const { mergeConfig, searchForWorkspaceRoot } = await import("vite") const merged = mergeConfig(config, { + // Concurrent package suites must not overwrite each other's optimized dependencies. + cacheDir: process.env.PLAYWRIGHT_STORYBOOK_PORT + ? path.resolve(here, "../node_modules/.cache/playwright", process.env.PLAYWRIGHT_STORYBOOK_PORT) + : config.cacheDir, plugins: [tailwindcss(), playgroundCss()], resolve: { dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"], diff --git a/packages/storybook/playwright/README.md b/packages/storybook/playwright/README.md index b9578e336417..c68de5bb26c4 100644 --- a/packages/storybook/playwright/README.md +++ b/packages/storybook/playwright/README.md @@ -30,7 +30,26 @@ bun turbo test:components --filter=@opencode-ai/session-ui bun turbo test:components --filter=@opencode-ai/app ``` -Component browser coverage deliberately remains separate from each package's default `test` script and from `packages/app`'s `test:e2e`, so expensive Storybook checks can be scheduled independently from required unit and full-app journey CI. Set `PLAYWRIGHT_STORYBOOK_URL` to reuse an existing Storybook instance or `PLAYWRIGHT_STORYBOOK_PORT` to choose its port. +Component browser coverage remains separate from each package's default `test` script and from `packages/app`'s `test:e2e`. Session UI uses port 6006 and app uses 6007, with a separate Vite dependency cache per port, so concurrent Turbo tasks do not compete for a server or overwrite each other's optimized modules. Set `PLAYWRIGHT_STORYBOOK_URL` to reuse an existing Storybook instance locally or `PLAYWRIGHT_STORYBOOK_PORT` to override a package's port; do not give concurrent server-owning tasks the same override. + +## CI selection + +The `test` workflow selects the suites independently using Turbo's affected workspace graph: + +- App-only implementation, story, or component-test changes run app components and full-app E2E, not session-ui components. +- Session-ui changes run session-ui components and dependent app suites. Shared UI, client, util, and other workspace dependencies flow through the same graph. +- Affected Storybook harness code, config, mocks, fixtures, or stories run both component suites, without turning on full-app E2E solely for a harness change. +- The shared preview also imports app CSS and localization outside the package graph. App CSS, public assets, localization, and the bounded persistence/platform/server-scope/path-key dependency chain select both component suites. `script/github/browser-suites.test.ts` checks the preview's transitive runtime imports so this exception cannot silently become stale. The check conservatively follows the real platform module even where Storybook mocks it. +- Lockfiles, package manifests, TypeScript/Turbo configuration, root tooling, unknown non-documentation paths, manual dispatch, and Git/Turbo failures select all browser suites. Documentation-only changes outside affected browser workspaces skip them. + +PR comparisons use the tested merge ref's first parent; pushes use the event's previous SHA. Both are normalized to a merge base, with deleted files and both sides of renames included. Each component suite runs on its own Linux runner. Existing Linux/Windows E2E check names and the `v2` ref exclusion are unchanged; selection does not override that exclusion, including on manual dispatch. + +Run the selection matrix (real Git refs and Turbo, no browser or workspace install required): + +```sh +cd script/github +bun test --root . browser-suites.test.ts +``` ## Adding a test diff --git a/packages/storybook/playwright/config.ts b/packages/storybook/playwright/config.ts index 718246e9bb0a..feacb9eabf7b 100644 --- a/packages/storybook/playwright/config.ts +++ b/packages/storybook/playwright/config.ts @@ -1,9 +1,8 @@ import { defineConfig, devices } from "@playwright/test" -const port = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT ?? 6006) -const baseURL = process.env.PLAYWRIGHT_STORYBOOK_URL ?? `http://127.0.0.1:${port}` - -export function componentConfig(directory: string) { +export function componentConfig(directory: string, defaultPort = 6006) { + const port = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT ?? defaultPort) + const baseURL = process.env.PLAYWRIGHT_STORYBOOK_URL ?? `http://127.0.0.1:${port}` return defineConfig({ testDir: `${directory}/component-tests`, outputDir: `${directory}/component-tests/test-results`, @@ -17,6 +16,7 @@ export function componentConfig(directory: string) { webServer: { command: `bun --bun run --cwd ${directory}/../storybook storybook -- --port ${port} --ci --no-open`, url: baseURL, + env: { PLAYWRIGHT_STORYBOOK_PORT: String(port) }, reuseExistingServer: !process.env.CI, timeout: 120_000, }, diff --git a/script/github/browser-suites.test.ts b/script/github/browser-suites.test.ts new file mode 100644 index 000000000000..e20b8100dd82 --- /dev/null +++ b/script/github/browser-suites.test.ts @@ -0,0 +1,162 @@ +import { afterAll, expect, test } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { browserSuites, sharedAppFile } from "./browser-suites" + +const root = path.resolve(import.meta.dir, "../..") +const cwd = mkdtempSync(path.join(tmpdir(), "browser-suites-")) +const all = { app: true, appComponents: true, sessionComponents: true } +const app = { app: true, appComponents: true, sessionComponents: false } +const components = { app: false, appComponents: true, sessionComponents: true } +const none = { app: false, appComponents: false, sessionComponents: false } + +function git(...args: string[]) { + const result = Bun.spawnSync(["git", ...args], { cwd }) + if (result.exitCode) throw new Error(result.stderr.toString()) + return result.stdout.toString().trim() +} + +git("init", "-q") +git("config", "user.name", "fixture") +git("config", "user.email", "fixture@example.com") +const files = Bun.spawnSync(["git", "ls-files", "-z"], { cwd: root }).stdout.toString().split("\0") +for (const file of files.filter( + (file) => file.endsWith("/package.json") || ["package.json", "bun.lock", "turbo.json"].includes(file), +)) { + await Bun.write(path.join(cwd, file), Bun.file(path.join(root, file))) +} +await Bun.write(path.join(cwd, "packages/app/src/runtime/i18n/deleted.ts"), "export const old = true") +await Bun.write(path.join(cwd, "packages/app/src/runtime/i18n/renamed.ts"), "export const renamed = true") +git("add", ".") +git("commit", "-qm", "base") +afterAll(() => rmSync(cwd, { recursive: true, force: true })) + +test.each([ + ["packages/app/component-tests/new.spec.ts", app], + ["packages/app/src/composer/new.tsx", app], + ["packages/app/src/shell/new.tsx", app], + ["packages/app/src/composer/new.stories.tsx", app], + ["packages/session-ui/src/timeline/new.tsx", all], + ["packages/session-ui/component-tests/new.spec.ts", all], + ["packages/ui/src/new.tsx", all], + ["packages/client/src/new.ts", all], + ["packages/util/src/new.ts", all], + ["packages/storybook/.storybook/main.ts", components], + ["packages/storybook/.storybook/preview.tsx", components], + ["packages/storybook/playwright/story.ts", components], + ["packages/storybook/playwright/config.ts", components], + ["packages/storybook/new.stories.tsx", components], + ["packages/app/src/index.css", all], + ["packages/app/public/assets/new.woff2", all], + ["packages/app/src/runtime/i18n/en.ts", all], + ["packages/app/src/runtime/i18n/fr.ts", all], + ["packages/app/src/runtime/persistence/storage.ts", all], + ["packages/app/src/runtime/server/scope.ts", all], + ["packages/app/src/workspaces/path-key.ts", all], + ["bun.lock", all], + ["package.json", all], + ["turbo.json", all], + ["packages/app/package.json", all], + [".github/actions/setup-bun/action.yml", all], + ["unknown-config", all], + ["packages/unknown/shared.ts", all], + ["packages/app/tsconfig.json", all], + ["patches/shared.patch", all], + ["README.md", none], + ["docs/guide.md", none], + ["packages/www/content/docs/guide.mdx", none], +] as const)( + "selects suites for %s with the real workspace graph", + async (file, expected) => { + const base = git("rev-parse", "HEAD") + const target = Bun.file(path.join(cwd, file)) + // Keep manifests valid; changing whitespace still participates in the Git diff. + await Bun.write(target, ((await target.exists()) ? await target.text() : "") + "\n") + git("add", ".") + git("commit", "-qm", file) + expect(browserSuites({ cwd, event: "pull_request", base, head: "HEAD" })).toEqual(expected) + }, + 30_000, +) + +test("deletions participate", () => { + const base = git("rev-parse", "HEAD") + git("rm", "packages/app/src/runtime/i18n/deleted.ts") + git("commit", "-qm", "remove shared input") + expect(browserSuites({ cwd, event: "push", base, head: "HEAD" })).toEqual(all) +}, 30_000) + +test("both sides of renames participate", () => { + const base = git("rev-parse", "HEAD") + git("mv", "packages/app/src/runtime/i18n/renamed.ts", "packages/app/src/renamed.ts") + git("commit", "-qm", "rename shared input") + expect(browserSuites({ cwd, event: "push", base, head: "HEAD" })).toEqual(all) +}, 30_000) + +test("manual dispatch and unavailable diffs fail safe", () => { + expect(browserSuites({ cwd, event: "workflow_dispatch" })).toEqual(all) + expect(browserSuites({ cwd, event: "push", base: "0".repeat(40), head: "HEAD" })).toEqual(all) + expect(browserSuites({ cwd, event: "pull_request", base: "missing", head: "HEAD" })).toEqual(all) + expect(browserSuites({ cwd, event: "push" })).toEqual(all) + expect(browserSuites({ cwd, base: "HEAD", head: "HEAD" })).toEqual(none) +}) + +test("uses the merge base, not the tip of a diverged comparison branch", async () => { + const base = git("rev-parse", "HEAD") + await Bun.write(path.join(cwd, "packages/app/src/branch.ts"), "// app branch") + git("add", ".") + git("commit", "-qm", "app branch") + const head = git("rev-parse", "HEAD") + git("switch", "--detach", base) + await Bun.write(path.join(cwd, "packages/session-ui/src/other.ts"), "// base branch only") + git("add", ".") + git("commit", "-qm", "diverged base") + const other = git("rev-parse", "HEAD") + git("switch", "--detach", head) + expect(browserSuites({ cwd, event: "pull_request", base: other, head })).toEqual(app) + expect(browserSuites({ cwd, event: "push", base, head })).toEqual(app) + const merge = git("commit-tree", `${head}^{tree}`, "-p", base, "-p", head, "-m", "PR merge ref") + expect(browserSuites({ cwd, event: "pull_request", base: `${merge}^1`, head: merge })).toEqual(app) +}, 30_000) + +test("Turbo failures run everything instead of silently skipping suites", async () => { + const file = Bun.file(path.join(cwd, "turbo.json")) + const original = await file.text() + try { + await Bun.write(file, "not json") + expect(browserSuites({ cwd, event: "push", base: "HEAD^", head: "HEAD" })).toEqual(all) + } finally { + await Bun.write(file, original) + } +}, 30_000) + +test("shared app exceptions cover the preview's transitive runtime imports", async () => { + const seen = new Set() + const pending = ["packages/storybook/.storybook/preview.tsx"] + while (pending.length) { + const file = pending.pop()! + if (seen.has(file)) continue + seen.add(file) + if (file.startsWith("packages/app/")) expect(sharedAppFile(file), file).toBe(true) + const source = await Bun.file(path.join(root, file)).text() + const imports = file.endsWith(".css") + ? Array.from(source.matchAll(/@import\s+["']([^"']+)["']/g), (match) => match[1]!) + : new Bun.Transpiler({ loader: "tsx" }).scanImports(source).map((item) => item.path) + for (const name of imports) { + if (name.startsWith("@opencode-ai/")) { + // These packages and their dependents are covered by the Turbo graph. + expect(["ui", "session-ui", "client", "util"]).toContain(name.split("/")[1]!) + continue + } + if (!name.startsWith(".") && !name.startsWith("@/")) continue + // Follow the real platform too: conservative, without duplicating Vite's mock aliases. + const target = name.startsWith("@/") + ? path.join(root, "packages/app/src", name.slice(2)) + : path.resolve(root, path.dirname(file), name) + pending.push(path.relative(root, Bun.resolveSync(target, root))) + } + } + expect(seen.has("packages/app/src/runtime/persistence/storage.ts")).toBe(true) + expect(seen.has("packages/app/src/workspaces/path-key.ts")).toBe(true) +}) diff --git a/script/github/browser-suites.ts b/script/github/browser-suites.ts new file mode 100644 index 000000000000..929331a2737a --- /dev/null +++ b/script/github/browser-suites.ts @@ -0,0 +1,91 @@ +import { appendFileSync } from "node:fs" + +const all = { app: true, appComponents: true, sessionComponents: true } + +// The preview imports app CSS and LanguageProvider outside the workspace graph. +// The import-closure test below guards this bounded exception against drift. +export function sharedAppFile(file: string) { + return ( + file.startsWith("packages/app/public/") || + (file.startsWith("packages/app/") && file.endsWith(".css")) || + file.startsWith("packages/app/src/runtime/i18n/") || + [ + "packages/app/package.json", + "packages/app/tsconfig.json", + "packages/app/src/runtime/persistence/storage.ts", + "packages/app/src/runtime/platform/platform.tsx", + "packages/app/src/runtime/server/registry.tsx", + "packages/app/src/runtime/server/scope.ts", + "packages/app/src/workspaces/path-key.ts", + ].includes(file) + ) +} + +export function browserSuites(input: { event?: string; base?: string; head?: string; cwd: string }) { + if (input.event === "workflow_dispatch") return all + try { + if (!input.base || !input.head) throw new Error("missing comparison refs") + const run = (cmd: string[], env = {}) => { + const result = Bun.spawnSync(cmd, { cwd: input.cwd, env: { ...process.env, ...env }, stderr: "pipe" }) + if (result.exitCode !== 0) throw new Error(`${cmd[0]} failed: ${result.stderr.toString()}`) + return result.stdout.toString() + } + const base = run(["git", "merge-base", input.base, input.head]).trim() + // Disable rename detection so both old and new paths participate, including deletions. + const files = run(["git", "diff", "--name-only", "--no-renames", "-z", base, input.head, "--"]) + .split("\0") + .filter(Boolean) + if (!files.length) return { app: false, appComponents: false, sessionComponents: false } + // Root config, lockfiles, CI tooling and unknown paths are global. Only known + // documentation outside workspaces can bypass Turbo's root-package invalidation. + const docs = (file: string) => /^(?:README(?:\.[^/]+)?\.md|AGENTS\.md|LICENSE|docs\/.*\.mdx?)$/.test(file) + if (files.every(docs)) return { app: false, appComponents: false, sessionComponents: false } + if (files.some((file) => !file.startsWith("packages/") && !docs(file))) return all + if (files.some((file) => /(?:^|\/)(?:bun\.lockb?|package\.json|turbo\.json|tsconfig[^/]*\.json)$/.test(file))) { + return all + } + const packages = run(["git", "ls-tree", "-r", "--name-only", "-z", input.head, "--", "packages"]) + .split("\0") + .filter((file) => file.endsWith("/package.json")) + .map((file) => file.slice(0, -"package.json".length)) + if (files.some((file) => !docs(file) && !packages.some((directory) => file.startsWith(directory)))) return all + const result = JSON.parse( + run(["bun", "x", "turbo@2.10.2", "ls", "--affected", "--output=json"], { + TURBO_SCM_BASE: base, + TURBO_SCM_HEAD: input.head, + }), + ) + if ( + !Array.isArray(result.packages?.items) || + result.packages.items.some((item: { name?: unknown }) => typeof item.name !== "string") + ) { + throw new Error("unexpected Turbo package output") + } + const names = new Set(result.packages.items.map((item: { name: string }) => item.name)) + const app = names.has("@opencode-ai/app") + const shared = names.has("@opencode-ai/storybook") || files.some(sharedAppFile) + return { + app, + appComponents: app || shared, + sessionComponents: names.has("@opencode-ai/session-ui") || shared, + } + } catch (error) { + console.warn("Unable to select browser suites; running all suites.", error) + return all + } +} + +if (import.meta.main) { + const result = browserSuites({ + event: process.env.GITHUB_EVENT_NAME, + base: process.env.TURBO_SCM_BASE, + head: process.env.TURBO_SCM_HEAD, + cwd: process.cwd(), + }) + const output = + Object.entries(result) + .map(([key, value]) => `${key}=${value}`) + .join("\n") + "\n" + console.log(output.trim()) + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, output) +}