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:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-16 08:45:41 -04:00
1 parent 361328c788
commit c0409862d2
4 files changed
+318 -90

No files matched your search

+62 -29
View File
@@ -12,12 +12,33 @@ import * as path from 'path';
export interface DashboardInfo { export interface DashboardInfo {
port: number; port: number;
baseUrl: string; baseUrl: string;
authRequired?: boolean;
} }
/** /**
* Read the port recorded in an existing bar.json. * Read the port recorded in an existing bar.json.
* Returns null when the file is absent or malformed. * 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 { export function resolveBarPort(ccsDir: string): number | null {
const barJsonPath = path.join(ccsDir, 'bar.json'); const barJsonPath = path.join(ccsDir, 'bar.json');
try { 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 * (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 * priority order so a lower-priority slow or streaming response cannot block
* returning an already-known higher-priority hit. * 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> { export async function defaultFindRunningServer(ccsDir: string): Promise<DashboardInfo | null> {
const { request } = await import('undici'); ensureLoopbackNoProxy();
async function probe(url: string): Promise<{ ok: boolean }> { async function probe(url: string): Promise<{ ok: boolean; authRequired: boolean }> {
try { const net = await import('net');
const { statusCode, body } = await request(url, { const parsed = new URL(url);
method: 'GET', const port = Number(parsed.port);
headersTimeout: 1500, const host = parsed.hostname.replace(/^\[|\]$/g, '');
bodyTimeout: 1500,
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`
);
}); });
socket.setTimeout(1500, () => finish());
// The summary endpoint only needs the status code for liveness. Do not socket.on('data', (chunk) => {
// buffer the response body: a non-CCS loopback service can stream forever buffer += chunk.toString('utf8');
// and keep discovery from returning a higher-priority hit. Release the const match = buffer.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/);
// response as soon as headers arrive. if (match) finish(Number(match[1]));
try { });
if (typeof body.resume === 'function') { socket.on('error', () => finish());
body.resume(); socket.on('end', () => finish());
} 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 };
}
} }
const barJsonPort = resolveBarPort(ccsDir); const barJsonPort = resolveBarPort(ccsDir);
@@ -83,9 +115,10 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
const probes = probeTargets.map((t) => probe(t.url)); const probes = probeTargets.map((t) => probe(t.url));
for (let i = 0; i < probeTargets.length; i++) { 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]; const { port, baseUrl } = probeTargets[i];
return { port, baseUrl }; return { port, baseUrl, authRequired: result.authRequired };
} }
} }
return null; return null;
+74 -14
View File
@@ -19,6 +19,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import type { ChildProcess } from 'child_process';
import { getCcsDir } from '../../config/config-loader-facade'; import { getCcsDir } from '../../config/config-loader-facade';
import { import {
getBarDir, getBarDir,
@@ -71,7 +72,7 @@ export interface LaunchDeps {
* Spawn the `ccs bar serve --port N` process detached and return immediately. * Spawn the `ccs bar serve --port N` process detached and return immediately.
* The spawned process must be unref()ed so the launcher can exit. * 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. * Poll GET {baseUrl}/api/bar/summary until HTTP 200 or timeout.
* Returns the live baseUrl on success, throws on 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 * stdio is redirected to serve.log so server output is preserved for
* debugging without a terminal. unref() lets the launcher exit immediately. * 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'); const { spawn } = require('child_process') as typeof import('child_process');
// Open (or create) the log file for appending. // Open (or create) the log file for appending.
@@ -118,30 +119,68 @@ function defaultSpawnDetachedServer(port: number, logPath: string): void {
child.unref(); child.unref();
// Close our copy of the fd — the child has its own reference. // Close our copy of the fd — the child has its own reference.
fs.closeSync(logFd); fs.closeSync(logFd);
return child;
} }
/** /**
* Poll GET {baseUrl}/api/bar/summary every 250 ms until HTTP 200 or ~10 s. * Poll GET {baseUrl}/api/bar/summary every 250 ms until HTTP 200 or ~10 s.
* Resolves when the server is live. Rejects on timeout. * 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> { async function defaultWaitForServerLive(baseUrl: string): Promise<void> {
const { request } = await import('undici'); const net = await import('net');
const INTERVAL_MS = 250; const INTERVAL_MS = 250;
const TIMEOUT_MS = 10_000; const TIMEOUT_MS = 10_000;
const deadline = Date.now() + TIMEOUT_MS; const deadline = Date.now() + TIMEOUT_MS;
while (Date.now() < deadline) { async function probe(): Promise<number | null> {
try { const url = new URL(`${baseUrl}/api/bar/summary`);
const { statusCode, body } = await request(`${baseUrl}/api/bar/summary`, { return new Promise((resolve) => {
method: 'GET', let buffer = '';
headersTimeout: 1500, let settled = false;
bodyTimeout: 1500, 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(); socket.on('error', () => finish());
if (statusCode === 200) return; socket.on('end', () => finish());
} catch { });
/* not yet live — keep polling */ }
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)); await new Promise<void>((resolve) => setTimeout(resolve, INTERVAL_MS));
} }
@@ -198,6 +237,16 @@ export async function handleBarLaunch(
} }
if (running !== null) { 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. // Reuse the live server — write bar.json and open the app.
const barJson: BarDiscoveryJson = { const barJson: BarDiscoveryJson = {
baseUrl: running.baseUrl, baseUrl: running.baseUrl,
@@ -249,9 +298,10 @@ export async function handleBarLaunch(
// 2c. Spawn the detached server. // 2c. Spawn the detached server.
const serveLogPath = getServeLogPath(ccsDir); const serveLogPath = getServeLogPath(ccsDir);
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
let spawnedChild: ChildProcess | void;
try { try {
fs.mkdirSync(getBarDir(ccsDir), { recursive: true }); fs.mkdirSync(getBarDir(ccsDir), { recursive: true });
spawnDetachedServer(port, serveLogPath); spawnedChild = spawnDetachedServer(port, serveLogPath);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Could not start CCS web-server: ${msg}`); console.error(`[X] Could not start CCS web-server: ${msg}`);
@@ -265,6 +315,16 @@ export async function handleBarLaunch(
try { try {
await waitForServerLive(baseUrl); await waitForServerLive(baseUrl);
} catch (err) { } 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); const msg = err instanceof Error ? err.message : String(err);
console.error(`[X] Could not connect to CCS web-server: ${msg}`); console.error(`[X] Could not connect to CCS web-server: ${msg}`);
console.error(`[i] Check logs at ${serveLogPath}`); console.error(`[i] Check logs at ${serveLogPath}`);
+112 -47
View File
@@ -274,10 +274,18 @@ function makeDetachedDeps(ccsDir: string, port = 4242) {
return { return {
findRunningServer: async () => null, findRunningServer: async () => null,
getPort: async () => port, getPort: async () => port,
spawnDetachedServer: (_p: number, _log: string) => { /* noop */ }, spawnDetachedServer: (_p: number, _log: string) => {
waitForServerLive: async (_url: string) => { /* live immediately */ }, /* noop */
writeLaunchDescriptor: () => { /* noop */ }, },
openApp: async (_appPath: string) => { /* noop */ }, waitForServerLive: async (_url: string) => {
/* live immediately */
},
writeLaunchDescriptor: () => {
/* noop */
},
openApp: async (_appPath: string) => {
/* noop */
},
getCcsDir: () => ccsDir, getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
}; };
@@ -501,7 +509,7 @@ describe('bar install subcommand', () => {
downloadAndExtract: fakeExtract(appsDir), downloadAndExtract: fakeExtract(appsDir),
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }), verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
readAppBundleVersion: (_appPath: string) => FAKE_VERSION, readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
isBarRunning: async () => false, isBarRunning: async () => false,
promptLaunch: async () => false, promptLaunch: async () => false,
getCcsDir: () => path.join(tempHome, '.ccs'), getCcsDir: () => path.join(tempHome, '.ccs'),
getAppsDir: () => appsDir, getAppsDir: () => appsDir,
@@ -848,7 +856,7 @@ describe('bar install: compat capability handshake', () => {
throw new Error('network explosion'); throw new Error('network explosion');
}, },
readAppBundleVersion: (_appPath: string) => '1.4.0', readAppBundleVersion: (_appPath: string) => '1.4.0',
isBarRunning: async () => false, isBarRunning: async () => false,
promptLaunch: async () => false, promptLaunch: async () => false,
getCcsDir: () => path.join(tempHome, '.ccs'), getCcsDir: () => path.join(tempHome, '.ccs'),
getAppsDir: () => appsDir, getAppsDir: () => appsDir,
@@ -1597,10 +1605,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => {
await handleBarLaunch([], { await handleBarLaunch([], {
findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }), findRunningServer: async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' }),
getPort: async () => 9999, getPort: async () => 9999,
spawnDetachedServer: () => { spawnCalled = true; }, spawnDetachedServer: () => {
waitForServerLive: async () => { /* noop */ }, spawnCalled = true;
writeLaunchDescriptor: () => { /* noop */ }, },
openApp: async () => { /* noop */ }, waitForServerLive: async () => {
/* noop */
},
writeLaunchDescriptor: () => {
/* noop */
},
openApp: async () => {
/* noop */
},
getCcsDir: () => ccsDir, getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
}); });
@@ -1628,10 +1644,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => {
await handleBarLaunch([], { await handleBarLaunch([], {
findRunningServer: async () => null, findRunningServer: async () => null,
getPort: async () => 4242, getPort: async () => 4242,
spawnDetachedServer: () => { spawnCalled = true; }, spawnDetachedServer: () => {
waitForServerLive: async () => { /* live */ }, spawnCalled = true;
writeLaunchDescriptor: () => { /* noop */ }, },
openApp: async () => { /* noop */ }, waitForServerLive: async () => {
/* live */
},
writeLaunchDescriptor: () => {
/* noop */
},
openApp: async () => {
/* noop */
},
getCcsDir: () => ccsDir, getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
}); });
@@ -1651,10 +1675,18 @@ describe('launch: findRunningServer reuse-first (GH-1500)', () => {
throw new Error('probe exploded'); throw new Error('probe exploded');
}, },
getPort: async () => 4242, getPort: async () => 4242,
spawnDetachedServer: () => { spawnCalled = true; }, spawnDetachedServer: () => {
waitForServerLive: async () => { /* live */ }, spawnCalled = true;
writeLaunchDescriptor: () => { /* noop */ }, },
openApp: async () => { /* noop */ }, waitForServerLive: async () => {
/* live */
},
writeLaunchDescriptor: () => {
/* noop */
},
openApp: async () => {
/* noop */
},
getCcsDir: () => ccsDir, getCcsDir: () => ccsDir,
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'), appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
}); });
@@ -1680,9 +1712,15 @@ describe('launch: bar.json contract (deterministic — GH-1500 null probe)', ()
await handleBarLaunch([], { await handleBarLaunch([], {
findRunningServer: async () => null, findRunningServer: async () => null,
getPort: async () => 4242, getPort: async () => 4242,
spawnDetachedServer: () => { /* noop */ }, spawnDetachedServer: () => {
waitForServerLive: async () => { /* live */ }, /* noop */
writeLaunchDescriptor: () => { /* noop */ }, },
waitForServerLive: async () => {
/* live */
},
writeLaunchDescriptor: () => {
/* noop */
},
openApp: async (_appPath: string) => { openApp: async (_appPath: string) => {
calls.push(`open:${_appPath}`); 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' }) JSON.stringify({ port: 41235, baseUrl: 'http://127.0.0.1:41235', authMode: 'loopback' })
); );
let highPriorityBodyDestroyed = false; let highPrioritySocketDestroyed = false;
let lowerPriorityProbeStarted = false; let lowerPriorityProbeStarted = false;
mock.module('undici', () => ({ // The probe speaks raw HTTP/1.1 over a `net` socket and resolves on the
request: (url: string) => { // status line, so mock `net.connect` rather than a higher-level client.
if (url === 'http://127.0.0.1:41235/api/bar/summary') { // The high-priority port (41235) answers HTTP 200 immediately; the
return Promise.resolve({ // lower-priority port (3000) connects but never sends a status line,
statusCode: 200, // emulating a non-CCS service that streams forever.
body: { mock.module('net', () => ({
destroy: () => { connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => {
highPriorityBodyDestroyed = true; // 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') { // Fire the connect callback asynchronously, mirroring net.connect.
lowerPriorityProbeStarted = true; setImmediate(() => {
return new Promise(() => { onConnect();
// Simulate a lower-priority service that never finishes responding. 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'));
return Promise.resolve({ }
statusCode: 404, }
body: { destroy: () => {} }, // 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( const { defaultFindRunningServer } = (await import(
`../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}` `../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}`
)) as { )) 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([ const result = await Promise.race([
@@ -2941,8 +3002,12 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => {
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 250)), new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 250)),
]); ]);
expect(result).toEqual({ port: 41235, baseUrl: 'http://127.0.0.1:41235' }); expect(result).toEqual({
expect(highPriorityBodyDestroyed).toBe(true); port: 41235,
baseUrl: 'http://127.0.0.1:41235',
authRequired: false,
});
expect(highPrioritySocketDestroyed).toBe(true);
expect(lowerPriorityProbeStarted).toBe(true); expect(lowerPriorityProbeStarted).toBe(true);
}); });
}); });
@@ -84,6 +84,7 @@ async function loadLaunchSubcommand() {
); );
return mod as { return mod as {
handleBarLaunch: (args: string[], deps?: Record<string, unknown>) => Promise<void>; 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); 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 () => { it('writes launch.json via writeLaunchDescriptor on the start path', async () => {
const ccsDir = path.join(tempHome, '.ccs'); const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true }); fs.mkdirSync(ccsDir, { recursive: true });