mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-04 14:13:13 +00:00
4014 lines
149 KiB
TypeScript
4014 lines
149 KiB
TypeScript
/**
|
|
* Tests for `ccs bar` command surface — Phase 3 TDD.
|
|
*
|
|
* Tests run FIRST per TDD mandate.
|
|
* Covers: subcommand routing, bar.json contract, floating-tag install,
|
|
* Info.plist version extraction, capability handshake compat, port-discovery
|
|
* fallback, uninstall, version, and verified review findings #8-#13.
|
|
*
|
|
* All network I/O and filesystem-home operations are mocked.
|
|
* Uses CCS_HOME env var for isolation — never touches real ~/.ccs.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import {
|
|
BAR_AUTH_NONCE_HEADER,
|
|
BAR_AUTH_TOKEN_HEADER,
|
|
createBarAuthProof,
|
|
getOrCreateBarAuthToken,
|
|
} from '../../../src/utils/bar-auth-token';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let calls: string[] = [];
|
|
let consoleOutput: string[] = [];
|
|
let tempHome: string;
|
|
let originalCcsHome: string | undefined;
|
|
let originalConsoleLog: typeof console.log;
|
|
let originalConsoleError: typeof console.error;
|
|
const FAKE_SHA256 = 'a'.repeat(64);
|
|
|
|
function captureConsole(): void {
|
|
originalConsoleLog = console.log;
|
|
originalConsoleError = console.error;
|
|
console.log = (...args: unknown[]) => {
|
|
consoleOutput.push(args.map(String).join(' '));
|
|
};
|
|
console.error = (...args: unknown[]) => {
|
|
consoleOutput.push(args.map(String).join(' '));
|
|
};
|
|
}
|
|
|
|
function restoreConsole(): void {
|
|
console.log = originalConsoleLog;
|
|
console.error = originalConsoleError;
|
|
}
|
|
|
|
// Unique module cache-buster so bun:test picks up fresh mocks each describe block.
|
|
let moduleSeq = 0;
|
|
async function loadHandleBarCommand() {
|
|
moduleSeq++;
|
|
const mod = await import(`../../../src/commands/bar/index?test=${Date.now()}-${moduleSeq}`);
|
|
return mod.handleBarCommand as (args: string[]) => Promise<void>;
|
|
}
|
|
|
|
async function loadInstallSubcommand() {
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/install-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
return mod as {
|
|
handleBarInstall: (args: string[], deps?: Record<string, unknown>) => Promise<void>;
|
|
validateDownloadUrl: (url: string) => void;
|
|
};
|
|
}
|
|
|
|
async function loadLaunchSubcommand() {
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
return mod as {
|
|
handleBarLaunch: (args: string[], deps?: Record<string, unknown>) => Promise<void>;
|
|
};
|
|
}
|
|
|
|
async function loadUninstallSubcommand() {
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/uninstall-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
return mod as {
|
|
handleBarUninstall: (args: string[], deps?: Record<string, unknown>) => Promise<void>;
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup / teardown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
beforeEach(() => {
|
|
calls = [];
|
|
consoleOutput = [];
|
|
captureConsole();
|
|
|
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-test-'));
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
process.env.CCS_HOME = tempHome;
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreConsole();
|
|
mock.restore();
|
|
process.exitCode = 0;
|
|
|
|
if (originalCcsHome === undefined) {
|
|
delete process.env.CCS_HOME;
|
|
} else {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
}
|
|
|
|
// Clean up temp dir
|
|
try {
|
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Subcommand routing via handleBarCommand dispatcher
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar command dispatcher (index.ts)', () => {
|
|
beforeEach(() => {
|
|
mock.module('../../../src/commands/bar/launch-subcommand', () => ({
|
|
handleBarLaunch: async (args: string[]) => {
|
|
calls.push(`launch:${args.join(' ')}`);
|
|
},
|
|
}));
|
|
|
|
mock.module('../../../src/commands/bar/install-subcommand', () => ({
|
|
handleBarInstall: async (args: string[]) => {
|
|
calls.push(`install:${args.join(' ')}`);
|
|
},
|
|
}));
|
|
|
|
mock.module('../../../src/commands/bar/uninstall-subcommand', () => ({
|
|
handleBarUninstall: async (args: string[]) => {
|
|
calls.push(`uninstall:${args.join(' ')}`);
|
|
},
|
|
}));
|
|
|
|
mock.module('../../../src/commands/bar/version-subcommand', () => ({
|
|
handleBarVersion: async () => {
|
|
calls.push(`version:`);
|
|
},
|
|
}));
|
|
|
|
mock.module('../../../src/commands/bar/help-subcommand', () => ({
|
|
showHelp: async () => {
|
|
calls.push(`help:`);
|
|
},
|
|
}));
|
|
});
|
|
|
|
it('dispatches bare `ccs bar` to launch', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand([]);
|
|
expect(calls).toEqual(['launch:']);
|
|
});
|
|
|
|
it('dispatches `ccs bar launch` to launch subcommand', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['launch']);
|
|
expect(calls).toEqual(['launch:']);
|
|
});
|
|
|
|
it('rejects an unknown bare launch flag without dispatching launch', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['--porrt', '3999']);
|
|
expect(calls).toEqual([]);
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it('dispatches `ccs bar install` to install subcommand', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['install']);
|
|
expect(calls).toEqual(['install:']);
|
|
});
|
|
|
|
it('dispatches `ccs bar uninstall` to uninstall subcommand', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['uninstall']);
|
|
expect(calls).toEqual(['uninstall:']);
|
|
});
|
|
|
|
it('dispatches `ccs bar --version` to version subcommand', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['--version']);
|
|
expect(calls).toEqual(['version:']);
|
|
});
|
|
|
|
it('dispatches `ccs bar version` to version subcommand', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['version']);
|
|
expect(calls).toEqual(['version:']);
|
|
});
|
|
|
|
it('passes remaining args to install subcommand', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['install', '--force']);
|
|
expect(calls).toEqual(['install:--force']);
|
|
});
|
|
|
|
it('treats unknown subcommands as an error and does not throw', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
// Should print help or error but not crash
|
|
await expect(handleBarCommand(['unknown-subcommand'])).resolves.toBeUndefined();
|
|
expect(process.exitCode).toBe(1);
|
|
process.exitCode = 0;
|
|
});
|
|
|
|
it('dispatches `ccs bar --help` to help subcommand and does not launch', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['--help']);
|
|
expect(calls).toEqual(['help:']);
|
|
expect(calls).not.toContain(expect.stringMatching(/^launch:/));
|
|
});
|
|
|
|
it('dispatches `ccs bar -h` to help subcommand and does not launch', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['-h']);
|
|
expect(calls).toEqual(['help:']);
|
|
expect(calls).not.toContain(expect.stringMatching(/^launch:/));
|
|
});
|
|
|
|
it('dispatches `ccs bar help` to help subcommand and does not hit unknown-subcommand error', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['help']);
|
|
expect(calls).toEqual(['help:']);
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).not.toMatch(/Unknown bar subcommand/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. root-command-router registers `bar`
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('root-command-router registers bar', () => {
|
|
beforeEach(() => {
|
|
mock.module('../../../src/commands/bar/index', () => ({
|
|
handleBarCommand: async (args: string[]) => {
|
|
calls.push(`bar:${args.join(' ')}`);
|
|
},
|
|
}));
|
|
});
|
|
|
|
it('routes `ccs bar` through the root router', async () => {
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/root-command-router?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { tryHandleRootCommand } = mod;
|
|
|
|
const handled = await tryHandleRootCommand(['bar']);
|
|
expect(handled).toBe(true);
|
|
expect(calls).toEqual(['bar:']);
|
|
});
|
|
|
|
it('routes `ccs bar install` with args preserved', async () => {
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/root-command-router?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { tryHandleRootCommand } = mod;
|
|
|
|
await tryHandleRootCommand(['bar', 'install', '--force']);
|
|
expect(calls).toContain('bar:install --force');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. bar.json contract written by launch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Detached-model launch dep helpers (shared by tests in this describe block)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a minimal set of detached-model deps that behave like a successful
|
|
* launch for most tests. Individual tests override specific deps as needed.
|
|
*/
|
|
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 */
|
|
},
|
|
getCcsDir: () => ccsDir,
|
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
|
};
|
|
}
|
|
|
|
describe('bar.json contract (launch subcommand)', () => {
|
|
it('writes ~/.ccs/bar.json with correct shape when server starts', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
|
|
await handleBarLaunch([], {
|
|
...makeDetachedDeps(ccsDir, 4242),
|
|
openApp: async (_appPath: string) => {
|
|
calls.push(`open:${_appPath}`);
|
|
},
|
|
});
|
|
|
|
const barJsonPath = path.join(ccsDir, 'bar.json');
|
|
expect(fs.existsSync(barJsonPath)).toBe(true);
|
|
|
|
const barJson = JSON.parse(fs.readFileSync(barJsonPath, 'utf8')) as unknown;
|
|
expect(barJson).toMatchObject({
|
|
baseUrl: 'http://127.0.0.1:4242',
|
|
port: 4242,
|
|
authMode: 'loopback',
|
|
});
|
|
});
|
|
|
|
it('bar.json authMode is always "loopback" in v1', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
|
|
await handleBarLaunch([], makeDetachedDeps(ccsDir, 9000));
|
|
|
|
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
|
|
authMode: string;
|
|
};
|
|
expect(barJson.authMode).toBe('loopback');
|
|
});
|
|
|
|
it('prints guidance when app is not installed', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
|
|
// App doesn't exist at appInstallPath — openApp throws so degraded path runs.
|
|
const nonExistentApp = path.join(tempHome, 'Applications', 'CCS Bar.app');
|
|
|
|
await handleBarLaunch([], {
|
|
...makeDetachedDeps(ccsDir, 3000),
|
|
openApp: async () => {
|
|
throw new Error('App not found');
|
|
},
|
|
appInstallPath: nonExistentApp,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// Should suggest installation
|
|
expect(allOutput.toLowerCase()).toMatch(/install|not found|ccs bar install/i);
|
|
});
|
|
|
|
it('writes bar.json even when open fails (degraded path)', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
|
|
await handleBarLaunch([], {
|
|
...makeDetachedDeps(ccsDir, 3001),
|
|
openApp: async () => {
|
|
throw new Error('open failed');
|
|
},
|
|
});
|
|
|
|
// bar.json should still be written despite open failure
|
|
const barJsonPath = path.join(ccsDir, 'bar.json');
|
|
expect(fs.existsSync(barJsonPath)).toBe(true);
|
|
});
|
|
|
|
it('prints degraded-path warning when spawnDetachedServer throws', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
|
|
await handleBarLaunch([], {
|
|
...makeDetachedDeps(ccsDir, 3000),
|
|
spawnDetachedServer: () => {
|
|
throw new Error('spawn failed');
|
|
},
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput.toLowerCase()).toMatch(/error|failed|could not|unable/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. install subcommand — floating tag + Info.plist version + compat handshake
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install subcommand', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
const FAKE_VERSION = '1.2.3';
|
|
|
|
/** Create the fake CCS Bar.app in appsDir so the post-extract assertion passes. */
|
|
function fakeExtract(appsDir: string) {
|
|
return async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
calls.push('download');
|
|
};
|
|
}
|
|
|
|
it('resolves the floating ccs-bar-latest tag (not exact CLI version)', async () => {
|
|
const fetchedUrls: string[] = [];
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async (tag: string, _asset: string) => {
|
|
fetchedUrls.push(tag);
|
|
return { downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 };
|
|
},
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async (_baseUrl: string) => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
// Must use the floating tag, NOT the CLI version
|
|
expect(fetchedUrls).toContain('ccs-bar-latest');
|
|
expect(fetchedUrls).not.toContain(expect.stringMatching(/^\d+\.\d+\.\d+$/));
|
|
});
|
|
|
|
it('passes the release asset sha256 digest to the downloader before install', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const seenDigests: string[] = [];
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: async (_url: string, dest: string, expectedSha256: string) => {
|
|
seenDigests.push(expectedSha256);
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
expect(seenDigests).toEqual([FAKE_SHA256]);
|
|
});
|
|
|
|
it('pins the Info.plist version (not tag name) to ~/.ccs/bar/.version', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => ccsDir,
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const versionFile = path.join(ccsDir, 'bar', '.version');
|
|
expect(fs.existsSync(versionFile)).toBe(true);
|
|
expect(fs.readFileSync(versionFile, 'utf8').trim()).toBe(FAKE_VERSION);
|
|
});
|
|
|
|
it('calls /api/bar/summary for compat handshake after install', async () => {
|
|
const compatCalls: string[] = [];
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async (baseUrl: string) => {
|
|
compatCalls.push(baseUrl);
|
|
return { compatible: true, reason: 'ok' };
|
|
},
|
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
expect(compatCalls.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('does not hard-fail when compat returns no-bar-api', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await expect(
|
|
handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
|
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
})
|
|
).resolves.toBeUndefined(); // does not throw
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput.toLowerCase()).toMatch(/warn|api|update/i);
|
|
});
|
|
|
|
it('prints xattr/Gatekeeper note for ad-hoc builds', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// Gatekeeper quarantine is preserved (not cleared) — the note is always printed.
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// Must mention either right-click or xattr quarantine command
|
|
expect(allOutput).toMatch(/xattr|right-click|quarantine/i);
|
|
});
|
|
|
|
it('does not overwrite ~/Applications if download fails', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await expect(
|
|
handleBarInstall([], {
|
|
fetchReleaseAsset: async () => {
|
|
throw new Error('network error');
|
|
},
|
|
downloadAndExtract: async () => {
|
|
/* noop */
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
})
|
|
).resolves.toBeUndefined(); // should not throw
|
|
|
|
// Apps dir should not be touched
|
|
expect(fs.existsSync(path.join(appsDir, 'CCS Bar.app'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4a. Finding #8 — redirect-following download (mock 302 then 200 via deps)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: redirect-following download (#8)', () => {
|
|
const REDIRECT_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
const FINAL_URL = 'https://objects.githubusercontent.com/download/CCS-Bar.app.zip';
|
|
const FAKE_VERSION = '1.0.0';
|
|
|
|
it('succeeds when downloadAndExtract follows a 302 redirect to githubusercontent', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const urlsRequested: string[] = [];
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// Simulate a downloader that internally follows a redirect (302 -> 200).
|
|
// The dep mock receives the initial URL and resolves to the final content.
|
|
const redirectFollowingExtract = async (url: string, dest: string) => {
|
|
urlsRequested.push(url);
|
|
// Simulate: original URL would 302 to FINAL_URL; mock follows it and succeeds.
|
|
if (url !== REDIRECT_URL) {
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
}
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
};
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl: REDIRECT_URL,
|
|
}),
|
|
downloadAndExtract: redirectFollowingExtract,
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
// The download should have been attempted with the original URL
|
|
expect(urlsRequested).toContain(REDIRECT_URL);
|
|
// No error in output
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).not.toMatch(/\[X\]/);
|
|
expect(allOutput).toMatch(/\[OK\]/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4b. Finding #11 — HTTP statusCode != 200 throws descriptive error
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: HTTP status code validation (#11)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
it('reports descriptive error when downloadAndExtract throws on non-200', async () => {
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// Simulate a downloader that respects status codes and throws on 403.
|
|
const statusCheckingExtract = async (url: string, _dest: string) => {
|
|
throw new Error(`Download failed: HTTP 403 for ${url}`);
|
|
};
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
}),
|
|
downloadAndExtract: statusCheckingExtract,
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => path.join(tempHome, 'Applications'),
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// Should surface the HTTP status in the output
|
|
expect(allOutput).toMatch(/403|Download failed/i);
|
|
expect(allOutput).toMatch(/\[X\]/);
|
|
});
|
|
|
|
it('reports descriptive error on 404 (asset not found)', async () => {
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
}),
|
|
downloadAndExtract: async (_url) => {
|
|
throw new Error(`Download failed: HTTP 404 for ${_url}`);
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => path.join(tempHome, 'Applications'),
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/404|Download failed/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4c. Finding #9 — host allowlist rejects non-github URLs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: host allowlist validation (#9)', () => {
|
|
it('validateDownloadUrl accepts github.com URLs', async () => {
|
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
|
expect(() =>
|
|
validateDownloadUrl('https://github.com/kaitranntt/ccs/releases/download/tag/CCS-Bar.app.zip')
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('validateDownloadUrl accepts objects.githubusercontent.com URLs', async () => {
|
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
|
expect(() =>
|
|
validateDownloadUrl('https://objects.githubusercontent.com/some/path/CCS-Bar.app.zip')
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('validateDownloadUrl accepts *.githubusercontent.com wildcard', async () => {
|
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
|
expect(() =>
|
|
validateDownloadUrl('https://raw.githubusercontent.com/some/path/file.zip')
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('validateDownloadUrl rejects http:// (non-HTTPS)', async () => {
|
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
|
expect(() =>
|
|
validateDownloadUrl('http://github.com/kaitranntt/ccs/releases/download/tag/file.zip')
|
|
).toThrow(/HTTPS|https/i);
|
|
});
|
|
|
|
it('validateDownloadUrl rejects untrusted hostnames', async () => {
|
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
|
expect(() => validateDownloadUrl('https://evil.example.com/CCS-Bar.app.zip')).toThrow(
|
|
/allowlist|trusted/i
|
|
);
|
|
});
|
|
|
|
it('validateDownloadUrl rejects a URL that looks like github but is not', async () => {
|
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
|
// A domain that ends in github.com.attacker.com must be rejected
|
|
expect(() => validateDownloadUrl('https://github.com.attacker.com/download/file.zip')).toThrow(
|
|
/allowlist|trusted/i
|
|
);
|
|
});
|
|
|
|
it('handleBarInstall surfaces a clear error for non-github download URLs', async () => {
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl: 'https://evil.example.com/CCS-Bar.app.zip',
|
|
}),
|
|
// downloadAndExtract is the production default; it calls validateDownloadUrl internally.
|
|
// We pass a test-double that applies the same validation.
|
|
downloadAndExtract: async (url: string, _dest: string) => {
|
|
// Inline the validation that the production code runs.
|
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
|
validateDownloadUrl(url);
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => path.join(tempHome, 'Applications'),
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// Must report download/extraction failure with a clear [X] marker.
|
|
expect(allOutput).toMatch(/\[X\]/);
|
|
expect(allOutput.toLowerCase()).toMatch(/download|extraction|failed/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4d. Compat capability handshake (replaces old major-version check)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: compat capability handshake', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
function fakeExtract(appsDir: string) {
|
|
return async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
};
|
|
}
|
|
|
|
it('verifyCompat receives only baseUrl (no installedVersion param)', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const capturedArgs: Array<{ baseUrl: string }> = [];
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async (baseUrl: string) => {
|
|
capturedArgs.push({ baseUrl });
|
|
return { compatible: true, reason: 'ok' };
|
|
},
|
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
expect(capturedArgs.length).toBe(1);
|
|
// verifyCompat receives only one argument (baseUrl)
|
|
expect(capturedArgs[0].baseUrl).toMatch(/http/);
|
|
});
|
|
|
|
it('prints [OK] server bar API reachable when compat returns ok', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[OK\].*[Ss]erver bar API/);
|
|
// Must NOT print any mismatch warning
|
|
expect(allOutput).not.toMatch(/mismatch/i);
|
|
});
|
|
|
|
it('warns with actionable message when compat returns no-bar-api (404)', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
|
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[!\]/);
|
|
// Must mention the bar API and instruct the user to update CCS
|
|
expect(allOutput.toLowerCase()).toMatch(/bar api|\/api\/bar\/summary/i);
|
|
expect(allOutput.toLowerCase()).toMatch(/update ccs/i);
|
|
});
|
|
|
|
it('soft-warns (not crash) when compat returns unreachable', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: false, reason: 'unreachable' }),
|
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// Should warn rather than print [OK] compat confirmed
|
|
expect(allOutput).not.toMatch(/\[OK\].*[Cc]ompat/);
|
|
expect(allOutput).toMatch(/\[!\].*[Cc]ould not verify|server may not be running/i);
|
|
});
|
|
|
|
it('install never hard-fails due to compat failure', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await expect(
|
|
handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => {
|
|
throw new Error('network explosion');
|
|
},
|
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
})
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4e. Finding #12 — post-extract CCS Bar.app existence assertion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: post-extract app-exists assertion (#12)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
it('prints [OK] only when CCS Bar.app exists after extraction', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// Correctly places CCS Bar.app in dest.
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
}),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[OK\].*CCS Bar/);
|
|
expect(allOutput).not.toMatch(/\[X\]/);
|
|
});
|
|
|
|
it('reports [X] and lists extracted files when CCS Bar.app is absent after extraction', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// Extraction "succeeds" but places wrong file name.
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
}),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
// Places a wrongly-named artifact (simulates a bad archive).
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
fs.writeFileSync(path.join(dest, 'WrongName.app'), 'dummy');
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[X\]/);
|
|
// Should mention the missing app name
|
|
expect(allOutput).toMatch(/CCS Bar\.app/);
|
|
});
|
|
|
|
it('does not print xattr guidance when app is missing after extraction', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
}),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
// Nothing extracted
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// xattr note should NOT appear if install didn't succeed
|
|
expect(allOutput).not.toMatch(/xattr.*quarantine/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4h. Regression: version source of truth — Info.plist, not tag_name
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: Info.plist version extraction regression tests', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
function fakeExtract(appsDir: string) {
|
|
return async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
};
|
|
}
|
|
|
|
it('output never contains "vccs-bar-latest" when release tag is floating', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
// readAppBundleVersion returns the real version from Info.plist
|
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// Must never print the floating tag as a version string
|
|
expect(allOutput).not.toMatch(/vccs-bar-latest/i);
|
|
// Must print the actual plist version
|
|
expect(allOutput).toMatch(/1\.4\.0/);
|
|
});
|
|
|
|
it('pins Info.plist version (1.4.0) to .version file, not the tag name', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => ccsDir,
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const versionFile = path.join(ccsDir, 'bar', '.version');
|
|
expect(fs.existsSync(versionFile)).toBe(true);
|
|
const pinned = fs.readFileSync(versionFile, 'utf8').trim();
|
|
// Must be the plist value, not the floating tag
|
|
expect(pinned).toBe('1.4.0');
|
|
expect(pinned).not.toMatch(/ccs-bar-latest/i);
|
|
});
|
|
|
|
it('no pin write and ASCII notice when readAppBundleVersion returns null', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
// Unreadable Info.plist — returns null
|
|
readAppBundleVersion: (_appPath: string) => null,
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => ccsDir,
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
// No pin written
|
|
const versionFile = path.join(ccsDir, 'bar', '.version');
|
|
expect(fs.existsSync(versionFile)).toBe(false);
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// ASCII notice must appear
|
|
expect(allOutput).toMatch(/\[!\].*Info\.plist/i);
|
|
// Install itself must still succeed (no [X])
|
|
expect(allOutput).toMatch(/\[OK\].*CCS Bar/);
|
|
// No crash — test reaches here
|
|
});
|
|
|
|
it('defaultReadAppBundleVersion: extracts CFBundleShortVersionString from XML plist fixture', async () => {
|
|
// Test the default implementation directly using a temp file.
|
|
// We import the real module (not a mock) to access the default implementation.
|
|
// Because the module only exports handleBarInstall and validateDownloadUrl,
|
|
// we exercise the default via handleBarInstall without mocking readAppBundleVersion.
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const appPath = path.join(appsDir, 'CCS Bar.app');
|
|
|
|
// Create a real Info.plist fixture inside the fake .app bundle
|
|
const contentsDir = path.join(appPath, 'Contents');
|
|
fs.mkdirSync(contentsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(contentsDir, 'Info.plist'),
|
|
`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CFBundleIdentifier</key>
|
|
<string>com.kaitranntt.CCSBar</string>
|
|
<key>CFBundleShortVersionString</key>
|
|
<string>2.7.1</string>
|
|
<key>CFBundleVersion</key>
|
|
<string>271</string>
|
|
</dict>
|
|
</plist>`
|
|
);
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// Use production readAppBundleVersion (omit from deps so default is used).
|
|
// downloadAndExtract must recreate the bundle because the stale-reinstall guard
|
|
// removes the pre-seeded app before extraction.
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl:
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip',
|
|
}),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
// Recreate the bundle with its Info.plist fixture after the old bundle is removed.
|
|
const contentsOut = path.join(dest, 'CCS Bar.app', 'Contents');
|
|
fs.mkdirSync(contentsOut, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(contentsOut, 'Info.plist'),
|
|
`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CFBundleIdentifier</key>
|
|
<string>com.kaitranntt.CCSBar</string>
|
|
<key>CFBundleShortVersionString</key>
|
|
<string>2.7.1</string>
|
|
<key>CFBundleVersion</key>
|
|
<string>271</string>
|
|
</dict>
|
|
</plist>`
|
|
);
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
// readAppBundleVersion intentionally omitted → uses production default
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => ccsDir,
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const versionFile = path.join(ccsDir, 'bar', '.version');
|
|
expect(fs.existsSync(versionFile)).toBe(true);
|
|
expect(fs.readFileSync(versionFile, 'utf8').trim()).toBe('2.7.1');
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/2\.7\.1/);
|
|
});
|
|
|
|
it('defaultReadAppBundleVersion: returns null when Info.plist is missing', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const appPath = path.join(appsDir, 'CCS Bar.app');
|
|
|
|
// Create app bundle WITHOUT Info.plist
|
|
fs.mkdirSync(appPath, { recursive: true });
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// downloadAndExtract must recreate the bundle (without Info.plist) after the
|
|
// stale-reinstall guard removes the pre-seeded app.
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl:
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip',
|
|
}),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
// Recreate bare bundle — no Info.plist, matching the test's intent.
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
// readAppBundleVersion omitted → uses production default
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => ccsDir,
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
// No pin because plist is missing
|
|
const versionFile = path.join(ccsDir, 'bar', '.version');
|
|
expect(fs.existsSync(versionFile)).toBe(false);
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// Should emit the ASCII notice
|
|
expect(allOutput).toMatch(/\[!\].*Info\.plist/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. Port discovery fallback
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('port discovery', () => {
|
|
it('reads port from existing bar.json when present', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const barJson = { baseUrl: 'http://127.0.0.1:5555', port: 5555, authMode: 'loopback' };
|
|
fs.writeFileSync(path.join(ccsDir, 'bar.json'), JSON.stringify(barJson));
|
|
|
|
// Import the port-discovery utility from the bar module
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { resolveBarPort } = mod as { resolveBarPort?: (ccsDir: string) => number | null };
|
|
|
|
if (resolveBarPort) {
|
|
const port = resolveBarPort(ccsDir);
|
|
expect(port).toBe(5555);
|
|
}
|
|
// If resolveBarPort is not exported separately, the behavior is tested through handleBarLaunch
|
|
});
|
|
|
|
it('returns null when bar.json is absent', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
// No bar.json written
|
|
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { resolveBarPort } = mod as { resolveBarPort?: (ccsDir: string) => number | null };
|
|
|
|
if (resolveBarPort) {
|
|
const port = resolveBarPort(ccsDir);
|
|
expect(port).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('returns null when bar.json is malformed', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(ccsDir, 'bar.json'), 'NOT_JSON{{{');
|
|
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { resolveBarPort } = mod as { resolveBarPort?: (ccsDir: string) => number | null };
|
|
|
|
if (resolveBarPort) {
|
|
const port = resolveBarPort(ccsDir);
|
|
expect(port).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. uninstall subcommand
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('uninstall subcommand', () => {
|
|
it('removes the app and clears the version pin', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const barDir = path.join(ccsDir, 'bar');
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const appPath = path.join(appsDir, 'CCS Bar.app');
|
|
|
|
fs.mkdirSync(barDir, { recursive: true });
|
|
fs.mkdirSync(appPath, { recursive: true }); // fake .app bundle (it's a dir on macOS)
|
|
fs.writeFileSync(path.join(barDir, '.version'), '1.0.0');
|
|
|
|
const { handleBarUninstall } = await loadUninstallSubcommand();
|
|
|
|
await handleBarUninstall([], {
|
|
getCcsDir: () => ccsDir,
|
|
getAppsDir: () => appsDir,
|
|
appName: 'CCS Bar.app',
|
|
});
|
|
|
|
expect(fs.existsSync(appPath)).toBe(false);
|
|
expect(fs.existsSync(path.join(barDir, '.version'))).toBe(false);
|
|
});
|
|
|
|
it('is a no-op and does not throw when app is not installed', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const { handleBarUninstall } = await loadUninstallSubcommand();
|
|
|
|
await expect(
|
|
handleBarUninstall([], {
|
|
getCcsDir: () => ccsDir,
|
|
getAppsDir: () => path.join(tempHome, 'Applications'),
|
|
appName: 'CCS Bar.app',
|
|
})
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 7. version subcommand — Finding #13: unambiguous CLI vs Bar app labels
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('version subcommand', () => {
|
|
it('prints the CCS version and exits cleanly', async () => {
|
|
let exitCode: number | undefined;
|
|
const origExit = process.exit;
|
|
process.exit = ((code?: number) => {
|
|
exitCode = code ?? 0;
|
|
}) as typeof process.exit;
|
|
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/version-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { handleBarVersion } = mod as { handleBarVersion: () => Promise<void> };
|
|
|
|
await handleBarVersion();
|
|
|
|
process.exit = origExit;
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// Should mention "bar" and a version string
|
|
expect(allOutput.toLowerCase()).toMatch(/bar|ccs/i);
|
|
expect(allOutput).toMatch(/\d+\.\d+/);
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it('labels the CLI version unambiguously as CCS CLI (not CCS Bar)', async () => {
|
|
// Finding #13: line must say "CCS CLI v..." not just "CCS Bar v..."
|
|
let exitCode: number | undefined;
|
|
const origExit = process.exit;
|
|
process.exit = ((code?: number) => {
|
|
exitCode = code ?? 0;
|
|
}) as typeof process.exit;
|
|
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/version-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { handleBarVersion } = mod as { handleBarVersion: () => Promise<void> };
|
|
|
|
await handleBarVersion();
|
|
|
|
process.exit = origExit;
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// Must include a line explicitly identifying the CLI version
|
|
expect(allOutput).toMatch(/CCS CLI v\d+/);
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it('labels the Bar app version separately from the CLI version', async () => {
|
|
// Finding #13: when Bar app is installed, its version must be on a separate labeled line
|
|
let exitCode: number | undefined;
|
|
const origExit = process.exit;
|
|
process.exit = ((code?: number) => {
|
|
exitCode = code ?? 0;
|
|
}) as typeof process.exit;
|
|
|
|
// getCcsDir() with CCS_HOME=tempHome returns tempHome/.ccs
|
|
// so the bar version file lives at tempHome/.ccs/bar/.version
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const barDir = path.join(ccsDir, 'bar');
|
|
fs.mkdirSync(barDir, { recursive: true });
|
|
fs.writeFileSync(path.join(barDir, '.version'), '9.8.7');
|
|
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/version-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { handleBarVersion } = mod as { handleBarVersion: () => Promise<void> };
|
|
|
|
await handleBarVersion();
|
|
|
|
process.exit = origExit;
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// CLI version line
|
|
expect(allOutput).toMatch(/CCS CLI v\d+/);
|
|
// Bar app version line — must say "CCS Bar app: v..." not just "CCS Bar v..."
|
|
expect(allOutput).toMatch(/CCS Bar app: v9\.8\.7/);
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it('prints not-installed guidance for Bar app when version file is absent', async () => {
|
|
let exitCode: number | undefined;
|
|
const origExit = process.exit;
|
|
process.exit = ((code?: number) => {
|
|
exitCode = code ?? 0;
|
|
}) as typeof process.exit;
|
|
|
|
// No bar version file written — tempHome/.ccs/bar/.version absent
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/version-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { handleBarVersion } = mod as { handleBarVersion: () => Promise<void> };
|
|
|
|
await handleBarVersion();
|
|
|
|
process.exit = origExit;
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/CCS CLI v\d+/);
|
|
expect(allOutput.toLowerCase()).toMatch(/not installed|ccs bar install/i);
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4f. Fix #6 — redirect host bypass: every hop is re-validated
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: redirect host re-validation (fix #6)', () => {
|
|
const INITIAL_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
const EVIL_REDIRECT_URL = 'https://evil.example.com/CCS-Bar.app.zip';
|
|
const FAKE_VERSION = '1.0.0';
|
|
|
|
it('rejects a download when a redirect leads to an untrusted host', async () => {
|
|
// Simulate a downloadAndExtract that follows a redirect to an untrusted host
|
|
// and validates each hop (the production fix). The mock replicates the fix logic.
|
|
const { handleBarInstall, validateDownloadUrl } = await loadInstallSubcommand();
|
|
|
|
const redirectFollowingExtract = async (url: string, _dest: string) => {
|
|
// Validate initial URL
|
|
validateDownloadUrl(url);
|
|
// Simulate a 302 to an evil host — production code re-validates Location
|
|
validateDownloadUrl(EVIL_REDIRECT_URL); // should throw
|
|
};
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl: INITIAL_URL,
|
|
}),
|
|
downloadAndExtract: redirectFollowingExtract,
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => path.join(tempHome, 'Applications'),
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// The validation error should surface as a download/extraction failure
|
|
expect(allOutput).toMatch(/\[X\]/);
|
|
expect(allOutput.toLowerCase()).toMatch(/download|extraction|failed/i);
|
|
});
|
|
|
|
it('validateDownloadUrl blocks redirect targets with untrusted hostnames', async () => {
|
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
|
|
|
// The redirect target must be rejected just like any initial URL
|
|
expect(() => validateDownloadUrl(EVIL_REDIRECT_URL)).toThrow(/allowlist|trusted/i);
|
|
});
|
|
|
|
it('validateDownloadUrl allows the expected redirect target (githubusercontent.com)', async () => {
|
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
|
const legitimateRedirect = 'https://objects.githubusercontent.com/file/CCS-Bar.app.zip';
|
|
expect(() => validateDownloadUrl(legitimateRedirect)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4g. Fix #14 — zip-slip guard: reject archives with traversal entries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: zip-slip guard (fix #14)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
const FAKE_VERSION = '1.0.0';
|
|
|
|
it('rejects download when downloadAndExtract detects a zip-slip entry', async () => {
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// Simulate an extractor that detects a zip-slip entry and throws
|
|
const zipSlipExtract = async (_url: string, _dest: string) => {
|
|
throw new Error(
|
|
'Zip-slip detected: archive entry "../../../evil" contains a path traversal component. Refusing to extract.'
|
|
);
|
|
};
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
}),
|
|
downloadAndExtract: zipSlipExtract,
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => path.join(tempHome, 'Applications'),
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// Should report failure and not proceed to install
|
|
expect(allOutput).toMatch(/\[X\]/);
|
|
expect(allOutput.toLowerCase()).toMatch(/download|extraction|failed/i);
|
|
// App should not be installed
|
|
expect(fs.existsSync(path.join(tempHome, 'Applications', 'CCS Bar.app'))).toBe(false);
|
|
});
|
|
|
|
it('allows a clean archive with safe paths to proceed normally', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// Simulate an extractor that validates entries and finds no traversal
|
|
const safeExtract = async (_url: string, dest: string) => {
|
|
// All entry paths are safe relative paths — no ".." or absolute
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
};
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
}),
|
|
downloadAndExtract: safeExtract,
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[OK\]/);
|
|
expect(allOutput).not.toMatch(/\[X\]/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 1 — stale .version cleanup when readAppBundleVersion returns null
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: stale version-pin removal on null plist read (Fix 1)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
function fakeExtract(appsDir: string) {
|
|
return async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
};
|
|
}
|
|
|
|
it('removes a stale .version pin when readAppBundleVersion returns null, and install still succeeds', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const barDir = path.join(ccsDir, 'bar');
|
|
|
|
// Pre-write a stale pin from a previous install
|
|
fs.mkdirSync(barDir, { recursive: true });
|
|
fs.writeFileSync(path.join(barDir, '.version'), '9.9.9');
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
// Simulate unreadable Info.plist
|
|
readAppBundleVersion: (_appPath: string) => null,
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => ccsDir,
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
// Stale pin must be gone
|
|
const versionFile = path.join(barDir, '.version');
|
|
expect(fs.existsSync(versionFile)).toBe(false);
|
|
|
|
// Install still reports success (no [X])
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[OK\].*CCS Bar/);
|
|
expect(allOutput).not.toMatch(/\[X\]/);
|
|
// ASCII notice still appears
|
|
expect(allOutput).toMatch(/\[!\].*Info\.plist/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 2 — `ccs bar install --help` dispatches to help, not install
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar command dispatcher: --help anywhere in args (Fix 2)', () => {
|
|
beforeEach(() => {
|
|
mock.module('../../../src/commands/bar/launch-subcommand', () => ({
|
|
handleBarLaunch: async (args: string[]) => {
|
|
calls.push(`launch:${args.join(' ')}`);
|
|
},
|
|
}));
|
|
|
|
mock.module('../../../src/commands/bar/install-subcommand', () => ({
|
|
handleBarInstall: async (args: string[]) => {
|
|
calls.push(`install:${args.join(' ')}`);
|
|
},
|
|
}));
|
|
|
|
mock.module('../../../src/commands/bar/uninstall-subcommand', () => ({
|
|
handleBarUninstall: async (args: string[]) => {
|
|
calls.push(`uninstall:${args.join(' ')}`);
|
|
},
|
|
}));
|
|
|
|
mock.module('../../../src/commands/bar/version-subcommand', () => ({
|
|
handleBarVersion: async () => {
|
|
calls.push(`version:`);
|
|
},
|
|
}));
|
|
|
|
mock.module('../../../src/commands/bar/help-subcommand', () => ({
|
|
showHelp: async () => {
|
|
calls.push(`help:`);
|
|
},
|
|
}));
|
|
});
|
|
|
|
it('`ccs bar install --help` dispatches to help and does NOT call install handler', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['install', '--help']);
|
|
expect(calls).toContain('help:');
|
|
expect(calls.some((c) => c.startsWith('install:'))).toBe(false);
|
|
});
|
|
|
|
it('`ccs bar uninstall -h` dispatches to help and does NOT call uninstall handler', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['uninstall', '-h']);
|
|
expect(calls).toContain('help:');
|
|
expect(calls.some((c) => c.startsWith('uninstall:'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GH-1500 — findRunningServer reuse-first behavior
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('launch: findRunningServer reuse-first (GH-1500)', () => {
|
|
it('reuses a running server: spawnDetachedServer NOT called; bar.json has reused port/baseUrl', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
let spawnCalled = false;
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
|
|
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 */
|
|
},
|
|
getCcsDir: () => ccsDir,
|
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
|
});
|
|
|
|
expect(spawnCalled).toBe(false);
|
|
|
|
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
|
|
port: number;
|
|
baseUrl: string;
|
|
};
|
|
expect(barJson.port).toBe(3000);
|
|
expect(barJson.baseUrl).toBe('http://127.0.0.1:3000');
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/Reusing/);
|
|
});
|
|
|
|
it('starts a new server when findRunningServer returns null', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
let spawnCalled = false;
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
|
|
await handleBarLaunch([], {
|
|
findRunningServer: async () => null,
|
|
getPort: async () => 4242,
|
|
spawnDetachedServer: () => {
|
|
spawnCalled = true;
|
|
},
|
|
waitForServerLive: async () => {
|
|
/* live */
|
|
},
|
|
writeLaunchDescriptor: () => {
|
|
/* noop */
|
|
},
|
|
openApp: async () => {
|
|
/* noop */
|
|
},
|
|
getCcsDir: () => ccsDir,
|
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
|
});
|
|
|
|
expect(spawnCalled).toBe(true);
|
|
});
|
|
|
|
it('treats findRunningServer throw as null: spawnDetachedServer called; no crash', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
let spawnCalled = false;
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
|
|
await handleBarLaunch([], {
|
|
findRunningServer: async () => {
|
|
throw new Error('probe exploded');
|
|
},
|
|
getPort: async () => 4242,
|
|
spawnDetachedServer: () => {
|
|
spawnCalled = true;
|
|
},
|
|
waitForServerLive: async () => {
|
|
/* live */
|
|
},
|
|
writeLaunchDescriptor: () => {
|
|
/* noop */
|
|
},
|
|
openApp: async () => {
|
|
/* noop */
|
|
},
|
|
getCcsDir: () => ccsDir,
|
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
|
});
|
|
|
|
expect(spawnCalled).toBe(true);
|
|
// Should not have printed any bind error
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).not.toMatch(/Could not start/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GH-1500 — existing launch tests: inject findRunningServer: null for determinism
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('launch: bar.json contract (deterministic — GH-1500 null probe)', () => {
|
|
it('writes correct bar.json shape on start path with explicit null probe', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
|
|
await handleBarLaunch([], {
|
|
findRunningServer: async () => null,
|
|
getPort: async () => 4242,
|
|
spawnDetachedServer: () => {
|
|
/* noop */
|
|
},
|
|
waitForServerLive: async () => {
|
|
/* live */
|
|
},
|
|
writeLaunchDescriptor: () => {
|
|
/* noop */
|
|
},
|
|
openApp: async (_appPath: string) => {
|
|
calls.push(`open:${_appPath}`);
|
|
},
|
|
getCcsDir: () => ccsDir,
|
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
|
});
|
|
|
|
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as unknown;
|
|
expect(barJson).toMatchObject({
|
|
baseUrl: 'http://127.0.0.1:4242',
|
|
port: 4242,
|
|
authMode: 'loopback',
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GH-1500 — defaultFindRunningServer integration-style tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('defaultFindRunningServer (GH-1500)', () => {
|
|
it('detects a real HTTP server responding 200 on /api/bar/summary', async () => {
|
|
const http = await import('http');
|
|
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const token = getOrCreateBarAuthToken(ccsDir);
|
|
const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? '');
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
[BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce),
|
|
});
|
|
res.end('{}');
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const addr = server.address() as { port: number };
|
|
const livePort = addr.port;
|
|
|
|
// Seed bar.json with the live port so it is checked first.
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({
|
|
port: livePort,
|
|
baseUrl: `http://127.0.0.1:${livePort}`,
|
|
authMode: 'loopback',
|
|
})
|
|
);
|
|
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { defaultFindRunningServer } = mod as {
|
|
defaultFindRunningServer: (
|
|
ccsDir: string
|
|
) => Promise<{ port: number; baseUrl: string } | null>;
|
|
};
|
|
|
|
let result: { port: number; baseUrl: string } | null = null;
|
|
try {
|
|
result = await defaultFindRunningServer(ccsDir);
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.port).toBe(livePort);
|
|
expect(result?.baseUrl).toBe(`http://127.0.0.1:${livePort}`);
|
|
});
|
|
|
|
it('rejects a spoofed 200 response without the CCS Bar auth token', async () => {
|
|
const http = await import('http');
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const server = http.createServer((_req, res) => {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end('{"not":"ccs"}');
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const livePort = (server.address() as { port: number }).port;
|
|
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({
|
|
port: livePort,
|
|
baseUrl: `http://127.0.0.1:${livePort}`,
|
|
authMode: 'loopback',
|
|
})
|
|
);
|
|
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { defaultFindRunningServer } = mod as {
|
|
defaultFindRunningServer: (
|
|
ccsDir: string
|
|
) => Promise<{ port: number; baseUrl: string } | null>;
|
|
};
|
|
|
|
let result: { port: number; baseUrl: string } | null = null;
|
|
try {
|
|
result = await defaultFindRunningServer(ccsDir);
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
if (result !== null) {
|
|
expect(result.port).not.toBe(livePort);
|
|
} else {
|
|
expect(result).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('returns null when no server is listening on the seeded port (port outside default candidates)', async () => {
|
|
const net = await import('net');
|
|
|
|
// We need a port that:
|
|
// 1. Is NOT in the default candidate list (3000, 3001, 3002, 8000, 8080) — otherwise a
|
|
// live server on one of those ports would be detected instead of "null".
|
|
// 2. Has nothing listening on it after we close it.
|
|
// Strategy: bind on a high ephemeral port (>= 50000), record it, close it, seed bar.json.
|
|
// defaultFindRunningServer dedupes bar.json port into the candidate list, but the default
|
|
// ports 3000/3001/3002/8000/8080 will also be probed. To guarantee null we need ALL
|
|
// candidates closed. We can't control the default ports if a live CCS server is running.
|
|
//
|
|
// Instead, use a port that's definitely not 3000/3001/3002/8000/8080 AND is closed,
|
|
// then wrap defaultFindRunningServer with a test-local variant that uses only bar.json's port.
|
|
// Since defaultFindRunningServer is not parameterizable, we test the null path by ensuring
|
|
// a server that was listening has been closed before the probe, using a high port that
|
|
// is unlikely to collide with any service on the test machine.
|
|
const closedPort = await new Promise<number>((resolve, reject) => {
|
|
const srv = net.createServer();
|
|
// High port range to avoid colliding with default candidates or live CCS server.
|
|
srv.listen(0, '127.0.0.1', () => {
|
|
const p = (srv.address() as { port: number }).port;
|
|
srv.close((err) => (err ? reject(err) : resolve(p)));
|
|
});
|
|
});
|
|
|
|
// Ephemeral ports are >= 49152 on macOS; they won't be in our default candidate list.
|
|
// If the port happens to be one of the defaults, skip — practically impossible but safe.
|
|
if ([3000, 3001, 3002, 8000, 8080].includes(closedPort)) {
|
|
return;
|
|
}
|
|
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
// Seed bar.json with ONLY the closed port. The probe will check this port first.
|
|
// The function also probes defaults (3000 etc.) — but we can't know if a live server
|
|
// is there. So we override: write bar.json with a port that ALSO appears as the sole
|
|
// candidate by replacing all defaults. Since we can't parameterize candidates, we test
|
|
// the "nothing on closed port" by using a port where connection is immediately refused
|
|
// and asserting the function either returns null or finds a real server on a default port.
|
|
// The definitive assertion is: the closed port itself is not returned.
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({
|
|
port: closedPort,
|
|
baseUrl: `http://127.0.0.1:${closedPort}`,
|
|
authMode: 'loopback',
|
|
})
|
|
);
|
|
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { defaultFindRunningServer } = mod as {
|
|
defaultFindRunningServer: (
|
|
ccsDir: string
|
|
) => Promise<{ port: number; baseUrl: string } | null>;
|
|
};
|
|
|
|
const result = await defaultFindRunningServer(ccsDir);
|
|
// The closed port must NOT be returned (ECONNREFUSED for that port is handled).
|
|
// If a live CCS server exists on one of the default ports the function returns that
|
|
// instead of null — this is correct behavior. We assert the closed port is not the result.
|
|
if (result !== null) {
|
|
expect(result.port).not.toBe(closedPort);
|
|
} else {
|
|
expect(result).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('detects a real HTTP server responding 200 on /api/bar/summary bound to ::1 only', async () => {
|
|
const http = await import('http');
|
|
const net = await import('net');
|
|
|
|
// Guard: check if IPv6 loopback is available on this runner.
|
|
// Some CI environments disable IPv6; we skip gracefully rather than fail.
|
|
let ipv6Available = false;
|
|
await new Promise<void>((resolve) => {
|
|
const probe = net.createServer();
|
|
probe.once('error', () => {
|
|
// EADDRNOTAVAIL or EAFNOSUPPORT → no IPv6 on this host
|
|
console.log('[i] Skipping IPv6 loopback test: ::1 not available on this runner');
|
|
resolve();
|
|
});
|
|
probe.listen(0, '::1', () => {
|
|
ipv6Available = true;
|
|
probe.close(() => resolve());
|
|
});
|
|
});
|
|
|
|
if (!ipv6Available) {
|
|
return;
|
|
}
|
|
|
|
// Some runners route bracketed IPv6 HTTP through an environment proxy even
|
|
// though raw ::1 sockets work. Skip in that environment; the production
|
|
// probe still supports ::1 where direct loopback HTTP is available.
|
|
const { request } = await import('undici');
|
|
const httpProbe = http.createServer((_req, res) => {
|
|
res.writeHead(204);
|
|
res.end();
|
|
});
|
|
await new Promise<void>((resolve) => httpProbe.listen(0, '::1', resolve));
|
|
const httpProbePort = (httpProbe.address() as { port: number }).port;
|
|
try {
|
|
const { statusCode, body } = await request(`http://[::1]:${httpProbePort}/`, {
|
|
headersTimeout: 1500,
|
|
bodyTimeout: 1500,
|
|
});
|
|
await body.text();
|
|
if (statusCode !== 204) {
|
|
console.log('[i] Skipping IPv6 loopback test: ::1 HTTP is not direct on this runner');
|
|
return;
|
|
}
|
|
} finally {
|
|
await new Promise<void>((resolve) => httpProbe.close(() => resolve()));
|
|
}
|
|
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
// Start an ephemeral HTTP server bound exclusively to ::1.
|
|
// This simulates `ccs config` starting the web-server with host 'localhost'
|
|
// on macOS, where 'localhost' resolves to ::1.
|
|
// The server echoes the token unconditionally (from the file), mirroring production.
|
|
const server = http.createServer((req, res) => {
|
|
const token = getOrCreateBarAuthToken(ccsDir);
|
|
const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? '');
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
[BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce),
|
|
});
|
|
res.end('{}');
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '::1', resolve));
|
|
const addr = server.address() as { port: number };
|
|
const livePort = addr.port;
|
|
|
|
// Seed bar.json with the live port so it is checked first.
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({ port: livePort, baseUrl: `http://[::1]:${livePort}`, authMode: 'loopback' })
|
|
);
|
|
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { defaultFindRunningServer } = mod as {
|
|
defaultFindRunningServer: (
|
|
ccsDir: string
|
|
) => Promise<{ port: number; baseUrl: string } | null>;
|
|
};
|
|
|
|
let result: { port: number; baseUrl: string } | null = null;
|
|
try {
|
|
result = await defaultFindRunningServer(ccsDir);
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.port).toBe(livePort);
|
|
// baseUrl must use bracketed IPv6 literal — valid in URLs per RFC 2732;
|
|
// the Swift app reads this verbatim and URLSession handles bracketed IPv6 hosts.
|
|
expect(result?.baseUrl).toBe(`http://[::1]:${livePort}`);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GH-1500 — concurrent probing: priority wins over speed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('defaultFindRunningServer: priority over response speed (GH-1500)', () => {
|
|
it('returns bar.json port even when a lower-priority port responds faster', async () => {
|
|
const http = await import('http');
|
|
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
// Lower-priority server (default port candidate): responds immediately with 200.
|
|
// Echoes token unconditionally (from file), mirroring production behavior.
|
|
const fastServer = http.createServer((req, res) => {
|
|
const token = getOrCreateBarAuthToken(ccsDir);
|
|
const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? '');
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
[BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce),
|
|
});
|
|
res.end('{}');
|
|
});
|
|
await new Promise<void>((resolve) => fastServer.listen(0, '127.0.0.1', resolve));
|
|
const fastPort = (fastServer.address() as { port: number }).port;
|
|
|
|
// Higher-priority server (bar.json port): adds ~300 ms artificial delay,
|
|
// but still responds 200 within the 1500 ms timeout.
|
|
const slowServer = http.createServer((req, res) => {
|
|
const token = getOrCreateBarAuthToken(ccsDir);
|
|
const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? '');
|
|
setTimeout(() => {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
[BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce),
|
|
});
|
|
res.end('{}');
|
|
}, 300);
|
|
});
|
|
await new Promise<void>((resolve) => slowServer.listen(0, '127.0.0.1', resolve));
|
|
const slowPort = (slowServer.address() as { port: number }).port;
|
|
|
|
// Seed bar.json with the slower/higher-priority port.
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({
|
|
port: slowPort,
|
|
baseUrl: `http://127.0.0.1:${slowPort}`,
|
|
authMode: 'loopback',
|
|
})
|
|
);
|
|
|
|
// Build a custom defaultFindRunningServer that uses only these two ports as
|
|
// candidates (to avoid colliding with real services on the default ports).
|
|
// We exercise the concurrent logic directly by importing the function and
|
|
// temporarily patching the candidate list via a wrapper that re-implements
|
|
// the same concurrent strategy with our controlled ports.
|
|
//
|
|
// Because defaultFindRunningServer reads bar.json for the first candidate
|
|
// and the test seeds bar.json with slowPort, the production function will
|
|
// treat slowPort as the bar.json port (highest priority) and fastPort will
|
|
// only appear if it happens to be in the default list (3000/3001/3002/8000/8080).
|
|
// To guarantee fastPort is also a candidate we use a thin wrapper that inserts
|
|
// fastPort into the default list, exercising the real priority logic.
|
|
//
|
|
// Strategy: import the real module and call defaultFindRunningServer after
|
|
// seeding bar.json with slowPort. To ensure fastPort is probed as well, we
|
|
// write fastPort into a second bar.json-like location — but instead use the
|
|
// simpler approach: place fastPort in the default candidate range by aliasing
|
|
// the test servers to known default ports is not feasible (ports are random).
|
|
// So we call the real function, which probes bar.json port (slowPort) + defaults.
|
|
// fastPort is NOT in the default list, so the result must be slowPort (the only
|
|
// responding server the function knows about via bar.json).
|
|
//
|
|
// To also prove that a fast low-priority server doesn't win, we create a second
|
|
// test setup: use only defaultFindRunningServer directly with slowPort seeded in
|
|
// bar.json; since fastPort is not in the default candidate list and slowPort IS
|
|
// in bar.json (highest priority), the function MUST return slowPort.
|
|
|
|
moduleSeq++;
|
|
const mod = await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
);
|
|
const { defaultFindRunningServer } = mod as {
|
|
defaultFindRunningServer: (
|
|
ccsDir: string
|
|
) => Promise<{ port: number; baseUrl: string } | null>;
|
|
};
|
|
|
|
let result: { port: number; baseUrl: string } | null = null;
|
|
try {
|
|
result = await defaultFindRunningServer(ccsDir);
|
|
} finally {
|
|
await new Promise<void>((resolve) => fastServer.close(() => resolve()));
|
|
await new Promise<void>((resolve) => slowServer.close(() => resolve()));
|
|
}
|
|
|
|
// The bar.json port (slowPort) must win even though fastPort responds faster.
|
|
expect(result).not.toBeNull();
|
|
expect(result?.port).toBe(slowPort);
|
|
expect(result?.baseUrl).toBe(`http://127.0.0.1:${slowPort}`);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GH-1504 — already-installed detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: already-installed detection (GH-1504)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
it('shows existing version when app is already installed', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
// Create a pre-existing CCS Bar.app with a known version
|
|
const existingAppPath = path.join(appsDir, 'CCS Bar.app');
|
|
fs.mkdirSync(path.join(existingAppPath, 'Contents'), { recursive: true });
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: (appPath: string) => {
|
|
// Return version for pre-existing app (before download replaces it)
|
|
if (fs.existsSync(appPath)) return '1.0.0';
|
|
return null;
|
|
},
|
|
launchBar: async () => {
|
|
calls.push('launch');
|
|
},
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/already installed|Reinstall/i);
|
|
expect(allOutput).toMatch(/1\.0\.0/);
|
|
});
|
|
|
|
it('shows generic already-installed message when version is unreadable', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const existingAppPath = path.join(appsDir, 'CCS Bar.app');
|
|
fs.mkdirSync(existingAppPath, { recursive: true });
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: () => null,
|
|
launchBar: async () => {
|
|
calls.push('launch');
|
|
},
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/already installed|Reinstall/i);
|
|
});
|
|
|
|
it('does not print already-installed message on fresh install', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
// Do NOT pre-create the app
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: () => '2.0.0',
|
|
launchBar: async () => {
|
|
calls.push('launch');
|
|
},
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).not.toMatch(/already installed/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GH-1504 — quarantine handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: quarantine handling (GH-1504)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
function fakeExtract(appsDir: string) {
|
|
return async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
};
|
|
}
|
|
|
|
it('does not clear quarantine automatically after successful extraction', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: () => '1.0.0',
|
|
launchBar: async () => {
|
|
/* noop */
|
|
},
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/Gatekeeper note/i);
|
|
expect(allOutput).toMatch(/right-click.*Open/i);
|
|
expect(allOutput).not.toMatch(/Cleared Gatekeeper quarantine/i);
|
|
expect(allOutput).not.toMatch(/xattr.*quarantine/i);
|
|
});
|
|
|
|
it('install exits early when extraction does not produce the expected app bundle', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
// Extraction does NOT place CCS Bar.app
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
readAppBundleVersion: () => '1.0.0',
|
|
launchBar: async () => {
|
|
/* noop */
|
|
},
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
// Should have returned early due to missing app
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[X\]/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gatekeeper preservation — quarantine is not cleared before launch handoff
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: Gatekeeper quarantine preservation', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
function fakeExtract(appsDir: string) {
|
|
return async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
};
|
|
}
|
|
|
|
function baseDeps(appsDir: string, extra?: Partial<Record<string, unknown>>) {
|
|
return {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
|
readAppBundleVersion: () => '1.0.0',
|
|
isBarRunning: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
it('promptLaunch is still called after install (quarantine preserved, not cleared)', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let promptCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
launchBar: async () => {
|
|
/* noop */
|
|
},
|
|
promptLaunch: async () => {
|
|
promptCalled = true;
|
|
return false;
|
|
},
|
|
});
|
|
|
|
expect(promptCalled).toBe(true);
|
|
});
|
|
|
|
it('--launch invokes launchBar', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let launchCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall(['--launch'], {
|
|
...baseDeps(appsDir),
|
|
launchBar: async () => {
|
|
launchCalled = true;
|
|
},
|
|
promptLaunch: async () => false,
|
|
});
|
|
|
|
expect(launchCalled).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GH-1504 — launch flags and prompt behavior
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: launch flags and prompt (GH-1504)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
function fakeExtract(appsDir: string) {
|
|
return async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
};
|
|
}
|
|
|
|
function baseDeps(appsDir: string, extra?: Partial<Record<string, unknown>>) {
|
|
return {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
|
readAppBundleVersion: () => '1.0.0',
|
|
// isBarRunning injected as false so tests are deterministic regardless of
|
|
// whether the real CCS Bar process is running on the test machine.
|
|
isBarRunning: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
it('--no-launch skips prompt and does not invoke launchBar', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let launchCalled = false;
|
|
let promptCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall(['--no-launch'], {
|
|
...baseDeps(appsDir),
|
|
launchBar: async () => {
|
|
launchCalled = true;
|
|
},
|
|
promptLaunch: async () => {
|
|
promptCalled = true;
|
|
return false;
|
|
},
|
|
});
|
|
|
|
expect(launchCalled).toBe(false);
|
|
expect(promptCalled).toBe(false);
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/Run `ccs bar` to launch/i);
|
|
});
|
|
|
|
it('--launch invokes launchBar without prompting', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let launchCalled = false;
|
|
let promptCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall(['--launch'], {
|
|
...baseDeps(appsDir),
|
|
launchBar: async () => {
|
|
launchCalled = true;
|
|
},
|
|
promptLaunch: async () => {
|
|
promptCalled = true;
|
|
return false;
|
|
},
|
|
});
|
|
|
|
expect(launchCalled).toBe(true);
|
|
expect(promptCalled).toBe(false);
|
|
});
|
|
|
|
it('non-TTY skips prompt and prints launch guidance', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let launchCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// Simulate non-TTY: promptLaunch returns false (mimics defaultPromptLaunch non-TTY path)
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
launchBar: async () => {
|
|
launchCalled = true;
|
|
},
|
|
promptLaunch: async () => false,
|
|
});
|
|
|
|
expect(launchCalled).toBe(false);
|
|
});
|
|
|
|
it('user answers yes to prompt: launchBar is invoked', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let launchCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
launchBar: async () => {
|
|
launchCalled = true;
|
|
},
|
|
promptLaunch: async () => true,
|
|
});
|
|
|
|
expect(launchCalled).toBe(true);
|
|
});
|
|
|
|
it('user answers no to prompt: launchBar not invoked', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let launchCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
launchBar: async () => {
|
|
launchCalled = true;
|
|
},
|
|
promptLaunch: async () => false,
|
|
});
|
|
|
|
expect(launchCalled).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Finding 2 — TTY gate: prompt gated on stdin.isTTY, not stdout.isTTY
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: stdin-TTY gate for launch prompt (Finding 2)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
function fakeExtract(appsDir: string) {
|
|
return async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
};
|
|
}
|
|
|
|
function baseDeps(appsDir: string, extra?: Partial<Record<string, unknown>>) {
|
|
return {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
|
readAppBundleVersion: () => '1.0.0',
|
|
isBarRunning: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
it('stdin-TTY true + stdout non-TTY: promptLaunch is called (prompt shown via seam)', async () => {
|
|
// This test proves the prompt path is reached even when stdout is not a TTY,
|
|
// as long as stdin is a TTY. We verify via the injectable promptLaunch dep.
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let promptCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
launchBar: async () => {
|
|
/* noop */
|
|
},
|
|
// promptLaunch returning true simulates stdin-TTY + user answered yes.
|
|
// The production defaultPromptLaunch now checks stdin.isTTY; by injecting
|
|
// a mock that returns true we confirm this code path (prompt called, launch invoked).
|
|
promptLaunch: async () => {
|
|
promptCalled = true;
|
|
return false; // user said no — we just want to confirm the dep was called
|
|
},
|
|
});
|
|
|
|
expect(promptCalled).toBe(true);
|
|
});
|
|
|
|
it('non-TTY stdin path: promptLaunch returns false + hint printed', async () => {
|
|
// Simulate defaultPromptLaunch behavior on non-TTY stdin: returns false.
|
|
// The handleBarInstall non-TTY fallback must print the launch hint.
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let launchCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// Save and override process.stdin.isTTY to simulate non-TTY stdin.
|
|
// We also inject promptLaunch that mirrors what defaultPromptLaunch does
|
|
// on non-TTY stdin (returns false) so the fallback branch executes.
|
|
const origStdinIsTTY = process.stdin.isTTY;
|
|
try {
|
|
// Force stdin to look like non-TTY so the fallback check fires.
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
launchBar: async () => {
|
|
launchCalled = true;
|
|
},
|
|
promptLaunch: async () => false, // non-TTY stdin: defaultPromptLaunch returns false
|
|
});
|
|
} finally {
|
|
Object.defineProperty(process.stdin, 'isTTY', {
|
|
value: origStdinIsTTY,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
expect(launchCalled).toBe(false);
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/Run `ccs bar` to launch/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Finding 3 — Already-running detection via isBarRunning dep
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: already-running detection (Finding 3)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
function fakeExtract(appsDir: string) {
|
|
return async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
};
|
|
}
|
|
|
|
function baseDeps(appsDir: string, extra?: Partial<Record<string, unknown>>) {
|
|
return {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: fakeExtract(appsDir),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
|
readAppBundleVersion: () => '1.0.0',
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
it('when running after install: prompt skipped, restart hint printed', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let promptCalled = false;
|
|
let launchCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
isBarRunning: async () => true,
|
|
launchBar: async () => {
|
|
launchCalled = true;
|
|
},
|
|
promptLaunch: async () => {
|
|
promptCalled = true;
|
|
return true;
|
|
},
|
|
});
|
|
|
|
// Prompt must NOT be called when bar is running (without --launch)
|
|
expect(promptCalled).toBe(false);
|
|
// launchBar must NOT be invoked without --launch
|
|
expect(launchCalled).toBe(false);
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
// Must print the restart hint
|
|
expect(allOutput).toMatch(/currently running an older build/i);
|
|
expect(allOutput).toMatch(/Quit it from the menu bar/i);
|
|
expect(allOutput).toMatch(/run `ccs bar` to relaunch/i);
|
|
});
|
|
|
|
it('when not running after install: prompt path unchanged', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let promptCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
isBarRunning: async () => false,
|
|
launchBar: async () => {
|
|
/* noop */
|
|
},
|
|
promptLaunch: async () => {
|
|
promptCalled = true;
|
|
return false;
|
|
},
|
|
});
|
|
|
|
// When bar is not running, normal prompt flow applies
|
|
expect(promptCalled).toBe(true);
|
|
const allOutput = consoleOutput.join('\n');
|
|
// The restart hint must NOT appear when bar is not running
|
|
expect(allOutput).not.toMatch(/currently running an older build/i);
|
|
});
|
|
|
|
it('pgrep error treated as not running: prompt path unchanged', async () => {
|
|
// Simulate pgrep failure (e.g. not available, permission error) — treated as not running.
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let promptCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
// Simulate pgrep error — isBarRunning returns false per spec
|
|
isBarRunning: async () => false,
|
|
launchBar: async () => {
|
|
/* noop */
|
|
},
|
|
promptLaunch: async () => {
|
|
promptCalled = true;
|
|
return false;
|
|
},
|
|
});
|
|
|
|
// pgrep error treated as not running → normal prompt flow
|
|
expect(promptCalled).toBe(true);
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).not.toMatch(/currently running an older build/i);
|
|
});
|
|
|
|
it('--launch with bar already running: launchBar still invoked', async () => {
|
|
// --launch overrides the already-running guard; opening/activating an already-running
|
|
// app is harmless.
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let launchCalled = false;
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall(['--launch'], {
|
|
...baseDeps(appsDir),
|
|
isBarRunning: async () => true,
|
|
launchBar: async () => {
|
|
launchCalled = true;
|
|
},
|
|
promptLaunch: async () => false,
|
|
});
|
|
|
|
expect(launchCalled).toBe(true);
|
|
// Restart hint must NOT appear when --launch is passed
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).not.toMatch(/currently running an older build/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fix 3 — defaultReadAppBundleVersion: whitespace-only plist value → null
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: whitespace-only CFBundleShortVersionString yields null (Fix 3)', () => {
|
|
it('defaultReadAppBundleVersion returns null for a whitespace-only version string', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const appPath = path.join(appsDir, 'CCS Bar.app');
|
|
const contentsDir = path.join(appPath, 'Contents');
|
|
|
|
// Create Info.plist with a whitespace-only CFBundleShortVersionString
|
|
fs.mkdirSync(contentsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(contentsDir, 'Info.plist'),
|
|
`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CFBundleShortVersionString</key>
|
|
<string> </string>
|
|
</dict>
|
|
</plist>`
|
|
);
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
// downloadAndExtract must recreate the bundle (with whitespace-only plist) after the
|
|
// stale-reinstall guard removes the pre-seeded app.
|
|
await handleBarInstall([], {
|
|
fetchReleaseAsset: async () => ({
|
|
downloadUrl:
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip',
|
|
}),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
// Recreate bundle with the whitespace-only plist fixture.
|
|
const contentsOut = path.join(dest, 'CCS Bar.app', 'Contents');
|
|
fs.mkdirSync(contentsOut, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(contentsOut, 'Info.plist'),
|
|
`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CFBundleShortVersionString</key>
|
|
<string> </string>
|
|
</dict>
|
|
</plist>`
|
|
);
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
|
// readAppBundleVersion intentionally omitted → uses production default
|
|
isBarRunning: async () => false,
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => ccsDir,
|
|
getAppsDir: () => appsDir,
|
|
});
|
|
|
|
// Whitespace-only version treated as null → no pin written
|
|
const versionFile = path.join(ccsDir, 'bar', '.version');
|
|
expect(fs.existsSync(versionFile)).toBe(false);
|
|
|
|
// ASCII notice must appear
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[!\].*Info\.plist/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Data Loss finding — stage-then-swap: old bundle preserved on failure
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: stage-then-swap safety (Data Loss finding)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
function baseDeps(
|
|
appsDir: string,
|
|
extra?: Partial<Record<string, unknown>>
|
|
): Record<string, unknown> {
|
|
return {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
|
readAppBundleVersion: () => '2.0.0',
|
|
isBarRunning: async () => false,
|
|
launchBar: async () => {
|
|
/* noop */
|
|
},
|
|
promptLaunch: async () => false,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
it('downloadAndExtract receives a staging dir inside appsDir, NOT appsDir itself', async () => {
|
|
// The new contract: downloadAndExtract dest is a hidden staging dir under appsDir,
|
|
// not appsDir directly. This keeps the old install untouched during download.
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const destDirs: string[] = [];
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
destDirs.push(dest);
|
|
// Create the expected bundle inside staging so the verify step passes.
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
});
|
|
|
|
expect(destDirs).toHaveLength(1);
|
|
const dest = destDirs[0];
|
|
// Must NOT equal appsDir
|
|
expect(dest).not.toBe(appsDir);
|
|
// Must be a child of appsDir (same filesystem for atomic rename)
|
|
expect(dest.startsWith(appsDir + path.sep)).toBe(true);
|
|
// Dotted prefix to minimise Finder noise
|
|
expect(path.basename(dest)).toMatch(/^\..+/);
|
|
});
|
|
|
|
it('downloadAndExtract throws during reinstall: old bundle still present, removeExistingApp NOT called', async () => {
|
|
// Core data-loss guard: if download/extraction fails, the old working install
|
|
// must remain intact — removeExistingApp must never have been called.
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const appPath = path.join(appsDir, 'CCS Bar.app');
|
|
// Pre-seed the existing install.
|
|
fs.mkdirSync(appPath, { recursive: true });
|
|
fs.writeFileSync(path.join(appPath, 'sentinel'), 'old-install');
|
|
|
|
let removeCalled = false;
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
removeExistingApp: (_path: unknown) => {
|
|
removeCalled = true;
|
|
fs.rmSync(String(_path), { recursive: true, force: true });
|
|
},
|
|
downloadAndExtract: async (_url: string, _dest: string) => {
|
|
throw new Error('network timeout');
|
|
},
|
|
});
|
|
|
|
// removeExistingApp must NOT have been called — download failed before the swap.
|
|
expect(removeCalled).toBe(false);
|
|
// Old bundle must still be present.
|
|
expect(fs.existsSync(appPath)).toBe(true);
|
|
expect(fs.existsSync(path.join(appPath, 'sentinel'))).toBe(true);
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[X\].*Download or extraction failed/i);
|
|
});
|
|
|
|
it('extracted archive missing the bundle: old bundle still present, install aborts with diagnostics', async () => {
|
|
// If the archive extracts successfully but does not contain CCS Bar.app,
|
|
// the old install must be left untouched and [X] with file listing printed.
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const appPath = path.join(appsDir, 'CCS Bar.app');
|
|
fs.mkdirSync(appPath, { recursive: true });
|
|
fs.writeFileSync(path.join(appPath, 'sentinel'), 'old-install');
|
|
|
|
let removeCalled = false;
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
removeExistingApp: (_path: unknown) => {
|
|
removeCalled = true;
|
|
fs.rmSync(String(_path), { recursive: true, force: true });
|
|
},
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
// Extraction produces a wrong-named artifact — CCS Bar.app absent.
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
fs.writeFileSync(path.join(dest, 'WrongName.app'), 'dummy');
|
|
},
|
|
});
|
|
|
|
// removeExistingApp must NOT have been called.
|
|
expect(removeCalled).toBe(false);
|
|
// Old bundle still present.
|
|
expect(fs.existsSync(appPath)).toBe(true);
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[X\]/);
|
|
expect(allOutput).toMatch(/CCS Bar\.app/);
|
|
// Diagnostics: should list staging contents
|
|
expect(allOutput).toMatch(/WrongName\.app/);
|
|
});
|
|
|
|
it('success path: staging dir removed, app present at appPath', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const appPath = path.join(appsDir, 'CCS Bar.app');
|
|
let stagingDirUsed = '';
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
stagingDirUsed = dest;
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
});
|
|
|
|
// App at the final location.
|
|
expect(fs.existsSync(appPath)).toBe(true);
|
|
// Staging dir cleaned up.
|
|
if (stagingDirUsed) {
|
|
expect(fs.existsSync(stagingDirUsed)).toBe(false);
|
|
}
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[OK\].*CCS Bar/);
|
|
expect(allOutput).not.toMatch(/\[X\]/);
|
|
});
|
|
|
|
it('aborts install and prints [X] when removeExistingApp throws (old app present = no swap)', async () => {
|
|
// removeExistingApp now runs AFTER staging verification.
|
|
// If it throws, the new bundle is still in staging (not yet moved); old app untouched.
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const appPath = path.join(appsDir, 'CCS Bar.app');
|
|
fs.mkdirSync(appPath, { recursive: true });
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
removeExistingApp: (_path: unknown) => {
|
|
throw new Error('Permission denied');
|
|
},
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[X\].*Could not remove.*CCS Bar\.app/);
|
|
});
|
|
|
|
it('fresh install (no pre-existing bundle): removeExistingApp not invoked, install proceeds', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
// Do NOT pre-create the app bundle.
|
|
|
|
let removeCalled = false;
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
removeExistingApp: (_path: unknown) => {
|
|
removeCalled = true;
|
|
},
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
});
|
|
|
|
expect(removeCalled).toBe(false);
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[OK\].*CCS Bar/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Review finding — silent decline: hint always printed when user says no
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: silent-decline fix — hint on user decline (review finding)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
function baseDeps(
|
|
appsDir: string,
|
|
extra?: Partial<Record<string, unknown>>
|
|
): Record<string, unknown> {
|
|
return {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
|
readAppBundleVersion: () => '1.0.0',
|
|
isBarRunning: async () => false,
|
|
launchBar: async () => {
|
|
/* noop */
|
|
},
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
it('prints hint when user declines launch prompt (TTY path, answers no)', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
// Simulate TTY user answering "n"
|
|
promptLaunch: async () => false,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[i\].*Run `ccs bar` to launch/i);
|
|
});
|
|
|
|
it('prints hint when non-TTY stdin (promptLaunch returns false on non-TTY)', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
promptLaunch: async () => false,
|
|
});
|
|
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/\[i\].*Run `ccs bar` to launch/i);
|
|
});
|
|
|
|
it('does NOT print the decline hint when user says yes (launch invoked instead)', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let launchCalled = false;
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall([], {
|
|
...baseDeps(appsDir),
|
|
launchBar: async () => {
|
|
launchCalled = true;
|
|
},
|
|
promptLaunch: async () => true,
|
|
});
|
|
|
|
expect(launchCalled).toBe(true);
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).not.toMatch(/Run `ccs bar` to launch later/i);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Socket-level 401/403 auth-required detection (#1551 coverage gap)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired=true', () => {
|
|
// These tests drive `net.connect` directly via mock.module('net') — the same
|
|
// pattern used by the streaming-probe test below. They prove the status-line
|
|
// parser correctly sets authRequired without relying on the higher-level dep
|
|
// injection that existing tests use (which bypasses real status-line parsing).
|
|
|
|
function makeNetMock(statusLine: string, token: string) {
|
|
return {
|
|
connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => {
|
|
const listeners: Record<string, Array<(arg?: unknown) => void>> = {};
|
|
const socket = {
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
(listeners[event] ??= []).push(cb);
|
|
return socket;
|
|
},
|
|
setTimeout(_ms: number, cb: () => void) {
|
|
// Do not fire the timeout — let the data path run.
|
|
void cb;
|
|
return socket;
|
|
},
|
|
write(request: string) {
|
|
const nonce =
|
|
request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ??
|
|
'';
|
|
const proof = createBarAuthProof(token, nonce);
|
|
setImmediate(() => {
|
|
for (const cb of listeners.data ?? []) {
|
|
cb(
|
|
Buffer.from(`${statusLine}\r\n${BAR_AUTH_TOKEN_HEADER}: ${proof}\r\n\r\n`, 'utf8')
|
|
);
|
|
}
|
|
});
|
|
return true;
|
|
},
|
|
destroy() {
|
|
return socket;
|
|
},
|
|
};
|
|
setImmediate(onConnect);
|
|
return socket;
|
|
},
|
|
};
|
|
}
|
|
|
|
it('classifies HTTP/1.1 401 off the wire as authRequired=true', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
// Seed bar.json so the probed port is deterministic and checked first.
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({ port: 41401, baseUrl: 'http://127.0.0.1:41401', authMode: 'loopback' })
|
|
);
|
|
|
|
const token = getOrCreateBarAuthToken(ccsDir);
|
|
mock.module('net', () => makeNetMock('HTTP/1.1 401 Unauthorized', token));
|
|
|
|
moduleSeq++;
|
|
const { defaultFindRunningServer } = (await import(
|
|
`../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}`
|
|
)) as {
|
|
defaultFindRunningServer: (
|
|
ccsDir: string
|
|
) => Promise<{ port: number; baseUrl: string; authRequired?: boolean } | null>;
|
|
};
|
|
|
|
const result = await Promise.race([
|
|
defaultFindRunningServer(ccsDir),
|
|
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 500)),
|
|
]);
|
|
|
|
// Must not time out and must report auth-required.
|
|
expect(result).not.toBe('timeout');
|
|
expect(result).not.toBeNull();
|
|
expect((result as { authRequired?: boolean }).authRequired).toBe(true);
|
|
});
|
|
|
|
it('classifies HTTP/1.1 403 off the wire as authRequired=true', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({ port: 41403, baseUrl: 'http://127.0.0.1:41403', authMode: 'loopback' })
|
|
);
|
|
|
|
const token = getOrCreateBarAuthToken(ccsDir);
|
|
mock.module('net', () => makeNetMock('HTTP/1.1 403 Forbidden', token));
|
|
|
|
moduleSeq++;
|
|
const { defaultFindRunningServer } = (await import(
|
|
`../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}`
|
|
)) as {
|
|
defaultFindRunningServer: (
|
|
ccsDir: string
|
|
) => Promise<{ port: number; baseUrl: string; authRequired?: boolean } | null>;
|
|
};
|
|
|
|
const result = await Promise.race([
|
|
defaultFindRunningServer(ccsDir),
|
|
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 500)),
|
|
]);
|
|
|
|
expect(result).not.toBe('timeout');
|
|
expect(result).not.toBeNull();
|
|
expect((result as { authRequired?: boolean }).authRequired).toBe(true);
|
|
});
|
|
|
|
it('does NOT set authRequired for a 200 that supplies the correct token', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
// Ensure the token file exists so getOrCreateBarAuthToken reads the same value.
|
|
const { getOrCreateBarAuthToken: getToken } = await import(
|
|
`../../../src/utils/bar-auth-token?test=${Date.now()}-token-ok`
|
|
);
|
|
const token = getToken(ccsDir);
|
|
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({ port: 41200, baseUrl: 'http://127.0.0.1:41200', authMode: 'loopback' })
|
|
);
|
|
|
|
mock.module('net', () => ({
|
|
connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => {
|
|
const listeners: Record<string, Array<(arg?: unknown) => void>> = {};
|
|
const socket = {
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
(listeners[event] ??= []).push(cb);
|
|
return socket;
|
|
},
|
|
setTimeout(_ms: number, _cb: () => void) {
|
|
return socket;
|
|
},
|
|
write(data: string) {
|
|
const nonce =
|
|
data.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? '';
|
|
for (const cb of listeners.data ?? []) {
|
|
cb(
|
|
Buffer.from(
|
|
`HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(token, nonce)}\r\n\r\n`,
|
|
'utf8'
|
|
)
|
|
);
|
|
}
|
|
return true;
|
|
},
|
|
destroy() {
|
|
return socket;
|
|
},
|
|
};
|
|
setImmediate(() => {
|
|
onConnect();
|
|
});
|
|
return socket;
|
|
},
|
|
}));
|
|
|
|
moduleSeq++;
|
|
const { defaultFindRunningServer } = (await import(
|
|
`../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}`
|
|
)) as {
|
|
defaultFindRunningServer: (
|
|
ccsDir: string
|
|
) => Promise<{ port: number; baseUrl: string; authRequired?: boolean } | null>;
|
|
};
|
|
|
|
const result = await Promise.race([
|
|
defaultFindRunningServer(ccsDir),
|
|
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 500)),
|
|
]);
|
|
|
|
expect(result).not.toBe('timeout');
|
|
expect(result).not.toBeNull();
|
|
// Correct-token 200 must be accepted and NOT flagged as auth-required.
|
|
expect((result as { authRequired?: boolean }).authRequired).toBeFalsy();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// defaultWaitForServerLive: rogue-server rejection (#1546 coverage gap)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('defaultWaitForServerLive: rogue 200 without matching token is rejected', () => {
|
|
// defaultWaitForServerLive (exported from launch-subcommand.ts) applies the
|
|
// same fail-closed echoedToken===token check as the discovery probe.
|
|
// A 200 response that does NOT echo the matching capability token must never
|
|
// cause the function to resolve — the liveness poll must keep retrying and
|
|
// eventually time out.
|
|
//
|
|
// We mock `net` (same pattern as the streaming-probe test) so the test is
|
|
// fully synchronous and immune to OS socket state. The invariant is
|
|
// behaviour-coupled: removing the token check causes both tests to fail.
|
|
|
|
function buildNetMock(responseHeaders: string | ((request: string) => string)) {
|
|
// Returns a `net` mock whose connect() immediately delivers the response,
|
|
// then emits 'end'.
|
|
return {
|
|
connect: (_opts: { host: string; port: number }, onConnect: () => void): unknown => {
|
|
const listeners: Record<string, Array<(arg?: unknown) => void>> = {};
|
|
const socket = {
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
(listeners[event] ??= []).push(cb);
|
|
return socket;
|
|
},
|
|
setTimeout(_ms: number, _cb: () => void) {
|
|
return socket;
|
|
},
|
|
write(data: string) {
|
|
const response =
|
|
typeof responseHeaders === 'function' ? responseHeaders(data) : responseHeaders;
|
|
for (const cb of listeners.data ?? []) {
|
|
cb(Buffer.from(response, 'utf8'));
|
|
}
|
|
return true;
|
|
},
|
|
destroy() {
|
|
return socket;
|
|
},
|
|
};
|
|
setImmediate(() => {
|
|
onConnect();
|
|
for (const cb of listeners.end ?? []) {
|
|
cb();
|
|
}
|
|
});
|
|
return socket;
|
|
},
|
|
};
|
|
}
|
|
|
|
it('rogue 200 (wrong token) never resolves; poll throws timeout and launch reports [X]', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
// Establish the real token on disk.
|
|
const { getOrCreateBarAuthToken: getToken } = await import(
|
|
`../../../src/utils/bar-auth-token?test=${Date.now()}-rogue-net`
|
|
);
|
|
const realToken = getToken(ccsDir);
|
|
|
|
// A valid-format but wrong 64-hex token.
|
|
const rogueToken = 'cafebabe'.repeat(8);
|
|
expect(rogueToken).not.toBe(realToken); // guard: tokens must differ
|
|
|
|
// Mock net: every probe gets 200 with the WRONG token.
|
|
mock.module('net', () =>
|
|
buildNetMock(`HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${rogueToken}\r\n\r\n`)
|
|
);
|
|
|
|
// Import defaultWaitForServerLive fresh with the mocked net.
|
|
// The export was added so this direct unit test is possible.
|
|
moduleSeq++;
|
|
const { defaultWaitForServerLive } = (await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
)) as {
|
|
defaultWaitForServerLive: (baseUrl: string) => Promise<void>;
|
|
};
|
|
|
|
// defaultWaitForServerLive has a hardcoded 10 s timeout — too long for a test.
|
|
// We race it against a 600 ms sentinel that expires before the real timeout.
|
|
// If the function resolves before 600 ms, the rogue check passed (test failure).
|
|
// If it neither resolves nor rejects by 600 ms, the loop is correctly stuck
|
|
// (rogue token never matches → function will eventually throw after 10 s).
|
|
const RACE_MS = 600;
|
|
const result = await Promise.race([
|
|
defaultWaitForServerLive(`http://127.0.0.1:9998`)
|
|
.then(() => 'resolved' as const)
|
|
.catch(() => 'rejected' as const),
|
|
new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), RACE_MS)),
|
|
]);
|
|
|
|
// The rogue server must NEVER cause the poll to resolve.
|
|
expect(result).not.toBe('resolved');
|
|
// It either timed out (still looping) or threw early — both correct outcomes.
|
|
expect(result === 'timeout' || result === 'rejected').toBe(true);
|
|
});
|
|
|
|
it('unrelated 401/403 without a CCS proof does not trigger auth-required identity', async () => {
|
|
for (const status of [401, 403]) {
|
|
mock.module('net', () =>
|
|
buildNetMock(`HTTP/1.1 ${status} Unrelated Service\r\nConnection: close\r\n\r\n`)
|
|
);
|
|
moduleSeq++;
|
|
const { defaultWaitForServerLive } = (await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
)) as {
|
|
defaultWaitForServerLive: (baseUrl: string) => Promise<void>;
|
|
};
|
|
|
|
const result = await Promise.race([
|
|
defaultWaitForServerLive(`http://127.0.0.1:${9900 + status}`).then(
|
|
() => 'resolved' as const,
|
|
() => 'rejected' as const
|
|
),
|
|
new Promise<'still-probing'>((resolve) => setTimeout(() => resolve('still-probing'), 300)),
|
|
]);
|
|
expect(result).toBe('still-probing');
|
|
mock.restore();
|
|
}
|
|
});
|
|
|
|
it('correct-token 200 resolves defaultWaitForServerLive immediately', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const { getOrCreateBarAuthToken: getToken } = await import(
|
|
`../../../src/utils/bar-auth-token?test=${Date.now()}-legit-net`
|
|
);
|
|
const realToken = getToken();
|
|
|
|
// Mock net: every probe gets 200 with the CORRECT nonce-bound proof.
|
|
mock.module('net', () =>
|
|
buildNetMock((request) => {
|
|
const nonce =
|
|
request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? '';
|
|
return `HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(realToken, nonce)}\r\n\r\n`;
|
|
})
|
|
);
|
|
|
|
moduleSeq++;
|
|
const { defaultWaitForServerLive } = (await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
)) as {
|
|
defaultWaitForServerLive: (baseUrl: string) => Promise<void>;
|
|
};
|
|
|
|
// Correct token → must resolve well within the 10 s timeout.
|
|
const RACE_MS = 500;
|
|
const result = await Promise.race([
|
|
defaultWaitForServerLive(`http://127.0.0.1:9997`)
|
|
.then(() => 'resolved' as const)
|
|
.catch(() => 'rejected' as const),
|
|
new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), RACE_MS)),
|
|
]);
|
|
|
|
expect(result).toBe('resolved');
|
|
});
|
|
});
|
|
|
|
describe('defaultFindRunningServer: streaming lower-priority probes', () => {
|
|
it('returns a higher-priority hit without waiting for lower-priority response bodies', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({ port: 41235, baseUrl: 'http://127.0.0.1:41235', authMode: 'loopback' })
|
|
);
|
|
|
|
let highPrioritySocketDestroyed = false;
|
|
let lowerPriorityProbeStarted = false;
|
|
|
|
// 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 with the auth token in the
|
|
// response headers (mirroring the real server, which reads from the 0600 file
|
|
// and includes it unconditionally — NOT echoed from the request); the
|
|
// lower-priority port (3000) connects but never sends a status line,
|
|
// emulating a non-CCS service that streams forever.
|
|
//
|
|
// The token file is written by getOrCreateBarAuthToken when ccsDir is set up
|
|
// in beforeEach, so we read it here the same way the mock server would.
|
|
const { getOrCreateBarAuthToken: getToken } = await import(
|
|
`../../../src/utils/bar-auth-token?test=${Date.now()}-mock`
|
|
);
|
|
const expectedToken = getToken(ccsDir);
|
|
|
|
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(data: string) {
|
|
if (opts.port === 41235) {
|
|
const nonce =
|
|
data.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? '';
|
|
for (const cb of listeners.data ?? []) {
|
|
cb(
|
|
Buffer.from(
|
|
`HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(expectedToken, nonce)}\r\n\r\n`,
|
|
'utf8'
|
|
)
|
|
);
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
destroy() {
|
|
if (opts.port === 41235) highPrioritySocketDestroyed = true;
|
|
return socket;
|
|
},
|
|
};
|
|
|
|
// Fire the connect callback asynchronously, mirroring net.connect.
|
|
setImmediate(() => {
|
|
onConnect();
|
|
// 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;
|
|
},
|
|
}));
|
|
|
|
moduleSeq++;
|
|
const { defaultFindRunningServer } = (await import(
|
|
`../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}`
|
|
)) as {
|
|
defaultFindRunningServer: (
|
|
ccsDir: string
|
|
) => Promise<{ port: number; baseUrl: string; authRequired?: boolean } | null>;
|
|
};
|
|
|
|
const result = await Promise.race([
|
|
defaultFindRunningServer(ccsDir),
|
|
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 250)),
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
port: 41235,
|
|
baseUrl: 'http://127.0.0.1:41235',
|
|
authRequired: false,
|
|
});
|
|
expect(highPrioritySocketDestroyed).toBe(true);
|
|
expect(lowerPriorityProbeStarted).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('bar raw socket probes: absolute deadline for malformed streaming peers', () => {
|
|
it('continues past a higher-priority trickling non-HTTP response and returns a lower-priority hit', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({ port: 41236, baseUrl: 'http://127.0.0.1:41236', authMode: 'loopback' })
|
|
);
|
|
|
|
const { getOrCreateBarAuthToken: getToken } = await import(
|
|
`../../../src/utils/bar-auth-token?test=${Date.now()}-deadline-find`
|
|
);
|
|
const expectedToken = getToken(ccsDir);
|
|
|
|
mock.module('net', () => ({
|
|
connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => {
|
|
const listeners: Record<string, Array<(arg?: unknown) => void>> = {};
|
|
let interval: ReturnType<typeof setInterval> | undefined;
|
|
let request = '';
|
|
const socket = {
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
(listeners[event] ??= []).push(cb);
|
|
return socket;
|
|
},
|
|
setTimeout() {
|
|
return socket;
|
|
},
|
|
write(data: string) {
|
|
request = data;
|
|
return true;
|
|
},
|
|
destroy() {
|
|
if (interval) clearInterval(interval);
|
|
return socket;
|
|
},
|
|
};
|
|
|
|
setImmediate(() => {
|
|
onConnect();
|
|
if (opts.port === 41236) {
|
|
interval = setInterval(() => {
|
|
for (const cb of listeners.data ?? []) cb(Buffer.from('x', 'utf8'));
|
|
}, 25);
|
|
interval.unref?.();
|
|
return;
|
|
}
|
|
if (opts.port === 3000) {
|
|
const nonce =
|
|
request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ??
|
|
'';
|
|
for (const cb of listeners.data ?? []) {
|
|
cb(
|
|
Buffer.from(
|
|
`HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(expectedToken, nonce)}\r\n\r\n`,
|
|
'utf8'
|
|
)
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
return socket;
|
|
},
|
|
}));
|
|
|
|
moduleSeq++;
|
|
const { defaultFindRunningServer } = (await import(
|
|
`../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}`
|
|
)) as {
|
|
defaultFindRunningServer: (
|
|
ccsDir: string
|
|
) => Promise<{ port: number; baseUrl: string; authRequired?: boolean } | null>;
|
|
};
|
|
|
|
const result = await Promise.race([
|
|
defaultFindRunningServer(ccsDir),
|
|
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 2500)),
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
port: 3000,
|
|
baseUrl: 'http://127.0.0.1:3000',
|
|
authRequired: false,
|
|
});
|
|
});
|
|
|
|
it('does not let a single launch wait probe outlive the outer deadline', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
|
|
const realDateNow = Date.now;
|
|
let dateCall = 0;
|
|
Date.now = () => {
|
|
dateCall++;
|
|
return dateCall < 4 ? 0 : 10_001;
|
|
};
|
|
|
|
mock.module('net', () => ({
|
|
connect: (_opts: { host: string; port: number }, onConnect: () => void): unknown => {
|
|
const listeners: Record<string, Array<(arg?: unknown) => void>> = {};
|
|
let interval: ReturnType<typeof setInterval> | undefined;
|
|
const socket = {
|
|
on(event: string, cb: (arg?: unknown) => void) {
|
|
(listeners[event] ??= []).push(cb);
|
|
return socket;
|
|
},
|
|
setTimeout() {
|
|
return socket;
|
|
},
|
|
write() {
|
|
return true;
|
|
},
|
|
destroy() {
|
|
if (interval) clearInterval(interval);
|
|
return socket;
|
|
},
|
|
};
|
|
|
|
setImmediate(() => {
|
|
onConnect();
|
|
interval = setInterval(() => {
|
|
for (const cb of listeners.data ?? []) cb(Buffer.from('x', 'utf8'));
|
|
}, 25);
|
|
interval.unref?.();
|
|
});
|
|
|
|
return socket;
|
|
},
|
|
}));
|
|
|
|
try {
|
|
moduleSeq++;
|
|
const { defaultWaitForServerLive } = (await import(
|
|
`../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}`
|
|
)) as {
|
|
defaultWaitForServerLive: (baseUrl: string) => Promise<void>;
|
|
};
|
|
|
|
const result = await Promise.race([
|
|
defaultWaitForServerLive('http://127.0.0.1:9996')
|
|
.then(() => 'resolved' as const)
|
|
.catch(() => 'rejected' as const),
|
|
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 2500)),
|
|
]);
|
|
|
|
expect(result).toBe('rejected');
|
|
} finally {
|
|
Date.now = realDateNow;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GH-1588 — `--await-quit` waits for a running app to exit, then swaps + relaunches
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar install: --await-quit waits for the running app to quit (GH-1588)', () => {
|
|
const FAKE_DOWNLOAD_URL =
|
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
|
|
|
function baseDeps(appsDir: string, isBarRunning: () => Promise<boolean>) {
|
|
return {
|
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, sha256: FAKE_SHA256 }),
|
|
downloadAndExtract: async (_url: string, dest: string) => {
|
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
|
},
|
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' as const }),
|
|
readAppBundleVersion: () => '1.0.0',
|
|
isBarRunning,
|
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
|
getAppsDir: () => appsDir,
|
|
};
|
|
}
|
|
|
|
it('proceeds with swap + relaunch once the running app exits', async () => {
|
|
const appsDir = path.join(tempHome, 'Applications');
|
|
let launchCalled = false;
|
|
// Running on the first probe (still up), gone on every probe after — this
|
|
// models the in-app updater quitting itself right after spawning
|
|
// `ccs bar install --launch --await-quit`.
|
|
let probes = 0;
|
|
const isBarRunning = async () => {
|
|
probes += 1;
|
|
return probes === 1;
|
|
};
|
|
|
|
const { handleBarInstall } = await loadInstallSubcommand();
|
|
|
|
await handleBarInstall(['--launch', '--await-quit'], {
|
|
...baseDeps(appsDir, isBarRunning),
|
|
launchBar: async () => {
|
|
launchCalled = true;
|
|
},
|
|
promptLaunch: async () => false,
|
|
});
|
|
|
|
// The wait fired (app seen running first), then the install completed and
|
|
// relaunched into the new build.
|
|
expect(consoleOutput.join('\n')).toMatch(/Waiting for CCS Bar to quit/i);
|
|
expect(launchCalled).toBe(true);
|
|
expect(probes).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// --port support: `ccs bar [launch] --port N` (user-selectable dashboard port)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('bar dispatcher: bare flags route to launch', () => {
|
|
beforeEach(() => {
|
|
mock.module('../../../src/commands/bar/launch-subcommand', () => ({
|
|
handleBarLaunch: async (args: string[]) => {
|
|
calls.push(`launch:${args.join(' ')}`);
|
|
},
|
|
}));
|
|
});
|
|
|
|
it('dispatches `ccs bar --port 3999` to launch with the flag preserved', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['--port', '3999']);
|
|
expect(calls).toEqual(['launch:--port 3999']);
|
|
});
|
|
|
|
it('dispatches `ccs bar launch --port 3999` to launch with the flag preserved', async () => {
|
|
const handleBarCommand = await loadHandleBarCommand();
|
|
await handleBarCommand(['launch', '--port', '3999']);
|
|
expect(calls).toEqual(['launch:--port 3999']);
|
|
});
|
|
});
|
|
|
|
describe('launch: --port selects the server port', () => {
|
|
function makePortDeps(ccsDir: string) {
|
|
const seen: {
|
|
spawnPort: number | null;
|
|
getPortCandidates: number[] | null;
|
|
stopped: boolean;
|
|
descriptorPort: number | null;
|
|
} = { spawnPort: null, getPortCandidates: null, stopped: false, descriptorPort: null };
|
|
|
|
const deps = {
|
|
findRunningServer: async () => null,
|
|
getPort: async (opts: { port: number[]; host: string }) => {
|
|
seen.getPortCandidates = opts.port;
|
|
return opts.port[0];
|
|
},
|
|
spawnDetachedServer: (p: number) => {
|
|
seen.spawnPort = p;
|
|
},
|
|
waitForServerLive: async () => {},
|
|
createLaunchDescriptor: (opts?: { port?: number }) => {
|
|
seen.descriptorPort = opts?.port ?? null;
|
|
return {
|
|
schema: 1 as const,
|
|
runtime: '/usr/bin/node',
|
|
args: ['/x/ccs.js', 'bar', 'serve'],
|
|
home: '/h',
|
|
};
|
|
},
|
|
writeLaunchDescriptor: () => {},
|
|
stopDetachedServer: () => {
|
|
seen.stopped = true;
|
|
},
|
|
openApp: async () => {},
|
|
getCcsDir: () => ccsDir,
|
|
appInstallPath: path.join(tempHome, 'Applications', 'CCS Bar.app'),
|
|
};
|
|
return { deps, seen };
|
|
}
|
|
|
|
it('spawns the detached server on the requested port and records it in bar.json', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps, seen } = makePortDeps(ccsDir);
|
|
|
|
await handleBarLaunch(['--port', '3999'], deps);
|
|
|
|
expect(seen.spawnPort).toBe(3999);
|
|
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
|
|
port: number;
|
|
baseUrl: string;
|
|
};
|
|
expect(barJson.port).toBe(3999);
|
|
expect(barJson.baseUrl).toBe('http://127.0.0.1:3999');
|
|
});
|
|
|
|
it('persists the chosen port into the launch descriptor for app self-start', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps, seen } = makePortDeps(ccsDir);
|
|
|
|
await handleBarLaunch(['--port', '3999'], deps);
|
|
|
|
expect(seen.descriptorPort).toBe(3999);
|
|
});
|
|
|
|
it('errors without spawning when the requested port is busy', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps, seen } = makePortDeps(ccsDir);
|
|
deps.getPort = async () => 4001; // get-port fell back: 3999 not free
|
|
|
|
await handleBarLaunch(['--port', '3999'], deps);
|
|
|
|
expect(seen.spawnPort).toBeNull();
|
|
const allOutput = consoleOutput.join('\n');
|
|
expect(allOutput).toMatch(/3999/);
|
|
expect(allOutput.toLowerCase()).toMatch(/in use|busy|not free|unavailable/);
|
|
});
|
|
|
|
it('errors on an invalid --port value', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps, seen } = makePortDeps(ccsDir);
|
|
|
|
await handleBarLaunch(['--port', 'banana'], deps);
|
|
|
|
expect(seen.spawnPort).toBeNull();
|
|
expect(consoleOutput.join('\n').toLowerCase()).toMatch(/invalid.*port|port.*invalid/);
|
|
});
|
|
|
|
it('reuses a running server already on the requested port', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps, seen } = makePortDeps(ccsDir);
|
|
deps.findRunningServer = async () => ({ port: 3999, baseUrl: 'http://127.0.0.1:3999' });
|
|
|
|
await handleBarLaunch(['--port', '3999'], deps);
|
|
|
|
expect(seen.spawnPort).toBeNull(); // reuse, no new spawn
|
|
expect(seen.stopped).toBe(false);
|
|
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
|
|
port: number;
|
|
};
|
|
expect(barJson.port).toBe(3999);
|
|
});
|
|
|
|
it('stops a running server on a different port, then starts on the requested one', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps, seen } = makePortDeps(ccsDir);
|
|
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
|
|
|
await handleBarLaunch(['--port', '3999'], deps);
|
|
|
|
expect(seen.stopped).toBe(true);
|
|
expect(seen.spawnPort).toBe(3999);
|
|
const barJson = JSON.parse(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')) as {
|
|
port: number;
|
|
};
|
|
expect(barJson.port).toBe(3999);
|
|
});
|
|
|
|
it('preflights the destination before stopping a healthy running server', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps } = makePortDeps(ccsDir);
|
|
const events: string[] = [];
|
|
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
|
deps.getPort = async () => {
|
|
events.push('preflight');
|
|
return 3999;
|
|
};
|
|
deps.stopDetachedServer = () => {
|
|
events.push('stop');
|
|
};
|
|
|
|
await handleBarLaunch(['--port', '3999'], deps);
|
|
expect(events).toEqual(['preflight', 'stop']);
|
|
});
|
|
|
|
it('leaves the healthy server running when destination preflight is busy', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps, seen } = makePortDeps(ccsDir);
|
|
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
|
deps.getPort = async () => 4000;
|
|
|
|
await handleBarLaunch(['--port', '3999'], deps);
|
|
expect(seen.stopped).toBe(false);
|
|
expect(seen.spawnPort).toBeNull();
|
|
});
|
|
|
|
it('aborts the move when the verified stop fails', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps, seen } = makePortDeps(ccsDir);
|
|
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
|
deps.stopDetachedServer = () => {
|
|
throw new Error('identity mismatch');
|
|
};
|
|
|
|
await handleBarLaunch(['--port', '3999'], deps);
|
|
expect(seen.spawnPort).toBeNull();
|
|
});
|
|
|
|
it('restores the prior server when a check-to-bind race breaks the replacement', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps } = makePortDeps(ccsDir);
|
|
const spawnedPorts: number[] = [];
|
|
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
|
deps.spawnDetachedServer = (port: number) => {
|
|
spawnedPorts.push(port);
|
|
};
|
|
deps.waitForServerLive = async (baseUrl: string) => {
|
|
if (baseUrl.endsWith(':3999')) throw new Error('EADDRINUSE after preflight');
|
|
};
|
|
|
|
await handleBarLaunch(['--port', '3999'], deps);
|
|
expect(spawnedPorts).toEqual([3999, 3000]);
|
|
});
|
|
|
|
it('restores the prior server when replacement spawn fails', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps } = makePortDeps(ccsDir);
|
|
const spawnedPorts: number[] = [];
|
|
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
|
deps.spawnDetachedServer = (port: number) => {
|
|
spawnedPorts.push(port);
|
|
if (port === 3999) throw new Error('spawn failed');
|
|
};
|
|
|
|
await handleBarLaunch(['--port', '3999'], deps);
|
|
expect(spawnedPorts).toEqual([3999, 3000]);
|
|
});
|
|
|
|
it('preserves prior discovery state when replacement and rollback both fail', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
|
|
const oldBarJson = JSON.stringify({
|
|
baseUrl: 'http://127.0.0.1:3000',
|
|
port: 3000,
|
|
authMode: 'loopback',
|
|
});
|
|
const oldLaunchJson = JSON.stringify({ args: ['ccs.js', 'bar', 'serve', '--port', '3000'] });
|
|
fs.writeFileSync(path.join(ccsDir, 'bar.json'), oldBarJson);
|
|
fs.writeFileSync(path.join(ccsDir, 'bar', 'launch.json'), oldLaunchJson);
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps } = makePortDeps(ccsDir);
|
|
deps.findRunningServer = async () => ({ port: 3000, baseUrl: 'http://127.0.0.1:3000' });
|
|
deps.spawnDetachedServer = () => {
|
|
throw new Error('spawn failed');
|
|
};
|
|
|
|
await handleBarLaunch(['--port', '3999'], deps);
|
|
expect(fs.readFileSync(path.join(ccsDir, 'bar.json'), 'utf8')).toBe(oldBarJson);
|
|
expect(fs.readFileSync(path.join(ccsDir, 'bar', 'launch.json'), 'utf8')).toBe(oldLaunchJson);
|
|
});
|
|
|
|
it('without --port, prefers the port recorded in bar.json (sticky port)', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(ccsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({ baseUrl: 'http://127.0.0.1:3777', port: 3777, authMode: 'loopback' })
|
|
);
|
|
const { handleBarLaunch } = await loadLaunchSubcommand();
|
|
const { deps, seen } = makePortDeps(ccsDir);
|
|
|
|
await handleBarLaunch([], deps);
|
|
|
|
expect(seen.getPortCandidates?.[0]).toBe(3777);
|
|
expect(seen.spawnPort).toBe(3777);
|
|
});
|
|
});
|
|
|
|
describe('resolveBarPort: launch.json fallback survives `ccs bar stop`', () => {
|
|
// `ccs bar stop` deletes bar.json, so bar.json alone cannot carry the sticky
|
|
// port across a stop/start cycle. launch.json (refreshed by launch, NOT
|
|
// deleted by stop) records the port in its args and acts as the fallback.
|
|
it('falls back to the --port recorded in launch.json when bar.json is absent', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar', 'launch.json'),
|
|
JSON.stringify({
|
|
schema: 1,
|
|
runtime: '/usr/bin/node',
|
|
args: ['/x/ccs.js', 'bar', 'serve', '--port', '3456'],
|
|
home: tempHome,
|
|
})
|
|
);
|
|
|
|
const { resolveBarPort } = await loadLaunchSubcommand();
|
|
expect(resolveBarPort(ccsDir)).toBe(3456);
|
|
});
|
|
|
|
it('bar.json port wins over launch.json when both exist', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar.json'),
|
|
JSON.stringify({ baseUrl: 'http://127.0.0.1:4000', port: 4000, authMode: 'loopback' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar', 'launch.json'),
|
|
JSON.stringify({
|
|
schema: 1,
|
|
runtime: '/usr/bin/node',
|
|
args: ['/x/ccs.js', 'bar', 'serve', '--port', '3456'],
|
|
home: tempHome,
|
|
})
|
|
);
|
|
|
|
const { resolveBarPort } = await loadLaunchSubcommand();
|
|
expect(resolveBarPort(ccsDir)).toBe(4000);
|
|
});
|
|
|
|
it('returns null when launch.json has no --port and bar.json is absent', async () => {
|
|
const ccsDir = path.join(tempHome, '.ccs');
|
|
fs.mkdirSync(path.join(ccsDir, 'bar'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(ccsDir, 'bar', 'launch.json'),
|
|
JSON.stringify({
|
|
schema: 1,
|
|
runtime: '/usr/bin/node',
|
|
args: ['/x/ccs.js', 'bar', 'serve'],
|
|
home: tempHome,
|
|
})
|
|
);
|
|
|
|
const { resolveBarPort } = await loadLaunchSubcommand();
|
|
expect(resolveBarPort(ccsDir)).toBeNull();
|
|
});
|
|
});
|