mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(web-shell): wait for session before checking subscription commands * fix(cli): keep signed-out subscription startup in the composer * fix(subscription): preserve model selection across sign-in * fix(subscription): align sign-in with model selection scope * fix(subscription): apply reasoning defaults across models * fix(subscription): tag Qwen web links by client * fix(subscription): prefix Qwen web client tags * fix(subscription): attribute Qwen web links by paywall scene * fix(subscription): recover historical sessions with saved API models * fix(cli): remove the signed-out subscription startup notice * fix(subscription): support standalone desktop activation and expose errors * fix(subscription): isolate activation and session model selection * fix(subscription): enforce auth policy and restore home login * test(cli): preserve the default initializer mock on restore --------- Co-authored-by: luoyun <[email protected]> Co-authored-by: 易良 <[email protected]>
1248 lines
42 KiB
TypeScript
1248 lines
42 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
import type { DaemonSubscriptionFlow } from '@qwen-code/sdk/daemon';
|
|
import { QwenSubscriptionPanel } from './QwenSubscriptionPanel';
|
|
|
|
const user = {
|
|
sub: 'one',
|
|
name: 'Test',
|
|
email: '[email protected]',
|
|
member_type: 'free',
|
|
};
|
|
const ready: DaemonSubscriptionFlow = {
|
|
id: 'flow-one',
|
|
status: 'ready',
|
|
userInfo: user,
|
|
};
|
|
const authorizationUrl =
|
|
'https://chat.qwen.ai/authorize?user_code=TEST-CODE&client=desktop&entry=qwen_code&paywallScene=desktop';
|
|
const transport = {
|
|
getSubscriptionConfig: vi.fn(),
|
|
startSubscriptionLogin: vi.fn(),
|
|
getSubscriptionLogin: vi.fn(),
|
|
cancelSubscriptionLogin: vi.fn(),
|
|
getSubscriptionUserInfo: vi.fn(),
|
|
signOutSubscription: vi.fn(),
|
|
};
|
|
let root: Root | undefined;
|
|
let container: HTMLDivElement;
|
|
let open: ReturnType<typeof vi.fn>;
|
|
let close: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.resetAllMocks();
|
|
transport.getSubscriptionConfig.mockResolvedValue({
|
|
enabled: true,
|
|
plansUrl:
|
|
'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
|
|
});
|
|
transport.startSubscriptionLogin.mockResolvedValue(ready);
|
|
transport.getSubscriptionUserInfo.mockResolvedValue(user);
|
|
transport.cancelSubscriptionLogin.mockResolvedValue(undefined);
|
|
transport.signOutSubscription.mockResolvedValue(undefined);
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
open = vi.fn();
|
|
close = vi.fn();
|
|
});
|
|
afterEach(() => {
|
|
act(() => root?.unmount());
|
|
root = undefined;
|
|
container.remove();
|
|
vi.useRealTimers();
|
|
});
|
|
async function render(
|
|
activation?: {
|
|
onActivate: (signal: AbortSignal) => Promise<string | void>;
|
|
onActivated: () => void;
|
|
},
|
|
activeTransport = transport,
|
|
presentation: {
|
|
dialog?: boolean;
|
|
desktopSuccess?: boolean;
|
|
onAuthError?: (message: string) => void;
|
|
onModelsUnavailable?: () => void;
|
|
onSignedOut?: () => void;
|
|
} = {},
|
|
) {
|
|
await act(async () => {
|
|
root!.render(
|
|
<QwenSubscriptionPanel
|
|
transport={activeTransport}
|
|
{...activation}
|
|
{...presentation}
|
|
onOpenExternal={open}
|
|
onClose={close}
|
|
onOtherProviders={vi.fn()}
|
|
language="en"
|
|
/>,
|
|
);
|
|
});
|
|
}
|
|
async function click(label: string) {
|
|
const button = [...container.querySelectorAll('button')].find((item) =>
|
|
item.textContent?.includes(label),
|
|
);
|
|
expect(button, `Missing ${label}`).toBeDefined();
|
|
await act(async () => {
|
|
button!.click();
|
|
});
|
|
}
|
|
async function tick(ms: number) {
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(ms);
|
|
});
|
|
}
|
|
|
|
it('does not flash Sign out while the VS Code login activates the subscription', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'plus' },
|
|
});
|
|
let finish!: (model: string) => void;
|
|
const onActivate = vi.fn(
|
|
() =>
|
|
new Promise<string>((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
const onActivated = vi.fn();
|
|
await render({ onActivate, onActivated });
|
|
expect(onActivate).toHaveBeenCalledOnce();
|
|
expect(container.textContent).toContain('Loading models from provider');
|
|
expect.soft(container.textContent).not.toContain('Sign out');
|
|
expect(container.textContent).not.toContain('Cancel');
|
|
expect(container.textContent).not.toContain('Qwen Code is ready');
|
|
|
|
await act(async () => finish('qwen3.8-max'));
|
|
expect(onActivated).toHaveBeenCalledOnce();
|
|
expect(onActivated).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'Common commands: /usage /upgrade /model /status /logout',
|
|
),
|
|
);
|
|
expect(container.textContent).not.toContain('Sign out');
|
|
expect(container.textContent).not.toContain('Qwen Code is ready');
|
|
expect(container.textContent).not.toContain('Cancel');
|
|
expect(container.textContent).toContain('Loading models from provider');
|
|
});
|
|
|
|
it('starts authorization directly after the native picker selection and opens the actual link once', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-one',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
});
|
|
transport.getSubscriptionLogin
|
|
.mockResolvedValueOnce({
|
|
id: 'flow-one',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
})
|
|
.mockResolvedValue(ready);
|
|
await render();
|
|
expect(container.textContent).not.toContain('Qwen Account with Subscription');
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
expect(container.textContent).toContain('Waiting for sign-in');
|
|
expect(container.textContent).not.toContain('Mock');
|
|
expect(open).toHaveBeenCalledWith(authorizationUrl);
|
|
await tick(2000);
|
|
expect(
|
|
open.mock.calls.filter(([url]) => url === authorizationUrl),
|
|
).toHaveLength(1);
|
|
expect(container.textContent).toContain(user.email);
|
|
expect(container.textContent).toContain('Free account');
|
|
expect(container.textContent).not.toContain('Retry Connection');
|
|
expect(open.mock.calls.map(([url]) => url)).toEqual([authorizationUrl]);
|
|
await click('View Plans');
|
|
expect(open.mock.calls.map(([url]) => url)).toEqual([
|
|
authorizationUrl,
|
|
'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
|
|
]);
|
|
});
|
|
|
|
it.each(
|
|
['free', 'FREE', 'lite', 'go', 'enterprise', 'VIP'].flatMap((member_type) =>
|
|
[false, true].map((dialog) => ({ member_type, dialog })),
|
|
),
|
|
)(
|
|
'opens authorization for $member_type membership (dialog=$dialog)',
|
|
async ({ member_type, dialog }) => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
authorizationUrl,
|
|
userInfo: { ...user, member_type },
|
|
});
|
|
await render(undefined, transport, { dialog });
|
|
expect(open.mock.calls.map(([url]) => url)).toEqual([authorizationUrl]);
|
|
expect(
|
|
[...document.querySelectorAll('section button')].map((button) =>
|
|
button.getAttribute('aria-label'),
|
|
),
|
|
).toEqual(['View Plans', 'Switch Provider', 'Use another account']);
|
|
await tick(5000);
|
|
expect(transport.getSubscriptionUserInfo).toHaveBeenCalledOnce();
|
|
expect(open).toHaveBeenCalledOnce();
|
|
await act(async () => {
|
|
document
|
|
.querySelector<HTMLButtonElement>(
|
|
'section button[aria-label="View Plans"]',
|
|
)!
|
|
.click();
|
|
});
|
|
expect(open.mock.calls.map(([url]) => url)).toEqual([
|
|
authorizationUrl,
|
|
'https://chat.qwen.ai/?memberPlan=qwen_code&entry=qwen_code&paywallScene=desktop',
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each(
|
|
['plus', 'PLUS', 'pro', 'Pro'].flatMap((member_type) =>
|
|
[false, true].map((dialog) => ({ member_type, dialog })),
|
|
),
|
|
)(
|
|
'opens authorization for paid $member_type membership (dialog=$dialog)',
|
|
async ({ member_type, dialog }) => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
authorizationUrl,
|
|
userInfo: { ...user, member_type },
|
|
});
|
|
await render(undefined, transport, { dialog });
|
|
expect(open.mock.calls.map(([url]) => url)).toEqual([authorizationUrl]);
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
},
|
|
);
|
|
|
|
it('keeps authorization navigation when a failed first membership query is retried', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-one',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
});
|
|
transport.getSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-one',
|
|
status: 'error',
|
|
errorCode: 'network_error',
|
|
errorStage: 'userinfo',
|
|
});
|
|
await render();
|
|
await tick(1000);
|
|
expect(container.textContent).toContain(
|
|
"Couldn't check your subscription status",
|
|
);
|
|
await click('Retry Connection');
|
|
expect(container.textContent).toContain('Free account');
|
|
await tick(5000);
|
|
expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(2);
|
|
expect(open.mock.calls.map(([url]) => url)).toEqual([authorizationUrl]);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
'waits for credential cleanup then opens a fresh authorization once (dialog=%s)',
|
|
async (dialog) => {
|
|
let finish!: () => void;
|
|
transport.signOutSubscription.mockReturnValue(
|
|
new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
const onSignedOut = vi.fn();
|
|
await render(undefined, transport, { dialog, onSignedOut });
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-two',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
});
|
|
transport.getSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-two',
|
|
status: 'ready',
|
|
userInfo: { ...user, email: '[email protected]', member_type: 'pro' },
|
|
});
|
|
const button = [...document.querySelectorAll('button')].find(
|
|
(item) => item.textContent === 'Use another account',
|
|
)!;
|
|
expect(button).toBeDefined();
|
|
await act(async () => {
|
|
button.click();
|
|
button.click();
|
|
});
|
|
expect(transport.signOutSubscription).toHaveBeenCalledOnce();
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
expect(open).not.toHaveBeenCalledWith(authorizationUrl);
|
|
expect(document.body.textContent).not.toContain('Use another account');
|
|
await tick(10_000);
|
|
expect(transport.getSubscriptionUserInfo).not.toHaveBeenCalled();
|
|
await act(async () => finish());
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(2);
|
|
expect(transport.cancelSubscriptionLogin).toHaveBeenCalledWith('flow-one');
|
|
expect(
|
|
open.mock.calls.filter(([url]) => url === authorizationUrl),
|
|
).toHaveLength(1);
|
|
expect(close).not.toHaveBeenCalled();
|
|
expect(onSignedOut).not.toHaveBeenCalled();
|
|
await tick(1000);
|
|
expect(document.body.textContent).toContain('[email protected]');
|
|
expect(document.body.textContent).toContain(
|
|
'Current plan: Qwen Pro. Qwen Code is ready.',
|
|
);
|
|
},
|
|
);
|
|
|
|
it('ignores a late purchase query after account switching', async () => {
|
|
let completeQuery!: (info: typeof user) => void;
|
|
let querySignal: AbortSignal | undefined;
|
|
transport.getSubscriptionUserInfo.mockImplementation(
|
|
({ signal }: { signal: AbortSignal }) => {
|
|
querySignal = signal;
|
|
return new Promise((resolve) => {
|
|
completeQuery = resolve;
|
|
});
|
|
},
|
|
);
|
|
await render();
|
|
await tick(5000);
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-two',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
});
|
|
transport.getSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-two',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
});
|
|
await click('Use another account');
|
|
expect(querySignal?.aborted).toBe(true);
|
|
await act(async () => completeQuery({ ...user, member_type: 'pro' }));
|
|
expect(container.textContent).toContain('Waiting for sign-in');
|
|
expect(container.textContent).not.toContain(
|
|
'Current plan: Qwen Pro. Qwen Code is ready.',
|
|
);
|
|
});
|
|
|
|
it.each(['unmount', 'transport'] as const)(
|
|
'ignores credential cleanup completion after %s',
|
|
async (change) => {
|
|
let finish!: () => void;
|
|
transport.signOutSubscription.mockReturnValue(
|
|
new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
await render();
|
|
await click('Use another account');
|
|
const nextTransport = {
|
|
...transport,
|
|
startSubscriptionLogin: vi.fn().mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'plus' },
|
|
}),
|
|
};
|
|
if (change === 'unmount') await act(async () => root!.render(null));
|
|
else await render(undefined, nextTransport);
|
|
await act(async () => finish());
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
expect(open).not.toHaveBeenCalledWith(authorizationUrl);
|
|
if (change === 'transport') {
|
|
expect(nextTransport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
expect(container.textContent).toContain(
|
|
'Current plan: Qwen Plus. Qwen Code is ready.',
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([false, true])(
|
|
'does not restart authorization after cancelling cleanup without unmount (dialog=%s)',
|
|
async (dialog) => {
|
|
let finish!: () => void;
|
|
transport.signOutSubscription.mockReturnValue(
|
|
new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
await render(undefined, transport, { dialog });
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-two',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
});
|
|
transport.getSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-two',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
});
|
|
const press = async (label: string) => {
|
|
const button = [...document.body.querySelectorAll('button')].find(
|
|
(item) => item.textContent === label,
|
|
);
|
|
expect(button, `Missing ${label}`).toBeDefined();
|
|
await act(async () => button!.click());
|
|
};
|
|
await press('Use another account');
|
|
expect(transport.signOutSubscription).toHaveBeenCalledOnce();
|
|
await press(dialog ? 'previous' : 'Cancel');
|
|
expect(close).toHaveBeenCalledOnce();
|
|
await act(async () => finish());
|
|
await tick(1000);
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
expect(open).not.toHaveBeenCalledWith(authorizationUrl);
|
|
},
|
|
);
|
|
|
|
it.each([false, true])(
|
|
'does not restart a queued account switch after cancel (dialog=%s)',
|
|
async (dialog) => {
|
|
let finish!: () => void;
|
|
transport.signOutSubscription.mockReturnValue(
|
|
new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
await render(undefined, transport, { dialog });
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-two',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
});
|
|
const findButton = (label: string) => {
|
|
const button = [...document.body.querySelectorAll('button')].find(
|
|
(item) => item.textContent === label,
|
|
);
|
|
expect(button, `Missing ${label}`).toBeDefined();
|
|
return button!;
|
|
};
|
|
await act(async () => findButton('Use another account').click());
|
|
await act(async () => {
|
|
finish();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
findButton(dialog ? 'previous' : 'Cancel').click();
|
|
});
|
|
expect(close).toHaveBeenCalledOnce();
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
expect(open).not.toHaveBeenCalledWith(authorizationUrl);
|
|
},
|
|
);
|
|
|
|
it.each(['cleanup', 'revoke'] as const)(
|
|
'does not restart authorization on account switch %s failure',
|
|
async (errorStage) => {
|
|
transport.signOutSubscription.mockRejectedValue({
|
|
body: { code: 'credentials_error', errorStage },
|
|
});
|
|
const onSignedOut = vi.fn();
|
|
await render(undefined, transport, { onSignedOut });
|
|
await click('Use another account');
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
|
errorStage === 'cleanup'
|
|
? 'remove local Qwen credentials'
|
|
: 'signed out locally',
|
|
);
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
expect(onSignedOut).not.toHaveBeenCalled();
|
|
expect(close).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('notifies final sign-out only after credentials are removed', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
const onSignedOut = vi.fn();
|
|
await render(undefined, transport, { onSignedOut });
|
|
await click('Sign out');
|
|
expect(onSignedOut).toHaveBeenCalledOnce();
|
|
expect(close).toHaveBeenCalledOnce();
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('cancels a late start response after the panel was closed and never opens its URL', async () => {
|
|
let finish!: (flow: DaemonSubscriptionFlow) => void;
|
|
transport.startSubscriptionLogin.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
await render();
|
|
await click('Cancel');
|
|
expect(close).toHaveBeenCalledOnce();
|
|
await act(async () => root!.render(null));
|
|
await act(async () => {
|
|
finish({ id: 'late', status: 'waiting', authorizationUrl });
|
|
});
|
|
expect(transport.cancelSubscriptionLogin).toHaveBeenCalledWith('late');
|
|
expect(open).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('stops purchase polling at 120 seconds and retries once on Retry Connection', async () => {
|
|
await render();
|
|
await tick(120000);
|
|
expect(container.textContent).toContain("Your subscription isn't active yet");
|
|
const calls = transport.getSubscriptionUserInfo.mock.calls.length;
|
|
await tick(10000);
|
|
expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(calls);
|
|
await click('Retry Connection');
|
|
expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(calls + 1);
|
|
expect(container.textContent).toContain('No active Plus or Pro');
|
|
});
|
|
|
|
it('retries membership after a query failure and discovers a paid plan', async () => {
|
|
transport.getSubscriptionUserInfo
|
|
.mockRejectedValueOnce(new Error('offline'))
|
|
.mockResolvedValue({ ...user, member_type: 'pro' });
|
|
await render();
|
|
await tick(5000);
|
|
expect(container.textContent).toContain(
|
|
"Couldn't check your subscription status",
|
|
);
|
|
await click('Retry Connection');
|
|
expect(container.textContent).toContain(
|
|
'Current plan: Qwen Pro. Qwen Code is ready.',
|
|
);
|
|
expect(container.textContent).not.toContain('mock-model');
|
|
expect(container.textContent).toContain('Sign out');
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('aborts a manual check on unmount without signing out', async () => {
|
|
transport.getSubscriptionUserInfo.mockRejectedValueOnce(new Error('offline'));
|
|
await render();
|
|
await tick(5000);
|
|
let signal: AbortSignal | undefined;
|
|
transport.getSubscriptionUserInfo.mockImplementationOnce(
|
|
(opts: { signal: AbortSignal }) => {
|
|
signal = opts.signal;
|
|
return new Promise((_resolve, reject) =>
|
|
signal!.addEventListener('abort', () => reject(signal!.reason), {
|
|
once: true,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
await click('Retry Connection');
|
|
expect(container.querySelectorAll('button')).toHaveLength(2);
|
|
act(() => {
|
|
root!.unmount();
|
|
root = undefined;
|
|
});
|
|
expect(signal?.aborted).toBe(true);
|
|
expect(transport.signOutSubscription).not.toHaveBeenCalled();
|
|
expect(close).not.toHaveBeenCalled();
|
|
expect(container.textContent).not.toContain('Qwen Account with Subscription');
|
|
});
|
|
|
|
it('does not retry login automatically after denial and hides backend details', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-one',
|
|
status: 'error',
|
|
errorCode: 'access_denied',
|
|
});
|
|
await render();
|
|
expect(container.textContent).toContain(
|
|
'Authorization or access was denied.',
|
|
);
|
|
await tick(10000);
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(1);
|
|
transport.startSubscriptionLogin.mockResolvedValue(ready);
|
|
await click('Retry Connection');
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(2);
|
|
expect(container.textContent).toContain('Free account');
|
|
});
|
|
|
|
it('shows session expiration without sending the user to purchase', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-one',
|
|
status: 'error',
|
|
errorCode: 'invalid_grant',
|
|
errorStage: 'refresh',
|
|
});
|
|
await render();
|
|
expect(container.textContent).toContain(
|
|
'Your login credentials are no longer valid',
|
|
);
|
|
expect(open).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('recognizes refresh expiry in the daemon HTTP error body during membership polling', async () => {
|
|
transport.getSubscriptionUserInfo.mockRejectedValue({
|
|
body: { code: 'invalid_grant', errorStage: 'refresh' },
|
|
});
|
|
await render();
|
|
await tick(5000);
|
|
expect(container.textContent).toContain(
|
|
'Your login credentials are no longer valid',
|
|
);
|
|
expect(container.textContent).not.toContain(
|
|
"Couldn't check your subscription",
|
|
);
|
|
await tick(10000);
|
|
expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('ignores an old sign-out failure after switching daemon transports', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
let rejectSignOut!: (error: Error) => void;
|
|
transport.signOutSubscription.mockReturnValue(
|
|
new Promise((_resolve, reject) => {
|
|
rejectSignOut = reject;
|
|
}),
|
|
);
|
|
await render();
|
|
await click('Sign out');
|
|
const nextTransport = {
|
|
...transport,
|
|
startSubscriptionLogin: vi.fn().mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'plus' },
|
|
}),
|
|
};
|
|
await act(async () => {
|
|
root!.render(
|
|
<QwenSubscriptionPanel
|
|
transport={nextTransport}
|
|
onOpenExternal={open}
|
|
onClose={close}
|
|
onOtherProviders={vi.fn()}
|
|
language="en"
|
|
/>,
|
|
);
|
|
});
|
|
expect(container.textContent).toContain(
|
|
'Current plan: Qwen Plus. Qwen Code is ready.',
|
|
);
|
|
await act(async () => rejectSignOut(new Error('old daemon disconnected')));
|
|
expect(container.textContent).toContain(
|
|
'Current plan: Qwen Plus. Qwen Code is ready.',
|
|
);
|
|
expect(container.textContent).not.toContain('An error occurred');
|
|
});
|
|
|
|
it('unmount aborts polling and cancels only its own flow', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'own',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
});
|
|
await render();
|
|
act(() => {
|
|
root!.unmount();
|
|
root = undefined;
|
|
});
|
|
await tick(5000);
|
|
expect(transport.cancelSubscriptionLogin).toHaveBeenCalledWith('own');
|
|
expect(transport.getSubscriptionLogin).not.toHaveBeenCalled();
|
|
expect(transport.signOutSubscription).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not carry an old daemon cooldown into a new transport', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'limited',
|
|
status: 'error',
|
|
errorCode: 'rate_limited',
|
|
errorStage: 'userinfo',
|
|
errorStatus: 429,
|
|
retryAfterMs: 60_000,
|
|
});
|
|
await render();
|
|
const nextTransport = {
|
|
...transport,
|
|
startSubscriptionLogin: vi.fn().mockResolvedValue({
|
|
id: 'new',
|
|
status: 'error',
|
|
errorCode: 'network_error',
|
|
errorStage: 'userinfo',
|
|
}),
|
|
getSubscriptionUserInfo: vi
|
|
.fn()
|
|
.mockResolvedValue({ ...user, member_type: 'plus' }),
|
|
};
|
|
await act(async () => {
|
|
root!.render(
|
|
<QwenSubscriptionPanel
|
|
transport={nextTransport}
|
|
onOpenExternal={open}
|
|
onClose={close}
|
|
onOtherProviders={vi.fn()}
|
|
language="en"
|
|
/>,
|
|
);
|
|
});
|
|
await click('Retry Connection');
|
|
expect(nextTransport.getSubscriptionUserInfo).toHaveBeenCalledTimes(1);
|
|
expect(container.textContent).toContain(
|
|
'Current plan: Qwen Plus. Qwen Code is ready.',
|
|
);
|
|
});
|
|
|
|
it('retries a failed first identity query without creating a second authorization flow', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'saved',
|
|
status: 'error',
|
|
errorCode: 'network_error',
|
|
errorStage: 'userinfo',
|
|
});
|
|
transport.getSubscriptionUserInfo.mockResolvedValue({
|
|
...user,
|
|
member_type: 'plus',
|
|
});
|
|
await render();
|
|
expect(container.textContent).toContain(
|
|
"Couldn't check your subscription status",
|
|
);
|
|
expect(open).not.toHaveBeenCalled();
|
|
await click('Retry Connection');
|
|
expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(1);
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(1);
|
|
expect(container.textContent).toContain(
|
|
'Current plan: Qwen Plus. Qwen Code is ready.',
|
|
);
|
|
});
|
|
|
|
it('keeps exchange invalid_grant distinct from session expiry', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'exchange',
|
|
status: 'error',
|
|
errorCode: 'invalid_grant',
|
|
errorStage: 'exchange',
|
|
});
|
|
await render();
|
|
expect(container.textContent).toContain('Could not complete authorization');
|
|
expect(container.textContent).not.toContain(
|
|
'credentials are no longer valid',
|
|
);
|
|
expect(container.textContent).toContain('Retry Connection');
|
|
});
|
|
|
|
it('respects Retry-After before retrying a failed first query and starts waiting after Free is confirmed', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'limited',
|
|
status: 'error',
|
|
errorCode: 'rate_limited',
|
|
errorStage: 'userinfo',
|
|
errorStatus: 429,
|
|
retryAfterMs: 60_000,
|
|
});
|
|
await render();
|
|
await click('Retry Connection');
|
|
expect(transport.getSubscriptionUserInfo).not.toHaveBeenCalled();
|
|
await tick(60_000);
|
|
await click('Retry Connection');
|
|
expect(transport.getSubscriptionUserInfo).toHaveBeenCalledTimes(1);
|
|
expect(container.textContent).toContain('Free account');
|
|
expect(container.textContent).not.toContain('Retry Connection');
|
|
await tick(120_000);
|
|
expect(container.textContent).toContain("Your subscription isn't active yet");
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each(['subscription_models_unavailable', 'subscription_model_unavailable'])(
|
|
'returns a real desktop %s activation failure to the composer',
|
|
async (code) => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
const onModelsUnavailable = vi.fn();
|
|
const onActivated = vi.fn();
|
|
await render(
|
|
{
|
|
onActivate: vi.fn().mockRejectedValue({
|
|
body: { code },
|
|
}),
|
|
onActivated,
|
|
},
|
|
transport,
|
|
{ dialog: true, onModelsUnavailable },
|
|
);
|
|
expect(onModelsUnavailable).toHaveBeenCalledOnce();
|
|
expect(onActivated).not.toHaveBeenCalled();
|
|
expect(container.querySelector('[role="alert"]')).toBeNull();
|
|
expect(container.textContent).not.toContain('Retry');
|
|
},
|
|
);
|
|
|
|
it('shows the backend model permission message with only retry in the desktop error area', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
const onModelsUnavailable = vi.fn();
|
|
const onActivate = vi
|
|
.fn()
|
|
.mockRejectedValueOnce({
|
|
status: 403,
|
|
body: {
|
|
code: 'request_failed',
|
|
errorStatus: 403,
|
|
message: 'Your account cannot access this model catalog.',
|
|
},
|
|
})
|
|
.mockResolvedValue('actual-model');
|
|
await render(
|
|
{
|
|
onActivate,
|
|
onActivated: vi.fn(),
|
|
},
|
|
transport,
|
|
{ dialog: true, desktopSuccess: true, onModelsUnavailable },
|
|
);
|
|
expect(document.querySelector('[role="alert"]')?.textContent).toBe(
|
|
'Your account cannot access this model catalog.',
|
|
);
|
|
expect(onModelsUnavailable).not.toHaveBeenCalled();
|
|
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
|
|
'Try again',
|
|
);
|
|
expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain(
|
|
'Sign out',
|
|
);
|
|
expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain(
|
|
'Cancel',
|
|
);
|
|
expect(close).not.toHaveBeenCalled();
|
|
await act(async () => {
|
|
[...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')]
|
|
.find((button) => button.textContent === 'Try again')!
|
|
.click();
|
|
});
|
|
expect(onActivate).toHaveBeenCalledTimes(2);
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
expect(document.querySelector('[data-subscription-success]')).not.toBeNull();
|
|
expect(transport.signOutSubscription).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('respects the activation Retry-After before allowing a manual retry', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
const onActivate = vi.fn().mockRejectedValue({
|
|
body: { code: 'rate_limited', errorStatus: 429, retryAfterMs: 30_000 },
|
|
});
|
|
await render({ onActivate, onActivated: vi.fn() });
|
|
const retry = [...container.querySelectorAll('button')].find(
|
|
(button) => button.textContent === 'Try again',
|
|
)!;
|
|
expect(container.textContent).toContain('Too many requests');
|
|
expect(retry.disabled).toBe(true);
|
|
await tick(29_000);
|
|
expect(retry.disabled).toBe(true);
|
|
expect(onActivate).toHaveBeenCalledOnce();
|
|
await tick(1000);
|
|
expect(retry.disabled).toBe(false);
|
|
await click('Try again');
|
|
expect(onActivate).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('leaves the paid ready state after activation discovers expired refresh credentials', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
const onActivate = vi.fn().mockRejectedValue({
|
|
body: { code: 'invalid_grant', errorStage: 'refresh', errorStatus: 400 },
|
|
});
|
|
await render({ onActivate, onActivated: vi.fn() }, transport, {
|
|
dialog: true,
|
|
});
|
|
expect(document.body.textContent).toContain(
|
|
'credentials are no longer valid',
|
|
);
|
|
expect(document.body.textContent).not.toContain('Signed in as');
|
|
expect(document.body.textContent).not.toContain('Try again');
|
|
expect(document.body.textContent).toContain('previous');
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
await tick(60_000);
|
|
expect(onActivate).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each(['revoke', 'cleanup'] as const)(
|
|
'keeps %s errors visible without offering an authorization retry',
|
|
async (errorStage) => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
transport.signOutSubscription.mockRejectedValue({
|
|
body: { code: 'credentials_error', errorStage },
|
|
});
|
|
await render();
|
|
await click('Sign out');
|
|
expect(container.textContent).toContain(
|
|
errorStage === 'revoke'
|
|
? 'signed out locally'
|
|
: 'remove local Qwen credentials',
|
|
);
|
|
expect(container.textContent).not.toContain('Retry Connection');
|
|
},
|
|
);
|
|
|
|
it.each(['plus', 'pro', 'PLUS', 'Pro', 'pLuS'])(
|
|
'automatically activates %s and completes only after activation succeeds',
|
|
async (member_type) => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type },
|
|
});
|
|
let finish!: () => void;
|
|
const onActivate = vi.fn(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
const onActivated = vi.fn();
|
|
await render({ onActivate, onActivated });
|
|
expect(onActivate).toHaveBeenCalledWith(
|
|
expect.any(AbortSignal),
|
|
expect.any(Function),
|
|
);
|
|
expect(onActivate).toHaveBeenCalledOnce();
|
|
expect(container.textContent).toContain('Loading models from provider…');
|
|
expect(
|
|
[...container.querySelectorAll('button')].some(
|
|
(button) => button.textContent === 'Connect',
|
|
),
|
|
).toBe(false);
|
|
expect(onActivated).not.toHaveBeenCalled();
|
|
await act(async () => finish());
|
|
expect(onActivated).toHaveBeenCalledOnce();
|
|
},
|
|
);
|
|
|
|
it('keeps activation failure visible without looping and retries only on request', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'plus' },
|
|
});
|
|
const onActivate = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('model configuration failed'))
|
|
.mockResolvedValue(undefined);
|
|
const onActivated = vi.fn();
|
|
await render({ onActivate, onActivated });
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
|
|
'model configuration failed',
|
|
);
|
|
expect(container.textContent).not.toContain('Loading models from provider');
|
|
expect(container.textContent).toContain('Sign out');
|
|
expect(container.textContent).toContain('Cancel');
|
|
expect(onActivated).not.toHaveBeenCalled();
|
|
await tick(10000);
|
|
expect(onActivate).toHaveBeenCalledOnce();
|
|
await click('Try again');
|
|
expect(onActivate).toHaveBeenCalledTimes(2);
|
|
expect(onActivated).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('aborts automatic activation on unmount and ignores late completion', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
let finish!: () => void;
|
|
let signal!: AbortSignal;
|
|
const onActivate = vi.fn((activeSignal: AbortSignal) => {
|
|
signal = activeSignal;
|
|
return new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
});
|
|
});
|
|
const onActivated = vi.fn();
|
|
await render({ onActivate, onActivated });
|
|
await act(async () => root!.render(null));
|
|
expect(signal.aborted).toBe(true);
|
|
await act(async () => finish());
|
|
expect(onActivated).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
['free', 'Free'],
|
|
['FREE', 'Free'],
|
|
['LiTe', 'Lite'],
|
|
['go', 'Go'],
|
|
['lite', 'Lite'],
|
|
['enterprise', 'Enterprise'],
|
|
['VIP', 'Vip'],
|
|
])(
|
|
'keeps %s in purchase waiting without activating',
|
|
async (member_type, plan) => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type },
|
|
});
|
|
const onActivate = vi.fn();
|
|
await render({ onActivate, onActivated: vi.fn() });
|
|
expect(onActivate).not.toHaveBeenCalled();
|
|
expect(container.textContent).toContain('View Plans');
|
|
expect(container.textContent).toContain(`${plan} account`);
|
|
},
|
|
);
|
|
|
|
it('replaces authorization step 2 with a separate purchase dialog without restarting login', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'flow-one',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
});
|
|
transport.getSubscriptionLogin.mockResolvedValue(ready);
|
|
await render(undefined, transport, { dialog: true });
|
|
const authorization = document.querySelector('[role="dialog"]');
|
|
expect(authorization?.textContent).toContain('Authorization');
|
|
expect(
|
|
authorization?.querySelector('[aria-current="step"]')?.textContent,
|
|
).toContain('2');
|
|
await tick(2000);
|
|
const membershipDialog = document.querySelector('[role="dialog"]');
|
|
expect(membershipDialog).not.toBe(authorization);
|
|
expect(membershipDialog?.textContent).toContain('Free account');
|
|
expect(membershipDialog?.querySelector('[aria-current="step"]')).toBeNull();
|
|
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1);
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
expect(transport.cancelSubscriptionLogin).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports authorization failure to the sidebar but retains membership query errors', async () => {
|
|
const onAuthError = vi.fn();
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'failed',
|
|
status: 'error',
|
|
errorCode: 'access_denied',
|
|
errorStage: 'exchange',
|
|
});
|
|
await render(undefined, transport, { onAuthError });
|
|
expect(onAuthError).toHaveBeenCalledOnce();
|
|
expect(onAuthError.mock.calls[0][0]).toContain('denied');
|
|
await act(async () => root!.render(null));
|
|
onAuthError.mockClear();
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'query',
|
|
status: 'error',
|
|
errorCode: 'network_error',
|
|
errorStage: 'userinfo',
|
|
});
|
|
await render(undefined, transport, { onAuthError });
|
|
expect(onAuthError).not.toHaveBeenCalled();
|
|
expect(container.textContent).toContain('Retry Connection');
|
|
});
|
|
|
|
it('automatically activates when purchase polling discovers paid membership', async () => {
|
|
transport.getSubscriptionUserInfo.mockResolvedValue({
|
|
...user,
|
|
member_type: 'pro',
|
|
});
|
|
const onActivate = vi.fn().mockResolvedValue(undefined);
|
|
const onActivated = vi.fn();
|
|
await render({ onActivate, onActivated });
|
|
expect(onActivate).not.toHaveBeenCalled();
|
|
await tick(5000);
|
|
expect(onActivate).toHaveBeenCalledOnce();
|
|
expect(onActivated).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('automatically activates after a failed membership query is retried successfully', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'saved',
|
|
status: 'error',
|
|
errorCode: 'network_error',
|
|
errorStage: 'userinfo',
|
|
});
|
|
transport.getSubscriptionUserInfo.mockResolvedValue({
|
|
...user,
|
|
member_type: 'plus',
|
|
});
|
|
const onActivate = vi.fn().mockResolvedValue(undefined);
|
|
const onActivated = vi.fn();
|
|
await render({ onActivate, onActivated });
|
|
expect(onActivate).not.toHaveBeenCalled();
|
|
await click('Retry Connection');
|
|
expect(onActivate).toHaveBeenCalledOnce();
|
|
expect(onActivated).toHaveBeenCalledOnce();
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('does not activate again when host callbacks change identity', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
const onActivate = vi.fn().mockResolvedValue(undefined);
|
|
const onActivated = vi.fn();
|
|
await render({ onActivate: (signal) => onActivate(signal), onActivated });
|
|
await render({
|
|
onActivate: (signal) => onActivate(signal),
|
|
onActivated: () => onActivated(),
|
|
});
|
|
expect(onActivate).toHaveBeenCalledOnce();
|
|
expect(onActivated).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each(['close', 'Escape'])(
|
|
'finishes desktop success once through %s after activation completes',
|
|
async (action) => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
let finish!: (model: string) => void;
|
|
const onActivate = vi.fn(
|
|
() =>
|
|
new Promise<string>((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
const onActivated = vi.fn();
|
|
await render({ onActivate, onActivated }, transport, {
|
|
dialog: true,
|
|
desktopSuccess: true,
|
|
});
|
|
expect(document.querySelector('[data-subscription-success]')).toBeNull();
|
|
const pending = document.querySelector('[role="dialog"]')!;
|
|
expect(pending.textContent).toContain('Loading models from provider');
|
|
expect(pending.textContent).not.toContain('Sign out');
|
|
expect(pending.textContent).not.toContain('Cancel');
|
|
expect(onActivated).not.toHaveBeenCalled();
|
|
await act(async () => finish('actual-model'));
|
|
await tick(0);
|
|
const success = document.querySelector('[data-subscription-success]')!;
|
|
expect(success).not.toBeNull();
|
|
expect(success.textContent).toContain(user.email);
|
|
expect(success.textContent).toContain('Qwen Pro');
|
|
expect(success.textContent).toContain('actual-model');
|
|
expect(success.textContent).toContain(
|
|
'Common commands: /usage /upgrade /model /status /logout',
|
|
);
|
|
expect(document.body.textContent).not.toContain('Start Using');
|
|
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1);
|
|
expect(onActivated).not.toHaveBeenCalled();
|
|
const dismiss = () => {
|
|
if (action === 'Escape')
|
|
document.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
|
);
|
|
else
|
|
[...document.querySelectorAll('[role="dialog"] button')]
|
|
.find(
|
|
(button) =>
|
|
button.textContent === action ||
|
|
button.getAttribute('aria-label') === action,
|
|
)!
|
|
.click();
|
|
};
|
|
await act(async () => {
|
|
dismiss();
|
|
dismiss();
|
|
});
|
|
expect(onActivated).toHaveBeenCalledOnce();
|
|
expect(onActivated).toHaveBeenCalledWith();
|
|
expect(onActivate).toHaveBeenCalledOnce();
|
|
expect(transport.signOutSubscription).not.toHaveBeenCalled();
|
|
expect(close).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('discards a completed desktop success when its transport changes', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
const onActivated = vi.fn();
|
|
await render(
|
|
{ onActivate: vi.fn().mockResolvedValue('actual-model'), onActivated },
|
|
transport,
|
|
{ dialog: true, desktopSuccess: true },
|
|
);
|
|
expect(document.querySelector('[data-subscription-success]')).not.toBeNull();
|
|
const nextTransport = {
|
|
...transport,
|
|
startSubscriptionLogin: vi.fn().mockResolvedValue({
|
|
id: 'new-flow',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
}),
|
|
};
|
|
await render({ onActivate: vi.fn(), onActivated }, nextTransport, {
|
|
dialog: true,
|
|
desktopSuccess: true,
|
|
});
|
|
expect(document.querySelector('[data-subscription-success]')).toBeNull();
|
|
expect(onActivated).not.toHaveBeenCalled();
|
|
expect(transport.cancelSubscriptionLogin).toHaveBeenCalledWith('flow-one');
|
|
});
|
|
|
|
it('restarts desktop authorization with Retry Connection before Previous and no Next', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'denied',
|
|
status: 'error',
|
|
errorCode: 'access_denied',
|
|
errorStage: 'exchange',
|
|
});
|
|
await render(undefined, transport, { dialog: true });
|
|
const dialog = document.querySelector('[role="dialog"]')!;
|
|
const buttons = [...dialog.querySelectorAll('button')];
|
|
const retry = buttons.find(
|
|
(button) => button.textContent === 'Retry Connection',
|
|
)!;
|
|
const previous = buttons.find((button) => button.textContent === 'previous')!;
|
|
expect(buttons.indexOf(retry)).toBeLessThan(buttons.indexOf(previous));
|
|
expect(buttons.some((button) => button.textContent === 'next')).toBe(false);
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
id: 'retry-auth',
|
|
status: 'waiting',
|
|
authorizationUrl,
|
|
});
|
|
await act(async () => retry.click());
|
|
expect(transport.startSubscriptionLogin).toHaveBeenCalledTimes(2);
|
|
expect(open).toHaveBeenCalledWith(authorizationUrl);
|
|
expect(transport.getSubscriptionUserInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('waits for the new transport to confirm membership and ignores the old activation', async () => {
|
|
transport.startSubscriptionLogin.mockResolvedValue({
|
|
...ready,
|
|
userInfo: { ...user, member_type: 'pro' },
|
|
});
|
|
let finishOld!: () => void;
|
|
let oldSignal!: AbortSignal;
|
|
const oldActivate = vi.fn((signal: AbortSignal) => {
|
|
oldSignal = signal;
|
|
return new Promise<void>((resolve) => {
|
|
finishOld = resolve;
|
|
});
|
|
});
|
|
const onActivated = vi.fn();
|
|
await render({ onActivate: oldActivate, onActivated });
|
|
let confirmNew!: (flow: DaemonSubscriptionFlow) => void;
|
|
const nextTransport = {
|
|
...transport,
|
|
startSubscriptionLogin: vi.fn(
|
|
() =>
|
|
new Promise<DaemonSubscriptionFlow>((resolve) => {
|
|
confirmNew = resolve;
|
|
}),
|
|
),
|
|
};
|
|
const nextActivate = vi.fn().mockResolvedValue(undefined);
|
|
await render({ onActivate: nextActivate, onActivated }, nextTransport);
|
|
expect(oldSignal.aborted).toBe(true);
|
|
expect(nextActivate).not.toHaveBeenCalled();
|
|
await act(async () => finishOld());
|
|
expect(onActivated).not.toHaveBeenCalled();
|
|
await act(async () =>
|
|
confirmNew({ ...ready, userInfo: { ...user, member_type: 'plus' } }),
|
|
);
|
|
expect(nextActivate).toHaveBeenCalledOnce();
|
|
expect(onActivated).toHaveBeenCalledOnce();
|
|
});
|