Skip to content

Commit d4c5f7b

Browse files
committed
Stabilize release and Windows transport tests
1 parent 5485691 commit d4c5f7b

3 files changed

Lines changed: 112 additions & 27 deletions

File tree

scripts/test/start-dev-stack.mjs

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,55 @@
1-
import { spawn } from 'node:child_process';
1+
import { spawn, spawnSync } from 'node:child_process';
22
import { fileURLToPath } from 'node:url';
33
import path from 'node:path';
44

55
const ROOT = fileURLToPath(new URL('../..', import.meta.url));
66
const PNPM_CMD = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
7-
const server = spawn(PNPM_CMD, ['dev:server'], {
8-
cwd: ROOT,
9-
stdio: 'inherit',
10-
windowsHide: true
11-
});
127
let frontend = null;
138

149
let shuttingDown = false;
1510

11+
function quoteForCmd(value) {
12+
if (value.length === 0) {
13+
return '""';
14+
}
15+
if (!/[\s"&^|<>()]/.test(value)) {
16+
return value;
17+
}
18+
return `"${value.replace(/"/g, '""')}"`;
19+
}
20+
21+
function resolveSpawn(command, args) {
22+
if (process.platform === 'win32' && command.toLowerCase().endsWith('.cmd')) {
23+
return {
24+
command: process.env.ComSpec || 'cmd.exe',
25+
args: ['/d', '/s', '/c', [command, ...args].map(quoteForCmd).join(' ')]
26+
};
27+
}
28+
29+
return { command, args };
30+
}
31+
32+
function spawnPnpm(args) {
33+
const resolved = resolveSpawn(PNPM_CMD, args);
34+
return spawn(resolved.command, resolved.args, {
35+
cwd: ROOT,
36+
stdio: 'inherit',
37+
windowsHide: true
38+
});
39+
}
40+
41+
const server = spawnPnpm(['dev:server']);
42+
1643
const killChild = (child) => {
17-
if (!child || child.killed) return;
44+
if (!child || child.killed || child.pid == null) return;
1845
try {
46+
if (process.platform === 'win32') {
47+
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
48+
stdio: 'ignore',
49+
windowsHide: true
50+
});
51+
return;
52+
}
1953
child.kill('SIGTERM');
2054
} catch {
2155
// Ignore already stopped child processes.
@@ -61,11 +95,7 @@ try {
6195
throw error;
6296
}
6397

64-
frontend = spawn(PNPM_CMD, ['dev:frontend'], {
65-
cwd: ROOT,
66-
stdio: 'inherit',
67-
windowsHide: true
68-
});
98+
frontend = spawnPnpm(['dev:frontend']);
6999

70100
frontend.on('exit', (code) => {
71101
if (!shuttingDown) {

tests/e2e/e2e.spec.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type { APIRequestContext, Page } from '@playwright/test';
55
import { expect, test } from '@playwright/test';
66

77
const HOME_DIR = os.homedir();
8-
const HOME_LABEL = path.basename(HOME_DIR) || HOME_DIR;
98
const TAB_STABILITY_DIRS = [
109
path.join(HOME_DIR, 'coder-studio-e2e-tab-a'),
1110
path.join(HOME_DIR, 'coder-studio-e2e-tab-b'),
@@ -84,6 +83,8 @@ const gotoWorkspaceRoot = async (page: Page) => {
8483

8584
const countWorkspaceTabs = async (page: Page) => page.locator('.workspace-top-tab').count();
8685

86+
const workspaceLabelForPath = (workspacePath: string) => path.basename(workspacePath) || workspacePath;
87+
8788
const openLaunchOverlay = async (page: Page) => {
8889
await gotoWorkspaceRoot(page);
8990
const overlay = page.getByTestId('overlay');
@@ -100,18 +101,21 @@ const openLaunchOverlay = async (page: Page) => {
100101
const launchLocalWorkspace = async (page: Page) => {
101102
await gotoWorkspaceRoot(page);
102103
if (await countWorkspaceTabs(page)) {
103-
return;
104+
const activeLabel = (await page.locator('.workspace-top-tab.active .session-top-label').textContent())?.trim();
105+
return activeLabel ?? '';
104106
}
105107

106108
await openLaunchOverlay(page);
107109
await expect(page.getByTestId('choice-local-only')).toBeVisible();
108110
await expect(page.getByTestId('folder-select')).toBeVisible();
109111

110112
await page.getByRole('button', { name: 'Home' }).click();
113+
const selectedPath = ((await page.locator('[data-testid="folder-select"] .web-folder-picker-paths strong').textContent()) ?? '').trim();
111114
await expect(page.getByTestId('start-workspace')).toBeEnabled();
112115
await page.getByTestId('start-workspace').click();
113116
await expect(page.getByTestId('overlay')).toHaveCount(0);
114117
await expect(page.getByTestId('workspace-topbar')).toBeVisible();
118+
return workspaceLabelForPath(selectedPath);
115119
};
116120

117121
const invokeRpc = async <T>(page: Page, command: string, payload: Record<string, unknown> = {}) => {
@@ -183,6 +187,7 @@ test.beforeEach(async ({ page }) => {
183187
}
184188
});
185189
await installRuntimeCommandMock(page);
190+
await closeAllOpenWorkspaces(page);
186191
});
187192

188193
test.beforeAll(async () => {
@@ -194,8 +199,8 @@ test.afterAll(async () => {
194199
});
195200

196201
test('local workspace flow opens the workspace shell', async ({ page }) => {
197-
await launchLocalWorkspace(page);
198-
await expect(page.getByTestId('workspace-topbar')).toContainText(HOME_LABEL);
202+
const expectedLabel = await launchLocalWorkspace(page);
203+
await expect(page.getByTestId('workspace-topbar')).toContainText(expectedLabel);
199204
await expect(page.getByTestId('settings-open')).toBeVisible();
200205
});
201206

@@ -257,11 +262,11 @@ test('settings persist across route changes and reloads', async ({ page }) => {
257262
});
258263

259264
test('restores the last workspace after reload', async ({ page }) => {
260-
await launchLocalWorkspace(page);
265+
const expectedLabel = await launchLocalWorkspace(page);
261266
await page.reload();
262267

263268
await expect(page.getByTestId('overlay')).toHaveCount(0);
264-
await expect(page.getByTestId('workspace-topbar')).toContainText(HOME_LABEL);
269+
await expect(page.getByTestId('workspace-topbar')).toContainText(expectedLabel);
265270
});
266271

267272
test('workspace tabs keep a stable order when switching between workspaces', async ({ page }) => {

tests/e2e/transport.spec.ts

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ const emptyPollCounts = (): PollCounts => ({
9999
workspace_tree: 0,
100100
});
101101

102+
test.beforeEach(async ({ page }) => {
103+
await closeAllOpenWorkspaces(page);
104+
});
105+
102106
test.describe('workspace transport baseline', () => {
103107
test('fallback polling refreshes git/tree/worktree on the configured cadence', async ({ page }) => {
104108
const baseline = await observePollingBaseline(page);
@@ -347,15 +351,31 @@ async function observeWatcherInvalidationBaseline(page: Page): Promise<WatcherIn
347351
'e2e',
348352
`.transport-watcher-probe-${process.pid}-${Date.now()}.txt`,
349353
);
354+
const predicate = (payload: Record<string, unknown>) =>
355+
payload.path === workspace.workspacePath && payload.reason === 'file_watcher';
350356

351357
await fs.mkdir(path.dirname(probeFile), { recursive: true });
352358
try {
353-
await fs.writeFile(probeFile, `watcher ${Date.now()}\n`, 'utf8');
354-
const dirtyFrame = await waitForWsEvent(
355-
page,
356-
'workspace://artifacts_dirty',
357-
(payload) => payload.path === workspace.workspacePath && payload.reason === 'file_watcher',
358-
);
359+
let dirtyFrame: WsEventFrame | null = null;
360+
let lastError: unknown = null;
361+
for (let attempt = 0; attempt < 3; attempt += 1) {
362+
await fs.writeFile(probeFile, `watcher ${Date.now()}-${attempt}\n`, 'utf8');
363+
try {
364+
dirtyFrame = await waitForWsEvent(
365+
page,
366+
'workspace://artifacts_dirty',
367+
predicate,
368+
2500,
369+
);
370+
break;
371+
} catch (error) {
372+
lastError = error;
373+
await page.waitForTimeout(350);
374+
}
375+
}
376+
if (!dirtyFrame) {
377+
throw lastError ?? new Error('watcher_invalidation_timeout');
378+
}
359379

360380
return {
361381
dirtyFrame,
@@ -381,12 +401,14 @@ async function observeGitIndexInvalidationBaseline(page: Page): Promise<GitIndex
381401
const predicate = (payload: Record<string, unknown>) =>
382402
payload.path === workspace.workspacePath && payload.reason === 'file_watcher';
383403
const baselineCount = await countWsEvents(page, 'workspace://artifacts_dirty', predicate);
404+
const countsBeforeFileWrite = snapshotCounts(probe.counts);
384405

385406
await fs.mkdir(path.dirname(probeFile), { recursive: true });
386407
try {
387408
await fs.writeFile(probeFile, `index ${Date.now()}\n`, 'utf8');
388409
await waitForWsEventCount(page, 'workspace://artifacts_dirty', predicate, baselineCount + 1);
389-
await page.waitForTimeout(400);
410+
await waitForCountsAtLeast(probe.counts, incrementCounts(countsBeforeFileWrite));
411+
await page.waitForTimeout(1000);
390412
const countsAfterFileWrite = await countWsEvents(page, 'workspace://artifacts_dirty', predicate);
391413

392414
await execFileAsync('git', ['add', '--', probeFile], { cwd: WORKSPACE_PATH });
@@ -532,6 +554,18 @@ async function invokeRpc<T>(page: Page, command: string, payload: Record<string,
532554
return body.data as T;
533555
}
534556

557+
async function closeAllOpenWorkspaces(page: Page) {
558+
const bootstrap = await invokeRpc<{
559+
ui_state: {
560+
open_workspace_ids: string[];
561+
};
562+
}>(page, 'workbench_bootstrap');
563+
564+
for (const workspaceId of bootstrap.ui_state.open_workspace_ids) {
565+
await invokeRpc(page, 'close_workspace', { workspace_id: workspaceId });
566+
}
567+
}
568+
535569
async function waitForPollCycle(counts: PollCounts) {
536570
await expect
537571
.poll(() => {
@@ -551,6 +585,20 @@ async function waitForCounts(actual: PollCounts, expected: PollCounts) {
551585
.toEqual(expected);
552586
}
553587

588+
async function waitForCountsAtLeast(actual: PollCounts, expectedMinimum: PollCounts) {
589+
await expect
590+
.poll(() => {
591+
const snapshot = snapshotCounts(actual);
592+
return snapshot.git_status >= expectedMinimum.git_status
593+
&& snapshot.git_changes >= expectedMinimum.git_changes
594+
&& snapshot.worktree_list >= expectedMinimum.worktree_list
595+
&& snapshot.workspace_tree >= expectedMinimum.workspace_tree;
596+
}, {
597+
timeout: 10000,
598+
})
599+
.toBe(true);
600+
}
601+
554602
function snapshotCounts(counts: PollCounts): PollCounts {
555603
return {
556604
git_status: counts.git_status,
@@ -602,6 +650,7 @@ async function waitForWsEvent(
602650
page: Page,
603651
eventName: string,
604652
predicate: (payload: Record<string, unknown>) => boolean,
653+
timeoutMs = 10000,
605654
): Promise<WsEventFrame> {
606655
await expect
607656
.poll(async () => {
@@ -615,7 +664,7 @@ async function waitForWsEvent(
615664
);
616665
return match ?? null;
617666
}, {
618-
timeout: 10000,
667+
timeout: timeoutMs,
619668
})
620669
.not.toBeNull();
621670

@@ -650,10 +699,11 @@ async function waitForWsEventCount(
650699
eventName: string,
651700
predicate: (payload: Record<string, unknown>) => boolean,
652701
expectedCount: number,
702+
timeoutMs = 10000,
653703
) {
654704
await expect
655705
.poll(() => countWsEvents(page, eventName, predicate), {
656-
timeout: 10000,
706+
timeout: timeoutMs,
657707
})
658708
.toBeGreaterThanOrEqual(expectedCount);
659709
}

0 commit comments

Comments
 (0)