From 1195416db810ae48b6483cac4aeb4392bcf1a665 Mon Sep 17 00:00:00 2001
From: Robert DeLuca
Date: Tue, 20 Oct 2026 12:32:06 -0700
Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Stabilize=20device=20login=20and?=
=?UTF-8?q?=20poll=20terminal=20authorization?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/auth/client.js | 5 +-
src/auth/core.js | 93 ++++-
src/auth/operations.js | 44 ++-
src/commands/login.js | 253 +++++--------
.../src/components/views/builds-view.jsx | 105 ++----
src/reporter/src/hooks/use-device-login.js | 80 ++++
src/server/routers/auth.js | 76 ++--
src/utils/browser.js | 4 +-
src/utils/login-poll-wait.js | 52 +++
tests/auth/core.test.js | 82 ++++
tests/commands/login-integration.test.js | 349 ++++++++++++++++++
tests/commands/login.test.js | 330 -----------------
tests/reporter/specs/builds-workflow.spec.js | 279 ++++++++++++++
tests/server/routers/auth-integration.test.js | 197 ++++++++++
tests/server/routers/auth.test.js | 18 +-
tests/utils/login-poll-wait.test.js | 130 +++++++
16 files changed, 1452 insertions(+), 645 deletions(-)
create mode 100644 src/reporter/src/hooks/use-device-login.js
create mode 100644 src/utils/login-poll-wait.js
create mode 100644 tests/commands/login-integration.test.js
delete mode 100644 tests/commands/login.test.js
create mode 100644 tests/server/routers/auth-integration.test.js
create mode 100644 tests/utils/login-poll-wait.test.js
diff --git a/src/auth/client.js b/src/auth/client.js
index f68dae23..362f9d9b 100644
--- a/src/auth/client.js
+++ b/src/auth/client.js
@@ -61,7 +61,10 @@ export function createAuthClient(options = {}) {
if (!response.ok) {
let body = await parseResponseBody(response);
- throw parseAuthError(response.status, body, endpoint);
+ let error = parseAuthError(response.status, body, endpoint);
+ let retryAfter = Number(response.headers?.get('retry-after'));
+ if (retryAfter > 0) error.context.retryAfter = retryAfter;
+ throw error;
}
return response.json();
diff --git a/src/auth/core.js b/src/auth/core.js
index 2b1ee9e1..f6f92134 100644
--- a/src/auth/core.js
+++ b/src/auth/core.js
@@ -68,23 +68,33 @@ export function parseAuthError(status, body, _endpoint) {
errorText = body;
}
+ let retryAfter = Number(body?.retryAfter);
+ let context = {
+ status,
+ serverCode: body?.code,
+ retryAfter: retryAfter > 0 ? retryAfter : undefined,
+ };
+
if (status === 401) {
return new AuthError(
errorText ||
- 'Invalid credentials. Please check your email/username and password.'
+ 'Invalid credentials. Please check your email/username and password.',
+ context
);
}
if (status === 429) {
return new VizzlyError(
'Too many login attempts. Please try again later.',
- 'RATE_LIMIT_ERROR'
+ 'RATE_LIMIT_ERROR',
+ context
);
}
return new VizzlyError(
`Authentication request failed: ${status}${errorText ? ` - ${errorText}` : ''}`,
- 'AUTH_REQUEST_ERROR'
+ 'AUTH_REQUEST_ERROR',
+ context
);
}
@@ -184,3 +194,80 @@ export function validateTokens(auth, requiredField = 'accessToken') {
return { valid: true, error: null };
}
+
+export function normalizeDeviceFlowResponse(deviceFlow) {
+ let verificationUri =
+ deviceFlow.verification_uri || deviceFlow.verificationUri;
+ let userCode = deviceFlow.user_code || deviceFlow.userCode;
+ let deviceCode = deviceFlow.device_code || deviceFlow.deviceCode;
+
+ if (!verificationUri || !userCode || !deviceCode) {
+ throw new Error('Invalid device flow response from server');
+ }
+
+ return {
+ verificationUri,
+ userCode,
+ deviceCode,
+ urlWithCode:
+ deviceFlow.verification_uri_complete ||
+ deviceFlow.verificationUriComplete ||
+ `${verificationUri}?code=${encodeURIComponent(userCode)}`,
+ };
+}
+
+export function resolveAuthorizedTokenData(pollResponse) {
+ let accessToken =
+ pollResponse.tokens?.accessToken || pollResponse.tokens?.access_token;
+
+ if (accessToken) {
+ return pollResponse;
+ }
+
+ if (pollResponse.status === 'pending') {
+ throw new Error(
+ 'Authorization not complete yet. Please complete the authorization in your browser and try running "vizzly login" again.'
+ );
+ }
+ if (pollResponse.status === 'expired') {
+ throw new Error('Device code expired. Please try logging in again.');
+ }
+ if (pollResponse.status === 'invalid') {
+ throw new Error(
+ 'Device code is invalid or expired. Please try logging in again.'
+ );
+ }
+ if (pollResponse.status === 'denied') {
+ throw new Error('Authorization denied. Please try logging in again.');
+ }
+
+ throw new Error(
+ 'Unexpected response from authorization server. Please try logging in again.'
+ );
+}
+
+export function buildTokens(tokenData, now = Date.now()) {
+ let tokensData = tokenData.tokens || tokenData;
+ let tokenExpiresIn = tokensData.expiresIn || tokensData.expires_in;
+ let tokenExpiresAt = tokenExpiresIn
+ ? new Date(now + tokenExpiresIn * 1000).toISOString()
+ : tokenData.expires_at || tokenData.expiresAt;
+
+ return {
+ accessToken: tokensData.accessToken || tokensData.access_token,
+ refreshToken: tokensData.refreshToken || tokensData.refresh_token,
+ expiresAt: tokenExpiresAt,
+ user: tokenData.user,
+ organizations: tokenData.organizations,
+ };
+}
+
+/** Map protocol codes without depending on the server's human-readable text. */
+export function deviceAuthorizationStatus(error) {
+ return {
+ AUTHORIZATION_PENDING: 'pending',
+ ACCESS_DENIED: 'denied',
+ EXPIRED_TOKEN: 'expired',
+ INVALID_DEVICE_CODE: 'invalid',
+ }[error.context?.serverCode];
+}
diff --git a/src/auth/operations.js b/src/auth/operations.js
index 81290e86..5fbd58c4 100644
--- a/src/auth/operations.js
+++ b/src/auth/operations.js
@@ -8,11 +8,13 @@
* This makes them trivially testable without mocking modules.
*/
+import * as output from '../utils/output.js';
import {
buildDevicePollPayload,
buildLogoutPayload,
buildRefreshPayload,
buildTokenData,
+ deviceAuthorizationStatus,
validateTokens,
} from './core.js';
@@ -38,12 +40,23 @@ export async function initiateDeviceFlow(httpClient) {
* @param {string} deviceCode - Device code from initiate
* @returns {Promise
+ {login.error && (
+
+ {login.error.message}
+ {login.error.data?.retryAfter > 0 &&
+ ` Try again after ${login.error.data.retryAfter} seconds.`}
+
+ )}
@@ -342,28 +317,24 @@ function DeviceFlowLogin({ onComplete }) {
}
function LoginPrompt() {
- const [showingLogin, setShowingLogin] = useState(false);
const queryClient = useQueryClient();
const { addToast } = useToast();
const handleLoginComplete = useCallback(() => {
- setShowingLogin(false);
queryClient.invalidateQueries({ queryKey: queryKeys.auth });
queryClient.invalidateQueries({ queryKey: queryKeys.projects() });
addToast('Login successful!', 'success');
}, [queryClient, addToast]);
- if (showingLogin) {
+ let login = useDeviceLogin({ onComplete: handleLoginComplete });
+
+ if (login.status !== 'idle') {
return (
-
);
}
@@ -383,7 +354,7 @@ function LoginPrompt() {
setShowingLogin(true)}
+ onClick={login.start}
icon={ArrowRightOnRectangleIcon}
>
Sign In
diff --git a/src/reporter/src/hooks/use-device-login.js b/src/reporter/src/hooks/use-device-login.js
new file mode 100644
index 00000000..01c56c93
--- /dev/null
+++ b/src/reporter/src/hooks/use-device-login.js
@@ -0,0 +1,80 @@
+import { useEffect, useRef, useState } from 'react';
+import {
+ useInitiateLogin,
+ usePollAuthorization,
+} from './queries/use-auth-queries.js';
+
+export function useDeviceLogin({ onComplete }) {
+ let [login, setLogin] = useState({ status: 'idle' });
+ let [checking, setChecking] = useState(false);
+ let attempt = useRef({ generation: 0, active: false, checking: false });
+ let { mutateAsync: initiate } = useInitiateLogin();
+ let { mutateAsync: poll } = usePollAuthorization();
+
+ useEffect(
+ () => () => {
+ attempt.current.generation++;
+ },
+ []
+ );
+
+ async function start() {
+ if (attempt.current.active) return;
+ let generation = ++attempt.current.generation;
+ attempt.current.active = true;
+ setLogin({ status: 'starting' });
+ try {
+ let flow = await initiate();
+ if (generation !== attempt.current.generation) return;
+ attempt.current.flow = flow;
+ setLogin({ status: 'pending', flow });
+ } catch (error) {
+ if (generation !== attempt.current.generation) return;
+ attempt.current.active = false;
+ setLogin({ status: 'error', error });
+ }
+ }
+
+ async function check() {
+ let current = attempt.current;
+ if (!current.flow || !current.active || current.checking) return;
+ let generation = current.generation;
+ current.checking = true;
+ setChecking(true);
+ setLogin({ status: 'pending', flow: current.flow });
+ try {
+ let result = await poll(current.flow.deviceCode);
+ if (generation !== attempt.current.generation) return;
+ if (
+ !['pending', 'complete', 'denied', 'expired', 'invalid'].includes(
+ result.status
+ )
+ ) {
+ throw new Error('Unexpected response from authorization server');
+ }
+ current.active = result.status === 'pending';
+ setLogin({ status: result.status, flow: current.flow });
+ if (result.status === 'complete') onComplete();
+ } catch (error) {
+ if (generation !== attempt.current.generation) return;
+ setLogin({ status: 'pending', flow: current.flow, error });
+ } finally {
+ if (generation === attempt.current.generation) {
+ current.checking = false;
+ setChecking(false);
+ }
+ }
+ }
+
+ function dismiss() {
+ attempt.current = {
+ generation: attempt.current.generation + 1,
+ active: false,
+ checking: false,
+ };
+ setChecking(false);
+ setLogin({ status: 'idle' });
+ }
+
+ return { ...login, checking, start, check, dismiss };
+}
diff --git a/src/server/routers/auth.js b/src/server/routers/auth.js
index efe09447..93d839ee 100644
--- a/src/server/routers/auth.js
+++ b/src/server/routers/auth.js
@@ -3,10 +3,12 @@
* Handles authentication endpoints (device flow login, logout, status)
*/
+import { buildTokens, normalizeDeviceFlowResponse } from '../../auth/core.js';
import * as output from '../../utils/output.js';
import { parseJsonBody } from '../middleware/json-parser.js';
import {
sendError,
+ sendJson,
sendServiceUnavailable,
sendSuccess,
} from '../middleware/response.js';
@@ -48,23 +50,20 @@ export function createAuthRouter({ authService }) {
// Initiate device flow login
if (req.method === 'POST' && pathname === '/api/auth/login') {
try {
- const deviceFlow = await authService.initiateDeviceFlow();
-
- // Transform snake_case to camelCase for frontend
- const response = {
- deviceCode: deviceFlow.device_code,
- userCode: deviceFlow.user_code,
- verificationUri: deviceFlow.verification_uri,
- verificationUriComplete: deviceFlow.verification_uri_complete,
- expiresIn: deviceFlow.expires_in,
+ let deviceFlow = await authService.initiateDeviceFlow();
+ let flow = normalizeDeviceFlowResponse(deviceFlow);
+ sendSuccess(res, {
+ deviceCode: flow.deviceCode,
+ userCode: flow.userCode,
+ verificationUri: flow.verificationUri,
+ verificationUriComplete: flow.urlWithCode,
+ expiresIn: deviceFlow.expires_in ?? deviceFlow.expiresIn,
interval: deviceFlow.interval,
- };
-
- sendSuccess(res, response);
+ });
return true;
} catch (error) {
output.error('Error initiating device flow:', error);
- sendError(res, 500, error.message);
+ sendAuthError(res, error);
return true;
}
}
@@ -80,43 +79,22 @@ export function createAuthRouter({ authService }) {
return true;
}
- let result;
- try {
- result = await authService.pollDeviceAuthorization(deviceCode);
- } catch (error) {
- // Handle "Authorization pending" as a valid response
- if (error.message?.includes('Authorization pending')) {
- sendSuccess(res, { status: 'pending' });
- return true;
- }
- throw error;
- }
-
- // Check if authorization is complete by looking for tokens
- if (result.tokens?.accessToken) {
- const tokensData = result.tokens;
- const tokenExpiresIn = tokensData.expiresIn || tokensData.expires_in;
- const tokenExpiresAt = tokenExpiresIn
- ? new Date(Date.now() + tokenExpiresIn * 1000).toISOString()
- : result.expires_at || result.expiresAt;
-
- const tokens = {
- accessToken: tokensData.accessToken || tokensData.access_token,
- refreshToken: tokensData.refreshToken || tokensData.refresh_token,
- expiresAt: tokenExpiresAt,
- user: result.user,
- };
-
+ let result = await authService.pollDeviceAuthorization(deviceCode);
+ if (result.tokens?.accessToken || result.tokens?.access_token) {
+ let tokens = buildTokens(result);
await authService.completeDeviceFlow(tokens);
-
sendSuccess(res, { status: 'complete', user: result.user });
+ } else if (
+ ['pending', 'expired', 'denied', 'invalid'].includes(result.status)
+ ) {
+ sendSuccess(res, { status: result.status });
} else {
- sendSuccess(res, { status: 'pending' });
+ throw new Error('Unexpected response from authorization server');
}
return true;
} catch (error) {
output.error('Error polling device authorization:', error);
- sendError(res, 500, error.message);
+ sendAuthError(res, error);
return true;
}
}
@@ -129,7 +107,7 @@ export function createAuthRouter({ authService }) {
return true;
} catch (error) {
output.error('Error logging out:', error);
- sendError(res, 500, error.message);
+ sendAuthError(res, error);
return true;
}
}
@@ -137,3 +115,13 @@ export function createAuthRouter({ authService }) {
return false;
};
}
+
+function sendAuthError(res, error) {
+ let retryAfter = error.context?.retryAfter;
+ if (retryAfter) res.setHeader('Retry-After', String(retryAfter));
+ sendJson(res, error.context?.status || 500, {
+ error: error.message,
+ code: error.context?.serverCode || error.code,
+ ...(retryAfter ? { retryAfter } : {}),
+ });
+}
diff --git a/src/utils/browser.js b/src/utils/browser.js
index f0e7a194..e606e0bf 100644
--- a/src/utils/browser.js
+++ b/src/utils/browser.js
@@ -52,7 +52,9 @@ export async function openBrowser(url) {
break;
}
- execFile(command, args, error => {
+ // Some launchers stay in the foreground. Bound the attempt so login and
+ // other CLI workflows can continue using the displayed URL.
+ execFile(command, args, { timeout: 5000, killSignal: 'SIGKILL' }, error => {
if (error) {
// Browser opening failed, but don't throw - user can manually open
resolve(false);
diff --git a/src/utils/login-poll-wait.js b/src/utils/login-poll-wait.js
new file mode 100644
index 00000000..ad42e08e
--- /dev/null
+++ b/src/utils/login-poll-wait.js
@@ -0,0 +1,52 @@
+import { setTimeout as delay } from 'node:timers/promises';
+
+/** Wait for the next check, with optional terminal input and a live countdown. */
+export async function waitForLoginPoll({
+ milliseconds,
+ manualAllowed = true,
+ earliestCheckAt = 0,
+ stdin = process.stdin,
+ now = Date.now,
+ sleep = delay,
+ onTick,
+}) {
+ if (!stdin.isTTY) {
+ onTick(milliseconds, false);
+ await sleep(milliseconds);
+ return;
+ }
+
+ let deadline = now() + milliseconds;
+ let requested = false;
+ let timer;
+ let wasFlowing = stdin.readableFlowing === true;
+ let onData = data => {
+ if (manualAllowed && /[\r\n]/.test(data.toString())) {
+ requested = true;
+ timer?.abort();
+ }
+ };
+ if (manualAllowed) {
+ stdin.on('data', onData);
+ stdin.resume();
+ }
+ try {
+ while (true) {
+ let remaining =
+ Math.min(deadline, requested ? earliestCheckAt : deadline) - now();
+ if (remaining <= 0) return;
+ onTick(remaining, manualAllowed && !requested);
+ timer = new AbortController();
+ try {
+ await sleep(Math.min(remaining, 1000), undefined, {
+ signal: timer.signal,
+ });
+ } catch (error) {
+ if (!timer.signal.aborted) throw error;
+ }
+ }
+ } finally {
+ stdin.removeListener('data', onData);
+ if (manualAllowed && !wasFlowing) stdin.pause();
+ }
+}
diff --git a/tests/auth/core.test.js b/tests/auth/core.test.js
index 7058fb74..06506a7e 100644
--- a/tests/auth/core.test.js
+++ b/tests/auth/core.test.js
@@ -8,8 +8,11 @@ import {
buildRefreshPayload,
buildRequestHeaders,
buildTokenData,
+ buildTokens,
+ normalizeDeviceFlowResponse,
parseAuthError,
parseAuthenticatedError,
+ resolveAuthorizedTokenData,
validateTokens,
} from '../../src/auth/core.js';
import { AuthError, VizzlyError } from '../../src/errors/vizzly-error.js';
@@ -262,4 +265,83 @@ describe('auth/core', () => {
assert.ok(result.error.message.includes('authentication token'));
});
});
+
+ describe('device flow helpers', () => {
+ it('normalizes snake_case and camelCase device flow responses', () => {
+ assert.deepStrictEqual(
+ normalizeDeviceFlowResponse({
+ verification_uri: 'https://auth.test/device',
+ user_code: 'A B',
+ device_code: 'device-1',
+ }),
+ {
+ verificationUri: 'https://auth.test/device',
+ userCode: 'A B',
+ deviceCode: 'device-1',
+ urlWithCode: 'https://auth.test/device?code=A%20B',
+ }
+ );
+ assert.deepStrictEqual(
+ normalizeDeviceFlowResponse({
+ verificationUri: 'https://auth.test/device',
+ userCode: 'CODE',
+ deviceCode: 'device-2',
+ }).deviceCode,
+ 'device-2'
+ );
+ assert.throws(
+ () => normalizeDeviceFlowResponse({}),
+ /Invalid device flow response/
+ );
+ });
+
+ it('resolves authorization responses into tokens or user-facing errors', () => {
+ let authorized = { tokens: { accessToken: 'token' } };
+ let snakeCaseAuthorized = { tokens: { access_token: 'token' } };
+
+ assert.strictEqual(resolveAuthorizedTokenData(authorized), authorized);
+ assert.strictEqual(
+ resolveAuthorizedTokenData(snakeCaseAuthorized),
+ snakeCaseAuthorized
+ );
+ assert.throws(
+ () => resolveAuthorizedTokenData({ status: 'pending' }),
+ /Authorization not complete/
+ );
+ assert.throws(
+ () => resolveAuthorizedTokenData({ status: 'expired' }),
+ /Device code expired/
+ );
+ assert.throws(
+ () => resolveAuthorizedTokenData({ status: 'denied' }),
+ /Authorization denied/
+ );
+ });
+
+ it('builds stored tokens deterministically', () => {
+ let now = Date.parse('2026-05-18T12:00:00.000Z');
+
+ assert.deepStrictEqual(
+ buildTokens(
+ {
+ tokens: {
+ access_token: 'access-token',
+ refresh_token: 'refresh-token',
+ expires_in: 3600,
+ },
+ user: { email: 'robert@example.test' },
+ organizations: [{ slug: 'vizzly' }],
+ },
+ now
+ ),
+ {
+ accessToken: 'access-token',
+ refreshToken: 'refresh-token',
+ expiresAt: '2026-05-18T13:00:00.000Z',
+ user: { email: 'robert@example.test' },
+ organizations: [{ slug: 'vizzly' }],
+ }
+ );
+ });
+ });
});
diff --git a/tests/commands/login-integration.test.js b/tests/commands/login-integration.test.js
new file mode 100644
index 00000000..5ecaa5c8
--- /dev/null
+++ b/tests/commands/login-integration.test.js
@@ -0,0 +1,349 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { once } from 'node:events';
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { createServer } from 'node:http';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { test } from 'node:test';
+import { pathToFileURL } from 'node:url';
+
+async function login(
+ t,
+ responses,
+ {
+ expiresIn = 600,
+ interval = 0.01,
+ initiateError,
+ fakeTime = false,
+ browserHangs = false,
+ } = {}
+) {
+ let directory = await mkdtemp(join(tmpdir(), 'vizzly-login-'));
+ let requests = [];
+ let waits = [];
+ let server = createServer(async (req, res) => {
+ let body = '';
+ for await (let chunk of req) body += chunk;
+ requests.push({ path: req.url, body: body && JSON.parse(body) });
+ res.setHeader('Content-Type', 'application/json');
+ let response;
+ if (req.url.endsWith('/initiate')) {
+ response = initiateError || {
+ status: 200,
+ body: {
+ device_code: 'same-device-code',
+ user_code: 'ABCD-EFGH',
+ verification_uri: 'https://example.test/authorize',
+ interval,
+ expires_in: expiresIn,
+ },
+ };
+ } else {
+ response = responses.length > 1 ? responses.shift() : responses[0];
+ }
+ if (response.hang) return;
+ res.writeHead(response.status, response.headers);
+ res.end(JSON.stringify(response.body));
+ });
+ t.after(async () => {
+ if (browserHangs) {
+ try {
+ let pid = Number(
+ await readFile(join(directory, 'browser.pid'), 'utf8')
+ );
+ process.kill(pid, 'SIGKILL');
+ } catch (error) {
+ if (!['ENOENT', 'ESRCH'].includes(error.code)) throw error;
+ }
+ }
+ server.closeAllConnections();
+ await new Promise(resolve => server.close(resolve));
+ await rm(directory, { recursive: true, force: true });
+ });
+ await mkdir(join(directory, 'bin'));
+ if (browserHangs) {
+ await writeFile(
+ join(directory, 'browser.mjs'),
+ `import { writeFileSync } from 'node:fs';
+writeFileSync('browser.pid', String(process.pid));
+process.on('SIGTERM', () => {});
+process.stdin.resume();
+`
+ );
+ }
+ // Browser launching is an external boundary; never open the user's browser.
+ for (let executable of ['open', 'xdg-open']) {
+ await writeFile(
+ join(directory, 'bin', executable),
+ browserHangs
+ ? '#!/bin/sh\nexec "$VIZZLY_TEST_NODE" "$VIZZLY_TEST_BROWSER"\n'
+ : '#!/bin/sh\nexit 1\n',
+ { mode: 0o755 }
+ );
+ }
+ server.listen(0, '127.0.0.1');
+ await once(server, 'listening');
+ let apiUrl = `http://127.0.0.1:${server.address().port}`;
+ let entrypoint = resolve('src/cli.js');
+ let args = [entrypoint, 'login', '--api-url', apiUrl, '--no-color'];
+ if (fakeTime) {
+ // Exercise the real command, HTTP client and token store, replacing only
+ // time. The process entrypoint is covered separately with real timers.
+ entrypoint = join(directory, 'timed-login.mjs');
+ let commandUrl = pathToFileURL(resolve('src/commands/login.js')).href;
+ await writeFile(
+ entrypoint,
+ `import { loginCommand } from ${JSON.stringify(commandUrl)};
+let time = Date.parse('2026-05-18T12:00:00Z');
+await loginCommand({ apiUrl: ${JSON.stringify(apiUrl)} }, { noColor: true }, {
+ now: () => time,
+ delay: async milliseconds => {
+ process.send({ wait: milliseconds });
+ time += milliseconds;
+ },
+});
+process.disconnect();
+`
+ );
+ args = [entrypoint];
+ }
+ let child = spawn(process.execPath, args, {
+ cwd: directory,
+ env: {
+ ...process.env,
+ VIZZLY_HOME: join(directory, 'home'),
+ VIZZLY_TEST_NODE: process.execPath,
+ VIZZLY_TEST_BROWSER: join(directory, 'browser.mjs'),
+ PATH: `${join(directory, 'bin')}:${process.env.PATH}`,
+ },
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
+ });
+ t.after(() => {
+ if (child.exitCode === null) child.kill();
+ });
+ child.on('message', message => waits.push(message.wait));
+ let output = '';
+ child.stdout.on('data', chunk => {
+ output += chunk;
+ });
+ child.stderr.on('data', chunk => {
+ output += chunk;
+ });
+ let [code] = await once(child, 'close');
+ let config;
+ try {
+ config = JSON.parse(await readFile(join(directory, 'home', 'config.json')));
+ } catch (error) {
+ if (error.code !== 'ENOENT') throw error;
+ }
+ let browserPid = browserHangs
+ ? Number(await readFile(join(directory, 'browser.pid'), 'utf8'))
+ : undefined;
+ return { code, output, config, requests, waits, browserPid };
+}
+
+let pending = { status: 400, body: { code: 'AUTHORIZATION_PENDING' } };
+let complete = {
+ status: 200,
+ body: {
+ tokens: {
+ accessToken: 'access',
+ refreshToken: 'refresh',
+ expiresIn: 900,
+ },
+ user: { name: 'Robert', email: 'robert@example.test' },
+ organizations: [{ name: 'Vizzly', slug: 'vizzly' }],
+ },
+};
+
+let options = { timeout: 10000 };
+
+test(
+ 'login automatically completes and saves credentials with stdin closed',
+ options,
+ async t => {
+ let result = await login(t, [pending, pending, complete]);
+ assert.equal(result.code, 0, result.output);
+ assert.equal(result.config.auth.accessToken, 'access');
+ assert.equal(result.config.auth.refreshToken, 'refresh');
+ assert.equal(result.config.auth.user.email, 'robert@example.test');
+ assert.match(result.output, /Authenticated/);
+ assert.match(result.output, /Vizzly \(@vizzly\)/);
+ assert.match(result.output, /Could not open browser automatically/);
+ assert.match(result.output, /Please open the URL manually/);
+ assert.match(result.output, /example.test\/authorize\?code=ABCD-EFGH/);
+ assert.doesNotMatch(result.output, /press Enter|Token expires/i);
+ assert.equal(
+ result.requests.filter(req => req.path.endsWith('/initiate')).length,
+ 1
+ );
+ assert.deepEqual(
+ result.requests.slice(1).map(req => req.body.device_code),
+ ['same-device-code', 'same-device-code', 'same-device-code']
+ );
+ }
+);
+
+test(
+ 'login respects the server interval and Retry-After header on the same attempt',
+ options,
+ async t => {
+ let result = await login(
+ t,
+ [
+ pending,
+ {
+ status: 429,
+ headers: { 'Retry-After': '12' },
+ body: { retryAfter: 1 },
+ },
+ complete,
+ ],
+ { interval: 5, fakeTime: true }
+ );
+ assert.equal(result.code, 0, result.output);
+ assert.deepEqual(result.waits, [5000, 5000, 12000]);
+ assert.match(result.output, /Rate limited. Next check in 12s/);
+ assert.equal(result.config.auth.accessToken, 'access');
+ assert.equal(
+ result.requests.filter(req => req.path.endsWith('/initiate')).length,
+ 1
+ );
+ }
+);
+
+for (let [serverCode, message] of [
+ ['ACCESS_DENIED', /Authorization denied/],
+ ['EXPIRED_TOKEN', /Device code expired/],
+ ['INVALID_DEVICE_CODE', /Device code is invalid/],
+]) {
+ test(
+ `login stops without saving credentials for ${serverCode}`,
+ options,
+ async t => {
+ let result = await login(t, [
+ { status: 401, body: { code: serverCode } },
+ ]);
+ assert.equal(result.code, 1);
+ assert.match(result.output, message);
+ assert.equal(result.config?.auth, undefined);
+ assert.equal(result.requests.length, 2);
+ }
+ );
+}
+
+for (let [expiresIn, seconds, message] of [
+ [600, 120, /timed out after 2 minutes/],
+ [12, 12, /Device code expired/],
+]) {
+ test(
+ `login stops after ${seconds}s when authorization stays pending`,
+ options,
+ async t => {
+ let result = await login(t, [pending], {
+ expiresIn,
+ interval: 5,
+ fakeTime: true,
+ });
+ assert.equal(result.code, 1);
+ assert.match(result.output, message);
+ assert.equal(result.config?.auth, undefined);
+ assert.equal(
+ result.waits.reduce((total, wait) => total + wait, 0),
+ seconds * 1000
+ );
+ assert.equal(
+ result.requests.filter(req => req.path.endsWith('/initiate')).length,
+ 1
+ );
+ }
+ );
+}
+
+test('login stops a stalled poll when the code expires', options, async t => {
+ let result = await login(t, [{ hang: true }], { expiresIn: 2 });
+ assert.equal(result.code, 1);
+ assert.match(result.output, /Device code expired/);
+ assert.equal(result.config?.auth, undefined);
+ assert.equal(result.requests.length, 2);
+});
+
+test(
+ 'login reports initiation rate limits with a recovery hint',
+ options,
+ async t => {
+ let result = await login(t, [], {
+ initiateError: { status: 429, body: { message: 'Slow down' } },
+ });
+ assert.equal(result.code, 1);
+ assert.match(result.output, /Too many login attempts/);
+ assert.match(
+ result.output,
+ /Please wait a few minutes before trying again/
+ );
+ assert.equal(result.requests.length, 1);
+ assert.equal(result.config?.auth, undefined);
+ }
+);
+
+test(
+ 'a cooldown longer than the remaining login lifetime cannot extend the deadline',
+ options,
+ async t => {
+ let result = await login(
+ t,
+ [{ status: 429, headers: { 'Retry-After': '300' }, body: {} }],
+ { interval: 5, fakeTime: true }
+ );
+ assert.equal(result.code, 1);
+ assert.match(result.output, /timed out after 2 minutes/);
+ assert.deepEqual(result.waits, [5000, 115000]);
+ assert.equal(result.requests.length, 2);
+ assert.equal(result.config?.auth, undefined);
+ }
+);
+
+test(
+ 'an unexpected server response does not save a session',
+ options,
+ async t => {
+ let result = await login(t, [{ status: 200, body: { status: 'unknown' } }]);
+ assert.equal(result.code, 1);
+ assert.match(result.output, /Unexpected response/);
+ assert.equal(result.config?.auth, undefined);
+ assert.equal(result.requests.length, 2);
+ }
+);
+
+test(
+ 'login reports authentication rejection with account recovery hints',
+ options,
+ async t => {
+ let result = await login(t, [], {
+ initiateError: {
+ status: 401,
+ body: { message: 'Authentication required' },
+ },
+ });
+ assert.equal(result.code, 1);
+ assert.match(result.output, /Authentication failed/);
+ assert.match(result.output, /Please try logging in again/);
+ assert.match(result.output, /sign up at https:\/\/vizzly.dev/);
+ assert.equal(result.config?.auth, undefined);
+ }
+);
+
+test('a stuck browser launcher cannot prevent login polling or leave the launcher running', {
+ timeout: 15000,
+ skip: process.platform === 'win32',
+}, async t => {
+ let result = await login(t, [pending, complete], { browserHangs: true });
+ assert.equal(result.code, 0, result.output);
+ assert.match(result.output, /Could not open browser automatically/);
+ assert.match(result.output, /Please open the URL manually/);
+ assert.match(result.output, /Authenticated/);
+ assert.equal(result.config.auth.accessToken, 'access');
+ assert.equal(result.requests.length, 3);
+ assert.throws(() => process.kill(result.browserPid, 0), { code: 'ESRCH' });
+});
diff --git a/tests/commands/login.test.js b/tests/commands/login.test.js
deleted file mode 100644
index 3d8a8df3..00000000
--- a/tests/commands/login.test.js
+++ /dev/null
@@ -1,330 +0,0 @@
-import assert from 'node:assert/strict';
-import { describe, it } from 'node:test';
-import {
- buildTokens,
- getTokenExpiryHint,
- loginCommand,
- normalizeDeviceFlowResponse,
- resolveAuthorizedTokenData,
-} from '../../src/commands/login.js';
-
-function createMockOutput() {
- let calls = [];
- return {
- calls,
- configure: opts => calls.push({ method: 'configure', args: [opts] }),
- header: command => calls.push({ method: 'header', args: [command] }),
- printBox: (lines, options) =>
- calls.push({ method: 'printBox', args: [lines, options] }),
- blank: () => calls.push({ method: 'blank', args: [] }),
- complete: message => calls.push({ method: 'complete', args: [message] }),
- warn: message => calls.push({ method: 'warn', args: [message] }),
- hint: message => calls.push({ method: 'hint', args: [message] }),
- keyValue: value => calls.push({ method: 'keyValue', args: [value] }),
- labelValue: (label, value) =>
- calls.push({ method: 'labelValue', args: [label, value] }),
- list: items => calls.push({ method: 'list', args: [items] }),
- error: (message, error) =>
- calls.push({ method: 'error', args: [message, error] }),
- startSpinner: message =>
- calls.push({ method: 'startSpinner', args: [message] }),
- stopSpinner: () => calls.push({ method: 'stopSpinner', args: [] }),
- cleanup: () => calls.push({ method: 'cleanup', args: [] }),
- getColors: () => ({
- bold: value => value,
- brand: {
- amber: value => value,
- info: value => value,
- },
- }),
- };
-}
-
-function createLoginHarness({ browserOpened = true, pollResponse } = {}) {
- let output = createMockOutput();
- let authClientConfig = null;
- let completedTokens = null;
- let polledDeviceCode = null;
- let exitCode = null;
- let waitCount = 0;
-
- return {
- output,
- get authClientConfig() {
- return authClientConfig;
- },
- get completedTokens() {
- return completedTokens;
- },
- get polledDeviceCode() {
- return polledDeviceCode;
- },
- get exitCode() {
- return exitCode;
- },
- get waitCount() {
- return waitCount;
- },
- deps: {
- completeDeviceFlow: async (_tokenStore, tokens) => {
- completedTokens = tokens;
- },
- createAuthClient: config => {
- authClientConfig = config;
- return { kind: 'auth-client' };
- },
- createTokenStore: () => ({ kind: 'token-store' }),
- getApiUrl: () => 'https://api.default.test',
- initiateDeviceFlow: async () => ({
- verification_uri: 'https://auth.example.test/device',
- user_code: 'ABCD-EFGH',
- device_code: 'device-code-1',
- }),
- now: () => Date.parse('2026-05-18T12:00:00.000Z'),
- openBrowser: async () => browserOpened,
- output,
- pollDeviceAuthorization: async (_client, deviceCode) => {
- polledDeviceCode = deviceCode;
- return (
- pollResponse || {
- tokens: {
- accessToken: 'access-token',
- refreshToken: 'refresh-token',
- expiresIn: 86400,
- },
- user: {
- name: 'Robert',
- email: 'robert@example.test',
- },
- organizations: [{ name: 'Vizzly', slug: 'vizzly' }],
- }
- );
- },
- waitForEnter: async () => {
- waitCount += 1;
- },
- exit: code => {
- exitCode = code;
- },
- },
- };
-}
-
-describe('commands/login', () => {
- describe('device flow helpers', () => {
- it('normalizes snake_case and camelCase device flow responses', () => {
- assert.deepStrictEqual(
- normalizeDeviceFlowResponse({
- verification_uri: 'https://auth.test/device',
- user_code: 'A B',
- device_code: 'device-1',
- }),
- {
- verificationUri: 'https://auth.test/device',
- userCode: 'A B',
- deviceCode: 'device-1',
- urlWithCode: 'https://auth.test/device?code=A%20B',
- }
- );
- assert.deepStrictEqual(
- normalizeDeviceFlowResponse({
- verificationUri: 'https://auth.test/device',
- userCode: 'CODE',
- deviceCode: 'device-2',
- }).deviceCode,
- 'device-2'
- );
- assert.throws(
- () => normalizeDeviceFlowResponse({}),
- /Invalid device flow response/
- );
- });
-
- it('resolves authorization responses into tokens or user-facing errors', () => {
- let authorized = { tokens: { accessToken: 'token' } };
- let snakeCaseAuthorized = { tokens: { access_token: 'token' } };
-
- assert.strictEqual(resolveAuthorizedTokenData(authorized), authorized);
- assert.strictEqual(
- resolveAuthorizedTokenData(snakeCaseAuthorized),
- snakeCaseAuthorized
- );
- assert.throws(
- () => resolveAuthorizedTokenData({ status: 'pending' }),
- /Authorization not complete/
- );
- assert.throws(
- () => resolveAuthorizedTokenData({ status: 'expired' }),
- /Device code expired/
- );
- assert.throws(
- () => resolveAuthorizedTokenData({ status: 'denied' }),
- /Authorization denied/
- );
- });
-
- it('builds stored tokens and expiry hints deterministically', () => {
- let now = Date.parse('2026-05-18T12:00:00.000Z');
-
- assert.deepStrictEqual(
- buildTokens(
- {
- tokens: {
- access_token: 'access-token',
- refresh_token: 'refresh-token',
- expires_in: 3600,
- },
- user: { email: 'robert@example.test' },
- organizations: [{ slug: 'vizzly' }],
- },
- now
- ),
- {
- accessToken: 'access-token',
- refreshToken: 'refresh-token',
- expiresAt: '2026-05-18T13:00:00.000Z',
- user: { email: 'robert@example.test' },
- organizations: [{ slug: 'vizzly' }],
- }
- );
- assert.strictEqual(
- getTokenExpiryHint('2026-05-20T12:00:00.000Z', now),
- 'Token expires in 2 days'
- );
- assert.strictEqual(
- getTokenExpiryHint('2026-05-18T14:00:00.000Z', now),
- 'Token expires in 2 hours'
- );
- assert.strictEqual(
- getTokenExpiryHint('2026-05-18T12:30:00.000Z', now),
- 'Token expires in 30 minutes'
- );
- });
- });
-
- describe('loginCommand', () => {
- it('completes device login and stores tokens', async () => {
- let harness = createLoginHarness();
-
- await loginCommand(
- { apiUrl: 'https://api.override.test' },
- {},
- harness.deps
- );
-
- assert.deepStrictEqual(harness.authClientConfig, {
- baseUrl: 'https://api.override.test',
- });
- assert.strictEqual(harness.polledDeviceCode, 'device-code-1');
- assert.strictEqual(harness.waitCount, 1);
- assert.deepStrictEqual(harness.completedTokens, {
- accessToken: 'access-token',
- refreshToken: 'refresh-token',
- expiresAt: '2026-05-19T12:00:00.000Z',
- user: {
- name: 'Robert',
- email: 'robert@example.test',
- },
- organizations: [{ name: 'Vizzly', slug: 'vizzly' }],
- });
- assert.ok(
- harness.output.calls.some(
- call => call.method === 'complete' && call.args[0] === 'Authenticated'
- )
- );
- assert.ok(harness.output.calls.some(call => call.method === 'cleanup'));
- });
-
- it('shows a manual browser hint when browser open fails', async () => {
- let harness = createLoginHarness({ browserOpened: false });
-
- await loginCommand({}, {}, harness.deps);
-
- assert.ok(
- harness.output.calls.some(
- call =>
- call.method === 'warn' &&
- call.args[0] === 'Could not open browser automatically'
- )
- );
- assert.ok(
- harness.output.calls.some(
- call =>
- call.method === 'hint' &&
- call.args[0] === 'Please open the URL manually'
- )
- );
- });
-
- it('exits with a helpful message when authorization is still pending', async () => {
- let harness = createLoginHarness({
- pollResponse: { status: 'pending' },
- });
-
- await loginCommand({}, {}, harness.deps);
-
- assert.strictEqual(harness.exitCode, 1);
- assert.strictEqual(harness.completedTokens, null);
- assert.ok(
- harness.output.calls.some(
- call => call.method === 'error' && call.args[0] === 'Login failed'
- )
- );
- assert.ok(harness.output.calls.some(call => call.method === 'cleanup'));
- });
-
- it('uses auth-specific recovery hints for auth errors', async () => {
- let harness = createLoginHarness();
- let authError = new Error('bad auth');
- authError.name = 'AuthError';
- harness.deps.initiateDeviceFlow = async () => {
- throw authError;
- };
-
- await loginCommand({}, {}, harness.deps);
-
- assert.strictEqual(harness.exitCode, 1);
- assert.ok(
- harness.output.calls.some(
- call =>
- call.method === 'error' && call.args[0] === 'Authentication failed'
- )
- );
- assert.ok(
- harness.output.calls.some(
- call =>
- call.method === 'hint' &&
- call.args[0] ===
- "If you don't have an account, sign up at https://vizzly.dev"
- )
- );
- });
-
- it('uses rate-limit recovery hints for rate-limit errors', async () => {
- let harness = createLoginHarness();
- let rateLimitError = new Error('slow down');
- rateLimitError.code = 'RATE_LIMIT_ERROR';
- harness.deps.initiateDeviceFlow = async () => {
- throw rateLimitError;
- };
-
- await loginCommand({}, {}, harness.deps);
-
- assert.strictEqual(harness.exitCode, 1);
- assert.ok(
- harness.output.calls.some(
- call =>
- call.method === 'error' &&
- call.args[0] === 'Too many login attempts'
- )
- );
- assert.ok(
- harness.output.calls.some(
- call =>
- call.method === 'hint' &&
- call.args[0] === 'Please wait a few minutes before trying again'
- )
- );
- });
- });
-});
diff --git a/tests/reporter/specs/builds-workflow.spec.js b/tests/reporter/specs/builds-workflow.spec.js
index 0f851ace..4030e5cc 100644
--- a/tests/reporter/specs/builds-workflow.spec.js
+++ b/tests/reporter/specs/builds-workflow.spec.js
@@ -72,4 +72,283 @@ test.describe('Builds Workflow', () => {
await server.stop();
}
});
+ async function loginPage(page, options = {}) {
+ let server = createReporterTestServer(fixtureData, port);
+ let starts = 0;
+ let checks = 0;
+ let authenticated = false;
+ await server.start();
+ await page.route('**/api/auth/status', route =>
+ route.fulfill({
+ json: {
+ authenticated,
+ user: authenticated ? { username: 'test-user' } : null,
+ },
+ })
+ );
+ await page.route('**/api/auth/login', async route => {
+ starts++;
+ if (options.initiate) return options.initiate(route, starts);
+ await route.fulfill({
+ json: {
+ deviceCode: `device-${starts}`,
+ userCode: `CODE-${starts}`,
+ verificationUri: 'https://auth.test/cli/authorize',
+ expiresIn: 600,
+ interval: 5,
+ },
+ });
+ });
+ await page.route('**/api/auth/poll', async route => {
+ checks++;
+ let result = options.poll
+ ? await options.poll(route, checks)
+ : { status: 'pending' };
+ if (!result) return;
+ if (result.status === 'complete') authenticated = true;
+ await route.fulfill({ json: result });
+ });
+ await page.goto(`http://localhost:${port}/builds`);
+ await expect(
+ page.getByRole('button', { name: 'Sign In', exact: true })
+ ).toBeVisible();
+ return {
+ server,
+ get starts() {
+ return starts;
+ },
+ get checks() {
+ return checks;
+ },
+ };
+ }
+
+ test('one action creates one stable code across duplicate input and pending rerenders', async ({
+ page,
+ }) => {
+ let state = await loginPage(page);
+ try {
+ await page
+ .getByRole('button', { name: 'Sign In', exact: true })
+ .evaluate(button => {
+ button.click();
+ button.click();
+ });
+ await expect(page.getByText('CODE-1', { exact: true })).toBeVisible();
+ for (let check = 0; check < 3; check++) {
+ let request = page.waitForResponse('**/api/auth/poll');
+ await page.getByRole('button', { name: 'Check Status' }).click();
+ await request;
+ await expect(
+ page.getByRole('button', { name: 'Check Status' })
+ ).toBeEnabled();
+ await expect(page.getByText('CODE-1', { exact: true })).toBeVisible();
+ }
+ expect(state.starts).toBe(1);
+ expect(state.checks).toBe(3);
+ } finally {
+ await state.server.stop();
+ }
+ });
+
+ test('Back and a fresh attempt ignore a delayed response from the old attempt', async ({
+ page,
+ }) => {
+ let release;
+ let waiting = new Promise(resolve => {
+ release = resolve;
+ });
+ let state = await loginPage(page, {
+ initiate: async (route, count) => {
+ if (count === 1) await waiting;
+ await route.fulfill({
+ json: {
+ deviceCode: `device-${count}`,
+ userCode: `CODE-${count}`,
+ verificationUri: 'https://auth.test/cli/authorize',
+ },
+ });
+ },
+ });
+ try {
+ let started = page.waitForRequest('**/api/auth/login');
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click();
+ await started;
+ await page.getByRole('button', { name: /Back/ }).click();
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click();
+ await expect(page.getByText('CODE-2', { exact: true })).toBeVisible();
+ let finished = page.waitForResponse('**/api/auth/login');
+ release();
+ await (await finished).finished();
+ await page.getByRole('button', { name: 'Check Status' }).click();
+ await expect(
+ page.getByRole('button', { name: 'Check Status' })
+ ).toBeEnabled();
+ await expect(page.getByText('CODE-2', { exact: true })).toBeVisible();
+ expect(state.starts).toBe(2);
+ } finally {
+ release();
+ await state.server.stop();
+ }
+ });
+
+ test('duplicate status checks share one request while the current check is pending', async ({
+ page,
+ }) => {
+ let release;
+ let waiting = new Promise(resolve => {
+ release = resolve;
+ });
+ let state = await loginPage(page, {
+ poll: async () => {
+ await waiting;
+ return { status: 'pending' };
+ },
+ });
+ try {
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click();
+ await expect(page.getByText('CODE-1', { exact: true })).toBeVisible();
+ let requested = page.waitForRequest('**/api/auth/poll');
+ await page
+ .getByRole('button', { name: 'Check Status' })
+ .evaluate(button => {
+ button.click();
+ button.click();
+ });
+ await requested;
+ await expect(
+ page.getByRole('button', { name: 'Check Status' })
+ ).toBeDisabled();
+ let response = page.waitForResponse('**/api/auth/poll');
+ release();
+ await (await response).finished();
+ await expect(
+ page.getByRole('button', { name: 'Check Status' })
+ ).toBeEnabled();
+ await expect(page.getByText('CODE-1', { exact: true })).toBeVisible();
+ expect(state.checks).toBe(1);
+ } finally {
+ release();
+ await state.server.stop();
+ }
+ });
+
+ test('a delayed status check cannot replace a new login after Back', async ({
+ page,
+ }) => {
+ let release;
+ let waiting = new Promise(resolve => {
+ release = resolve;
+ });
+ let state = await loginPage(page, {
+ poll: async (_route, count) => {
+ if (count === 1) {
+ await waiting;
+ return { status: 'denied' };
+ }
+ return { status: 'pending' };
+ },
+ });
+ try {
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click();
+ await expect(page.getByText('CODE-1', { exact: true })).toBeVisible();
+ let requested = page.waitForRequest('**/api/auth/poll');
+ await page.getByRole('button', { name: 'Check Status' }).click();
+ await requested;
+ await page.getByRole('button', { name: /Back/ }).click();
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click();
+ await expect(page.getByText('CODE-2', { exact: true })).toBeVisible();
+ let response = page.waitForResponse('**/api/auth/poll');
+ release();
+ await (await response).finished();
+ await page.getByRole('button', { name: 'Check Status' }).click();
+ await expect(
+ page.getByRole('button', { name: 'Check Status' })
+ ).toBeEnabled();
+ await expect(page.getByText('CODE-2', { exact: true })).toBeVisible();
+ await expect(
+ page.getByRole('button', { name: 'Try Again' })
+ ).not.toBeVisible();
+ expect(state.starts).toBe(2);
+ expect(state.checks).toBe(2);
+ } finally {
+ release();
+ await state.server.stop();
+ }
+ });
+
+ for (let status of ['denied', 'expired', 'invalid']) {
+ test(`${status} requires an explicit new login`, async ({ page }) => {
+ let state = await loginPage(page, { poll: async () => ({ status }) });
+ try {
+ await page
+ .getByRole('button', { name: 'Sign In', exact: true })
+ .click();
+ await expect(page.getByText('CODE-1', { exact: true })).toBeVisible();
+ await page.getByRole('button', { name: 'Check Status' }).click();
+ await expect(
+ page.getByRole('button', { name: 'Try Again' })
+ ).toBeVisible();
+ expect(state.starts).toBe(1);
+ await page.getByRole('button', { name: 'Try Again' }).click();
+ await expect(page.getByText('CODE-2', { exact: true })).toBeVisible();
+ expect(state.starts).toBe(2);
+ } finally {
+ await state.server.stop();
+ }
+ });
+ }
+
+ test('a recoverable check error retains the code and successful retry signs in', async ({
+ page,
+ }) => {
+ let state = await loginPage(page, {
+ poll: async (route, count) => {
+ if (count === 1) {
+ await route.fulfill({
+ status: 503,
+ json: { error: 'Temporarily unavailable' },
+ });
+ return null;
+ }
+ return { status: 'complete', user: { username: 'test-user' } };
+ },
+ });
+ try {
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click();
+ await expect(page.getByText('CODE-1', { exact: true })).toBeVisible();
+ await page.getByRole('button', { name: 'Check Status' }).click();
+ await expect(page.getByText('Temporarily unavailable')).toBeVisible();
+ await expect(page.getByText('CODE-1', { exact: true })).toBeVisible();
+ await page.getByRole('button', { name: 'Check Status' }).click();
+ await expect(
+ page.getByRole('heading', { name: /remote builds/i })
+ ).toBeVisible();
+ expect(state.starts).toBe(1);
+ } finally {
+ await state.server.stop();
+ }
+ });
+
+ test('rate-limited initiation shows retry timing and does not restart automatically', async ({
+ page,
+ }) => {
+ let state = await loginPage(page, {
+ initiate: route =>
+ route.fulfill({
+ status: 429,
+ json: { error: 'Too many login attempts', retryAfter: 73 },
+ }),
+ });
+ try {
+ await page.getByRole('button', { name: 'Sign In', exact: true }).click();
+ await expect(page.getByText(/Try again after 73 seconds/)).toBeVisible();
+ await expect(
+ page.getByRole('button', { name: 'Try Again' })
+ ).toBeVisible();
+ expect(state.starts).toBe(1);
+ } finally {
+ await state.server.stop();
+ }
+ });
});
diff --git a/tests/server/routers/auth-integration.test.js b/tests/server/routers/auth-integration.test.js
new file mode 100644
index 00000000..0fb46ca8
--- /dev/null
+++ b/tests/server/routers/auth-integration.test.js
@@ -0,0 +1,197 @@
+import assert from 'node:assert/strict';
+import { once } from 'node:events';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { createServer } from 'node:http';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { after, before, beforeEach, test } from 'node:test';
+import { createAuthRouter } from '../../../src/server/routers/auth.js';
+import { createAuthService } from '../../../src/services/auth-service.js';
+import {
+ clearAuthTokens,
+ getAuthTokens,
+ saveAuthTokens,
+} from '../../../src/utils/global-config.js';
+
+let upstream;
+let local;
+let base;
+let response;
+let testHome;
+let originalHome = process.env.VIZZLY_HOME;
+let paths;
+let authorizations;
+
+before(async () => {
+ testHome = await mkdtemp(join(tmpdir(), 'vizzly-auth-router-'));
+ process.env.VIZZLY_HOME = testHome;
+ upstream = createServer((req, res) => {
+ paths.push(req.url);
+ authorizations.push(req.headers.authorization);
+ res.writeHead(response.status, {
+ 'Content-Type': 'application/json',
+ ...response.headers,
+ });
+ res.end(JSON.stringify(response.body));
+ });
+ upstream.listen(0, '127.0.0.1');
+ await once(upstream, 'listening');
+ let service = createAuthService({
+ apiUrl: `http://127.0.0.1:${upstream.address().port}`,
+ });
+ let router = createAuthRouter({ authService: service });
+ local = createServer(async (req, res) => {
+ if (!(await router(req, res, new URL(req.url, 'http://local').pathname))) {
+ res.writeHead(404).end();
+ }
+ });
+ local.listen(0, '127.0.0.1');
+ await once(local, 'listening');
+ base = `http://127.0.0.1:${local.address().port}`;
+});
+beforeEach(async () => {
+ await clearAuthTokens();
+ paths = [];
+ authorizations = [];
+ response = { status: 200, body: {} };
+});
+after(async () => {
+ for (let server of [local, upstream].filter(Boolean)) {
+ server.closeAllConnections();
+ await new Promise(resolve => server.close(resolve));
+ }
+ if (originalHome === undefined) delete process.env.VIZZLY_HOME;
+ else process.env.VIZZLY_HOME = originalHome;
+ await rm(testHome, { recursive: true, force: true });
+});
+
+async function post(path, body = {}) {
+ let result = await fetch(`${base}/api/auth/${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return {
+ status: result.status,
+ headers: result.headers,
+ body: await result.json(),
+ };
+}
+
+test('initiation preserves supported field aliases and complete verification URL', async () => {
+ for (let flow of [
+ {
+ device_code: 'device',
+ user_code: 'CODE',
+ verification_uri: 'https://auth.test/cli/authorize',
+ expires_in: 600,
+ },
+ {
+ deviceCode: 'device',
+ userCode: 'CODE',
+ verificationUri: 'https://auth.test/cli/authorize',
+ expiresIn: 600,
+ },
+ {
+ device_code: 'device',
+ user_code: 'CODE',
+ verification_uri: 'https://auth.test/cli/authorize',
+ verification_uri_complete:
+ 'https://auth.test/cli/authorize?code=CODE&app=cli',
+ expires_in: 600,
+ },
+ {
+ deviceCode: 'device',
+ userCode: 'CODE',
+ verificationUri: 'https://auth.test/cli/authorize',
+ verificationUriComplete:
+ 'https://auth.test/cli/authorize?code=CODE&app=cli',
+ expiresIn: 600,
+ },
+ ]) {
+ response.body = { ...flow, interval: 5 };
+ let started = await post('login');
+ assert.equal(started.status, 200);
+ assert.equal(started.body.deviceCode, 'device');
+ assert.equal(started.body.userCode, 'CODE');
+ assert.equal(started.body.expiresIn, 600);
+ assert.equal(
+ started.body.verificationUriComplete,
+ flow.verification_uri_complete ||
+ flow.verificationUriComplete ||
+ 'https://auth.test/cli/authorize?code=CODE'
+ );
+ }
+});
+
+test('expected outcomes use protocol codes rather than the error wording', async () => {
+ for (let [code, status, expected] of [
+ ['AUTHORIZATION_PENDING', 400, 'pending'],
+ ['ACCESS_DENIED', 401, 'denied'],
+ ['EXPIRED_TOKEN', 401, 'expired'],
+ ['INVALID_DEVICE_CODE', 400, 'invalid'],
+ ]) {
+ response = {
+ status,
+ body: { error: 'An unrelated human-readable sentence', code },
+ };
+ let result = await post('poll', { deviceCode: 'device' });
+ assert.equal(result.status, 200);
+ assert.deepEqual(result.body, { status: expected });
+ assert.equal(await getAuthTokens(), null);
+ }
+ assert.ok(paths.every(path => path === '/api/auth/cli/device/poll'));
+});
+
+test('upstream throttling retains status, code and retry timing through both endpoints', async () => {
+ response = {
+ status: 429,
+ headers: { 'Retry-After': '73' },
+ body: { error: 'Slow down', code: 'THROTTLED', retryAfter: 60 },
+ };
+ for (let path of ['login', 'poll']) {
+ let result = await post(path, { deviceCode: 'device' });
+ assert.equal(result.status, 429);
+ assert.equal(result.body.code, 'THROTTLED');
+ assert.equal(result.body.retryAfter, 73);
+ assert.equal(result.headers.get('retry-after'), '73');
+ }
+ assert.equal(await getAuthTokens(), null);
+});
+
+test('token aliases save normalized credentials without returning them to the browser', async () => {
+ for (let tokens of [
+ { accessToken: 'access', refreshToken: 'refresh', expiresIn: 900 },
+ { access_token: 'access', refresh_token: 'refresh', expires_in: 900 },
+ ]) {
+ response.body = { tokens, user: { username: 'test-user' } };
+ let result = await post('poll', { deviceCode: 'device' });
+ assert.equal(result.status, 200);
+ assert.deepEqual(result.body, {
+ status: 'complete',
+ user: { username: 'test-user' },
+ });
+ let saved = await getAuthTokens();
+ assert.equal(saved.accessToken, 'access');
+ assert.equal(saved.refreshToken, 'refresh');
+ assert.ok(Date.parse(saved.expiresAt) > Date.now());
+ assert.equal(JSON.stringify(result.body).includes('refresh'), false);
+ }
+});
+
+test('unknown successful authorization payloads fail instead of silently staying pending', async () => {
+ let result = await post('poll', { deviceCode: 'device' });
+ assert.equal(result.status, 500);
+ assert.match(result.body.error, /Unexpected response/);
+ assert.equal(await getAuthTokens(), null);
+});
+
+test('sign-out authenticates revocation and clears the local session', async () => {
+ await saveAuthTokens({ accessToken: 'access', refreshToken: 'refresh' });
+ response.body = { message: 'CLI logout successful' };
+ let result = await post('logout');
+ assert.equal(result.status, 200);
+ assert.deepEqual(paths, ['/api/auth/cli/logout']);
+ assert.deepEqual(authorizations, ['Bearer access']);
+ assert.equal(await getAuthTokens(), null);
+});
diff --git a/tests/server/routers/auth.test.js b/tests/server/routers/auth.test.js
index 4f06ebae..389ce702 100644
--- a/tests/server/routers/auth.test.js
+++ b/tests/server/routers/auth.test.js
@@ -164,7 +164,7 @@ describe('server/routers/auth', () => {
it('returns pending status', async () => {
let handler = createAuthRouter({
authService: createMockAuthService({
- pollResult: {},
+ pollResult: { status: 'pending' },
}),
});
let req = createMockRequest('POST', { deviceCode: 'device-123' });
@@ -191,22 +191,6 @@ describe('server/routers/auth', () => {
assert.ok(body.error.includes('deviceCode'));
});
- it('handles authorization pending error', async () => {
- let handler = createAuthRouter({
- authService: createMockAuthService({
- pollError: new Error('Authorization pending'),
- }),
- });
- let req = createMockRequest('POST', { deviceCode: 'device-123' });
- let res = createMockResponse();
-
- await handler(req, res, '/api/auth/poll');
-
- assert.strictEqual(res.statusCode, 200);
- let body = res.getParsedBody();
- assert.strictEqual(body.status, 'pending');
- });
-
it('completes authorization with tokens', async () => {
let completedTokens = null;
let handler = createAuthRouter({
diff --git a/tests/utils/login-poll-wait.test.js b/tests/utils/login-poll-wait.test.js
new file mode 100644
index 00000000..a7db7b3e
--- /dev/null
+++ b/tests/utils/login-poll-wait.test.js
@@ -0,0 +1,130 @@
+import assert from 'node:assert/strict';
+import { PassThrough } from 'node:stream';
+import { test } from 'node:test';
+import { waitForLoginPoll } from '../../src/utils/login-poll-wait.js';
+
+function terminal() {
+ let stdin = new PassThrough();
+ stdin.isTTY = true;
+ return stdin;
+}
+
+test('Enter checks early, coalesces repeated input, and restores terminal input', async () => {
+ let stdin = terminal();
+ let time = 0;
+ let countdown = [];
+ let sent = false;
+ await waitForLoginPoll({
+ milliseconds: 5000,
+ earliestCheckAt: 1000,
+ stdin,
+ now: () => time,
+ onTick: remaining => countdown.push(remaining),
+ sleep: async (milliseconds, _, { signal }) => {
+ if (!sent) {
+ sent = true;
+ stdin.write('\n\n');
+ if (signal.aborted) throw new Error('Input interrupted timer');
+ }
+ time += milliseconds;
+ },
+ });
+ assert.equal(time, 1000);
+ assert.deepEqual(countdown, [5000, 1000]);
+ assert.equal(stdin.listenerCount('data'), 0);
+ assert.equal(stdin.isPaused(), true);
+});
+
+test('countdown reaches the scheduled check without input', async () => {
+ let time = 0;
+ let countdown = [];
+ let stdin = terminal();
+ await waitForLoginPoll({
+ milliseconds: 3000,
+ stdin,
+ now: () => time,
+ onTick: remaining => countdown.push(remaining),
+ sleep: async milliseconds => {
+ time += milliseconds;
+ },
+ });
+ assert.deepEqual(countdown, [3000, 2000, 1000]);
+ assert.equal(time, 3000);
+ assert.equal(stdin.listenerCount('data'), 0);
+});
+
+test('Enter cannot bypass a server rate-limit cooldown', async () => {
+ let stdin = terminal();
+ stdin.resume();
+ let time = 0;
+ let manualHints = [];
+ await waitForLoginPoll({
+ milliseconds: 3000,
+ manualAllowed: false,
+ stdin,
+ now: () => time,
+ onTick: (_, canCheck) => manualHints.push(canCheck),
+ sleep: async milliseconds => {
+ stdin.write('\n');
+ time += milliseconds;
+ },
+ });
+ assert.equal(time, 3000);
+ assert.deepEqual(manualHints, [false, false, false]);
+ assert.equal(stdin.listenerCount('data'), 0);
+ stdin.destroy();
+});
+
+test('input listeners are removed if waiting fails', async () => {
+ let stdin = terminal();
+ await assert.rejects(
+ waitForLoginPoll({
+ milliseconds: 3000,
+ stdin,
+ onTick: () => {},
+ sleep: async () => {
+ throw new Error('Timer failed');
+ },
+ }),
+ /Timer failed/
+ );
+ assert.equal(stdin.listenerCount('data'), 0);
+ assert.equal(stdin.isPaused(), true);
+});
+
+test('non-Enter input does not trigger a check and preserves flowing input', async () => {
+ let stdin = terminal();
+ stdin.resume();
+ let time = 0;
+ await waitForLoginPoll({
+ milliseconds: 3000,
+ stdin,
+ now: () => time,
+ onTick: () => {},
+ sleep: async milliseconds => {
+ stdin.write('hello');
+ time += milliseconds;
+ },
+ });
+ assert.equal(time, 3000);
+ assert.equal(stdin.isPaused(), false);
+ assert.equal(stdin.listenerCount('data'), 0);
+ stdin.destroy();
+});
+
+test('closed noninteractive input waits until the scheduled check', async () => {
+ let stdin = new PassThrough();
+ stdin.end();
+ let elapsed = 0;
+ let countdown = [];
+ await waitForLoginPoll({
+ milliseconds: 5000,
+ stdin,
+ onTick: (remaining, canCheck) => countdown.push({ remaining, canCheck }),
+ sleep: async milliseconds => {
+ elapsed += milliseconds;
+ },
+ });
+ assert.equal(elapsed, 5000);
+ assert.deepEqual(countdown, [{ remaining: 5000, canCheck: false }]);
+});