Compare commits
1 Commits
dev
...
revert-173
| Author | SHA1 | Date |
|---|---|---|
|
|
e89849d632 |
|
|
@ -36,22 +36,6 @@ async function terminalID(term: Locator) {
|
||||||
throw new Error(`Active terminal missing ${terminalAttr}`)
|
throw new Error(`Active terminal missing ${terminalAttr}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
|
|
||||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
|
||||||
const id = await terminalID(term)
|
|
||||||
return page.evaluate((id) => {
|
|
||||||
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
|
|
||||||
}, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
|
|
||||||
const term = input?.term ?? page.locator(terminalSelector).first()
|
|
||||||
const id = await terminalID(term)
|
|
||||||
await page.evaluate((id) => {
|
|
||||||
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
|
|
||||||
}, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function terminalReady(page: Page, term?: Locator) {
|
async function terminalReady(page: Page, term?: Locator) {
|
||||||
const next = term ?? page.locator(terminalSelector).first()
|
const next = term ?? page.locator(terminalSelector).first()
|
||||||
const id = await terminalID(next)
|
const id = await terminalID(next)
|
||||||
|
|
@ -604,19 +588,12 @@ export async function seedSessionTask(
|
||||||
.flatMap((message) => message.parts)
|
.flatMap((message) => message.parts)
|
||||||
.find((part) => {
|
.find((part) => {
|
||||||
if (part.type !== "tool" || part.tool !== "task") return false
|
if (part.type !== "tool" || part.tool !== "task") return false
|
||||||
if (!("state" in part) || !part.state || typeof part.state !== "object") return false
|
if (part.state.input?.description !== input.description) return false
|
||||||
if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
|
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
||||||
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
|
|
||||||
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
|
|
||||||
return false
|
|
||||||
if (!("sessionId" in part.state.metadata)) return false
|
|
||||||
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
|
if (!part) return
|
||||||
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
const id = part.state.metadata?.sessionId
|
||||||
if (!("sessionId" in part.state.metadata)) return
|
|
||||||
const id = part.state.metadata.sessionId
|
|
||||||
if (typeof id !== "string" || !id) return
|
if (typeof id !== "string" || !id) return
|
||||||
const child = await sdk.session
|
const child = await sdk.session
|
||||||
.get({ sessionID: id })
|
.get({ sessionID: id })
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { disconnectTerminal, runTerminal, terminalConnects, waitTerminalReady } from "../actions"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { terminalSelector } from "../selectors"
|
|
||||||
import { terminalToggleKey } from "../utils"
|
|
||||||
|
|
||||||
async function open(page: Page) {
|
|
||||||
const term = page.locator(terminalSelector).first()
|
|
||||||
const visible = await term.isVisible().catch(() => false)
|
|
||||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
|
||||||
await waitTerminalReady(page, { term })
|
|
||||||
return term
|
|
||||||
}
|
|
||||||
|
|
||||||
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
|
|
||||||
await withProject(async ({ gotoSession }) => {
|
|
||||||
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
|
||||||
const token = `E2E_RECONNECT_${Date.now()}`
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const term = await open(page)
|
|
||||||
const id = await term.getAttribute("data-pty-id")
|
|
||||||
if (!id) throw new Error("Active terminal missing data-pty-id")
|
|
||||||
|
|
||||||
const prev = await terminalConnects(page, { term })
|
|
||||||
|
|
||||||
await runTerminal(page, {
|
|
||||||
term,
|
|
||||||
cmd: `export ${name}=${token}; echo ${token}`,
|
|
||||||
token,
|
|
||||||
})
|
|
||||||
|
|
||||||
await disconnectTerminal(page, { term })
|
|
||||||
|
|
||||||
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
|
|
||||||
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
|
|
||||||
|
|
||||||
await runTerminal(page, {
|
|
||||||
term,
|
|
||||||
cmd: `echo $${name}`,
|
|
||||||
token,
|
|
||||||
timeout: 15_000,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"rootDir": "..",
|
|
||||||
"types": ["node", "bun"]
|
"types": ["node", "bun"]
|
||||||
},
|
},
|
||||||
"include": ["./**/*.ts", "../src/testing/terminal.ts"]
|
"include": ["./**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,16 +65,6 @@ const debugTerminal = (...values: unknown[]) => {
|
||||||
console.debug("[terminal]", ...values)
|
console.debug("[terminal]", ...values)
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorStatus = (err: unknown) => {
|
|
||||||
if (!err || typeof err !== "object") return
|
|
||||||
if (!("data" in err)) return
|
|
||||||
const data = err.data
|
|
||||||
if (!data || typeof data !== "object") return
|
|
||||||
if (!("statusCode" in data)) return
|
|
||||||
const status = data.statusCode
|
|
||||||
return typeof status === "number" ? status : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const useTerminalUiBindings = (input: {
|
const useTerminalUiBindings = (input: {
|
||||||
container: HTMLDivElement
|
container: HTMLDivElement
|
||||||
term: Term
|
term: Term
|
||||||
|
|
@ -199,11 +189,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
const start =
|
const start =
|
||||||
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
|
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
|
||||||
let cursor = start ?? 0
|
let cursor = start ?? 0
|
||||||
let seek = start !== undefined ? start : restore ? -1 : 0
|
|
||||||
let output: ReturnType<typeof terminalWriter> | undefined
|
let output: ReturnType<typeof terminalWriter> | undefined
|
||||||
let drop: VoidFunction | undefined
|
|
||||||
let reconn: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let tries = 0
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (!cleanups.length) return
|
if (!cleanups.length) return
|
||||||
|
|
@ -467,135 +453,85 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const once = { value: false }
|
const once = { value: false }
|
||||||
const decoder = new TextDecoder()
|
let closing = false
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
const url = new URL(sdk.url + `/pty/${id}/connect`)
|
||||||
|
url.searchParams.set("directory", sdk.directory)
|
||||||
|
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
|
||||||
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||||
|
url.username = server.current?.http.username ?? "opencode"
|
||||||
|
url.password = server.current?.http.password ?? ""
|
||||||
|
|
||||||
|
const socket = new WebSocket(url)
|
||||||
|
socket.binaryType = "arraybuffer"
|
||||||
|
ws = socket
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
probe.connect()
|
||||||
|
local.onConnect?.()
|
||||||
|
scheduleSize(t.cols, t.rows)
|
||||||
|
}
|
||||||
|
socket.addEventListener("open", handleOpen)
|
||||||
|
if (socket.readyState === WebSocket.OPEN) handleOpen()
|
||||||
|
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
if (closing) return
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
const bytes = new Uint8Array(event.data)
|
||||||
|
if (bytes[0] !== 0) return
|
||||||
|
const json = decoder.decode(bytes.subarray(1))
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(json) as { cursor?: unknown }
|
||||||
|
const next = meta?.cursor
|
||||||
|
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
|
||||||
|
cursor = next
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debugTerminal("invalid websocket control frame", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = typeof event.data === "string" ? event.data : ""
|
||||||
|
if (!data) return
|
||||||
|
output?.push(data)
|
||||||
|
cursor += data.length
|
||||||
|
}
|
||||||
|
socket.addEventListener("message", handleMessage)
|
||||||
|
|
||||||
|
const handleError = (error: Event) => {
|
||||||
|
if (disposed) return
|
||||||
|
if (closing) return
|
||||||
if (once.value) return
|
if (once.value) return
|
||||||
once.value = true
|
once.value = true
|
||||||
local.onConnectError?.(err)
|
console.error("WebSocket error:", error)
|
||||||
|
local.onConnectError?.(error)
|
||||||
}
|
}
|
||||||
|
socket.addEventListener("error", handleError)
|
||||||
|
|
||||||
const gone = () =>
|
const handleClose = (event: CloseEvent) => {
|
||||||
sdk.client.pty
|
|
||||||
.get({ ptyID: id })
|
|
||||||
.then(() => false)
|
|
||||||
.catch((err) => {
|
|
||||||
if (errorStatus(err) === 404) return true
|
|
||||||
debugTerminal("failed to inspect terminal session", err)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
const retry = (err: unknown) => {
|
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (reconn !== undefined) return
|
if (closing) return
|
||||||
|
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
|
||||||
const ms = Math.min(250 * 2 ** Math.min(tries, 4), 4_000)
|
// For other codes (network issues, server restart), trigger error handler
|
||||||
reconn = setTimeout(async () => {
|
if (event.code !== 1000) {
|
||||||
reconn = undefined
|
if (once.value) return
|
||||||
if (disposed) return
|
once.value = true
|
||||||
if (await gone()) {
|
local.onConnectError?.(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
|
||||||
if (disposed) return
|
}
|
||||||
fail(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (disposed) return
|
|
||||||
tries += 1
|
|
||||||
open()
|
|
||||||
}, ms)
|
|
||||||
}
|
}
|
||||||
|
socket.addEventListener("close", handleClose)
|
||||||
|
|
||||||
const open = () => {
|
cleanups.push(() => {
|
||||||
if (disposed) return
|
closing = true
|
||||||
drop?.()
|
socket.removeEventListener("open", handleOpen)
|
||||||
|
socket.removeEventListener("message", handleMessage)
|
||||||
const url = new URL(sdk.url + `/pty/${id}/connect`)
|
socket.removeEventListener("error", handleError)
|
||||||
url.searchParams.set("directory", sdk.directory)
|
socket.removeEventListener("close", handleClose)
|
||||||
url.searchParams.set("cursor", String(seek))
|
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
|
||||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
|
||||||
url.username = server.current?.http.username ?? "opencode"
|
|
||||||
url.password = server.current?.http.password ?? ""
|
|
||||||
|
|
||||||
const socket = new WebSocket(url)
|
|
||||||
socket.binaryType = "arraybuffer"
|
|
||||||
ws = socket
|
|
||||||
|
|
||||||
const handleOpen = () => {
|
|
||||||
if (disposed) return
|
|
||||||
tries = 0
|
|
||||||
probe.connect()
|
|
||||||
local.onConnect?.()
|
|
||||||
scheduleSize(t.cols, t.rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
|
||||||
if (disposed) return
|
|
||||||
if (event.data instanceof ArrayBuffer) {
|
|
||||||
const bytes = new Uint8Array(event.data)
|
|
||||||
if (bytes[0] !== 0) return
|
|
||||||
const json = decoder.decode(bytes.subarray(1))
|
|
||||||
try {
|
|
||||||
const meta = JSON.parse(json) as { cursor?: unknown }
|
|
||||||
const next = meta?.cursor
|
|
||||||
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
|
|
||||||
cursor = next
|
|
||||||
seek = next
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
debugTerminal("invalid websocket control frame", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = typeof event.data === "string" ? event.data : ""
|
|
||||||
if (!data) return
|
|
||||||
output?.push(data)
|
|
||||||
cursor += data.length
|
|
||||||
seek = cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleError = (error: Event) => {
|
|
||||||
if (disposed) return
|
|
||||||
debugTerminal("websocket error", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
socket.removeEventListener("open", handleOpen)
|
|
||||||
socket.removeEventListener("message", handleMessage)
|
|
||||||
socket.removeEventListener("error", handleError)
|
|
||||||
socket.removeEventListener("close", handleClose)
|
|
||||||
if (ws === socket) ws = undefined
|
|
||||||
if (drop === stop) drop = undefined
|
|
||||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = (event: CloseEvent) => {
|
|
||||||
if (ws === socket) ws = undefined
|
|
||||||
if (drop === stop) drop = undefined
|
|
||||||
socket.removeEventListener("open", handleOpen)
|
|
||||||
socket.removeEventListener("message", handleMessage)
|
|
||||||
socket.removeEventListener("error", handleError)
|
|
||||||
socket.removeEventListener("close", handleClose)
|
|
||||||
if (disposed) return
|
|
||||||
if (event.code === 1000) return
|
|
||||||
retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
|
|
||||||
}
|
|
||||||
|
|
||||||
drop = stop
|
|
||||||
socket.addEventListener("open", handleOpen)
|
|
||||||
socket.addEventListener("message", handleMessage)
|
|
||||||
socket.addEventListener("error", handleError)
|
|
||||||
socket.addEventListener("close", handleClose)
|
|
||||||
}
|
|
||||||
|
|
||||||
probe.control({
|
|
||||||
disconnect: () => {
|
|
||||||
if (!ws) return
|
|
||||||
ws.close(4_000, "e2e")
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
open()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void run().catch((err) => {
|
void run().catch((err) => {
|
||||||
|
|
@ -613,8 +549,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
disposed = true
|
disposed = true
|
||||||
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
||||||
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||||
if (reconn !== undefined) clearTimeout(reconn)
|
|
||||||
drop?.()
|
|
||||||
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
|
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
|
||||||
|
|
||||||
const finalize = () => {
|
const finalize = () => {
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,21 @@ export const terminalAttr = "data-pty-id"
|
||||||
|
|
||||||
export type TerminalProbeState = {
|
export type TerminalProbeState = {
|
||||||
connected: boolean
|
connected: boolean
|
||||||
connects: number
|
|
||||||
rendered: string
|
rendered: string
|
||||||
settled: number
|
settled: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type TerminalProbeControl = {
|
|
||||||
disconnect?: VoidFunction
|
|
||||||
}
|
|
||||||
|
|
||||||
export type E2EWindow = Window & {
|
export type E2EWindow = Window & {
|
||||||
__opencode_e2e?: {
|
__opencode_e2e?: {
|
||||||
terminal?: {
|
terminal?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
terminals?: Record<string, TerminalProbeState>
|
terminals?: Record<string, TerminalProbeState>
|
||||||
controls?: Record<string, TerminalProbeControl>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = (): TerminalProbeState => ({
|
const seed = (): TerminalProbeState => ({
|
||||||
connected: false,
|
connected: false,
|
||||||
connects: 0,
|
|
||||||
rendered: "",
|
rendered: "",
|
||||||
settled: 0,
|
settled: 0,
|
||||||
})
|
})
|
||||||
|
|
@ -32,28 +25,15 @@ const root = () => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
const state = (window as E2EWindow).__opencode_e2e?.terminal
|
const state = (window as E2EWindow).__opencode_e2e?.terminal
|
||||||
if (!state?.enabled) return
|
if (!state?.enabled) return
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
const terms = () => {
|
|
||||||
const state = root()
|
|
||||||
if (!state) return
|
|
||||||
state.terminals ??= {}
|
state.terminals ??= {}
|
||||||
return state.terminals
|
return state.terminals
|
||||||
}
|
}
|
||||||
|
|
||||||
const controls = () => {
|
|
||||||
const state = root()
|
|
||||||
if (!state) return
|
|
||||||
state.controls ??= {}
|
|
||||||
return state.controls
|
|
||||||
}
|
|
||||||
|
|
||||||
export const terminalProbe = (id: string) => {
|
export const terminalProbe = (id: string) => {
|
||||||
const set = (next: Partial<TerminalProbeState>) => {
|
const set = (next: Partial<TerminalProbeState>) => {
|
||||||
const state = terms()
|
const terms = root()
|
||||||
if (!state) return
|
if (!terms) return
|
||||||
state[id] = { ...(state[id] ?? seed()), ...next }
|
terms[id] = { ...(terms[id] ?? seed()), ...next }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -61,37 +41,24 @@ export const terminalProbe = (id: string) => {
|
||||||
set(seed())
|
set(seed())
|
||||||
},
|
},
|
||||||
connect() {
|
connect() {
|
||||||
const state = terms()
|
set({ connected: true })
|
||||||
if (!state) return
|
|
||||||
const prev = state[id] ?? seed()
|
|
||||||
state[id] = {
|
|
||||||
...prev,
|
|
||||||
connected: true,
|
|
||||||
connects: prev.connects + 1,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
render(data: string) {
|
render(data: string) {
|
||||||
const state = terms()
|
const terms = root()
|
||||||
if (!state) return
|
if (!terms) return
|
||||||
const prev = state[id] ?? seed()
|
const prev = terms[id] ?? seed()
|
||||||
state[id] = { ...prev, rendered: prev.rendered + data }
|
terms[id] = { ...prev, rendered: prev.rendered + data }
|
||||||
},
|
},
|
||||||
settle() {
|
settle() {
|
||||||
const state = terms()
|
const terms = root()
|
||||||
if (!state) return
|
if (!terms) return
|
||||||
const prev = state[id] ?? seed()
|
const prev = terms[id] ?? seed()
|
||||||
state[id] = { ...prev, settled: prev.settled + 1 }
|
terms[id] = { ...prev, settled: prev.settled + 1 }
|
||||||
},
|
|
||||||
control(next: Partial<TerminalProbeControl>) {
|
|
||||||
const state = controls()
|
|
||||||
if (!state) return
|
|
||||||
state[id] = { ...(state[id] ?? {}), ...next }
|
|
||||||
},
|
},
|
||||||
drop() {
|
drop() {
|
||||||
const state = terms()
|
const terms = root()
|
||||||
if (state) delete state[id]
|
if (!terms) return
|
||||||
const control = controls()
|
delete terms[id]
|
||||||
if (control) delete control[id]
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue