Skip to content

Commit 96adac1

Browse files
authored
Merge pull request #31 from spencerkit/develop
Merge develop into main
2 parents d517cfe + 3139ef4 commit 96adac1

49 files changed

Lines changed: 4353 additions & 77 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/swift-hounds-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@spencer-kit/coder-studio": patch
3+
---
4+
5+
Fix terminal websocket recovery so buffered PTY output is replayed after silent disconnects, including probe-based recovery and keepalive handling.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ data-wl
5656
# E2E test runner scripts (ad-hoc)
5757
e2e/playwright.session.config.ts
5858
e2e/run-session-tests.*
59+
e2e-ui/output/
60+
e2e-ui/test-results/
5961
.playwright-cli/
6062
/superpowers/
6163
/.superpowers/
527 KB
Loading

e2e-ui/fixtures/capture.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Locator, Page } from "@playwright/test";
2+
3+
export async function disableAnimations(page: Page) {
4+
await page.emulateMedia({ reducedMotion: "reduce" });
5+
await page.addStyleTag({
6+
content: `
7+
*,
8+
*::before,
9+
*::after {
10+
animation: none !important;
11+
transition: none !important;
12+
caret-color: transparent !important;
13+
}
14+
`,
15+
});
16+
}
17+
18+
export async function waitForFonts(page: Page) {
19+
await page.evaluate(async () => {
20+
if (!document.fonts?.ready) {
21+
return;
22+
}
23+
await document.fonts.ready;
24+
});
25+
}
26+
27+
export async function waitForStableScene(page: Page) {
28+
await disableAnimations(page);
29+
await waitForFonts(page);
30+
await page.waitForTimeout(80);
31+
}
32+
33+
export async function resolveCaptureTarget(page: Page, selector?: string): Promise<Locator | Page> {
34+
if (!selector) {
35+
return page;
36+
}
37+
38+
const target = page.locator(selector).first();
39+
await target.waitFor({ state: "visible" });
40+
return target;
41+
}

e2e-ui/fixtures/prefs.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { Page } from "@playwright/test";
2+
3+
export interface OpenPreviewSceneArgs {
4+
sceneId: string;
5+
device: "desktop" | "mobile";
6+
theme: "dark" | "light";
7+
locale: "zh" | "en";
8+
}
9+
10+
async function seedPreviewPreferences(
11+
page: Page,
12+
args: Pick<OpenPreviewSceneArgs, "theme" | "locale">
13+
) {
14+
await page.goto("/ui-preview.html", {
15+
waitUntil: "domcontentloaded",
16+
});
17+
18+
await page.evaluate(({ theme, locale }) => {
19+
window.localStorage.setItem("ui.theme", JSON.stringify(theme));
20+
window.localStorage.setItem("ui.locale", JSON.stringify(locale));
21+
}, args);
22+
}
23+
24+
export async function openPreviewScene(page: Page, args: OpenPreviewSceneArgs) {
25+
await seedPreviewPreferences(page, args);
26+
27+
const params = new URLSearchParams({
28+
scene: args.sceneId,
29+
device: args.device,
30+
theme: args.theme,
31+
locale: args.locale,
32+
});
33+
34+
await page.goto(`/ui-preview.html?${params.toString()}`, {
35+
waitUntil: "networkidle",
36+
});
37+
}

e2e-ui/fixtures/scene-runner.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
import type { Page } from "@playwright/test";
4+
import type { UiCaptureScene, UiCaptureVariant } from "../scenes";
5+
import { resolveCaptureTarget, waitForStableScene } from "./capture";
6+
import { openPreviewScene } from "./prefs";
7+
8+
const OUTPUT_ROOT = path.resolve(process.cwd(), "output");
9+
const SCREENSHOT_ROOT = path.join(OUTPUT_ROOT, "screenshots");
10+
11+
export interface CaptureSceneVariantArgs {
12+
scene: UiCaptureScene;
13+
variant: UiCaptureVariant;
14+
}
15+
16+
export function buildScreenshotPath(scene: UiCaptureScene, variant: UiCaptureVariant) {
17+
return path.join(
18+
SCREENSHOT_ROOT,
19+
scene.category,
20+
scene.id,
21+
`${variant.device}__${variant.theme}__${variant.locale}.png`
22+
);
23+
}
24+
25+
export async function ensureParentDir(filePath: string) {
26+
await fs.mkdir(path.dirname(filePath), { recursive: true });
27+
}
28+
29+
async function openSettingsSection(
30+
page: Page,
31+
section: NonNullable<UiCaptureScene["settingsSection"]>,
32+
device: UiCaptureVariant["device"]
33+
) {
34+
const sectionOrder = {
35+
general: 0,
36+
providers: 1,
37+
appearance: 2,
38+
shortcuts: 3,
39+
} as const;
40+
41+
const index = sectionOrder[section];
42+
43+
if (device === "mobile") {
44+
await page.locator(".settings-mobile-item").nth(index).click();
45+
await page.locator(".settings-content--mobile, .settings-content").first().waitFor();
46+
return;
47+
}
48+
49+
if (section !== "general") {
50+
await page.locator(".settings-nav-item").nth(index).click();
51+
}
52+
}
53+
54+
export async function captureSceneVariant(page: Page, args: CaptureSceneVariantArgs) {
55+
await openPreviewScene(page, {
56+
sceneId: args.scene.id,
57+
device: args.variant.device,
58+
theme: args.variant.theme,
59+
locale: args.variant.locale,
60+
});
61+
62+
await waitForStableScene(page);
63+
64+
if (args.scene.settingsSection) {
65+
await openSettingsSection(page, args.scene.settingsSection, args.variant.device);
66+
await waitForStableScene(page);
67+
}
68+
69+
const filePath = buildScreenshotPath(args.scene, args.variant);
70+
await ensureParentDir(filePath);
71+
72+
if (args.scene.fullPage) {
73+
await page.screenshot({
74+
path: filePath,
75+
animations: "disabled",
76+
fullPage: true,
77+
scale: "device",
78+
});
79+
return;
80+
}
81+
82+
const target = await resolveCaptureTarget(page, args.scene.targetSelector);
83+
await target.screenshot({
84+
path: filePath,
85+
animations: "disabled",
86+
scale: "device",
87+
});
88+
}

e2e-ui/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@coder-studio/e2e-ui",
3+
"private": true,
4+
"type": "module",
5+
"devDependencies": {
6+
"@playwright/test": "^1.59.1",
7+
"tsx": "^4.21.0",
8+
"typescript": "^6.0.3",
9+
"vitest": "^4.1.5"
10+
}
11+
}

e2e-ui/playwright.config.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
const HOST = "127.0.0.1";
4+
const PORT = 4174;
5+
const BASE_URL = `http://${HOST}:${PORT}`;
6+
const iPhone14 = devices["iPhone 14"];
7+
8+
export default defineConfig({
9+
testDir: "./specs",
10+
fullyParallel: false,
11+
workers: 1,
12+
retries: 0,
13+
timeout: 60000,
14+
reporter: [["list"]],
15+
outputDir: "./test-results",
16+
use: {
17+
baseURL: BASE_URL,
18+
trace: "off",
19+
screenshot: "off",
20+
video: "off",
21+
},
22+
projects: [
23+
{
24+
name: "desktop",
25+
use: {
26+
viewport: { width: 1440, height: 960 },
27+
},
28+
},
29+
{
30+
name: "mobile",
31+
use: {
32+
...iPhone14,
33+
browserName: "chromium",
34+
},
35+
},
36+
],
37+
webServer: {
38+
command: `pnpm --dir ../packages/web exec vite --host ${HOST} --port ${PORT}`,
39+
cwd: ".",
40+
url: `${BASE_URL}/ui-preview.html`,
41+
reuseExistingServer: false,
42+
timeout: 30000,
43+
env: {
44+
...process.env,
45+
NODE_ENV: "development",
46+
},
47+
},
48+
});

e2e-ui/report/build-report.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildManifestEntry, renderReportHtml } from "./build-report";
3+
4+
describe("build-report", () => {
5+
it("builds a manifest entry from a screenshot path", () => {
6+
const entry = buildManifestEntry({
7+
scene: {
8+
id: "welcome",
9+
title: "Welcome",
10+
category: "page",
11+
source: "real-route",
12+
description: "Welcome page",
13+
},
14+
screenshotPath: "screenshots/page/welcome/desktop__dark__zh.png",
15+
variant: {
16+
device: "desktop",
17+
theme: "dark",
18+
locale: "zh",
19+
},
20+
});
21+
22+
expect(entry).toMatchObject({
23+
id: "welcome",
24+
category: "page",
25+
path: "screenshots/page/welcome/desktop__dark__zh.png",
26+
device: "desktop",
27+
theme: "dark",
28+
locale: "zh",
29+
});
30+
});
31+
32+
it("renders a report html shell with filters and grouped scenes", () => {
33+
const html = renderReportHtml([
34+
{
35+
id: "welcome",
36+
title: "Welcome",
37+
category: "page",
38+
source: "real-route",
39+
device: "desktop",
40+
theme: "dark",
41+
locale: "zh",
42+
path: "screenshots/page/welcome/desktop__dark__zh.png",
43+
description: "Welcome page",
44+
},
45+
]);
46+
47+
expect(html).toContain("UI Preview Report");
48+
expect(html).toContain("screenshots/page/welcome/desktop__dark__zh.png");
49+
expect(html).toContain('data-category="page"');
50+
});
51+
});

0 commit comments

Comments
 (0)