Migrate core render util to use xterm.js as part of the rendering loop. (#19044)

This commit is contained in:
Jacob Richman
2026-02-18 16:46:50 -08:00
committed by GitHub
parent 04c52513e7
commit 04f65f3d55
213 changed files with 7065 additions and 3852 deletions

View File

@@ -65,21 +65,24 @@ describe('ApiAuthDialog', () => {
mockedUseTextBuffer.mockReturnValue(mockBuffer);
});
it('renders correctly', () => {
const { lastFrame } = render(
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with a defaultValue', () => {
render(
it('renders with a defaultValue', async () => {
const { waitUntilReady, unmount } = render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
defaultValue="test-key"
/>,
);
await waitUntilReady();
expect(mockedUseTextBuffer).toHaveBeenCalledWith(
expect.objectContaining({
initialText: 'test-key',
@@ -88,6 +91,7 @@ describe('ApiAuthDialog', () => {
}),
}),
);
unmount();
});
it.each([
@@ -100,9 +104,12 @@ describe('ApiAuthDialog', () => {
{ keyName: 'escape', sequence: '\u001b', expectedCall: onCancel, args: [] },
])(
'calls $expectedCall.name when $keyName is pressed',
({ keyName, sequence, expectedCall, args }) => {
async ({ keyName, sequence, expectedCall, args }) => {
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
const { waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
// calls[1] is the TextInput's useKeypress (typing handler)
const keypressHandler = mockedUseKeypress.mock.calls[1][0];
@@ -117,23 +124,29 @@ describe('ApiAuthDialog', () => {
});
expect(expectedCall).toHaveBeenCalledWith(...args);
unmount();
},
);
it('displays an error message', () => {
const { lastFrame } = render(
it('displays an error message', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
error="Invalid API Key"
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Invalid API Key');
unmount();
});
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
const { waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// Call 0 is ApiAuthDialog (isActive: true)
// Call 1 is TextInput (isActive: true, priority: true)
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
@@ -149,5 +162,6 @@ describe('ApiAuthDialog', () => {
expect(clearApiKey).toHaveBeenCalled();
expect(mockBuffer.setText).toHaveBeenCalledWith('');
});
unmount();
});
});

View File

@@ -139,11 +139,14 @@ describe('AuthDialog', () => {
},
])(
'correctly shows/hides COMPUTE_ADC options $desc',
({ env, shouldContain, shouldNotContain }) => {
async ({ env, shouldContain, shouldNotContain }) => {
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value as string);
}
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
for (const item of shouldContain) {
expect(items).toContainEqual(item);
@@ -151,23 +154,32 @@ describe('AuthDialog', () => {
for (const item of shouldNotContain) {
expect(items).not.toContainEqual(item);
}
unmount();
},
);
});
it('filters auth types when enforcedType is set', () => {
it('filters auth types when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(1);
expect(items[0].value).toBe(AuthType.USE_GEMINI);
unmount();
});
it('sets initial index to 0 when enforcedType is set', () => {
it('sets initial index to 0 when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(initialIndex).toBe(0);
unmount();
});
describe('Initial Auth Type Selection', () => {
@@ -199,18 +211,25 @@ describe('AuthDialog', () => {
expected: AuthType.LOGIN_WITH_GOOGLE,
desc: 'defaults to Login with Google',
},
])('selects initial auth type $desc', ({ setup, expected }) => {
])('selects initial auth type $desc', async ({ setup, expected }) => {
setup();
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(items[initialIndex].value).toBe(expected);
unmount();
});
});
describe('handleAuthSelect', () => {
it('calls onAuthError if validation fails', () => {
it('calls onAuthError if validation fails', async () => {
mockedValidateAuthMethod.mockReturnValue('Invalid method');
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
handleAuthSelect(AuthType.USE_GEMINI);
@@ -221,11 +240,15 @@ describe('AuthDialog', () => {
);
expect(props.onAuthError).toHaveBeenCalledWith('Invalid method');
expect(props.settings.setValue).not.toHaveBeenCalled();
unmount();
});
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
@@ -233,16 +256,21 @@ describe('AuthDialog', () => {
expect(props.setAuthContext).toHaveBeenCalledWith({
requiresRestart: true,
});
unmount();
});
it('sets auth context with empty object for other auth types', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
expect(props.setAuthContext).toHaveBeenCalledWith({});
unmount();
});
it('skips API key dialog on initial setup if env var is present', async () => {
@@ -250,7 +278,10 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -258,6 +289,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
unmount();
});
it('skips API key dialog if env var is present but empty', async () => {
@@ -265,7 +297,10 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -273,6 +308,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
unmount();
});
it('shows API key dialog on initial setup if no env var is present', async () => {
@@ -280,7 +316,10 @@ describe('AuthDialog', () => {
// process.env['GEMINI_API_KEY'] is not set
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -288,6 +327,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.AwaitingApiKeyInput,
);
unmount();
});
it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => {
@@ -297,7 +337,10 @@ describe('AuthDialog', () => {
props.settings.merged.security.auth.selectedType =
AuthType.LOGIN_WITH_GOOGLE;
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -305,6 +348,7 @@ describe('AuthDialog', () => {
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
unmount();
});
it('exits process for Login with Google when browser is suppressed', async () => {
@@ -316,7 +360,10 @@ describe('AuthDialog', () => {
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
@@ -330,13 +377,18 @@ describe('AuthDialog', () => {
exitSpy.mockRestore();
logSpy.mockRestore();
vi.useRealTimers();
unmount();
});
});
it('displays authError when provided', () => {
it('displays authError when provided', async () => {
props.authError = 'Something went wrong';
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Something went wrong');
unmount();
});
describe('useKeypress', () => {
@@ -375,31 +427,47 @@ describe('AuthDialog', () => {
expect(p.settings.setValue).not.toHaveBeenCalled();
},
},
])('$desc', ({ setup, expectations }) => {
])('$desc', async ({ setup, expectations }) => {
setup();
renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expectations(props);
unmount();
});
});
describe('Snapshots', () => {
it('renders correctly with default props', () => {
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
it('renders correctly with default props', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with auth error', () => {
it('renders correctly with auth error', async () => {
props.authError = 'Something went wrong';
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with enforced auth type', () => {
it('renders correctly with enforced auth type', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
});

View File

@@ -54,48 +54,80 @@ describe('AuthInProgress', () => {
vi.useRealTimers();
});
it('renders initial state with spinner', () => {
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
it('renders initial state with spinner', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('[Spinner] Waiting for auth...');
expect(lastFrame()).toContain('Press ESC or CTRL+C to cancel');
unmount();
});
it('calls onTimeout when ESC is pressed', () => {
render(<AuthInProgress onTimeout={onTimeout} />);
it('calls onTimeout when ESC is pressed', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
keypressHandler({ name: 'escape' } as unknown as Key);
await act(async () => {
keypressHandler({ name: 'escape' } as unknown as Key);
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
expect(onTimeout).toHaveBeenCalled();
unmount();
});
it('calls onTimeout when Ctrl+C is pressed', () => {
render(<AuthInProgress onTimeout={onTimeout} />);
it('calls onTimeout when Ctrl+C is pressed', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
keypressHandler({ name: 'c', ctrl: true } as unknown as Key);
await act(async () => {
keypressHandler({ name: 'c', ctrl: true } as unknown as Key);
});
await waitUntilReady();
expect(onTimeout).toHaveBeenCalled();
unmount();
});
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
vi.advanceTimersByTime(180000);
});
await waitUntilReady();
expect(onTimeout).toHaveBeenCalled();
await vi.waitUntil(
() => lastFrame()?.includes('Authentication timed out'),
{ timeout: 1000 },
);
expect(lastFrame()).toContain('Authentication timed out');
unmount();
});
it('clears timer on unmount', () => {
const { unmount } = render(<AuthInProgress onTimeout={onTimeout} />);
act(() => {
it('clears timer on unmount', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
unmount();
});
vi.advanceTimersByTime(180000);
await act(async () => {
vi.advanceTimersByTime(180000);
});
expect(onTimeout).not.toHaveBeenCalled();
});
});

View File

@@ -40,23 +40,26 @@ describe('LoginWithGoogleRestartDialog', () => {
vi.useRealTimers();
});
it('renders correctly', () => {
const { lastFrame } = render(
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('calls onDismiss when escape is pressed', () => {
render(
it('calls onDismiss when escape is pressed', async () => {
const { waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
@@ -68,6 +71,7 @@ describe('LoginWithGoogleRestartDialog', () => {
});
expect(onDismiss).toHaveBeenCalledTimes(1);
unmount();
});
it.each(['r', 'R'])(
@@ -75,12 +79,13 @@ describe('LoginWithGoogleRestartDialog', () => {
async (keyName) => {
vi.useFakeTimers();
render(
const { waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
@@ -98,6 +103,7 @@ describe('LoginWithGoogleRestartDialog', () => {
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
vi.useRealTimers();
unmount();
},
);
});

View File

@@ -14,5 +14,6 @@ exports[`ApiAuthDialog > renders correctly 1`] = `
│ │
│ (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;

View File

@@ -1,57 +1,60 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ? Get started
│ How would you like to authenticate for this project?
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI
│ Something went wrong
│ (Use Enter to select)
│ Terms of Services and Privacy Notice for Gemini CLI
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
│ │
│ Something went wrong │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────
"
`;
exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ? Get started
│ How would you like to authenticate for this project?
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI
│ (Use Enter to select)
│ Terms of Services and Privacy Notice for Gemini CLI
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────
"
`;
exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
│ ? Get started
│ How would you like to authenticate for this project?
│ (selected) Use Gemini API Key
│ (Use Enter to select)
│ Terms of Services and Privacy Notice for Gemini CLI
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Use Gemini API Key │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────
"
`;

View File

@@ -4,5 +4,6 @@ exports[`LoginWithGoogleRestartDialog > renders correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ You have successfully logged in with Google. Gemini CLI needs to be restarted. Press 'r' to │
│ restart, or 'escape' to choose a different auth method. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;