mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
fix: handle auth-protected bar launch probes (#1551)
Raw-socket probe detects auth-protected (401/403) bar servers, reuses them, and kills the orphaned detached server on auth timeout. Integrated with the non-blocking priority probe.
This commit is contained in:
1 parent
361328c788
commit
c0409862d2
4 files changed
+318
-90
No files matched your search
@@ -12,12 +12,33 @@ import * as path from 'path';
|
||||
export interface DashboardInfo {
|
||||
port: number;
|
||||
baseUrl: string;
|
||||
authRequired?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the port recorded in an existing bar.json.
|
||||
* Returns null when the file is absent or malformed.
|
||||
*/
|
||||
|
||||
function ensureLoopbackNoProxy(): void {
|
||||
const loopbackHosts = ['localhost', '127.0.0.1', '::1'];
|
||||
const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? '';
|
||||
const parts = new Set(
|
||||
existing
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
for (const host of loopbackHosts) {
|
||||
parts.add(host);
|
||||
}
|
||||
|
||||
const next = Array.from(parts).join(',');
|
||||
process.env.NO_PROXY = next;
|
||||
process.env.no_proxy = next;
|
||||
}
|
||||
|
||||
export function resolveBarPort(ccsDir: string): number | null {
|
||||
const barJsonPath = path.join(ccsDir, 'bar.json');
|
||||
try {
|
||||
@@ -37,37 +58,48 @@ export function resolveBarPort(ccsDir: string): number | null {
|
||||
* (one timeout) rather than N × 1.5 s sequentially. Results are awaited in
|
||||
* priority order so a lower-priority slow or streaming response cannot block
|
||||
* returning an already-known higher-priority hit.
|
||||
*
|
||||
* Each probe speaks raw HTTP/1.1 over a socket and resolves on the status line,
|
||||
* which lets discovery distinguish a live-but-auth-protected server (401/403)
|
||||
* from a healthy one (200) without depending on a higher-level HTTP client.
|
||||
*/
|
||||
export async function defaultFindRunningServer(ccsDir: string): Promise<DashboardInfo | null> {
|
||||
const { request } = await import('undici');
|
||||
ensureLoopbackNoProxy();
|
||||
|
||||
async function probe(url: string): Promise<{ ok: boolean }> {
|
||||
try {
|
||||
const { statusCode, body } = await request(url, {
|
||||
method: 'GET',
|
||||
headersTimeout: 1500,
|
||||
bodyTimeout: 1500,
|
||||
async function probe(url: string): Promise<{ ok: boolean; authRequired: boolean }> {
|
||||
const net = await import('net');
|
||||
const parsed = new URL(url);
|
||||
const port = Number(parsed.port);
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let buffer = '';
|
||||
let settled = false;
|
||||
const finish = (statusCode = 0) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// Tear down the socket the moment the status line is known. The summary
|
||||
// endpoint only needs the status code for liveness, so a non-CCS
|
||||
// loopback service that streams forever cannot block discovery from
|
||||
// returning a higher-priority hit.
|
||||
socket.destroy();
|
||||
const authRequired = statusCode === 401 || statusCode === 403;
|
||||
resolve({ ok: statusCode === 200 || authRequired, authRequired });
|
||||
};
|
||||
const socket = net.connect({ host, port }, () => {
|
||||
socket.write(
|
||||
`GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
});
|
||||
|
||||
// The summary endpoint only needs the status code for liveness. Do not
|
||||
// buffer the response body: a non-CCS loopback service can stream forever
|
||||
// and keep discovery from returning a higher-priority hit. Release the
|
||||
// response as soon as headers arrive.
|
||||
try {
|
||||
if (typeof body.resume === 'function') {
|
||||
body.resume();
|
||||
} else if (typeof body.destroy === 'function') {
|
||||
body.destroy();
|
||||
}
|
||||
} catch {
|
||||
// Closing the response is a best-effort cleanup; liveness depends only
|
||||
// on the status code already received.
|
||||
}
|
||||
|
||||
return { ok: statusCode === 200 };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
socket.setTimeout(1500, () => finish());
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
const match = buffer.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
||||
if (match) finish(Number(match[1]));
|
||||
});
|
||||
socket.on('error', () => finish());
|
||||
socket.on('end', () => finish());
|
||||
});
|
||||
}
|
||||
|
||||
const barJsonPort = resolveBarPort(ccsDir);
|
||||
@@ -83,9 +115,10 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
|
||||
const probes = probeTargets.map((t) => probe(t.url));
|
||||
|
||||
for (let i = 0; i < probeTargets.length; i++) {
|
||||
if ((await probes[i]).ok) {
|
||||
const result = await probes[i];
|
||||
if (result.ok) {
|
||||
const { port, baseUrl } = probeTargets[i];
|
||||
return { port, baseUrl };
|
||||
return { port, baseUrl, authRequired: result.authRequired };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { getCcsDir } from '../../config/config-loader-facade';
|
||||
import {
|
||||
getBarDir,
|
||||
@@ -71,7 +72,7 @@ export interface LaunchDeps {
|
||||
* Spawn the `ccs bar serve --port N` process detached and return immediately.
|
||||
* The spawned process must be unref()ed so the launcher can exit.
|
||||
*/
|
||||
spawnDetachedServer: (port: number, logPath: string) => void;
|
||||
spawnDetachedServer: (port: number, logPath: string) => ChildProcess | void;
|
||||
/**
|
||||
* Poll GET {baseUrl}/api/bar/summary until HTTP 200 or timeout.
|
||||
* Returns the live baseUrl on success, throws on timeout.
|
||||
@@ -104,7 +105,7 @@ async function defaultGetPort(opts: { port: number[]; host: string }): Promise<n
|
||||
* stdio is redirected to serve.log so server output is preserved for
|
||||
* debugging without a terminal. unref() lets the launcher exit immediately.
|
||||
*/
|
||||
function defaultSpawnDetachedServer(port: number, logPath: string): void {
|
||||
function defaultSpawnDetachedServer(port: number, logPath: string): ChildProcess {
|
||||
const { spawn } = require('child_process') as typeof import('child_process');
|
||||
|
||||
// Open (or create) the log file for appending.
|
||||
@@ -118,30 +119,68 @@ function defaultSpawnDetachedServer(port: number, logPath: string): void {
|
||||
child.unref();
|
||||
// Close our copy of the fd — the child has its own reference.
|
||||
fs.closeSync(logFd);
|
||||
return child;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll GET {baseUrl}/api/bar/summary every 250 ms until HTTP 200 or ~10 s.
|
||||
* Resolves when the server is live. Rejects on timeout.
|
||||
*/
|
||||
export class BarServerAuthRequiredError extends Error {
|
||||
constructor(baseUrl: string, statusCode: number) {
|
||||
super(`CCS Bar server at ${baseUrl} requires dashboard authentication (HTTP ${statusCode})`);
|
||||
this.name = 'BarServerAuthRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthRequiredStatus(statusCode: number): boolean {
|
||||
return statusCode === 401 || statusCode === 403;
|
||||
}
|
||||
|
||||
async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
|
||||
const { request } = await import('undici');
|
||||
const net = await import('net');
|
||||
const INTERVAL_MS = 250;
|
||||
const TIMEOUT_MS = 10_000;
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const { statusCode, body } = await request(`${baseUrl}/api/bar/summary`, {
|
||||
method: 'GET',
|
||||
headersTimeout: 1500,
|
||||
bodyTimeout: 1500,
|
||||
async function probe(): Promise<number | null> {
|
||||
const url = new URL(`${baseUrl}/api/bar/summary`);
|
||||
return new Promise((resolve) => {
|
||||
let buffer = '';
|
||||
let settled = false;
|
||||
const finish = (statusCode: number | null = null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(statusCode);
|
||||
};
|
||||
const socket = net.connect(
|
||||
{ host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) },
|
||||
() => {
|
||||
socket.write(
|
||||
`GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.host}\r\nConnection: close\r\n\r\n`
|
||||
);
|
||||
}
|
||||
);
|
||||
socket.setTimeout(1500, () => finish());
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
const match = buffer.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
|
||||
if (match) finish(Number(match[1]));
|
||||
});
|
||||
await body.text();
|
||||
if (statusCode === 200) return;
|
||||
} catch {
|
||||
/* not yet live — keep polling */
|
||||
socket.on('error', () => finish());
|
||||
socket.on('end', () => finish());
|
||||
});
|
||||
}
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const statusCode = await probe();
|
||||
|
||||
if (statusCode === 200) return;
|
||||
if (statusCode !== null && isAuthRequiredStatus(statusCode)) {
|
||||
throw new BarServerAuthRequiredError(baseUrl, statusCode);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, INTERVAL_MS));
|
||||
}
|
||||
|
||||
@@ -198,6 +237,16 @@ export async function handleBarLaunch(
|
||||
}
|
||||
|
||||
if (running !== null) {
|
||||
if (running.authRequired) {
|
||||
console.error(
|
||||
`[X] CCS Bar cannot launch while dashboard authentication protects ${running.baseUrl}.`
|
||||
);
|
||||
console.error(
|
||||
'[i] Disable dashboard authentication for CCS Bar or start the dashboard manually.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuse the live server — write bar.json and open the app.
|
||||
const barJson: BarDiscoveryJson = {
|
||||
baseUrl: running.baseUrl,
|
||||
@@ -249,9 +298,10 @@ export async function handleBarLaunch(
|
||||
// 2c. Spawn the detached server.
|
||||
const serveLogPath = getServeLogPath(ccsDir);
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
let spawnedChild: ChildProcess | void;
|
||||
try {
|
||||
fs.mkdirSync(getBarDir(ccsDir), { recursive: true });
|
||||
spawnDetachedServer(port, serveLogPath);
|
||||
spawnedChild = spawnDetachedServer(port, serveLogPath);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not start CCS web-server: ${msg}`);
|
||||
@@ -265,6 +315,16 @@ export async function handleBarLaunch(
|
||||
try {
|
||||
await waitForServerLive(baseUrl);
|
||||
} catch (err) {
|
||||
if (err instanceof BarServerAuthRequiredError) {
|
||||
spawnedChild?.kill();
|
||||
console.error(
|
||||
`[X] CCS Bar cannot launch while dashboard authentication protects ${baseUrl}.`
|
||||
);
|
||||
console.error(
|
||||
'[i] Disable dashboard authentication for CCS Bar or start the dashboard manually.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[X] Could not connect to CCS web-server: ${msg}`);
|
||||
console.error(`[i] Check logs at ${serveLogPath}`);
|
||||
|
||||
@@ -274,10 +274,18 @@ function makeDetachedDeps(ccsDir: string, port = 4242) {
|
||||
return {
|
||||
findRunningServer: async () => null,
|
||||
getPort: async () => port,
|
||||
spawnDetachedServer: (_p: number, _log: string) => { /* noop */ },
|
||||
waitForServerLive: async (_url: string) => { /* live immediately */ },
|
||||
writeLaunchDescriptor: () => { /* noop */ },
|
||||
openApp: async (_appPath: string) => { /* noop */ },
|
||||
spawnDetachedServer: (_p: number, _log: string) => {
|
||||
/* noop */
|
||||
},
|
||||
waitForServerLive: async (_url: string) => {
|
||||
/* live immediately */
|
||||
},
|
||||
writeLaunchDescriptor: () => {
|
||||
/* noop */
|
||||
},
|
||||
openApp: async (_appPath: string) => {
|
||||
/* noop */
|
||||
},
|
||||
getCcsDir: () => ccsDir,
|
||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||
};
|
||||
@@ -501,7 +509,7 @@ describe('bar install subcommand', () => {
|
||||
downloadAndExtract: fakeExtract(appsDir),
|
||||
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
|
||||
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||
isBarRunning: async () => false,
|
||||
isBarRunning: async () => false,
|
||||
promptLaunch: async () => false,
|
||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||
getAppsDir: () => appsDir,
|
||||
@@ -848,7 +856,7 @@ describe('bar install: compat capability handshake', () => {
|
||||
throw new Error('network explosion');
|
||||
},
|
||||
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||
isBarRunning: async () => false,
|
||||
isBarRunning: async () => false,
|
||||
promptLaunch: async () => false,
|
||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||
getAppsDir: () => appsDir,
|
||||
@@ -1597,10 +1605,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => {
|
||||
await handleBarLaunch([], {
|
||||
findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }),
|
||||
getPort: async () => 9999,
|
||||
spawnDetachedServer: () => { spawnCalled = true; },
|
||||
waitForServerLive: async () => { /* noop */ },
|
||||
writeLaunchDescriptor: () => { /* noop */ },
|
||||
openApp: async () => { /* noop */ },
|
||||
spawnDetachedServer: () => {
|
||||
spawnCalled = true;
|
||||
},
|
||||
waitForServerLive: async () => {
|
||||
/* noop */
|
||||
},
|
||||
writeLaunchDescriptor: () => {
|
||||
/* noop */
|
||||
},
|
||||
openApp: async () => {
|
||||
/* noop */
|
||||
},
|
||||
getCcsDir: () => ccsDir,
|
||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||
});
|
||||
@@ -1628,10 +1644,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => {
|
||||
await handleBarLaunch([], {
|
||||
findRunningServer: async () => null,
|
||||
getPort: async () => 4242,
|
||||
spawnDetachedServer: () => { spawnCalled = true; },
|
||||
waitForServerLive: async () => { /* live */ },
|
||||
writeLaunchDescriptor: () => { /* noop */ },
|
||||
openApp: async () => { /* noop */ },
|
||||
spawnDetachedServer: () => {
|
||||
spawnCalled = true;
|
||||
},
|
||||
waitForServerLive: async () => {
|
||||
/* live */
|
||||
},
|
||||
writeLaunchDescriptor: () => {
|
||||
/* noop */
|
||||
},
|
||||
openApp: async () => {
|
||||
/* noop */
|
||||
},
|
||||
getCcsDir: () => ccsDir,
|
||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||
});
|
||||
@@ -1651,10 +1675,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => {
|
||||
throw new Error('probe exploded');
|
||||
},
|
||||
getPort: async () => 4242,
|
||||
spawnDetachedServer: () => { spawnCalled = true; },
|
||||
waitForServerLive: async () => { /* live */ },
|
||||
writeLaunchDescriptor: () => { /* noop */ },
|
||||
openApp: async () => { /* noop */ },
|
||||
spawnDetachedServer: () => {
|
||||
spawnCalled = true;
|
||||
},
|
||||
waitForServerLive: async () => {
|
||||
/* live */
|
||||
},
|
||||
writeLaunchDescriptor: () => {
|
||||
/* noop */
|
||||
},
|
||||
openApp: async () => {
|
||||
/* noop */
|
||||
},
|
||||
getCcsDir: () => ccsDir,
|
||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||
});
|
||||
@@ -1680,9 +1712,15 @@ describe('launch: bar.json contract (deterministic — GH-1500 null probe)', ()
|
||||
await handleBarLaunch([], {
|
||||
findRunningServer: async () => null,
|
||||
getPort: async () => 4242,
|
||||
spawnDetachedServer: () => { /* noop */ },
|
||||
waitForServerLive: async () => { /* live */ },
|
||||
writeLaunchDescriptor: () => { /* noop */ },
|
||||
spawnDetachedServer: () => {
|
||||
/* noop */
|
||||
},
|
||||
waitForServerLive: async () => {
|
||||
/* live */
|
||||
},
|
||||
writeLaunchDescriptor: () => {
|
||||
/* noop */
|
||||
},
|
||||
openApp: async (_appPath: string) => {
|
||||
calls.push(`open:${_appPath}`);
|
||||
},
|
||||
@@ -2899,33 +2937,54 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => {
|
||||
JSON.stringify({ port: 41235, baseUrl: 'http://127.0.0.1:41235', authMode: 'loopback' })
|
||||
);
|
||||
|
||||
let highPriorityBodyDestroyed = false;
|
||||
let highPrioritySocketDestroyed = false;
|
||||
let lowerPriorityProbeStarted = false;
|
||||
|
||||
mock.module('undici', () => ({
|
||||
request: (url: string) => {
|
||||
if (url === 'http://127.0.0.1:41235/api/bar/summary') {
|
||||
return Promise.resolve({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
destroy: () => {
|
||||
highPriorityBodyDestroyed = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
// The probe speaks raw HTTP/1.1 over a `net` socket and resolves on the
|
||||
// status line, so mock `net.connect` rather than a higher-level client.
|
||||
// The high-priority port (41235) answers HTTP 200 immediately; the
|
||||
// lower-priority port (3000) connects but never sends a status line,
|
||||
// emulating a non-CCS service that streams forever.
|
||||
mock.module('net', () => ({
|
||||
connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => {
|
||||
// net.connect is called synchronously for every probe target, so the
|
||||
// lower-priority probe is observably "started" the moment discovery
|
||||
// fires it — even though it never receives a status line.
|
||||
if (opts.port === 3000) lowerPriorityProbeStarted = true;
|
||||
const listeners: Record<string, Array<(arg?: unknown) => void>> = {};
|
||||
const socket = {
|
||||
on(event: string, cb: (arg?: unknown) => void) {
|
||||
(listeners[event] ??= []).push(cb);
|
||||
return socket;
|
||||
},
|
||||
setTimeout() {
|
||||
return socket;
|
||||
},
|
||||
write() {
|
||||
return true;
|
||||
},
|
||||
destroy() {
|
||||
if (opts.port === 41235) highPrioritySocketDestroyed = true;
|
||||
return socket;
|
||||
},
|
||||
};
|
||||
|
||||
if (url === 'http://127.0.0.1:3000/api/bar/summary') {
|
||||
lowerPriorityProbeStarted = true;
|
||||
return new Promise(() => {
|
||||
// Simulate a lower-priority service that never finishes responding.
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
statusCode: 404,
|
||||
body: { destroy: () => {} },
|
||||
// Fire the connect callback asynchronously, mirroring net.connect.
|
||||
setImmediate(() => {
|
||||
onConnect();
|
||||
if (opts.port === 41235) {
|
||||
const data = listeners.data ?? [];
|
||||
for (const cb of data) {
|
||||
cb(Buffer.from('HTTP/1.1 200 OK\r\n\r\n', 'utf8'));
|
||||
}
|
||||
}
|
||||
// Port 3000 never emits a status line: simulate an endlessly
|
||||
// streaming service that must not block the higher-priority hit.
|
||||
// Any other port stays silent and is settled by the 1.5s timeout,
|
||||
// which the Promise.race below short-circuits.
|
||||
});
|
||||
|
||||
return socket;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -2933,7 +2992,9 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => {
|
||||
const { defaultFindRunningServer } = (await import(
|
||||
`../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}`
|
||||
)) as {
|
||||
defaultFindRunningServer: (ccsDir: string) => Promise<{ port: number; baseUrl: string } | null>;
|
||||
defaultFindRunningServer: (
|
||||
ccsDir: string
|
||||
) => Promise<{ port: number; baseUrl: string; authRequired?: boolean } | null>;
|
||||
};
|
||||
|
||||
const result = await Promise.race([
|
||||
@@ -2941,8 +3002,12 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => {
|
||||
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 250)),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ port: 41235, baseUrl: 'http://127.0.0.1:41235' });
|
||||
expect(highPriorityBodyDestroyed).toBe(true);
|
||||
expect(result).toEqual({
|
||||
port: 41235,
|
||||
baseUrl: 'http://127.0.0.1:41235',
|
||||
authRequired: false,
|
||||
});
|
||||
expect(highPrioritySocketDestroyed).toBe(true);
|
||||
expect(lowerPriorityProbeStarted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,7 @@ async function loadLaunchSubcommand() {
|
||||
);
|
||||
return mod as {
|
||||
handleBarLaunch: (args: string[], deps?: Record<string, unknown>) => Promise<void>;
|
||||
BarServerAuthRequiredError: new (baseUrl: string, statusCode: number) => Error;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -611,6 +612,75 @@ describe('launch: detached-spawn model', () => {
|
||||
expect(allOutput()).toMatch(/timeout|connect|server/i);
|
||||
});
|
||||
|
||||
it('stops the spawned child when the bar API is protected by dashboard auth', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
let killCalled = false;
|
||||
const { handleBarLaunch, BarServerAuthRequiredError } = await loadLaunchSubcommand();
|
||||
|
||||
await handleBarLaunch([], {
|
||||
getCcsDir: () => ccsDir,
|
||||
findRunningServer: async () => null,
|
||||
getPort: async () => 3000,
|
||||
spawnDetachedServer: () => ({
|
||||
kill: () => {
|
||||
killCalled = true;
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
waitForServerLive: async () => {
|
||||
throw new BarServerAuthRequiredError('http://127.0.0.1:3000', 401);
|
||||
},
|
||||
writeLaunchDescriptor: () => {
|
||||
/* noop */
|
||||
},
|
||||
openApp: async () => {
|
||||
/* noop */
|
||||
},
|
||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||
});
|
||||
|
||||
expect(killCalled).toBe(true);
|
||||
expect(fs.existsSync(path.join(ccsDir, 'bar.json'))).toBe(false);
|
||||
expect(allOutput()).toMatch(/authentication/i);
|
||||
});
|
||||
|
||||
it('does not spawn a new server when an existing server is protected by dashboard auth', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
let spawnCalled = false;
|
||||
const { handleBarLaunch } = await loadLaunchSubcommand();
|
||||
|
||||
await handleBarLaunch([], {
|
||||
getCcsDir: () => ccsDir,
|
||||
findRunningServer: async () => ({
|
||||
port: 3000,
|
||||
baseUrl: 'http://127.0.0.1:3000',
|
||||
authRequired: true,
|
||||
}),
|
||||
getPort: async () => 3001,
|
||||
spawnDetachedServer: () => {
|
||||
spawnCalled = true;
|
||||
},
|
||||
waitForServerLive: async () => {
|
||||
/* noop */
|
||||
},
|
||||
writeLaunchDescriptor: () => {
|
||||
/* noop */
|
||||
},
|
||||
openApp: async () => {
|
||||
/* noop */
|
||||
},
|
||||
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
||||
});
|
||||
|
||||
expect(spawnCalled).toBe(false);
|
||||
expect(fs.existsSync(path.join(ccsDir, 'bar.json'))).toBe(false);
|
||||
expect(allOutput()).toMatch(/authentication/i);
|
||||
});
|
||||
|
||||
it('writes launch.json via writeLaunchDescriptor on the start path', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
Reference in new issue
Block a user