Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
8 changes: 7 additions & 1 deletion e2e/lib/playwright/amr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,16 @@ export async function readAppConfig(page: Page) {
}

export async function seedBrowserConfig(page: Page, value: Record<string, unknown>) {
const payload = { key: STORAGE_KEY, config: value };
await page.addInitScript(
({ key, config }) => {
window.localStorage.setItem(key, JSON.stringify(config));
},
{ key: STORAGE_KEY, config: value },
payload,
);
await page.evaluate(({ key, config }) => {
window.localStorage.setItem(key, JSON.stringify(config));
}, payload).catch(() => {
// Some pre-navigation pages do not expose localStorage yet; the init script above covers the next load.
});
}
2 changes: 2 additions & 0 deletions e2e/lib/playwright/suites.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type UiPlaywrightGroup = {
files: readonly string[];
grep: string;
workers?: number;
};

export type UiP0CiMatrixEntry = {
Expand Down Expand Up @@ -46,6 +47,7 @@ export const uiP0Groups = {
},
"project-runtime": {
grep: String.raw`\[P0\]`,
workers: 1,
files: [
"ui/real-daemon-run.test.ts",
"ui/amr-run-failure-recovery.test.ts",
Expand Down
3 changes: 3 additions & 0 deletions e2e/scripts/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ async function runUiGroup(): Promise<void> {
}

const args = ['test', '-c', 'playwright.config.ts', ...group.files, '--grep', group.grep];
if (group.workers != null) {
args.push(`--workers=${group.workers}`);
}
const child = spawn('playwright', args, {
stdio: 'inherit',
shell: false,
Expand Down
15 changes: 2 additions & 13 deletions e2e/ui/amr-logout-requires-relogin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,9 @@ test.describe.configure({ timeout: T.long });

test('[P0] after local Sign out, AMR runs require re-login and Settings keeps AMR selected', async ({ page }) => {
const root = join(tmpdir(), `open-design-amr-logout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
const successVelaBin = await writeFakeVelaBin(join(root, 'bin-success'), {
assistantText: 'Hello from the e2e fake vela.',
requireLoginConfig: false,
});
const reloginVelaBin = await writeFakeVelaBin(join(root, 'bin-relogin'), {
failAuthAtPrompt: true,
requireLoginConfig: false,
});
await mkdir(root, { recursive: true });
let loggedIn = true;
Expand Down Expand Up @@ -67,7 +64,7 @@ test('[P0] after local Sign out, AMR runs require re-login and Settings keeps AM
amr: { model: 'default', reasoning: 'default' },
},
agentCliEnv: {
amr: { VELA_BIN: successVelaBin },
amr: { VELA_BIN: reloginVelaBin },
},
};

Expand All @@ -92,14 +89,6 @@ test('[P0] after local Sign out, AMR runs require re-login and Settings keeps AM
await expect(reopenedSettings.getByRole('button', { name: /^Authorize$|^Sign in$/i })).toBeVisible();
await page.keyboard.press('Escape');
await expect(reopenedSettings).toHaveCount(0);
const reloginConfig = {
...config,
agentCliEnv: {
amr: { VELA_BIN: reloginVelaBin },
},
};
await seedBrowserConfig(page, reloginConfig);
await putAppConfig(page, reloginConfig);
await sendPrompt(page, 'AMR logout should require relogin');

await expect(runErrorCard(page)).toContainText(/authorize|sign in again|login missing|expired|ACP session exited before completion/i, {
Expand Down
230 changes: 216 additions & 14 deletions e2e/ui/amr-onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,39 @@ test('[P0] @critical onboarding Local CLI card lets the user pick an agent model
await expect(page.getByRole('button', { name: /^Continue$/i })).toBeVisible();
});

test('[P0] onboarding Local CLI path completes setup with the selected agent model', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: false,
initialLoggedIn: false,
codexModels: [
{ id: 'gpt-5.4-mini', label: 'gpt-5.4-mini' },
{ id: 'glm-5', label: 'GLM 5' },
],
});

await seedOnboardingConfig(page, config);
await gotoOnboarding(page);

await page.getByRole('button', { name: /Local coding agent/i }).click();
const localPanel = page.locator('.onboarding-view__setup-panel');
await expect(localPanel).toBeVisible();
await selectOnboardingOption(localPanel, 'Model', 'GLM 5');
await page.getByRole('button', { name: /^Continue$/i }).click();

await expect(page.getByRole('heading', { name: /About you/i })).toBeVisible({ timeout: T.long });
await advanceFromAboutYouToBrand(page);

await expectOnboardingFinished(page);
await pollStoredConfig(page).toMatchObject({
mode: 'daemon',
agentId: 'codex',
agentModels: {
codex: { model: 'glm-5' },
},
onboardingCompleted: true,
});
});

test('[P0] onboarding Local CLI path stays gated when no local CLI is available', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: false,
Expand Down Expand Up @@ -444,20 +477,6 @@ test('[P0] onboarding gate cannot be bypassed by direct Home navigation or new-t
await expect(connectLandingHeading(page)).toBeVisible();
});

test('[P0] onboarding Connect step exposes no Skip affordance', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: true,
});

await seedOnboardingConfig(page, config);
await gotoOnboarding(page);

// "Skip for now" was removed — Connect is now a required step, so there is
// no way to exit onboarding from here without connecting a runtime.
await expect(page.getByRole('button', { name: /Skip for now/i })).toHaveCount(0);
});

test('[P0] onboarding visited steps become locked again when the Connect runtime becomes invalid', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
Expand Down Expand Up @@ -591,6 +610,128 @@ test('[P0] onboarding newsletter malformed email does not block finishing setup'
});
});

test('[P0] onboarding newsletter submits a valid email only when setup finishes', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: true,
});
const newsletterBodies: Array<Record<string, unknown>> = [];
await page.route('https://open-design.ai/subscribe', async (route) => {
newsletterBodies.push(route.request().postDataJSON() as Record<string, unknown>);
await route.fulfill({ json: { ok: true } });
});

await seedOnboardingConfig(page, config);
await gotoOnboarding(page);
await advanceToNewsletterStep(page);

await page.getByPlaceholder('you@studio.com').fill('Designer@Example.COM ');
await page.getByRole('button', { name: /^Continue$/i }).click();
await expect(page.getByRole('heading', { name: /Create once, build everywhere/i })).toBeVisible();
await expect.poll(() => newsletterBodies.length).toBe(0);

await expectOnboardingFinished(page);
await expect.poll(() => newsletterBodies).toEqual([
{ email: 'designer@example.com', source: 'client' },
]);
await pollStoredConfig(page).toMatchObject({
onboardingCompleted: true,
});
});

test('[P0] onboarding newsletter submit failure does not block finishing setup', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: true,
});
let newsletterCalls = 0;
await page.route('https://open-design.ai/subscribe', async (route) => {
newsletterCalls += 1;
await route.fulfill({
status: 500,
json: { ok: false },
});
});

await seedOnboardingConfig(page, config);
await gotoOnboarding(page);
await advanceToNewsletterStep(page);

await page.getByPlaceholder('you@studio.com').fill('designer@example.com');
await page.getByRole('button', { name: /^Continue$/i }).click();
await expect(page.getByRole('heading', { name: /Create once, build everywhere/i })).toBeVisible();

await expectOnboardingFinished(page);
await expect.poll(() => newsletterCalls).toBe(1);
await pollStoredConfig(page).toMatchObject({
onboardingCompleted: true,
});
});

test('[P0] onboarding build CTA opens design-system creation and completes onboarding', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: true,
});

await seedOnboardingConfig(page, config);
await gotoOnboarding(page);
await clickCloudPrimary(page);
await advanceFromAboutYouToBrand(page);

await page.getByRole('button', { name: /Build a design system/i }).click();

await expect(page).toHaveURL(/\/design-systems\/create$/);
await expect(
page.getByRole('heading', { name: /Extract from GitHub, websites, or source material/i }),
).toBeVisible();
await expect(page.getByRole('button', { name: /Continue to generation/i })).toBeDisabled();
await pollStoredConfig(page).toMatchObject({
onboardingCompleted: true,
});
});

test('[P0] onboarding build step still allows finishing directly to home', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: true,
});

await seedOnboardingConfig(page, config);
await gotoOnboarding(page);
await clickCloudPrimary(page);
await advanceFromAboutYouToBrand(page);

await expect(page.getByRole('heading', { name: /Create once, build everywhere/i })).toBeVisible();

await expectOnboardingFinished(page);
await pollStoredConfig(page).toMatchObject({
onboardingCompleted: true,
});
});

test('[P0] onboarding Back keeps newsletter email and brand URL draft intact', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: true,
});

await seedOnboardingConfig(page, config);
await gotoOnboarding(page);
await advanceToNewsletterStep(page);

await page.getByPlaceholder('you@studio.com').fill('designer@example.com');
await page.getByRole('button', { name: /^Continue$/i }).click();
await expect(page.getByRole('heading', { name: /Create once, build everywhere/i })).toBeVisible();

await page.getByRole('button', { name: /^Back$/i }).click();
await expect(page.getByRole('heading', { name: /Stay in the loop/i })).toBeVisible();
await expect(page.getByPlaceholder('you@studio.com')).toHaveValue('designer@example.com');

await page.getByRole('button', { name: /^Continue$/i }).click();
await expect(page.getByRole('heading', { name: /Create once, build everywhere/i })).toBeVisible();
});

test('[P0] @critical onboarding BYOK path can fetch models, test the provider, and complete setup', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
Expand Down Expand Up @@ -719,6 +860,67 @@ test('[P0] onboarding BYOK path cannot continue before a successful connection t
await expect(continueButton).toBeEnabled();
});

test('[P0] onboarding BYOK path supports Anthropic model selection and API key visibility before completing', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: true,
});
let connectionBody: Record<string, unknown> | null = null;
await page.route('**/api/test/connection', async (route) => {
connectionBody = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
json: {
ok: true,
kind: 'success',
latencyMs: 19,
model: 'claude-custom-onboarding',
sample: 'Connected',
},
});
});

await seedOnboardingConfig(page, config);
await gotoOnboarding(page);

await page.getByRole('button', { name: /Bring your own key/i }).click();
await expect(page.getByRole('tab', { name: /^Anthropic$/i })).toHaveAttribute('aria-selected', 'true');

const apiKeyField = onboardingField(page, 'API key');
const apiKeyInput = apiKeyField.locator('input');
await expect(apiKeyInput).toHaveAttribute('type', 'password');
await fillInlineField(page, 'API key', 'anthropic-test-key');
await apiKeyField.getByRole('button', { name: /^Show$/i }).click();
await expect(apiKeyInput).toHaveAttribute('type', 'text');

await fillInlineField(page, 'Base URL', 'https://api.anthropic.com');
const byokPanel = page.locator('.onboarding-view__setup-panel').filter({ hasText: /BYOK/ });
await selectOnboardingOption(byokPanel, 'Model', 'claude-sonnet-4-5');
await page.getByRole('button', { name: /^Test$/i }).click();
await expectProviderConnectionSuccess(page);

await expect.poll(() => connectionBody).toMatchObject({
mode: 'provider',
protocol: 'anthropic',
apiKey: 'anthropic-test-key',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
});

await page.getByRole('button', { name: /^Continue$/i }).click();
await expect(page.getByRole('heading', { name: /About you/i })).toBeVisible();
await advanceFromAboutYouToBrand(page);

await expectOnboardingFinished(page);
await pollStoredConfig(page).toMatchObject({
mode: 'api',
apiProtocol: 'anthropic',
apiKey: 'anthropic-test-key',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
onboardingCompleted: true,
});
});

test('[P0] onboarding BYOK successful test is invalidated when connection settings change', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
Expand Down
Loading
Loading