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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 11 additions & 14 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

125 changes: 125 additions & 0 deletions packages/app/e2e/regression/server-pairing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { expect, test } from "@playwright/test"
import { renderSVG } from "uqr"
import { mockOpenCodeServer } from "../utils/mock-server"

test.use({ serviceWorkers: "block" })

test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory: "/pairing-demo",
project: { id: "proj_pairing_demo", canonical: "/pairing-demo", time: { created: 1, updated: 1 } },
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
await settings.getByRole("button", { name: "Add server", exact: true }).click()
await expect(page.getByRole("dialog", { name: "Add server" }).getByLabel("Server address")).toBeEditable()
})

test("imports a pairing image and authenticates only after confirmation", async ({ page }) => {
const info = {
urls: ["http://192.168.1.20:49374", "http://100.100.10.20:49374"],
username: "opencode",
password: "pairing-test-password",
}
const requests: string[] = []
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url())
if (!info.urls.includes(url.origin)) return route.fallback()
if (route.request().method() === "OPTIONS")
return route.fulfill({
status: 204,
headers: {
"access-control-allow-origin": "*",
"access-control-allow-headers": "authorization",
"access-control-allow-methods": "GET",
},
})
requests.push(url.origin)
const authorized = route.request().headers().authorization === `Basic ${btoa(`${info.username}:${info.password}`)}`
return route.fulfill({
status: authorized ? 200 : 401,
headers: { "access-control-allow-origin": "*" },
json: url.pathname === "/api/health" ? { healthy: true, version: "2.0.0" } : {},
})
})
const dialog = page.getByRole("dialog", { name: "Add server" })
await expect(dialog.getByText(/Run opencode2 pair on the server/)).toBeVisible()
await dialog.getByLabel("Choose QR image").setInputFiles({
name: "pairing.svg",
mimeType: "image/svg+xml",
buffer: Buffer.from(renderSVG(JSON.stringify(info), { border: 2 })),
})
await expect(dialog.getByLabel("Server address")).toHaveValue(info.urls[1])
await expect(dialog.getByLabel("Username (optional)")).toHaveValue(info.username)
await expect(dialog.getByLabel("Password (optional)")).toHaveValue(info.password)
await expect(dialog.getByRole("status")).toHaveText(
"Connection details filled in. Review the server address, then select Add server.",
)
const addresses = dialog.getByRole("group", { name: "Addresses from the pairing code" })
await addresses.getByRole("button").click()
await page.getByRole("option").filter({ hasText: info.urls[0] }).click()
await expect(dialog.getByLabel("Server address")).toHaveValue(info.urls[0])
await expect(page.getByRole("listbox")).toBeHidden()
await addresses.getByRole("button").click()
await page.getByRole("option").filter({ hasText: info.urls[1] }).click()
await expect(dialog.getByLabel("Server address")).toHaveValue(info.urls[1])
expect(requests).toEqual([])

const health = page.waitForResponse(`${info.urls[1]}/api/health`)
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
expect((await health).status()).toBe(200)
await expect(dialog).toBeHidden()
await expect(page.getByTestId("settings-screen").getByText("100.100.10.20:49374", { exact: true })).toBeVisible()
})

test("rejects an unrelated QR image without changing the form", async ({ page }) => {
const dialog = page.getByRole("dialog", { name: "Add server" })
await dialog.getByLabel("Choose QR image").setInputFiles({
name: "unrelated.svg",
mimeType: "image/svg+xml",
buffer: Buffer.from(renderSVG("https://example.test", { border: 2 })),
})
await expect(dialog.getByRole("alert")).toHaveText(
"This is not an OpenCode pairing code. Run opencode2 pair on the server to get one.",
)
await expect(dialog.getByLabel("Server address")).toBeEmpty()
await expect(dialog.getByLabel("Password (optional)")).toBeEmpty()
})

test("keeps camera controls accessible on a short screen and releases the camera", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 600 })
await page.evaluate(() => {
// Supply a real browser MediaStream without requiring camera hardware or OS permission dialogs.
navigator.mediaDevices.getUserMedia = async () => {
const canvas = document.createElement("canvas")
canvas.width = 640
canvas.height = 480
const context = canvas.getContext("2d")
if (!context) throw new Error("Missing canvas context")
context.fillStyle = "white"
context.fillRect(0, 0, canvas.width, canvas.height)
return canvas.captureStream(10)
}
})
const dialog = page.getByRole("dialog", { name: "Add server" })
await dialog.getByRole("button", { name: "Scan QR code" }).click()
const video = dialog.getByLabel("QR code camera preview")
await expect
.poll(() => video.evaluate((element) => element instanceof HTMLVideoElement && element.videoWidth > 0))
.toBe(true)
const track = await video.evaluateHandle((element) => {
if (!(element instanceof HTMLVideoElement) || !(element.srcObject instanceof MediaStream))
throw new Error("Expected a camera stream")
return element.srcObject.getVideoTracks()[0]
})
await expect(dialog.getByRole("heading", { name: "Add server" })).toBeInViewport()
await expect(dialog.getByRole("button", { name: "Add server", exact: true })).toBeInViewport()
await dialog.getByRole("button", { name: "Stop camera" }).click()
await expect(video).toHaveCount(0)
await expect.poll(() => track.evaluate((value) => value.readyState)).toBe("ended")
})
3 changes: 3 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"diff": "catalog:",
"happy-dom": "20.11.1",
"tw-animate-css": "1.4.0",
"uqr": "0.1.3",
"vite": "8.2.2",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-solid": "2.11.14"
Expand Down Expand Up @@ -84,7 +85,9 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
"ipaddr.js": "2.5.0",
"luxon": "catalog:",
"qr-scanner": "1.4.2",
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,24 @@ export const dict = {
"dialog.server.add.password": "Password (optional)",
"dialog.server.add.passwordPlaceholder": "password",
"dialog.server.edit.title": "Edit server",
"dialog.server.pair.description":
"Run {{command}} on the server, then scan its QR code or choose an image to fill in the connection details.",
"dialog.server.pair.scan": "Scan QR code",
"dialog.server.pair.stop": "Stop camera",
"dialog.server.pair.image": "Choose QR image",
"dialog.server.pair.reading": "Reading image...",
"dialog.server.pair.camera": "QR code camera preview",
"dialog.server.pair.cameraError":
"Camera unavailable. Check camera permissions and use HTTPS or localhost, or choose a QR image.",
"dialog.server.pair.imageError":
"Could not read a QR code from this image. Try another image or enter the details manually.",
"dialog.server.pair.invalid": "This is not an OpenCode pairing code. Run opencode2 pair on the server to get one.",
"dialog.server.pair.addresses": "Addresses from the pairing code",
"dialog.server.pair.review": "Connection details filled in. Review the server address, then select Add server.",
"dialog.server.pair.loopback":
"This address only works on the server's device. To connect from another device, configure the server to listen on a LAN or tailnet address.",
"dialog.server.pair.http":
"Your browser may block this HTTP address from an HTTPS page. Use a trusted HTTPS server address if the connection fails.",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
"Connect to this server on app launch instead of starting a local server. Requires restart.",
Expand Down
Loading
Loading