|
| 1 | +import type { Locator, Page, WebSocketRoute } from "@playwright/test" |
| 2 | +import { expect, sourceURL, story } from "../../stories/playwright/story" |
| 3 | + |
| 4 | +const fixture = sourceURL(new URL("../../gui-plugins/src/terminal/terminal.fixture.tsx", import.meta.url)) |
| 5 | + |
| 6 | +// No page loads anything from this origin; the routes below answer its terminal API and socket. |
| 7 | +const SERVER = "http://127.0.0.1:4599" |
| 8 | + |
| 9 | +const SIZE = { cols: 80, rows: 24 } |
| 10 | + |
| 11 | +type Frame = { type: number; data: string } |
| 12 | + |
| 13 | +/** |
| 14 | + * A server with one terminal that already wrote `output`: it answers the snapshot and connect ticket, then speaks the |
| 15 | + * persistent PTY socket protocol at `inputProtocol`. A missing `output` is a terminal the server no longer has. |
| 16 | + */ |
| 17 | +async function serveTerminal(page: Page, input: { ptyID: string; output?: string; inputProtocol?: 0 | 1 }) { |
| 18 | + const frames: Frame[] = [] |
| 19 | + const sockets: WebSocketRoute[] = [] |
| 20 | + const tail = Buffer.byteLength(input.output ?? "") |
| 21 | + |
| 22 | + const info = { |
| 23 | + id: input.ptyID, |
| 24 | + title: "Terminal 1", |
| 25 | + userTitle: null, |
| 26 | + command: "bash", |
| 27 | + args: [], |
| 28 | + cwd: "C:/OpenCode/Terminal", |
| 29 | + status: "running", |
| 30 | + pid: 1, |
| 31 | + sessionID: "ses_terminal", |
| 32 | + foregroundProcess: null, |
| 33 | + size: SIZE, |
| 34 | + output: { head: 0, tail }, |
| 35 | + } |
| 36 | + |
| 37 | + await page.route(`${SERVER}/api/experimental/persistent-pty/**`, (route) => { |
| 38 | + const headers = { "access-control-allow-origin": "*", "access-control-allow-headers": "*" } |
| 39 | + |
| 40 | + if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers }) |
| 41 | + |
| 42 | + if (input.output === undefined) { |
| 43 | + return route.fulfill({ |
| 44 | + status: 404, |
| 45 | + headers, |
| 46 | + // SAFETY: the server's wire body for a terminal it does not have. |
| 47 | + // oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above |
| 48 | + json: { _tag: "PtyNotFoundError", ptyID: input.ptyID, message: `PTY session not found: ${input.ptyID}` }, |
| 49 | + }) |
| 50 | + } |
| 51 | + |
| 52 | + if (route.request().url().endsWith("/snapshot")) { |
| 53 | + const checkpoint = Buffer.from(input.output).toString("base64") |
| 54 | + |
| 55 | + return route.fulfill({ |
| 56 | + headers, |
| 57 | + json: { data: { info, text: input.output, checkpoint, cursor: { x: 0, y: 0 } } }, |
| 58 | + }) |
| 59 | + } |
| 60 | + |
| 61 | + return route.fulfill({ headers, json: { data: { ticket: "component-ticket", expires_in: 60 } } }) |
| 62 | + }) |
| 63 | + |
| 64 | + // The socket mock patches the page's WebSocket when it loads, so this runs before the story mounts. |
| 65 | + await page.routeWebSocket(new RegExp(`/api/experimental/persistent-pty/${input.ptyID}/connect`), (ws) => { |
| 66 | + const url = new URL(ws.url()) |
| 67 | + const attachmentID = url.searchParams.get("attachment_id") ?? "" |
| 68 | + sockets.push(ws) |
| 69 | + ws.onMessage((message) => { |
| 70 | + const data = Buffer.from(message) |
| 71 | + |
| 72 | + frames.push({ type: data[0] ?? -1, data: data.subarray(5).toString() }) |
| 73 | + }) |
| 74 | + ws.send( |
| 75 | + JSON.stringify({ |
| 76 | + type: "attached", |
| 77 | + attachmentID, |
| 78 | + inputProtocol: input.inputProtocol ?? 1, |
| 79 | + info, |
| 80 | + role: "controller", |
| 81 | + generation: 1, |
| 82 | + replay: { requestedOffset: tail, availableOffset: 0, endOffset: tail, truncated: false }, |
| 83 | + }), |
| 84 | + ) |
| 85 | + ws.send(JSON.stringify({ type: "replay_complete", endOffset: tail })) |
| 86 | + }) |
| 87 | + |
| 88 | + return { frames, sockets } |
| 89 | +} |
| 90 | + |
| 91 | +async function mountTerminal(page: Page, mount: (id: string) => Promise<Locator>, ptyID: string) { |
| 92 | + // Any story loads the app styles; the fixture mounts the pane beside it. |
| 93 | + await mount("ui-line-comment--editor") |
| 94 | + await page.evaluate( |
| 95 | + async (input) => { |
| 96 | + const { mountTerminal } = await import(input.fixture) |
| 97 | + mountTerminal({ url: input.server, ptyID: input.ptyID }) |
| 98 | + }, |
| 99 | + { fixture, server: SERVER, ptyID }, |
| 100 | + ) |
| 101 | + |
| 102 | + return page.getByTestId("terminal-fixture") |
| 103 | +} |
| 104 | + |
| 105 | +story("does not send the emulator's own answers to terminal queries as input", async ({ page, mount }) => { |
| 106 | + // Device status and attribute queries, in the restored screen and in live output. The daemon answers them. |
| 107 | + const server = await serveTerminal(page, { ptyID: "pty_queries", output: "restored\x1b[6n\x1b[c\r\n" }) |
| 108 | + const root = await mountTerminal(page, mount, "pty_queries") |
| 109 | + |
| 110 | + await expect.poll(() => server.sockets.length).toBe(1) |
| 111 | + await expect.poll(() => server.frames.some((frame) => frame.type === 0)).toBe(true) |
| 112 | + server.sockets[0]!.send(Buffer.from("live\x1b[6n\x1b[5n\x1b[c\r\n")) |
| 113 | + |
| 114 | + // Typed input lands after the output was written, so any answer would already have been sent. |
| 115 | + await expect(root.locator("textarea")).toBeFocused() |
| 116 | + await page.keyboard.type("x") |
| 117 | + await expect.poll(() => server.frames.filter((frame) => frame.type === 1).length).toBeGreaterThan(0) |
| 118 | + expect(server.frames.filter((frame) => frame.type === 1).map((frame) => frame.data)).toEqual(["x"]) |
| 119 | +}) |
| 120 | + |
| 121 | +story("tells the user a server that predates the input protocol is too old", async ({ page, mount }) => { |
| 122 | + await serveTerminal(page, { ptyID: "pty_old", output: "old server\r\n", inputProtocol: 0 }) |
| 123 | + const root = await mountTerminal(page, mount, "pty_old") |
| 124 | + |
| 125 | + await expect( |
| 126 | + root.getByText("This server is too old for terminals; update OpenCode on it", { exact: true }), |
| 127 | + ).toBeVisible() |
| 128 | + await expect(root.locator("[data-failures]")).toHaveAttribute("data-failures", "1") |
| 129 | +}) |
| 130 | + |
| 131 | +story("says the connection failed when the terminal cannot be restored", async ({ page, mount }) => { |
| 132 | + const server = await serveTerminal(page, { ptyID: "pty_missing" }) |
| 133 | + const root = await mountTerminal(page, mount, "pty_missing") |
| 134 | + |
| 135 | + await expect(root.getByText("Terminal connection failed", { exact: true })).toBeVisible() |
| 136 | + await expect(root.locator("[data-failures]")).toHaveAttribute("data-failures", "1") |
| 137 | + expect(server.sockets).toEqual([]) |
| 138 | +}) |
0 commit comments