mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user