Skip to content

Commit 6545704

Browse files
committed
WIP: add branch quick pick and remote branch checkout
1 parent d103be2 commit 6545704

21 files changed

Lines changed: 1487 additions & 31 deletions
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3+
import { dirname, join } from 'node:path';
4+
import { closeDatabase, openDatabase } from '../../packages/server/src/storage/db.ts';
5+
6+
const WORKSPACE_ID = 'ws-branch-switcher';
7+
8+
const [, , dbPath, workspacesRoot] = process.argv;
9+
10+
if (!dbPath || !workspacesRoot) {
11+
throw new Error('Usage: tsx seed-git-branch-switching-db.ts <db-path> <workspaces-root>');
12+
}
13+
14+
mkdirSync(dirname(dbPath), { recursive: true });
15+
mkdirSync(workspacesRoot, { recursive: true });
16+
rmSync(dbPath, { force: true });
17+
18+
const runGit = (args: string[], cwd: string) => {
19+
execFileSync('git', args, {
20+
cwd,
21+
env: {
22+
...process.env,
23+
GIT_AUTHOR_NAME: 'Coder Studio E2E',
24+
GIT_AUTHOR_EMAIL: 'e2e@coder-studio.test',
25+
GIT_COMMITTER_NAME: 'Coder Studio E2E',
26+
GIT_COMMITTER_EMAIL: 'e2e@coder-studio.test',
27+
},
28+
stdio: 'pipe',
29+
});
30+
};
31+
32+
const createWorkspaceDir = (dirName: string): string => {
33+
const workspacePath = join(workspacesRoot, dirName);
34+
mkdirSync(workspacePath, { recursive: true });
35+
mkdirSync(join(workspacePath, 'src'), { recursive: true });
36+
writeFileSync(join(workspacePath, 'README.md'), `# ${dirName}\n`);
37+
writeFileSync(join(workspacePath, 'src', 'index.ts'), 'export const ready = true;\n');
38+
39+
runGit(['init', '--initial-branch=main'], workspacePath);
40+
runGit(['add', '.'], workspacePath);
41+
runGit(['commit', '-m', 'init'], workspacePath);
42+
43+
return workspacePath;
44+
};
45+
46+
const db = openDatabase(dbPath);
47+
const now = Date.now();
48+
49+
try {
50+
const workspacePath = createWorkspaceDir('branch-switcher-workspace');
51+
52+
const insertWorkspace = db.prepare(
53+
`INSERT INTO workspaces (id, path, target_runtime, wsl_distro, opened_at, last_active_at, ui_state)
54+
VALUES (?, ?, ?, ?, ?, ?, ?)`
55+
);
56+
57+
insertWorkspace.run(
58+
WORKSPACE_ID,
59+
workspacePath,
60+
'native',
61+
null,
62+
now - 10_000,
63+
now,
64+
JSON.stringify({
65+
leftPanelWidth: 280,
66+
bottomPanelHeight: 200,
67+
focusMode: false,
68+
})
69+
);
70+
71+
console.log(
72+
JSON.stringify({
73+
dbPath,
74+
workspaceId: WORKSPACE_ID,
75+
workspacePath,
76+
})
77+
);
78+
} finally {
79+
closeDatabase(db);
80+
}

e2e/playwright.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ export default defineConfig({
88
timeout: 60000,
99
reporter: [['list']],
1010
webServer: {
11-
command: 'cd ../packages/web && pnpm vite --port 5173 --host 127.0.0.1',
11+
command: 'cd .. && pnpm dev',
1212
url: 'http://127.0.0.1:5173/',
1313
reuseExistingServer: true,
1414
timeout: 30000,
1515
},
1616
use: {
17-
baseURL: 'http://127.0.0.1:5173',
17+
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:5173',
1818
trace: 'off',
1919
screenshot: 'on',
2020
video: 'off',
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { test, expect } from '@playwright/test';
2+
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { spawn, type ChildProcess } from 'node:child_process';
6+
7+
const HOST = '127.0.0.1';
8+
const SERVER_PORT = 43175;
9+
const WEB_PORT = 53175;
10+
const BACKEND_HTTP_URL = `http://${HOST}:${SERVER_PORT}`;
11+
const BASE_URL = `http://${HOST}:${WEB_PORT}`;
12+
const NEW_BRANCH_NAME = 'feature/e2e-create-branch';
13+
14+
let sandboxDir: string;
15+
let dbPath: string;
16+
let runtimeDir: string;
17+
let workspacesRoot: string;
18+
let backendProcess: ChildProcess | undefined;
19+
let webProcess: ChildProcess | undefined;
20+
21+
const startProcess = (
22+
command: string,
23+
args: string[],
24+
options: {
25+
cwd: string;
26+
env?: NodeJS.ProcessEnv;
27+
}
28+
): ChildProcess => {
29+
const child = spawn(command, args, {
30+
cwd: options.cwd,
31+
env: {
32+
...process.env,
33+
...options.env,
34+
},
35+
stdio: ['ignore', 'pipe', 'pipe'],
36+
});
37+
38+
child.stdout?.on('data', () => {});
39+
child.stderr?.on('data', () => {});
40+
child.on('error', (error) => {
41+
throw error;
42+
});
43+
44+
return child;
45+
};
46+
47+
const waitForHttp = async (url: string, timeoutMs = 30000): Promise<void> => {
48+
const start = Date.now();
49+
50+
while (Date.now() - start < timeoutMs) {
51+
try {
52+
const response = await fetch(url);
53+
if (response.ok) {
54+
return;
55+
}
56+
} catch {
57+
// keep polling
58+
}
59+
60+
await new Promise((resolve) => setTimeout(resolve, 250));
61+
}
62+
63+
throw new Error(`Timed out waiting for ${url}`);
64+
};
65+
66+
test.describe('git branch switching acceptance', () => {
67+
test.beforeAll(async () => {
68+
sandboxDir = mkdtempSync(join(tmpdir(), 'coder-studio-branch-switcher-e2e-'));
69+
dbPath = join(sandboxDir, 'coder-studio.db');
70+
runtimeDir = join(sandboxDir, 'runtime');
71+
workspacesRoot = join(sandboxDir, 'workspaces');
72+
73+
mkdirSync(runtimeDir, { recursive: true });
74+
mkdirSync(workspacesRoot, { recursive: true });
75+
76+
const seed = spawn('pnpm', [
77+
'exec',
78+
'tsx',
79+
'e2e/fixtures/seed-git-branch-switching-db.ts',
80+
dbPath,
81+
workspacesRoot,
82+
], {
83+
cwd: '/home/spencer/workspace/coder-studio',
84+
env: process.env,
85+
stdio: ['ignore', 'pipe', 'pipe'],
86+
});
87+
88+
await new Promise<void>((resolve, reject) => {
89+
let stderr = '';
90+
seed.stderr?.on('data', (chunk) => {
91+
stderr += chunk.toString();
92+
});
93+
seed.on('exit', (code) => {
94+
if (code === 0) {
95+
resolve();
96+
return;
97+
}
98+
reject(new Error(stderr || `seed exited with code ${code}`));
99+
});
100+
seed.on('error', reject);
101+
});
102+
103+
backendProcess = startProcess('pnpm', ['exec', 'tsx', 'packages/server/src/server.ts'], {
104+
cwd: '/home/spencer/workspace/coder-studio',
105+
env: {
106+
HOST,
107+
PORT: String(SERVER_PORT),
108+
DATA_DIR: dbPath,
109+
RUNTIME_DIR: runtimeDir,
110+
NO_AUTH: 'true',
111+
},
112+
});
113+
114+
await waitForHttp(`${BACKEND_HTTP_URL}/healthz`);
115+
116+
webProcess = startProcess('pnpm', ['exec', 'vite', '--host', HOST, '--port', String(WEB_PORT)], {
117+
cwd: '/home/spencer/workspace/coder-studio/packages/web',
118+
env: {
119+
VITE_BACKEND_HTTP_URL: BACKEND_HTTP_URL,
120+
VITE_BACKEND_WS_URL: `ws://${HOST}:${SERVER_PORT}/ws`,
121+
},
122+
});
123+
124+
await waitForHttp(`${BASE_URL}/`);
125+
});
126+
127+
test.afterAll(async () => {
128+
const kill = async (child: ChildProcess | undefined) => {
129+
if (!child || child.killed) {
130+
return;
131+
}
132+
133+
child.kill('SIGTERM');
134+
await new Promise((resolve) => child.once('exit', resolve));
135+
};
136+
137+
await kill(webProcess);
138+
await kill(backendProcess);
139+
rmSync(sandboxDir, { recursive: true, force: true });
140+
});
141+
142+
test.use({
143+
baseURL: BASE_URL,
144+
});
145+
146+
test('creates a new branch only after explicit confirmation from the branch quick pick', async ({ page }) => {
147+
await page.goto('/workspace');
148+
await expect(page.getByTestId('workspace-resolving-shell')).toHaveCount(0, { timeout: 20000 });
149+
150+
const branchButton = page.getByRole('button', {
151+
name: 'Open branch switcher for main',
152+
});
153+
await expect(branchButton).toBeVisible({ timeout: 20000 });
154+
155+
await branchButton.click();
156+
157+
await expect(page.getByRole('button', { name: 'Git Diff' })).toHaveClass(/active/);
158+
await expect(page.locator('.branch-quick-pick-overlay')).toBeVisible();
159+
await expect(
160+
page.locator('.branch-quick-pick-item').filter({ hasText: 'main' })
161+
).toBeVisible();
162+
163+
const input = page.getByPlaceholder('Search branches or create new branch...');
164+
await input.fill(NEW_BRANCH_NAME);
165+
166+
await expect(page.getByText(`Create branch: ${NEW_BRANCH_NAME}`)).toBeVisible();
167+
await input.press('Enter');
168+
169+
await expect(page.getByText(`Confirm create branch: ${NEW_BRANCH_NAME}`)).toBeVisible();
170+
await expect(branchButton).toBeVisible();
171+
172+
await input.press('Enter');
173+
174+
await expect(page.locator('.branch-quick-pick-overlay')).toHaveCount(0);
175+
await expect(
176+
page.getByRole('button', {
177+
name: `Open branch switcher for ${NEW_BRANCH_NAME}`,
178+
})
179+
).toBeVisible({ timeout: 20000 });
180+
});
181+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test('Minimal title test', async ({ page }) => {
4+
await page.goto('http://127.0.0.1:5173');
5+
await page.waitForTimeout(3000);
6+
7+
// Check console for debug logs
8+
page.on('console', msg => {
9+
if (msg.text().includes('[DEBUG]')) {
10+
console.log('Browser DEBUG:', msg.text());
11+
}
12+
});
13+
14+
// Check if on welcome page
15+
const welcomeHeading = page.getByRole('heading', { name: 'Welcome to Coder Studio' });
16+
if (await welcomeHeading.isVisible()) {
17+
const openButton = page.getByRole('button', { name: 'Open Workspace' });
18+
await openButton.click();
19+
await page.waitForTimeout(1000);
20+
21+
const workspaceDir = page.locator('text=coder-studio-workspaces').first();
22+
await workspaceDir.click();
23+
await page.waitForTimeout(500);
24+
25+
const startButton = page.getByRole('button', { name: 'Start Workspace' });
26+
await startButton.click();
27+
await page.waitForTimeout(5000);
28+
}
29+
30+
// Wait for page to load completely
31+
await page.waitForTimeout(2000);
32+
33+
// Click Claude analysis button to create a new session
34+
console.log('Looking for Claude analysis button...');
35+
const claudeButton = page.getByRole('button', { name: 'Claude analysis' });
36+
await claudeButton.waitFor({ state: 'visible', timeout: 10000 });
37+
console.log('✓ Claude button found, clicking...');
38+
await claudeButton.click();
39+
40+
// Wait for session to be created (SessionStart hook should fire)
41+
console.log('Waiting for session creation...');
42+
await page.waitForTimeout(10000);
43+
44+
// Now look for the session card with an actual session (not draft launcher)
45+
// The session card should have a session-state element (Idle/Running/Interrupted)
46+
const sessionCard = page.locator('.session-card').filter({
47+
has: page.locator('.session-state')
48+
}).first();
49+
50+
await sessionCard.waitFor({ state: 'visible', timeout: 15000 });
51+
console.log('✓ Session card found');
52+
53+
const titleElement = sessionCard.locator('.session-title');
54+
const initialTitle = await titleElement.textContent();
55+
console.log('Initial session title:', initialTitle);
56+
57+
// Click the terminal input textbox directly
58+
const terminalInput = sessionCard.getByRole('textbox', { name: 'Terminal input' });
59+
await terminalInput.click();
60+
await page.waitForTimeout(500);
61+
62+
await terminalInput.fill('test input for title');
63+
await page.keyboard.press('Enter');
64+
65+
await page.waitForTimeout(5000);
66+
67+
const newTitle = await titleElement.textContent();
68+
console.log('New session title:', newTitle);
69+
70+
// Verify title changed from SESSION-XX format
71+
expect(newTitle).not.toMatch(/^SESSION-\d+$/);
72+
expect(newTitle).toContain('test');
73+
});

0 commit comments

Comments
 (0)