fix(web): allow signed-out Local CLI and BYOK onboarding (#7381)

* fix(web): allow signed-out Local CLI and BYOK onboarding

* test(web): align onboarding checks with signed-out alternatives

* test(e2e): drop obsolete Pricing campaign snapshot

* fix(web): restore active onboarding Cloud login

Generated-By: looper 0.12.0 (runner=fixer, agent=codex)

* fix(web): resume hydrated onboarding login

Generated-By: looper 0.12.0 (runner=fixer, agent=codex)

* fix(web): preserve direct setup during Cloud hydration

Generated-By: looper 0.12.0 (runner=fixer, agent=codex)

* fix(web): preserve signed-out local onboarding flows

* fix(web): resume hydrated login after onboarding back

---------

Co-authored-by: Amy <1184569493@qq.com>
This commit is contained in:
Ray Xi
2026-08-26 07:45:03 +00:00
committed by GitHub
co-authored by Amy
parent ebb5b24c07
commit 4076a77135
8 changed files with 427 additions and 59 deletions
+107 -10
View File
@@ -2081,6 +2081,7 @@ function OnboardingView({
const analytics = useAnalytics();
const [step, setStep] = useState(0);
const [runtime, setRuntime] = useState<'amr' | 'local' | 'byok' | null>(null);
const [runtimeSetupEntry, setRuntimeSetupEntry] = useState<'cloud' | 'chooser'>('chooser');
const [modelSource, setModelSource] = useState<'amr' | 'local' | 'byok'>('amr');
const modelSourceOptionRefs = useRef<
Record<'amr' | 'local' | 'byok', HTMLButtonElement | null>
@@ -2140,6 +2141,8 @@ function OnboardingView({
} | null>(null);
const cliRefreshPendingTokenRef = useRef<number | null>(null);
const amrLoginPollCancelledRef = useRef(false);
const amrHydratedLoginPollStartedRef = useRef(false);
const onboardingMountedRef = useRef(true);
const amrLoginStartPendingRef = useRef(false);
const amrLoginCancelRequestedRef = useRef(false);
const amrAuthAttemptIdRef = useRef<string | null>(null);
@@ -2204,6 +2207,7 @@ function OnboardingView({
);
const visibleAgents = candidateCliAgents.filter((agent) => visibleAgentIds.includes(agent.id));
const amrSignedIn = isAmrSessionAuthenticated(amrStatus);
const amrLoginBusy = amrLoginPending || amrStatus?.loginInFlight === true;
const selectedAgent = visibleAgents.find((agent) => agent.id === config.agentId) ?? null;
const selectedAgentChoice = selectedAgent ? (config.agentModels?.[selectedAgent.id] ?? {}) : {};
const normalizedSelectedAgentChoice = effectiveAgentModelChoice(selectedAgent, selectedAgentChoice) ?? selectedAgentChoice;
@@ -2247,6 +2251,10 @@ function OnboardingView({
agentTestInputKey,
providerTestInputKey,
};
const cloudLandingIntentStillCurrent = () => {
const intent = onboardingIntentRef.current;
return intent.step === 0 && intent.runtime === null;
};
const connectGateReason: 'no_runtime' | 'local_agent_unavailable' | 'byok_unverified' | null =
!runtimeSetupStep
? null
@@ -2276,7 +2284,9 @@ function OnboardingView({
);
useEffect(() => {
onboardingMountedRef.current = true;
return () => {
onboardingMountedRef.current = false;
amrLoginPollCancelledRef.current = true;
agentRevealTimersRef.current.forEach((timer) => clearTimeout(timer));
agentRevealTimersRef.current = [];
@@ -2321,6 +2331,12 @@ function OnboardingView({
.then((next) => {
if (!cancelled && next) {
setAmrStatus(next);
if (next.authAttemptId) {
amrAuthAttemptIdRef.current = next.authAttemptId;
}
if (next.loginInFlight && cloudLandingIntentStillCurrent()) {
startHydratedAmrLoginPoll();
}
onAmrLoginStatusChange?.(next);
}
})
@@ -2332,6 +2348,21 @@ function OnboardingView({
};
}, [onAmrLoginStatusChange]);
useEffect(() => {
if (
step !== 0
|| runtime !== null
|| amrLoginPending
|| amrStatus?.loginInFlight !== true
) {
return;
}
// The mount status request may settle while a direct Local/BYOK setup is
// active. Returning to the Cloud landing must resume observation of that
// hydrated attempt instead of leaving its stale cancel state indefinitely.
startHydratedAmrLoginPoll();
}, [amrLoginPending, amrStatus?.loginInFlight, runtime, step]);
useEffect(() => {
if (
!amrStatusResolved
@@ -2358,7 +2389,7 @@ function OnboardingView({
]);
useEffect(() => {
if (runtime === 'amr') return;
if (runtime === 'amr' || runtime === null) return;
amrLoginPollCancelledRef.current = true;
setAmrLoginPending(false);
setAmrLoginCancelPending(false);
@@ -2636,7 +2667,7 @@ function OnboardingView({
emitOnboardingClick('back', 'back');
clearAgentRevealTimers();
setRuntime(null);
setStep(1);
setStep(runtimeSetupEntry === 'cloud' ? 0 : 1);
}
function completeStreamlinedOnboarding(
@@ -2660,6 +2691,28 @@ function OnboardingView({
setStep(1);
}
function startHydratedAmrLoginPoll(): void {
if (amrHydratedLoginPollStartedRef.current) return;
amrHydratedLoginPollStartedRef.current = true;
amrLoginPollCancelledRef.current = false;
setAmrLoginPending(true);
void pollAmrLoginCompletion()
.then((completed) => {
if (
completed
&& onboardingMountedRef.current
&& cloudLandingIntentStillCurrent()
) {
continueAfterCloudSignIn();
}
})
.finally(() => {
if (onboardingMountedRef.current) {
setAmrLoginPending(false);
}
});
}
function handleModelSourceKeyDown(
event: ReactKeyboardEvent<HTMLButtonElement>,
currentSource: 'amr' | 'local' | 'byok',
@@ -2704,6 +2757,7 @@ function OnboardingView({
runtime_type: 'local_cli',
});
setRuntime('local');
setRuntimeSetupEntry('chooser');
void scanCliAgents({ preferExisting: true });
setStep(2);
return;
@@ -2711,6 +2765,7 @@ function OnboardingView({
emitOnboardingClick('byok', 'select_runtime', { runtime_type: 'byok' });
setRuntime('byok');
setRuntimeSetupEntry('chooser');
setStep(2);
}
/**
@@ -2777,7 +2832,7 @@ function OnboardingView({
// chosen on the following screen so signing in never overwrites a restored
// Local/BYOK configuration.
async function handleCloudSignIn() {
if (amrLoginPending || amrLoginCancelPending) return;
if (amrLoginBusy || amrLoginCancelPending) return;
const cardAttribution = recordAmrEntry(
analytics.track,
'onboarding_amr_card',
@@ -2799,7 +2854,7 @@ function OnboardingView({
async function handleAmrSignInToContinue(
attribution?: AmrEntryAttribution | null,
) {
if (amrLoginPending || amrLoginCancelPending) return;
if (amrLoginBusy || amrLoginCancelPending) return;
amrLoginPollCancelledRef.current = false;
amrLoginCancelRequestedRef.current = false;
setAmrLoginError(null);
@@ -2921,7 +2976,7 @@ function OnboardingView({
}
async function handleCancelAmrLogin() {
if (!amrLoginPending || amrLoginCancelPending) return;
if (!amrLoginBusy || amrLoginCancelPending) return;
const loginStartPending = amrLoginStartPendingRef.current;
const authAttemptId = amrAuthAttemptIdRef.current;
setAmrLoginError(null);
@@ -3017,7 +3072,14 @@ function OnboardingView({
resolveAmrAuthTracking(analytics.track, 'timeout', 'login_timeout', {
authAttemptId,
});
void cancelVelaLogin(authAttemptId);
const cancelResult = await cancelVelaLogin(authAttemptId);
if (cancelResult.canceled === true) {
setAmrStatus((current) => (
current
? { ...current, loggedIn: false, loginInFlight: false, user: null }
: current
));
}
}
console.error('[amr-login] poll timed out waiting for a signed-in status', { nextStatus });
} else {
@@ -3292,10 +3354,10 @@ function OnboardingView({
const primaryActionLabel = t('settings.onboardingContinue');
// Step 1 is identity only: every user signs into OpenDesign Cloud before
// choosing Hosted, Local, or BYOK on the next screen.
// Cloud remains the primary identity path. Local CLI and BYOK are independent
// direct setup paths; authenticated users keep the full source chooser.
if (step === 0) {
const cloudBusy = amrLoginPending;
const cloudBusy = amrLoginBusy;
const amrStatusResolving = !amrStatusResolved;
return (
<section
@@ -3388,7 +3450,42 @@ function OnboardingView({
>
{t('settings.amrCancelSignIn')}
</button>
) : null}
) : (
<div className="onboarding-cloud__alts">
<Button
variant="subtle"
className="onboarding-cloud__alt-btn"
onClick={() => {
emitOnboardingClick('local_coding_agent', 'select_runtime', {
runtime_type: 'local_cli',
});
setRuntime('local');
setRuntimeSetupEntry('cloud');
void scanCliAgents({ preferExisting: true });
setStep(2);
}}
>
<Icon name="robot" size={16} />
{t('settings.onboardingLocalTitle')}
</Button>
<span className="onboarding-cloud__alts-or">
{t('settings.onboardingCloudOr')}
</span>
<Button
variant="subtle"
className="onboarding-cloud__alt-btn"
onClick={() => {
emitOnboardingClick('byok', 'select_runtime', { runtime_type: 'byok' });
setRuntime('byok');
setRuntimeSetupEntry('cloud');
setStep(2);
}}
>
<Icon name="key" size={16} />
{t('settings.onboardingByokTitle')}
</Button>
</div>
)}
</div>
<footer className="onboarding-cloud__footer">
<LanguageMenu placement="up" align="start" />
@@ -1061,8 +1061,12 @@ describe('EntryShell onboarding OpenDesign AMR runtime', () => {
await waitFor(() => {
expect(props.onAgentChange).not.toHaveBeenCalledWith('amr');
});
expect(screen.queryByRole('button', { name: /Local Agent/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Bring Your Own Key/i })).toBeNull();
expect(
(screen.getByRole('button', { name: /Local Agent/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: /Bring Your Own Key/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(screen.queryByText('Sign in to continue')).toBeNull();
});
@@ -1079,15 +1083,69 @@ describe('EntryShell onboarding OpenDesign AMR runtime', () => {
expect(screen.queryByText('AMR v0.1.0')).toBeNull();
expect(screen.queryByRole('button', { name: /Sign in to continue/i })).toBeNull();
expect(screen.queryByRole('link', { name: /Authorize AMR/i })).toBeNull();
// Model-source choices stay behind the mandatory identity gate.
expect(screen.queryByRole('button', { name: /Local Agent/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Bring Your Own Key/i })).toBeNull();
// Cloud stays primary while identity-independent setup paths remain available.
expect(
(screen.getByRole('button', { name: /Local Agent/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: /Bring Your Own Key/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(screen.queryByRole('button', { name: /OpenDesign AMR/i })).toBeNull();
expect(screen.queryByRole('link', { name: /Authorize AMR/i })).toBeNull();
expect(screen.queryByText('Not signed in')).toBeNull();
expect(screen.queryByRole('button', { name: /^Sign in$/i })).toBeNull();
});
it('keeps direct Local CLI setup active when delayed status discovers a Cloud login', async () => {
let releaseInitialStatus!: (response: Response) => void;
const initialStatus = new Promise<Response>((resolve) => {
releaseInitialStatus = resolve;
});
let statusCalls = 0;
globalThis.fetch = vi.fn(async (input) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
statusCalls += 1;
if (statusCalls === 1) return initialStatus;
return jsonResponse({
loggedIn: false,
loginInFlight: true,
authAttemptId: '11111111-1111-4111-8111-111111111111',
profile: 'prod',
user: null,
configPath: '/x',
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
renderOnboarding({
config: baseConfig({
agentId: 'claude-code',
agentModels: { 'claude-code': { model: 'sonnet' } },
}),
});
fireEvent.click(screen.getByRole('button', { name: /Local Agent/i }));
expect(await screen.findByText('Local CLI')).toBeTruthy();
await act(async () => {
releaseInitialStatus(jsonResponse({
loggedIn: false,
loginInFlight: true,
authAttemptId: '11111111-1111-4111-8111-111111111111',
profile: 'prod',
user: null,
configPath: '/x',
}));
await Promise.resolve();
});
expect(screen.getByText('Local CLI')).toBeTruthy();
expect(
(screen.getByRole('button', { name: /^Continue$/i }) as HTMLButtonElement).disabled,
).toBe(false);
});
it('excludes AMR from the Local CLI agent list', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
@@ -1285,6 +1343,8 @@ describe('EntryShell onboarding OpenDesign AMR runtime', () => {
await act(async () => {});
expect(screen.getByText('Signing in…')).toBeTruthy();
expect(signIn.hasAttribute('disabled')).toBe(true);
expect(screen.queryByRole('button', { name: /Local Agent/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Bring Your Own Key/i })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /Cancel sign-in/i }));
await act(async () => {});
@@ -1295,7 +1355,12 @@ describe('EntryShell onboarding OpenDesign AMR runtime', () => {
name: /Sign in to OpenDesign/i,
});
expect(cloudButton.hasAttribute('disabled')).toBe(false);
expect(screen.queryByRole('button', { name: /Local Agent/i })).toBeNull();
expect(
(screen.getByRole('button', { name: /Local Agent/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: /Bring Your Own Key/i }) as HTMLButtonElement).disabled,
).toBe(false);
fireEvent.click(cloudButton);
await act(async () => {});
@@ -1975,8 +2040,12 @@ describe('EntryShell onboarding OpenDesign AMR runtime', () => {
expect((primary as HTMLButtonElement).disabled).toBe(true);
expect(document.querySelector('.onboarding-view__card--skeleton')).toBeNull();
expect(screen.queryByRole('button', { name: /OpenDesign AMR/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Local Agent/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Bring Your Own Key/i })).toBeNull();
expect(
(screen.getByRole('button', { name: /Local Agent/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: /Bring Your Own Key/i }) as HTMLButtonElement).disabled,
).toBe(false);
});
it('renders the cloud sign-in CTA and no legacy AMR card once AMR is available', async () => {
+199 -23
View File
@@ -35,10 +35,12 @@ type OnboardingConfig = {
declare global {
interface Window {
__amrOnboardingCancelCalls?: number;
__amrOnboardingCompleteLogin?: boolean;
__amrOnboardingDelayNextSignedOutStatus?: boolean;
__amrOnboardingLoginCalls?: number;
__amrOnboardingSlowStatusResolved?: boolean;
__amrOnboardingStatusCalls?: number;
__amrOnboardingStatusResponses?: number;
}
}
@@ -82,7 +84,7 @@ test('[P0] @critical onboarding lets AMR Cloud sign in and complete setup after
});
});
test('[P0] signed-out onboarding requires Cloud authorization before model source selection', async ({ page }) => {
test('[P0] signed-out onboarding can open Local CLI setup without Cloud authorization', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: false,
@@ -92,15 +94,106 @@ test('[P0] signed-out onboarding requires Cloud authorization before model sourc
await seedOnboardingConfig(page, config);
await gotoOnboarding(page);
// Execution-source selection belongs behind Cloud identity. Signed-out
// users see only the login gate; Local Agent and BYOK become available
// after authentication resolves.
// Local CLI is independent from Cloud identity. The Cloud sign-in remains
// the primary action while Local CLI is available as a direct setup path.
const primary = cloudPrimaryButton(page);
await expect(primary).toBeVisible();
await expect(primary).toHaveText(/Sign in to OpenDesign|登录 OpenDesign/i);
await expect(page.getByRole('button', { name: /Local (coding )?agent/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /Bring Your Own Key/i })).toHaveCount(0);
await page.getByRole('button', { name: /Local (coding )?agent/i }).click();
await expect(page.locator('.onboarding-view__setup-panel')).toBeVisible();
await expect(page.getByRole('heading', { name: /Local (coding )?agent/i })).toBeVisible();
await expect.poll(() => page.evaluate(() => window.__amrOnboardingLoginCalls ?? 0)).toBe(0);
await page.getByRole('button', { name: /^Back$|返回/i }).click();
await expect(cloudPrimaryButton(page)).toHaveText(/Sign in to OpenDesign|登录 OpenDesign/i);
await expect(page.getByRole('radiogroup')).toHaveCount(0);
});
test('[P0] signed-out onboarding can open BYOK setup without Cloud authorization', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: false,
keepAmrLoginIncomplete: true,
});
await seedOnboardingConfig(page, config);
await gotoOnboarding(page);
await page.getByRole('button', { name: /Bring Your Own Key/i }).click();
await expect(page.locator('.onboarding-view__setup-panel')).toBeVisible();
await expect(page.getByRole('heading', { name: /Bring Your Own Key/i })).toBeVisible();
await expect.poll(() => page.evaluate(() => window.__amrOnboardingLoginCalls ?? 0)).toBe(0);
await page.getByRole('button', { name: /^Back$|返回/i }).click();
await expect(cloudPrimaryButton(page)).toHaveText(/Sign in to OpenDesign|登录 OpenDesign/i);
await expect(page.getByRole('radiogroup')).toHaveCount(0);
});
test('[P0] Cloud status loading does not block signed-out Local CLI or BYOK setup', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: false,
keepAmrLoginIncomplete: true,
delayAllStatusMs: 5_000,
});
await seedOnboardingConfig(page, config);
await page.goto('/onboarding', { waitUntil: 'domcontentloaded' });
await expect(connectLandingHeading(page)).toBeVisible();
await expect(cloudPrimaryButton(page)).toBeDisabled();
await expect(page.getByRole('button', { name: /Local (coding )?agent/i })).toBeEnabled();
await expect(page.getByRole('button', { name: /Bring Your Own Key/i })).toBeEnabled();
await expect
.poll(() => page.evaluate(() => window.__amrOnboardingSlowStatusResolved ?? false))
.toBe(true);
});
test('[P0] delayed active Cloud login stays out of Local setup and resumes after Back', async ({ page }) => {
let releaseInitialStatus!: () => void;
const statusGate = new Promise<void>((resolve) => {
releaseInitialStatus = resolve;
});
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: false,
initialLoginInFlight: true,
keepAmrLoginIncomplete: true,
statusGate,
});
await seedOnboardingConfig(page, config);
await page.goto('/onboarding', { waitUntil: 'domcontentloaded' });
await expect(connectLandingHeading(page)).toBeVisible();
await page.getByRole('button', { name: /Local (coding )?agent/i }).click();
const localPanel = page.locator('.onboarding-view__setup-panel');
const continueButton = page.getByRole('button', { name: /^Continue$|继续/i });
await expect(localPanel).toBeVisible();
await expect(continueButton).toBeEnabled();
await expect(continueButton).not.toHaveAttribute('aria-disabled', 'true');
releaseInitialStatus();
await expect
.poll(() => page.evaluate(() => window.__amrOnboardingStatusResponses ?? 0))
.toBeGreaterThanOrEqual(1);
await expect(localPanel).toBeVisible();
await expect(continueButton).toBeEnabled();
await expect(continueButton).not.toHaveAttribute('aria-disabled', 'true');
await expect(page.getByRole('radiogroup')).toHaveCount(0);
await expect(page.getByRole('button', { name: /Cancel sign-in/i })).toHaveCount(0);
await expectStableCount(() => localPanel.count(), 1, {
timeout: 2_500,
message: 'a resumed Cloud login must not replace the active Local CLI setup',
});
await page.getByRole('button', { name: /^Back$|返回/i }).click();
await expect(page.getByRole('button', { name: /Cancel sign-in/i })).toBeVisible();
await page.evaluate(() => {
window.__amrOnboardingCompleteLogin = true;
});
await expectModelSourceChooser(page);
});
test('[P0] @critical onboarding Local CLI card lets the user pick an agent model before continuing', async ({ page }) => {
@@ -139,6 +232,9 @@ test('[P0] @critical onboarding Local CLI card lets the user pick an agent model
'aria-disabled',
'true',
);
await page.getByRole('button', { name: /^Back$|返回/i }).click();
await expectModelSourceChooser(page);
});
test('[P0] onboarding Local CLI path completes setup with the selected agent model', async ({ page }) => {
@@ -242,7 +338,7 @@ test('[P0] onboarding supports Local CLI when the AMR agent is unavailable', asy
await expect(page.getByRole('button', { name: /^Continue$/i })).toBeVisible();
});
test('[P0] onboarding signed-in AMR status failure stays gated instead of bypassing Connect', async ({ page }) => {
test('[P0] Cloud status failure does not block signed-out Local CLI or BYOK setup', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: true,
@@ -255,15 +351,16 @@ test('[P0] onboarding signed-in AMR status failure stays gated instead of bypass
await expect
.poll(() => page.evaluate(() => window.__amrOnboardingStatusCalls ?? 0))
.toBeGreaterThanOrEqual(1);
// The signed-in status never resolves (all polls fail), so the cloud landing
// can't show "Continue (signed in)" and falls back to the sign-in CTA. There
// is no Skip, no Continue, and no stepper to bypass the gate.
// The signed-in status never resolves (all polls fail), so the Cloud action
// stays gated. Independent Local CLI and BYOK setup remain available.
await expect(page.getByRole('button', { name: /Skip for now/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Continue$/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /About you|了解你/i })).toHaveCount(0);
const primary = cloudPrimaryButton(page);
await expect(primary).toBeVisible();
await expect(primary).toHaveText(/Sign in to OpenDesign|登录 OpenDesign/i);
await expect(page.getByRole('button', { name: /Local (coding )?agent/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Bring Your Own Key/i })).toBeVisible();
await expect(page.getByText(/Optional details for better defaults/i)).toHaveCount(0);
await page.goto('/', { waitUntil: 'domcontentloaded' });
@@ -295,6 +392,8 @@ test('[P0] onboarding cancel during a slow AMR status check does not start login
const cancelSignIn = page.getByRole('button', { name: /Cancel sign-in/i });
await expect(cancelSignIn).toBeVisible();
await expect(page.getByRole('button', { name: /Local (coding )?agent/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /Bring Your Own Key/i })).toHaveCount(0);
await cancelSignIn.click();
const primary = cloudPrimaryButton(page);
@@ -306,6 +405,8 @@ test('[P0] onboarding cancel during a slow AMR status check does not start login
.poll(() => page.evaluate(() => window.__amrOnboardingSlowStatusResolved ?? false))
.toBe(true);
await expect(page.getByRole('button', { name: /Cancel sign-in/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /Local (coding )?agent/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Bring Your Own Key/i })).toBeVisible();
await expectStableCount(
() => page.evaluate(() => window.__amrOnboardingLoginCalls ?? 0),
0,
@@ -316,6 +417,53 @@ test('[P0] onboarding cancel during a slow AMR status check does not start login
);
});
test('[P0] onboarding reload restores and cancels an active Cloud login', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: false,
initialLoginInFlight: true,
keepAmrLoginIncomplete: true,
});
await seedOnboardingConfig(page, config);
await gotoOnboarding(page);
await expect(page.getByRole('button', { name: /Cancel sign-in/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Local (coding )?agent/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /Bring Your Own Key/i })).toHaveCount(0);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
await expect(page.getByRole('button', { name: /Cancel sign-in/i })).toBeVisible();
await page.getByRole('button', { name: /Cancel sign-in/i }).click();
await expect.poll(() => page.evaluate(() => window.__amrOnboardingCancelCalls ?? 0)).toBe(1);
await expect(page.getByRole('button', { name: /Local (coding )?agent/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Bring Your Own Key/i })).toBeVisible();
});
test('[P0] onboarding reload resumes an active Cloud login through completion', async ({ page }) => {
const config = await wireOnboardingMocks(page, {
amrAvailable: true,
initialLoggedIn: false,
initialLoginInFlight: true,
keepAmrLoginIncomplete: true,
});
await seedOnboardingConfig(page, config);
await gotoOnboarding(page);
await expect(page.getByRole('button', { name: /Cancel sign-in/i })).toBeVisible();
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForLoadingToClear(page);
await expect(page.getByRole('button', { name: /Cancel sign-in/i })).toBeVisible();
await page.evaluate(() => {
window.__amrOnboardingCompleteLogin = true;
});
await expectModelSourceChooser(page);
await expect(page.getByRole('button', { name: /Cancel sign-in/i })).toHaveCount(0);
});
// The AMR card + per-runtime model picker on the connect step were removed.
// A signed-in user now accepts Hosted on the model-source chooser.
test('[P0] @critical onboarding signed-in AMR path finishes setup with the AMR runtime', async ({ page }) => {
@@ -879,10 +1027,13 @@ async function wireOnboardingMocks(
options: {
amrAvailable: boolean;
initialLoggedIn: boolean;
initialLoginInFlight?: boolean;
failAllStatusPolls?: boolean;
keepAmrLoginIncomplete?: boolean;
sessionState?: 'signed_out' | 'authenticated' | 'reauth_required';
delayAllStatusMs?: number;
delaySignedOutStatusMs?: number;
statusGate?: Promise<void>;
agentsDelayMs?: number;
codexModels?: Array<{ id: string; label: string }>;
localAgents?: Array<{
@@ -911,11 +1062,14 @@ async function wireOnboardingMocks(
};
let loggedIn = options.initialLoggedIn;
let loginInFlight = false;
let loginInFlight = options.initialLoginInFlight ?? false;
let statusCalls = 0;
let statusResponses = 0;
let loginCalls = 0;
let cancelCalls = 0;
let authAttemptId: string | null = null;
let authAttemptId: string | null = loginInFlight
? '11111111-1111-4111-8111-111111111111'
: null;
await page.route('**/api/health', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' });
@@ -977,27 +1131,44 @@ async function wireOnboardingMocks(
await page.evaluate((calls) => {
window.__amrOnboardingStatusCalls = calls;
}, statusCalls);
if (options.statusGate) {
await options.statusGate;
}
if (options.failAllStatusPolls) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'status unavailable' }),
});
statusResponses += 1;
await page.evaluate((responses) => {
window.__amrOnboardingStatusResponses = responses;
}, statusResponses);
return;
}
if (loginInFlight && await page.evaluate(() => (
window.__amrOnboardingCompleteLogin === true
))) {
loggedIn = true;
loginInFlight = false;
}
const delayAllStatusMs = options.delayAllStatusMs ?? 0;
const shouldDelayAllStatuses = delayAllStatusMs > 0;
const shouldDelaySignedOutStatus =
!loggedIn
&& typeof options.delaySignedOutStatusMs === 'number'
&& options.delaySignedOutStatusMs > 0
&& await page.evaluate(() => {
if (!window.__amrOnboardingDelayNextSignedOutStatus) return false;
window.__amrOnboardingDelayNextSignedOutStatus = false;
return true;
});
shouldDelayAllStatuses ||
(!loggedIn &&
typeof options.delaySignedOutStatusMs === 'number' &&
options.delaySignedOutStatusMs > 0 &&
(await page.evaluate(() => {
if (!window.__amrOnboardingDelayNextSignedOutStatus) return false;
window.__amrOnboardingDelayNextSignedOutStatus = false;
return true;
})));
if (shouldDelaySignedOutStatus) {
await new Promise((resolve) =>
setTimeout(resolve, options.delaySignedOutStatusMs),
);
const delayMs = shouldDelayAllStatuses
? delayAllStatusMs
: options.delaySignedOutStatusMs ?? 0;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
await route.fulfill({
json: loggedIn
@@ -1013,6 +1184,7 @@ async function wireOnboardingMocks(
: {
loggedIn: false,
loginInFlight,
authAttemptId,
sessionState: 'signed_out',
credentialRevision: 'signed-out',
profile: 'local',
@@ -1020,6 +1192,10 @@ async function wireOnboardingMocks(
user: null,
},
});
statusResponses += 1;
await page.evaluate((responses) => {
window.__amrOnboardingStatusResponses = responses;
}, statusResponses);
if (shouldDelaySignedOutStatus) {
await page.evaluate(() => {
window.__amrOnboardingSlowStatusResolved = true;
@@ -11,7 +11,7 @@
import { expect, test } from '@/playwright/suite';
import type { Page } from '@playwright/test';
import { applyStandardMocks } from '@/playwright/mock-factory';
import { applyStandardMocks, routeSignedOutVelaStatus } from '@/playwright/mock-factory';
import { ensureRailOpen } from '@/playwright/rail';
import { T } from '@/timeouts';
@@ -126,15 +126,23 @@ async function gotoCommunity(page: Page) {
await expect(page.locator('article.community-template-card').first()).toBeVisible();
}
async function openDeckCommunityCard(page: Page) {
await page.getByRole('button', { name: 'Slides', exact: true }).click();
const card = page.locator('article.community-template-card').first();
await expect(card.locator('.community-template-card__foot')).toContainText('Slides');
await card.click();
}
test.beforeEach(async ({ page }) => {
await applyStandardMocks(page);
await routeSignedOutVelaStatus(page);
await routeMappingFixtures(page);
});
test('[P1] community template card opens the full details modal (Use split + Share), not the lightweight preview', async ({ page }) => {
await gotoCommunity(page);
await page.locator('article.community-template-card').first().click();
await openDeckCommunityCard(page);
// Full modal chrome: Use split action (main face + caret), Share menu,
// and the ds-modal shell — the lightweight footer-Remix preview must not
@@ -153,12 +161,12 @@ test('[P1] community template card opens the full details modal (Use split + Sha
await page.keyboard.press('Escape');
});
test('[P1] community Use hands into Home and the active template chip opens the lightweight preview', async ({ page }) => {
test('[P0] signed-out Local setup can use a Community template on Home', async ({ page }) => {
await gotoCommunity(page);
// Use from the community card's full modal routes the plugin as the Home
// composer's active driver (EntryShell onUsePlugin hand-off).
await page.locator('article.community-template-card').first().click();
await openDeckCommunityCard(page);
await page.getByTestId(`plugin-details-use-${DECK_PLUGIN.id}`).click();
await expect(page.getByTestId('home-hero-active-plugin')).toBeVisible();
@@ -182,10 +190,10 @@ test('[P1] community category tabs filter the current template catalog', async (
const cards = page.locator('article.community-template-card');
await expect(cards).toHaveCount(1);
await expect(cards.locator('.community-template-card__foot')).toContainText('Slides');
await expect(cards.locator('.community-template-card__foot')).toContainText('Prototype');
await page.getByRole('button', { name: 'Prototype', exact: true }).click();
await page.getByRole('button', { name: 'Slides', exact: true }).click();
await expect(cards).toHaveCount(1);
await expect(cards.locator('.community-template-card__foot')).toContainText('Prototype');
await expect(cards.locator('.community-template-card__foot')).toContainText('Slides');
});
+8 -1
View File
@@ -2,13 +2,20 @@ import { expect, test } from '@/playwright/suite';
import { ensureRailOpen, openNewProjectModal as openNewProjectModalFromProjects } from '@/playwright/rail';
import { settingsSurface } from '@/playwright/amr';
import type { Locator, Page } from '@playwright/test';
import { applyStandardMocks } from '@/playwright/mock-factory';
import { applyStandardMocks, routeSignedOutVelaStatus } from '@/playwright/mock-factory';
import { T } from '@/timeouts';
test.describe.configure({ timeout: T.xlong });
test.beforeEach(async ({ page }) => {
await applyStandardMocks(page);
// This file is the compact Personal/local capability lane. Pin Cloud to a
// definitive signed-out response so Home and project creation cannot pass
// merely because identity stayed unresolved behind the standard 503 mock.
await routeSignedOutVelaStatus(page);
await page.route('**/api/workspace/directory', async (route) => {
await route.fulfill({ json: { items: [] } });
});
});
test('[P0] @critical home loads with the primary entry controls', async ({ page }) => {
+12 -3
View File
@@ -8,7 +8,13 @@ import type {
WorkspaceDirectoryItem,
} from '@open-design/contracts';
import type { Page, Request } from '@playwright/test';
import { applyStandardMocks, fulfillAgentsRoute, routeSuccessfulRuns, STORAGE_KEY } from '@/playwright/mock-factory';
import {
applyStandardMocks,
fulfillAgentsRoute,
routeSignedOutVelaStatus,
routeSuccessfulRuns,
STORAGE_KEY,
} from '@/playwright/mock-factory';
import { T } from '@/timeouts';
const LOCAL_CLI_LABEL = /Local CLI|Local coding agent|本机 CLI|本地 CLI/i;
const STARTER_PLUGIN = makeStarterPlugin({
@@ -365,6 +371,7 @@ test('[P1] onboarding lands on the home composer without a recommended-start str
});
test('[P1] entry top navigation matches the current home tab structure', async ({ page }) => {
await routeSignedOutVelaStatus(page);
await gotoEntryHome(page);
await ensureRailOpen(page);
@@ -1054,7 +1061,8 @@ test('[P2] home topbar overlays close on outside click, Escape, and Settings ope
// inside the Home composer footer and does not follow the user to secondary
// entry pages. This spec now pins the rail's surviving destinations plus the
// pill at its new, Home-only home.
test('[P1] rail destinations navigate and Home keeps its composer execution pill', async ({ page }) => {
test('[P0] signed-out Local setup can navigate the surviving rail destinations', async ({ page }) => {
await routeSignedOutVelaStatus(page);
await routeDesignSystems(page);
await gotoEntryHome(page);
@@ -1244,7 +1252,8 @@ test('[P0] @critical home hero input keeps Shift+Enter as a newline and submits
await expect(page).toHaveURL(/\/projects\//);
});
test('[P1] home hero @ mention picker opens and Enter applies the highlighted plugin', async ({ page }) => {
test('[P0] signed-out Local setup can apply a plugin from the Home composer', async ({ page }) => {
await routeSignedOutVelaStatus(page);
await page.route('**/api/plugins', async (route) => {
await route.fulfill({
json: {
+3 -1
View File
@@ -6,6 +6,7 @@ import {
} from '@/playwright/home-hero';
import {
routeAgents,
routeSignedOutVelaStatus,
routeSuccessfulRuns,
successfulRunEventBody,
suppressWhatsNew,
@@ -1421,8 +1422,9 @@ test('[P0] home design-system picker carries explicit and cleared selections int
expect(clearedBody.designSystemId ?? null).toBeNull();
});
test('[P1] home design-system picker Create opens design-system creation and starts brand extraction', async ({ page }) => {
test('[P0] signed-out Local setup can create a design system and start brand extraction', async ({ page }) => {
const brandRequests: Array<{ url?: string; locale?: string }> = [];
await routeSignedOutVelaStatus(page);
await routeHomeDesignSystems(page);
await routeProjectCreates(page);
await routeRunsAccepted(page);
+6 -6
View File
@@ -28,8 +28,8 @@ test('[P2] captures the onboarding cloud sign-in surface', async ({ page }) => {
await page.goto('/onboarding', { waitUntil: 'domcontentloaded' });
await page.getByText('Loading OpenDesign…').waitFor({ state: 'hidden', timeout: T.long });
// Execution-source selection is intentionally gated behind Cloud identity.
// The signed-out landing exposes only the authentication action.
// Cloud stays primary while identity-independent Local Agent and BYOK setup
// remain available directly from the signed-out landing.
await expect(
page.getByRole('heading', { name: /Sign in to OpenDesign|登录 OpenDesign/i }),
).toBeVisible({ timeout: T.medium });
@@ -37,11 +37,11 @@ test('[P2] captures the onboarding cloud sign-in surface', async ({ page }) => {
page.getByRole('button', { name: /Sign in to OpenDesign|登录 OpenDesign/i }),
).toBeVisible();
await expect(
page.getByRole('button', { name: /Local coding agent|本地 Coding Agent/i }),
).toHaveCount(0);
page.getByRole('button', { name: /Local (coding )?agent|本地 (Coding )?Agent/i }),
).toBeVisible();
await expect(
page.getByRole('button', { name: /Bring your own key|自己的模型 Key/i }),
).toHaveCount(0);
page.getByRole('button', { name: /Bring your own key|使用自己的 Key|自己的模型 Key/i }),
).toBeVisible();
await waitForVisualFonts(page);
await captureVisual(page, 'visual-onboarding-cloud');