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/bun.lock b/bun.lock index 44d1ed98ab9e..2ed9629f8e33 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/.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/composer.spec.ts b/packages/app/component-tests/composer.spec.ts new file mode 100644 index 000000000000..3334e46f685f --- /dev/null +++ b/packages/app/component-tests/composer.spec.ts @@ -0,0 +1,26 @@ +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 }) => { + 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/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-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index f4bc001fc95a..21f06eac2bf7 100644 --- a/packages/app/e2e/regression/session-queue.spec.ts +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -201,12 +201,15 @@ 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") await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([ diff --git a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts index 05c89cd43530..3888c4a6e174 100644 --- a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts +++ b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts @@ -1,16 +1,43 @@ import { expect, test } from "@playwright/test" -import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture" +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))])], + 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") 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 dede6b12a2c6..bd14afe5d1b6 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-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index b83009c2e0b2..71ef8a03a7ef 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -122,6 +122,7 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({ 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) @@ -129,6 +130,7 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({ 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 }) => { diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index 45f1ca218be7..d189662ffdd0 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -122,6 +122,7 @@ test("updates running compactions to failed and cancelled boundaries", async ({ 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, @@ -140,6 +141,9 @@ test("updates running compactions to failed and cancelled boundaries", async ({ 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, @@ -152,88 +156,7 @@ test("updates running compactions to failed and cancelled boundaries", async ({ 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("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() + await expect(cancelled).not.toContainText("Summary before cancellation.") }) test("moves blocking work to the background with Ctrl+B", async ({ page }) => { @@ -271,11 +194,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 0b26de7f1281..ba0b730f310f 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -7,86 +7,9 @@ import { toolPart, userMessage, userText, - type PartSeed, } 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)] }) - - const first = page.locator( - '[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list,prt_webfetch,prt_websearch,prt_task,prt_bash,prt_edit,prt_write,prt_patch"]', - ) - const second = page.locator('[data-timeline-part-ids="prt_skill,prt_custom"]') - await expect(first).toBeVisible() - await expect(second).toBeVisible() - await first.getByRole("button").click() - await second.getByRole("button").click() - 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).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" @@ -158,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( [ @@ -236,25 +122,6 @@ test.describe("session timeline projection", () => { 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" @@ -291,77 +158,8 @@ test.describe("session timeline projection", () => { 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/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts index 5603c2ef797d..96391515bb27 100644 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts @@ -7,33 +7,25 @@ import { renderedPartID, setupTimeline, shell, + toolPart, status, textPart, - toolPart, userMessage, } from "../performance/timeline-stability/fixture" -test("groups every collapsed tool until visible text separates the stack", 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)] }) +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 }) + const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] }) + await timeline.send(status("busy"), 100) + await timeline.send(status("idle"), 100) + await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120) + await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250) - await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible() - const group = page.locator( - '[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep,prt_boundary_05_shell,prt_boundary_06_list"]', + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText( + "Final after early idle", ) - await expect(group).toBeVisible() - await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List") - await expect(group.locator('[data-component="tag"]')).toHaveText("4") - await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3) - await expect(page.locator('[data-timeline-spacing="content"]')).toHaveCount(2) - await expect(page.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px") }) test("expands a mixed collapsed tool stack without expanding its individual calls", async ({ page }) => { @@ -132,18 +124,3 @@ test("keeps failed search calls and their error cards inside the collapsed stack "Search timed out after 30 seconds", ) }) - -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 }) - const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] }) - await timeline.send(status("busy"), 100) - await timeline.send(status("idle"), 100) - await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120) - await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250) - - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText( - "Final after early idle", - ) -}) 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 98377fffce43..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,31 +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)] }) - - const group = page.locator(`[data-timeline-part-ids="${ordinary.map((_, index) => `prt_error_${index}`).join(",")}"]`) - await expect(group.locator('[data-component="tag"]')).toHaveText(String(ordinary.length)) - await group.getByRole("button").click() - 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" @@ -138,62 +113,6 @@ 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 page.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click() - - const tools = page.locator('[data-component="context-tool-group-list"]') - await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() - await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() - await expect(tools.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("groups instruction files loaded by the same read", async ({ page }) => { const id = "prt_read_instructions" await setupTimeline(page, { @@ -221,36 +140,6 @@ test("groups instruction files loaded by the same read", async ({ page }) => { await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveCount(0) }) -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" } }), - ]), - ], - }) - - const group = page.locator(`[data-timeline-part-ids="${pending},${completed}"]`) - await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill") - await expect(group.locator('[data-component="tag"]')).toHaveText("2") - await group.getByRole("button").click() - - const loaded = group.locator('[data-component="tool-loaded-item"]') - await expect(loaded).toHaveCount(1) - await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills") - 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("skills") - const names = loaded.locator('[data-component="text-shimmer"]') - await expect(names).toHaveCount(2) - await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design") - await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode") -}) - test("groups only consecutive successful skill tools", async ({ page }) => { const parts = [ toolPart("prt_skill_first", "skill", "completed", { id: "ocpr" }), @@ -273,14 +162,3 @@ test("groups only consecutive successful skill tools", async ({ page }) => { 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/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/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..2a2f2970dd96 --- /dev/null +++ b/packages/app/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)), 6007) 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/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/.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/session-ui/component-tests/session-lifecycle.spec.ts b/packages/session-ui/component-tests/session-lifecycle.spec.ts new file mode 100644 index 000000000000..0c57f9064a51 --- /dev/null +++ b/packages/session-ui/component-tests/session-lifecycle.spec.ts @@ -0,0 +1,127 @@ +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--terminal-commands", { args: { expanded } }) + const trigger = expanded + ? timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]') + : timeline.getByRole("button", { name: "Used Shell", exact: true }) + 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(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() + await timeline.getByRole("button", { name: "Mark session idle" }).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-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"]') + 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: "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") + await expect(tool).not.toContainText("Writing command...") + await timeline.getByRole("button", { name: "Complete command" }).click() + const summary = timeline.getByRole("button", { name: "Used Shell", exact: true }) + await expect(summary).toHaveAttribute("aria-expanded", "false") + await summary.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-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"]') + 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") +}) + +// 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--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() + 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-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() + 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 locale of ["de", "ar"] as const) { + // Moved from packages/app/e2e/regression/session-timeline-locale-projection.spec.ts + story(`projects localized tool names with an English fallback in ${locale}`, async ({ mount, page }) => { + const timeline = await mount("current-session-research-agents--agent-research", { + args: { scenario: "exploration" }, + globals: { 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"]') + 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/session-ui/component-tests/session-message-projection.spec.ts b/packages/session-ui/component-tests/session-message-projection.spec.ts new file mode 100644 index 000000000000..a5956f8fdf83 --- /dev/null +++ b/packages/session-ui/component-tests/session-message-projection.spec.ts @@ -0,0 +1,61 @@ +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--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") + 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--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() + 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--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 }) + 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--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() + await expect(timeline.getByText("@explore", { 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 new file mode 100644 index 000000000000..480de59cf043 --- /dev/null +++ b/packages/session-ui/component-tests/session-notice-projection.spec.ts @@ -0,0 +1,72 @@ +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 }) => { + 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-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") + 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-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() + 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-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"]') + 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-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"]') + 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-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-review-comments.spec.ts b/packages/session-ui/component-tests/session-review-comments.spec.ts new file mode 100644 index 000000000000..0924f1bc958c --- /dev/null +++ b/packages/session-ui/component-tests/session-review-comments.spec.ts @@ -0,0 +1,50 @@ +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 }) => { + 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 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") +}) + +// 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(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") +}) + +// 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.hover() + 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/session-ui/component-tests/session-timeline-notices.spec.ts b/packages/session-ui/component-tests/session-timeline-notices.spec.ts new file mode 100644 index 000000000000..3c18e403c6d1 --- /dev/null +++ b/packages/session-ui/component-tests/session-timeline-notices.spec.ts @@ -0,0 +1,42 @@ +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 }) => { + 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--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"]') + 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/session-ui/component-tests/session-timeline-reasoning.spec.ts b/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts new file mode 100644 index 000000000000..0a375cba2d01 --- /dev/null +++ b/packages/session-ui/component-tests/session-timeline-reasoning.spec.ts @@ -0,0 +1,74 @@ +import { expect, story } from "../../storybook/playwright/story" + +const profiles = [ + { name: "summaries off no reasoning", summaries: false, reasoning: "none", tool: false, thinking: true, body: false }, + { + name: "summaries off reasoning heading", + summaries: false, + reasoning: "heading", + tool: false, + thinking: true, + body: false, + heading: true, + }, + { + name: "summaries off with visible tool", + summaries: false, + reasoning: "heading", + tool: true, + thinking: true, + body: false, + heading: true, + }, + { 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", + summaries: true, + reasoning: "heading", + tool: false, + thinking: false, + body: true, + }, + { + name: "summaries on visible tool no reasoning", + summaries: true, + reasoning: "none", + tool: true, + thinking: false, + body: false, + }, +] 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-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( + profile.body ? 1 : 0, + ) + if ("heading" in profile) { + await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible() + } + }) +} + +// 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--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) + 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 new file mode 100644 index 000000000000..65e287a838ba --- /dev/null +++ b/packages/session-ui/component-tests/session-timeline.spec.ts @@ -0,0 +1,65 @@ +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") + await expect(timeline.locator('[data-component="session-timeline"]')).toBeVisible() + 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-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") + 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") +}) + +// 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() + 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) +}) + +// 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--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--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 new file mode 100644 index 000000000000..3b5047ff7771 --- /dev/null +++ b/packages/session-ui/component-tests/session-tool-projection.spec.ts @@ -0,0 +1,142 @@ +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--agent-research", { args: { scenario: "workflow" } }) + const first = timeline.locator( + '[data-timeline-part-ids="tool_family_read,tool_family_glob,tool_family_grep,tool_family_list,tool_family_webfetch,tool_family_websearch,tool_family_subagent,tool_family_shell,tool_family_edit,tool_family_write,tool_family_patch"]', + ) + const second = timeline.locator('[data-timeline-part-ids="tool_family_skill,tool_family_custom"]') + await expect(first).toBeVisible() + await expect(second).toBeVisible() + await first.getByRole("button").click() + await second.getByRole("button").click() + 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).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-research-agents--agent-research", { args: { scenario: "failures" } }) + const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"] + const group = timeline.locator(`[data-timeline-part-ids="${names.map((name) => `tool_error_${name}`).join(",")}"]`) + 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) + 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() +}) + +// 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--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() + 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-research-agents--agent-research", { args: { scenario: "providers" } }) + await timeline.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click() + const tools = timeline.locator('[data-component="context-tool-group-list"]') + await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() + await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() + await expect(tools.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-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"]') + 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-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 + .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-research-agents--agent-research", { args: { scenario: "skills" } }) + const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name"]') + await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill") + await expect(group.locator('[data-component="tag"]')).toHaveText("2") + await group.getByRole("button").click() + const loaded = group.locator('[data-component="tool-loaded-item"]') + await expect(loaded).toHaveCount(1) + await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills") + 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("skills") + const names = loaded.locator('[data-component="text-shimmer"]') + await expect(names).toHaveCount(2) + await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design") + await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode") +}) + +// Moved from packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +story("groups every collapsed tool until visible text separates the stack", async ({ mount }) => { + 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() + const group = timeline.locator( + '[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep,tool_boundary_shell,tool_boundary_list"]', + ) + await expect(group).toBeVisible() + await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List") + await expect(group.locator('[data-component="tag"]')).toHaveText("4") + await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3) + await expect(timeline.locator('[data-timeline-spacing="content"]')).toHaveCount(2) + await expect(timeline.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px") +}) + +// 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--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() + 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") +}) 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/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/storybook/current-session-scenarios.ts b/packages/session-ui/src/storybook/current-session-scenarios.ts new file mode 100644 index 000000000000..71d6e615a52f --- /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; raw?: string } = {}, +): SessionMessageAssistantTool { + const state = + status === "streaming" + ? { status, input: options.raw ?? 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/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..7f3a8f1776df 100644 --- a/packages/session-ui/src/timeline/file-changes.stories.tsx +++ b/packages/session-ui/src/timeline/file-changes.stories.tsx @@ -1,4 +1,7 @@ -import { CurrentSessionTimelineStory } from "../storybook/current-session-story" +import { createTwoFilesPatch } from "diff" +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story" import { editThenTestDocument, fileChangeLoadingDocument, @@ -6,6 +9,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 { @@ -70,6 +74,137 @@ export const PatchedTwoFiles = { ), } +const RepeatedEdits = { + render: () => ( + + ), +} + +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") return message + return { + ...message, + time: { created: message.time.created }, + content: [ + ...message.content, + ...(state.sibling ? [{ type: "text" as const, text: "Streaming added a later assistant text part." }] : []), + ], + } + }), + })) + return ( + + setState("sibling", true)}> + Stream sibling content + + + + + + ) +} + +const EditWithStreamedSibling = { render: () => } + +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" }, + argTypes: { scenario: { control: "select", options: Object.keys(fileScenarios) } }, + render: (args: { scenario: string }) => fileScenarios[args.scenario as keyof typeof fileScenarios].render(), +} + export const CreatedANewFile = { render: () => ( ), } + +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 ( + + + setState("read", true)}> + Complete read + + setState("glob", true)}> + Complete glob + + + + + + + ) +} + +const ExploreTheCodebase = { render: () => } + +const CompareSearchProviders = { + render: () => ( + + ), +} + +const SearchResultsAndFiles = { + render: () => ( + + ), +} + +const ReadOneFile = { + render: () => ( + + ), +} + +const LoadingSpecializedSkills = { + render: () => ( + + ), +} + +const ResearchAcrossSteps = { + render: () => ( + + ), +} + +const questions = { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } + +const CompleteAgentWorkflow = { + render: () => ( + + ), +} + +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 ( + + setState("failed", true)}> + Fail running tools + + + + + + ) +} + +const FailedCommandAndQuestion = { render: () => } + +const DelegatingAnAgent = { + render: () => ( + + ), +} + +const StartingBackgroundWork = { + render: () => ( + + ), +} + +const researchScenarios = { + workflow: CompleteAgentWorkflow, + exploration: ExploreTheCodebase, + providers: CompareSearchProviders, + results: SearchResultsAndFiles, + read: ReadOneFile, + 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 2d0f8b879f11..c29c6e47bb6d 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 { @@ -62,6 +66,17 @@ export const UserCommandCompleted = { ), } +const CollapsedShell = { + render: () => ( + + ), +} + export const TestsPassed = { render: () => ( { + 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 === "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.sibling ? [{ type: "text" as const, text: "Sibling content" }] : []), + ] + return { + ...storyDocument(content, phase !== "completed"), + status: { type: phase !== "completed" || state.busy ? ("busy" as const) : ("idle" as const) }, + } + }) + return ( + + + setState("phase", "input")}> + Complete input + + setState("phase", "running")}> + Run command + + setState("phase", "completed")}> + Complete command + + setState("lines", 6)}> + Update output + + setState("sibling", true)}> + Append sibling + + setState("busy", true)}> + Mark session busy + + setState("busy", false)}> + Mark session idle + + + + + + + ) +} + +const RunACommand = { + args: { expanded: false, streaming: false }, + render: (args: { expanded: boolean; streaming: boolean }) => , +} + +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: () => ( + + + + + ) +} + +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 ( + + + setState("phase", "running")}> + Start shell + + setState("phase", "idle")}> + Finish session + + + + + + + ) +} + +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 ( + + + setState("phase", "retry")}> + Retry request + + setState("phase", "thinking")}> + Recover request + + setState("phase", "idle")}> + Finish response + + + + + + + ) +} + +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 ( + + + setState("summary", "## Checkpoint\n\nStreamed implementation details.")}> + Stream summary + + setState({ phase: "completed", summary: "## Checkpoint\n\nFinal implementation details." })} + > + Complete summary + + setState("phase", "failed")}> + Fail compaction + + setState("second", true)}> + Cancel next compaction + + + + + + + ) +} + +const CompactSession = { render: () => } + export const CompactionInProgress = { render: () => ( ( + + ), +} + +const InterruptedTurn = { + render: () => ( + + ), +} + +const AliasedModelNotices = { + render: () => ( + + ), +} + +const RichUserAttachments = { + render: () => ( + + ), +} + +const conversationScenarios = { + reasoning: AgentReasoning, + hidden: WorkingWithoutReasoningDetails, + retry: RetryAndRecover, + notices: AgentActivityNotices, + compaction: CompactSession, + location: MovedLocation, + interruption: InterruptedTurn, + models: AliasedModelNotices, + attachments: RichUserAttachments, +} + +export const Conversation = { + args: { scenario: "notices", summaries: true, reasoning: "heading", tool: false, text: "" }, + argTypes: { + scenario: { control: "select", options: Object.keys(conversationScenarios) }, + reasoning: { control: "select", options: ["none", "blank", "heading"] }, + }, + render: (args: { scenario: string; summaries: boolean; reasoning: string; tool: boolean; text: string }) => { + if (args.scenario === "reasoning") return + return conversationScenarios[args.scenario as Exclude].render() + }, +} + export const InstructionsUpdatedSingle = { render: () => ( { - + @@ -109,6 +109,11 @@ export default definePreview({ description: "Interface direction", defaultValue: "ltr", }, + locale: { + name: "Locale", + description: "Interface language", + defaultValue: "en", + }, }, parameters: { actions: { 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..c68de5bb26c4 --- /dev/null +++ b/packages/storybook/playwright/README.md @@ -0,0 +1,80 @@ +# 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 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 + +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-research-agents--agent-research", { + args: { scenario: "exploration" }, + }) + 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/`. + +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. diff --git a/packages/storybook/playwright/config.ts b/packages/storybook/playwright/config.ts new file mode 100644 index 000000000000..feacb9eabf7b --- /dev/null +++ b/packages/storybook/playwright/config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from "@playwright/test" + +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`, + 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, + env: { PLAYWRIGHT_STORYBOOK_PORT: String(port) }, + 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/storybook/playwright/story.ts b/packages/storybook/playwright/story.ts new file mode 100644 index 000000000000..f11b25fa4edc --- /dev/null +++ b/packages/storybook/playwright/story.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test" +import type { Locator } from "@playwright/test" + +export { expect } + +export const story = test.extend<{ + mount: ( + id: string, + options?: { args?: Record; globals?: Record }, + ) => Promise +}>({ + mount: async ({ page }, use) => { + 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 + }) + }, +}) 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/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) +} 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"] },