mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 02:14:32 +00:00
fix(bar): read app version from Info.plist and verify bar API instead of version majors
The floating ccs-bar-latest release tag carries no version, so deriving the app version from tag_name printed 'vccs-bar-latest' and pinned that literal string. The version now comes from CFBundleShortVersionString in the extracted bundle's Info.plist; an unreadable plist skips pinning and clears any stale pin. The post-install check compared app semver major against the CCS server major, but the app is versioned independently of the CLI, so the mismatch warning fired on every install. It is now a capability handshake against GET /api/bar/summary: 200 confirms the server serves the bar API, 404 warns the server predates CCS Bar, and anything else keeps the soft warning. Install never hard-fails on the handshake. Closes #1497
This commit is contained in:
1 parent
9ae6f85fa7
commit
c572d9f860
2 files changed
+547
-164
No files matched your search
@@ -3,9 +3,14 @@
|
|||||||
* `ccs-bar-latest` GitHub release tag and install to ~/Applications.
|
* `ccs-bar-latest` GitHub release tag and install to ~/Applications.
|
||||||
*
|
*
|
||||||
* Intentionally uses a FLOATING tag (not the exact CLI version) so the
|
* Intentionally uses a FLOATING tag (not the exact CLI version) so the
|
||||||
* Swift app can be rebuilt and published independently. After install the
|
* Swift app can be rebuilt and published independently. After extraction,
|
||||||
* handler calls GET /api/overview to verify version compatibility and warns
|
* the real app version is read from the bundle's Contents/Info.plist
|
||||||
* (but does not hard-fail) on mismatch.
|
* (CFBundleShortVersionString) and pinned to ~/.ccs/bar/.version.
|
||||||
|
*
|
||||||
|
* Post-install compat check: single GET {baseUrl}/api/bar/summary.
|
||||||
|
* 200 = server serves the bar API (compatible).
|
||||||
|
* 404 = server too old (actionable warning).
|
||||||
|
* Other / unreachable = soft-warn, never hard-fail install.
|
||||||
*
|
*
|
||||||
* Mirrors the download/version-pin pattern in src/cliproxy/binary-manager.ts.
|
* Mirrors the download/version-pin pattern in src/cliproxy/binary-manager.ts.
|
||||||
*/
|
*/
|
||||||
@@ -45,19 +50,18 @@ const DOWNLOAD_HOST_ALLOWLIST: ReadonlyArray<string> = [
|
|||||||
|
|
||||||
export interface ReleaseAssetResult {
|
export interface ReleaseAssetResult {
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
version: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompatResult {
|
export interface CompatResult {
|
||||||
version: string;
|
|
||||||
compatible: boolean;
|
compatible: boolean;
|
||||||
|
reason: 'ok' | 'no-bar-api' | 'unreachable';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstallDeps {
|
export interface InstallDeps {
|
||||||
/**
|
/**
|
||||||
* Resolve the asset download URL + version string from a GitHub release tag.
|
* Resolve the asset download URL from a GitHub release tag.
|
||||||
* Production: calls GitHub API releases/tags/{tag}.
|
* Production: calls GitHub API releases/tags/{tag}.
|
||||||
* Test: mock that returns a fake URL + version.
|
* Test: mock that returns a fake URL.
|
||||||
*/
|
*/
|
||||||
fetchReleaseAsset: (tag: string, asset: string) => Promise<ReleaseAssetResult>;
|
fetchReleaseAsset: (tag: string, asset: string) => Promise<ReleaseAssetResult>;
|
||||||
/**
|
/**
|
||||||
@@ -66,11 +70,16 @@ export interface InstallDeps {
|
|||||||
*/
|
*/
|
||||||
downloadAndExtract: (url: string, dest: string) => Promise<void>;
|
downloadAndExtract: (url: string, dest: string) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Call GET {baseUrl}/api/overview and return { version, compatible }.
|
* GET {baseUrl}/api/bar/summary — capability handshake.
|
||||||
* compatible = server version major === installed app version major.
|
* 200 → compatible; 404 → no-bar-api; else/unreachable → unreachable.
|
||||||
* Returns compatible:false (never true) when versions cannot be compared.
|
* Never hard-fails install.
|
||||||
*/
|
*/
|
||||||
verifyCompat: (baseUrl: string, installedVersion: string) => Promise<CompatResult>;
|
verifyCompat: (baseUrl: string) => Promise<CompatResult>;
|
||||||
|
/**
|
||||||
|
* Read CFBundleShortVersionString from {appPath}/Contents/Info.plist.
|
||||||
|
* Returns null if the file is absent or unreadable.
|
||||||
|
*/
|
||||||
|
readAppBundleVersion: (appPath: string) => string | null;
|
||||||
/** Returns path to ~/.ccs (respects CCS_HOME). */
|
/** Returns path to ~/.ccs (respects CCS_HOME). */
|
||||||
getCcsDir: () => string;
|
getCcsDir: () => string;
|
||||||
/** Destination directory for the .app bundle (~/Applications by default). */
|
/** Destination directory for the .app bundle (~/Applications by default). */
|
||||||
@@ -131,9 +140,6 @@ async function defaultFetchReleaseAsset(tag: string, asset: string): Promise<Rel
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const release = (await body.json()) as any;
|
const release = (await body.json()) as any;
|
||||||
const tagName: string = release.tag_name ?? tag;
|
|
||||||
// Strip leading 'v' for the version pin file.
|
|
||||||
const version = tagName.replace(/^v/, '');
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const found = (release.assets as any[]).find((a: { name: string }) => a.name === asset);
|
const found = (release.assets as any[]).find((a: { name: string }) => a.name === asset);
|
||||||
@@ -141,7 +147,7 @@ async function defaultFetchReleaseAsset(tag: string, asset: string): Promise<Rel
|
|||||||
throw new Error(`Asset "${asset}" not found in release ${tag}`);
|
throw new Error(`Asset "${asset}" not found in release ${tag}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { downloadUrl: found.browser_download_url as string, version };
|
return { downloadUrl: found.browser_download_url as string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,56 +277,59 @@ async function defaultDownloadAndExtract(url: string, dest: string): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call GET {baseUrl}/api/overview and compare server version against the
|
* Single-request capability handshake: GET {baseUrl}/api/bar/summary.
|
||||||
* installed bar version. Returns compat=false whenever the comparison cannot
|
* 200 → server serves the bar API (compatible).
|
||||||
* be made (server unreachable, version strings unparseable, etc.).
|
* 404 → server is too old and does not serve the bar API.
|
||||||
|
* Any other status or network error → soft-warn (unreachable).
|
||||||
*
|
*
|
||||||
* Fix for Finding #10: replaces the phantom check that always returned true.
|
* The route is loopback-gated server-side; install-time baseUrl is always
|
||||||
* Compatible is defined as same semver major (0 vs 0, 1 vs 1, etc.).
|
* loopback (bar.json baseUrl or http://127.0.0.1:3000), so the gate passes.
|
||||||
*/
|
*/
|
||||||
async function defaultVerifyCompat(
|
async function defaultVerifyCompat(baseUrl: string): Promise<CompatResult> {
|
||||||
baseUrl: string,
|
|
||||||
installedVersion: string
|
|
||||||
): Promise<CompatResult> {
|
|
||||||
try {
|
try {
|
||||||
const { request } = await import('undici');
|
const { request } = await import('undici');
|
||||||
const { statusCode, body } = await request(`${baseUrl}/api/overview`, {
|
const { statusCode, body } = await request(`${baseUrl}/api/bar/summary`, {
|
||||||
headers: { 'User-Agent': 'ccs-cli' },
|
headers: { 'User-Agent': 'ccs-cli' },
|
||||||
});
|
});
|
||||||
if (statusCode !== 200) {
|
|
||||||
console.log(
|
|
||||||
`[!] Version compatibility check failed: /api/overview returned HTTP ${statusCode}.`
|
|
||||||
);
|
|
||||||
return { version: 'unknown', compatible: false };
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const data = (await body.json()) as any;
|
|
||||||
const serverVersion: string = (data.version as string) ?? 'unknown';
|
|
||||||
|
|
||||||
if (serverVersion === 'unknown') {
|
// Drain the body regardless of status to free the socket.
|
||||||
console.log('[!] Version compatibility: server did not report a version.');
|
try {
|
||||||
return { version: serverVersion, compatible: false };
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await (body as any).dump?.();
|
||||||
|
} catch {
|
||||||
|
/* ignore drain errors */
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse installed version major from the pinned version string.
|
if (statusCode === 200) {
|
||||||
const installedMajorRaw = installedVersion.split('.')[0];
|
return { compatible: true, reason: 'ok' };
|
||||||
const serverMajorRaw = serverVersion.split('.')[0];
|
|
||||||
const installedMajor = parseInt(installedMajorRaw ?? '', 10);
|
|
||||||
const serverMajor = parseInt(serverMajorRaw ?? '', 10);
|
|
||||||
|
|
||||||
if (isNaN(installedMajor) || isNaN(serverMajor)) {
|
|
||||||
console.log(
|
|
||||||
`[!] Version compatibility: cannot parse major versions ` +
|
|
||||||
`(installed="${installedVersion}", server="${serverVersion}").`
|
|
||||||
);
|
|
||||||
return { version: serverVersion, compatible: false };
|
|
||||||
}
|
}
|
||||||
|
if (statusCode === 404) {
|
||||||
const compatible = installedMajor === serverMajor;
|
return { compatible: false, reason: 'no-bar-api' };
|
||||||
return { version: serverVersion, compatible };
|
}
|
||||||
|
return { compatible: false, reason: 'unreachable' };
|
||||||
} catch {
|
} catch {
|
||||||
// Server unreachable — warn but do not claim compatible.
|
// Network error or server not running.
|
||||||
return { version: 'unknown', compatible: false };
|
return { compatible: false, reason: 'unreachable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read CFBundleShortVersionString from {appPath}/Contents/Info.plist.
|
||||||
|
* The plist is XML text post-codesign (verified on the installed app).
|
||||||
|
* Returns null on any error (missing file, binary plist, parse failure).
|
||||||
|
*/
|
||||||
|
function defaultReadAppBundleVersion(appPath: string): string | null {
|
||||||
|
try {
|
||||||
|
const plistPath = path.join(appPath, 'Contents', 'Info.plist');
|
||||||
|
const contents = fs.readFileSync(plistPath, 'utf8');
|
||||||
|
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
|
||||||
|
contents
|
||||||
|
);
|
||||||
|
if (!match || !match[1]) return null;
|
||||||
|
const v = match[1].trim();
|
||||||
|
return v || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,12 +366,13 @@ export async function handleBarInstall(
|
|||||||
const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset;
|
const fetchReleaseAsset = deps.fetchReleaseAsset ?? defaultFetchReleaseAsset;
|
||||||
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
|
const downloadAndExtract = deps.downloadAndExtract ?? defaultDownloadAndExtract;
|
||||||
const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat;
|
const verifyCompat = deps.verifyCompat ?? defaultVerifyCompat;
|
||||||
|
const readAppBundleVersion = deps.readAppBundleVersion ?? defaultReadAppBundleVersion;
|
||||||
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
|
const ccsDir = (deps.getCcsDir ?? defaultGetCcsDir)();
|
||||||
const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)();
|
const appsDir = (deps.getAppsDir ?? defaultGetAppsDir)();
|
||||||
|
|
||||||
console.log('[i] Fetching CCS Bar release info...');
|
console.log('[i] Fetching CCS Bar release info...');
|
||||||
|
|
||||||
// 1. Resolve the floating tag → download URL + version.
|
// 1. Resolve the floating tag → download URL.
|
||||||
let releaseInfo: ReleaseAssetResult;
|
let releaseInfo: ReleaseAssetResult;
|
||||||
try {
|
try {
|
||||||
releaseInfo = await fetchReleaseAsset(BAR_RELEASE_TAG, BAR_ASSET_NAME);
|
releaseInfo = await fetchReleaseAsset(BAR_RELEASE_TAG, BAR_ASSET_NAME);
|
||||||
@@ -373,8 +383,8 @@ export async function handleBarInstall(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { downloadUrl, version } = releaseInfo;
|
const { downloadUrl } = releaseInfo;
|
||||||
console.log(`[i] Installing CCS Bar v${version} from ${BAR_RELEASE_TAG}...`);
|
console.log(`[i] Installing CCS Bar from ${BAR_RELEASE_TAG}...`);
|
||||||
|
|
||||||
// 2. Download and extract into ~/Applications.
|
// 2. Download and extract into ~/Applications.
|
||||||
try {
|
try {
|
||||||
@@ -402,18 +412,33 @@ export async function handleBarInstall(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Pin the installed version to ~/.ccs/bar/.version.
|
// 4. Read the real version from the extracted bundle's Info.plist.
|
||||||
try {
|
const installedVersion = readAppBundleVersion(appPath);
|
||||||
pinBarVersion(ccsDir, version);
|
|
||||||
} catch (err) {
|
if (installedVersion !== null) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
// Pin the real version string.
|
||||||
console.error(`[!] Could not write version pin: ${msg}`);
|
try {
|
||||||
// Non-fatal — continue.
|
pinBarVersion(ccsDir, installedVersion);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[!] Could not write version pin: ${msg}`);
|
||||||
|
// Non-fatal — continue.
|
||||||
|
}
|
||||||
|
console.log(`[OK] CCS Bar v${installedVersion} installed to ${appsDir}/${BAR_APP_NAME}`);
|
||||||
|
} else {
|
||||||
|
// Info.plist unreadable — best-effort remove any stale version pin from a previous install,
|
||||||
|
// so `ccs bar version` does not show an outdated version string.
|
||||||
|
try {
|
||||||
|
fs.rmSync(path.join(ccsDir, 'bar', '.version'), { force: true });
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — ignore */
|
||||||
|
}
|
||||||
|
// No pin written; ASCII notice; no crash.
|
||||||
|
console.log(`[OK] CCS Bar installed to ${appsDir}/${BAR_APP_NAME}`);
|
||||||
|
console.log('[!] Could not read app version from Info.plist.');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[OK] CCS Bar v${version} installed to ${appsDir}/${BAR_APP_NAME}`);
|
// 5. Capability handshake via GET /api/bar/summary.
|
||||||
|
|
||||||
// 5. Version-compat handshake via /api/overview.
|
|
||||||
// Read bar.json for baseUrl if present; otherwise fall back to localhost:3000.
|
// Read bar.json for baseUrl if present; otherwise fall back to localhost:3000.
|
||||||
const barJsonPath = path.join(ccsDir, 'bar.json');
|
const barJsonPath = path.join(ccsDir, 'bar.json');
|
||||||
let baseUrl = 'http://127.0.0.1:3000';
|
let baseUrl = 'http://127.0.0.1:3000';
|
||||||
@@ -426,19 +451,21 @@ export async function handleBarInstall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pass the pinned version so verifyCompat can do a real major-version comparison.
|
const compat = await verifyCompat(baseUrl);
|
||||||
const compat = await verifyCompat(baseUrl, version);
|
if (compat.reason === 'ok') {
|
||||||
if (!compat.compatible) {
|
console.log('[OK] Server bar API reachable.');
|
||||||
|
} else if (compat.reason === 'no-bar-api') {
|
||||||
console.log(
|
console.log(
|
||||||
`[!] Version mismatch: CCS server reports v${compat.version}, ` +
|
'[!] CCS server does not serve the bar API (/api/bar/summary returned 404).' +
|
||||||
`app is v${version}. Some features may not work until you restart ` +
|
' Update CCS, then restart `ccs bar`.'
|
||||||
'`ccs bar` or update CCS.'
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[OK] Version compatibility confirmed (server: v${compat.version}).`);
|
// unreachable — soft-warn
|
||||||
|
console.log('[!] Could not verify server compatibility (server may not be running).');
|
||||||
|
console.log('[i] Run `ccs bar` to start the server and recheck.');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
console.log('[!] Could not verify version compatibility (server may not be running).');
|
console.log('[!] Could not verify server compatibility (server may not be running).');
|
||||||
console.log('[i] Run `ccs bar` to start the server and recheck.');
|
console.log('[i] Run `ccs bar` to start the server and recheck.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
*
|
*
|
||||||
* Tests run FIRST per TDD mandate.
|
* Tests run FIRST per TDD mandate.
|
||||||
* Covers: subcommand routing, bar.json contract, floating-tag install,
|
* Covers: subcommand routing, bar.json contract, floating-tag install,
|
||||||
* version-compat handshake, port-discovery fallback, uninstall, version,
|
* Info.plist version extraction, capability handshake compat, port-discovery
|
||||||
* and verified review findings #8-#13.
|
* fallback, uninstall, version, and verified review findings #8-#13.
|
||||||
*
|
*
|
||||||
* All network I/O and filesystem-home operations are mocked.
|
* All network I/O and filesystem-home operations are mocked.
|
||||||
* Uses CCS_HOME env var for isolation — never touches real ~/.ccs.
|
* Uses CCS_HOME env var for isolation — never touches real ~/.ccs.
|
||||||
@@ -141,6 +141,12 @@ describe('bar command dispatcher (index.ts)', () => {
|
|||||||
calls.push(`version:`);
|
calls.push(`version:`);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
mock.module('../../../src/commands/bar/help-subcommand', () => ({
|
||||||
|
showHelp: async () => {
|
||||||
|
calls.push(`help:`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches bare `ccs bar` to launch', async () => {
|
it('dispatches bare `ccs bar` to launch', async () => {
|
||||||
@@ -190,6 +196,28 @@ describe('bar command dispatcher (index.ts)', () => {
|
|||||||
// Should print help or error but not crash
|
// Should print help or error but not crash
|
||||||
await expect(handleBarCommand(['unknown-subcommand'])).resolves.toBeUndefined();
|
await expect(handleBarCommand(['unknown-subcommand'])).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -352,7 +380,7 @@ describe('bar.json contract (launch subcommand)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 4. install subcommand — floating tag + version-compat handshake
|
// 4. install subcommand — floating tag + Info.plist version + compat handshake
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('bar install subcommand', () => {
|
describe('bar install subcommand', () => {
|
||||||
@@ -377,13 +405,11 @@ describe('bar install subcommand', () => {
|
|||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async (tag: string, _asset: string) => {
|
fetchReleaseAsset: async (tag: string, _asset: string) => {
|
||||||
fetchedUrls.push(tag);
|
fetchedUrls.push(tag);
|
||||||
return { downloadUrl: FAKE_DOWNLOAD_URL, version: FAKE_VERSION };
|
return { downloadUrl: FAKE_DOWNLOAD_URL };
|
||||||
},
|
},
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async (_baseUrl: string, _installedVersion: string) => ({
|
verifyCompat: async (_baseUrl: string) => ({ compatible: true, reason: 'ok' }),
|
||||||
version: FAKE_VERSION,
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
compatible: true,
|
|
||||||
}),
|
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
@@ -393,7 +419,7 @@ describe('bar install subcommand', () => {
|
|||||||
expect(fetchedUrls).not.toContain(expect.stringMatching(/^\d+\.\d+\.\d+$/));
|
expect(fetchedUrls).not.toContain(expect.stringMatching(/^\d+\.\d+\.\d+$/));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pins the installed version to ~/.ccs/bar/.version', async () => {
|
it('pins the Info.plist version (not tag name) to ~/.ccs/bar/.version', async () => {
|
||||||
const ccsDir = path.join(tempHome, '.ccs');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
@@ -401,9 +427,10 @@ describe('bar install subcommand', () => {
|
|||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
const { handleBarInstall } = await loadInstallSubcommand();
|
||||||
|
|
||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, version: FAKE_VERSION }),
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
getCcsDir: () => ccsDir,
|
getCcsDir: () => ccsDir,
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
@@ -413,19 +440,20 @@ describe('bar install subcommand', () => {
|
|||||||
expect(fs.readFileSync(versionFile, 'utf8').trim()).toBe(FAKE_VERSION);
|
expect(fs.readFileSync(versionFile, 'utf8').trim()).toBe(FAKE_VERSION);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls /api/overview for version-compat handshake after install', async () => {
|
it('calls /api/bar/summary for compat handshake after install', async () => {
|
||||||
const compatCalls: string[] = [];
|
const compatCalls: string[] = [];
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
|
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
const { handleBarInstall } = await loadInstallSubcommand();
|
||||||
|
|
||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, version: FAKE_VERSION }),
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async (baseUrl: string, _installedVersion: string) => {
|
verifyCompat: async (baseUrl: string) => {
|
||||||
compatCalls.push(baseUrl);
|
compatCalls.push(baseUrl);
|
||||||
return { version: FAKE_VERSION, compatible: true };
|
return { compatible: true, reason: 'ok' };
|
||||||
},
|
},
|
||||||
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
@@ -433,26 +461,23 @@ describe('bar install subcommand', () => {
|
|||||||
expect(compatCalls.length).toBeGreaterThan(0);
|
expect(compatCalls.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('warns on version mismatch but does not hard-fail', async () => {
|
it('does not hard-fail when compat returns no-bar-api', async () => {
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
const { handleBarInstall } = await loadInstallSubcommand();
|
||||||
|
|
||||||
// Version mismatch: server says 2.0.0 but app is 1.2.3
|
|
||||||
await expect(
|
await expect(
|
||||||
handleBarInstall([], {
|
handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
||||||
version: '1.2.3',
|
|
||||||
}),
|
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ version: '2.0.0', compatible: false }),
|
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
})
|
})
|
||||||
).resolves.toBeUndefined(); // does not throw
|
).resolves.toBeUndefined(); // does not throw
|
||||||
|
|
||||||
const allOutput = consoleOutput.join('\n');
|
const allOutput = consoleOutput.join('\n');
|
||||||
expect(allOutput.toLowerCase()).toMatch(/warn|mismatch|version/i);
|
expect(allOutput.toLowerCase()).toMatch(/warn|api|update/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prints xattr/Gatekeeper note for ad-hoc builds', async () => {
|
it('prints xattr/Gatekeeper note for ad-hoc builds', async () => {
|
||||||
@@ -460,9 +485,10 @@ describe('bar install subcommand', () => {
|
|||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
const { handleBarInstall } = await loadInstallSubcommand();
|
||||||
|
|
||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL, version: FAKE_VERSION }),
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
@@ -485,7 +511,8 @@ describe('bar install subcommand', () => {
|
|||||||
downloadAndExtract: async () => {
|
downloadAndExtract: async () => {
|
||||||
/* noop */
|
/* noop */
|
||||||
},
|
},
|
||||||
verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
})
|
})
|
||||||
@@ -526,10 +553,10 @@ describe('bar install: redirect-following download (#8)', () => {
|
|||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({
|
||||||
downloadUrl: REDIRECT_URL,
|
downloadUrl: REDIRECT_URL,
|
||||||
version: FAKE_VERSION,
|
|
||||||
}),
|
}),
|
||||||
downloadAndExtract: redirectFollowingExtract,
|
downloadAndExtract: redirectFollowingExtract,
|
||||||
verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
@@ -562,10 +589,10 @@ describe('bar install: HTTP status code validation (#11)', () => {
|
|||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
||||||
version: '1.0.0',
|
|
||||||
}),
|
}),
|
||||||
downloadAndExtract: statusCheckingExtract,
|
downloadAndExtract: statusCheckingExtract,
|
||||||
verifyCompat: async () => ({ version: '1.0.0', compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => path.join(tempHome, 'Applications'),
|
getAppsDir: () => path.join(tempHome, 'Applications'),
|
||||||
});
|
});
|
||||||
@@ -582,12 +609,12 @@ describe('bar install: HTTP status code validation (#11)', () => {
|
|||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
||||||
version: '1.0.0',
|
|
||||||
}),
|
}),
|
||||||
downloadAndExtract: async (_url) => {
|
downloadAndExtract: async (_url) => {
|
||||||
throw new Error(`Download failed: HTTP 404 for ${_url}`);
|
throw new Error(`Download failed: HTTP 404 for ${_url}`);
|
||||||
},
|
},
|
||||||
verifyCompat: async () => ({ version: '1.0.0', compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => path.join(tempHome, 'Applications'),
|
getAppsDir: () => path.join(tempHome, 'Applications'),
|
||||||
});
|
});
|
||||||
@@ -651,7 +678,6 @@ describe('bar install: host allowlist validation (#9)', () => {
|
|||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({
|
||||||
downloadUrl: 'https://evil.example.com/CCS-Bar.app.zip',
|
downloadUrl: 'https://evil.example.com/CCS-Bar.app.zip',
|
||||||
version: '1.0.0',
|
|
||||||
}),
|
}),
|
||||||
// downloadAndExtract is the production default; it calls validateDownloadUrl internally.
|
// downloadAndExtract is the production default; it calls validateDownloadUrl internally.
|
||||||
// We pass a test-double that applies the same validation.
|
// We pass a test-double that applies the same validation.
|
||||||
@@ -660,7 +686,8 @@ describe('bar install: host allowlist validation (#9)', () => {
|
|||||||
const { validateDownloadUrl } = await loadInstallSubcommand();
|
const { validateDownloadUrl } = await loadInstallSubcommand();
|
||||||
validateDownloadUrl(url);
|
validateDownloadUrl(url);
|
||||||
},
|
},
|
||||||
verifyCompat: async () => ({ version: '1.0.0', compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => path.join(tempHome, 'Applications'),
|
getAppsDir: () => path.join(tempHome, 'Applications'),
|
||||||
});
|
});
|
||||||
@@ -673,10 +700,10 @@ describe('bar install: host allowlist validation (#9)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 4d. Finding #10 — verifyCompat real major-version comparison
|
// 4d. Compat capability handshake (replaces old major-version check)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('bar install: real version compatibility check (#10)', () => {
|
describe('bar install: compat capability handshake', () => {
|
||||||
const FAKE_DOWNLOAD_URL =
|
const FAKE_DOWNLOAD_URL =
|
||||||
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
'https://github.com/kaitranntt/ccs/releases/download/ccs-bar-latest/CCS-Bar.app.zip';
|
||||||
|
|
||||||
@@ -686,90 +713,76 @@ describe('bar install: real version compatibility check (#10)', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it('passes installedVersion to verifyCompat so major comparison is possible', async () => {
|
it('verifyCompat receives only baseUrl (no installedVersion param)', async () => {
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
const capturedArgs: Array<{ baseUrl: string; installedVersion: string }> = [];
|
const capturedArgs: Array<{ baseUrl: string }> = [];
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
const { handleBarInstall } = await loadInstallSubcommand();
|
||||||
|
|
||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
||||||
version: '2.5.0',
|
|
||||||
}),
|
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async (baseUrl: string, installedVersion: string) => {
|
verifyCompat: async (baseUrl: string) => {
|
||||||
capturedArgs.push({ baseUrl, installedVersion });
|
capturedArgs.push({ baseUrl });
|
||||||
// Same major — compatible.
|
return { compatible: true, reason: 'ok' };
|
||||||
return { version: '2.1.0', compatible: true };
|
|
||||||
},
|
},
|
||||||
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(capturedArgs.length).toBe(1);
|
expect(capturedArgs.length).toBe(1);
|
||||||
// installedVersion must be the version from the release, not a hardcoded 0.
|
// verifyCompat receives only one argument (baseUrl)
|
||||||
expect(capturedArgs[0].installedVersion).toBe('2.5.0');
|
expect(capturedArgs[0].baseUrl).toMatch(/http/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports compatible when server major equals installed major', async () => {
|
it('prints [OK] server bar API reachable when compat returns ok', async () => {
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
const { handleBarInstall } = await loadInstallSubcommand();
|
||||||
|
|
||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
||||||
version: '3.0.0',
|
|
||||||
}),
|
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async (_baseUrl: string, _installedVersion: string) => {
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
// Same major (3 == 3).
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
return { version: '3.1.0', compatible: true };
|
|
||||||
},
|
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allOutput = consoleOutput.join('\n');
|
const allOutput = consoleOutput.join('\n');
|
||||||
expect(allOutput).toMatch(/\[OK\].*[Vv]ersion/i);
|
expect(allOutput).toMatch(/\[OK\].*[Ss]erver bar API/);
|
||||||
|
// Must NOT print any mismatch warning
|
||||||
|
expect(allOutput).not.toMatch(/mismatch/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('warns when server major differs from installed major', async () => {
|
it('warns with actionable message when compat returns no-bar-api (404)', async () => {
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
const { handleBarInstall } = await loadInstallSubcommand();
|
||||||
|
|
||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
||||||
version: '1.0.0',
|
|
||||||
}),
|
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async (_baseUrl: string, _installedVersion: string) => {
|
verifyCompat: async () => ({ compatible: false, reason: 'no-bar-api' }),
|
||||||
// Major mismatch: server v2, installed v1.
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
return { version: '2.0.0', compatible: false };
|
|
||||||
},
|
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allOutput = consoleOutput.join('\n');
|
const allOutput = consoleOutput.join('\n');
|
||||||
expect(allOutput).toMatch(/\[!\]/);
|
expect(allOutput).toMatch(/\[!\]/);
|
||||||
expect(allOutput.toLowerCase()).toMatch(/mismatch|version/i);
|
// 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('warns (not silently compatible) when server is unreachable', async () => {
|
it('soft-warns (not crash) when compat returns unreachable', async () => {
|
||||||
const appsDir = path.join(tempHome, 'Applications');
|
const appsDir = path.join(tempHome, 'Applications');
|
||||||
const { handleBarInstall } = await loadInstallSubcommand();
|
const { handleBarInstall } = await loadInstallSubcommand();
|
||||||
|
|
||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({ downloadUrl: FAKE_DOWNLOAD_URL }),
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
|
||||||
version: '1.0.0',
|
|
||||||
}),
|
|
||||||
downloadAndExtract: fakeExtract(appsDir),
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
verifyCompat: async (_baseUrl: string, _installedVersion: string) => {
|
verifyCompat: async () => ({ compatible: false, reason: 'unreachable' }),
|
||||||
// Server unreachable — never claim compatible.
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
return { version: 'unknown', compatible: false };
|
|
||||||
},
|
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
@@ -777,6 +790,25 @@ describe('bar install: real version compatibility check (#10)', () => {
|
|||||||
const allOutput = consoleOutput.join('\n');
|
const allOutput = consoleOutput.join('\n');
|
||||||
// Should warn rather than print [OK] compat confirmed
|
// Should warn rather than print [OK] compat confirmed
|
||||||
expect(allOutput).not.toMatch(/\[OK\].*[Cc]ompat/);
|
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 }),
|
||||||
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
|
verifyCompat: async () => {
|
||||||
|
throw new Error('network explosion');
|
||||||
|
},
|
||||||
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
|
getAppsDir: () => appsDir,
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -796,12 +828,12 @@ describe('bar install: post-extract app-exists assertion (#12)', () => {
|
|||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
||||||
version: '1.0.0',
|
|
||||||
}),
|
}),
|
||||||
downloadAndExtract: async (_url: string, dest: string) => {
|
downloadAndExtract: async (_url: string, dest: string) => {
|
||||||
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
fs.mkdirSync(path.join(dest, 'CCS Bar.app'), { recursive: true });
|
||||||
},
|
},
|
||||||
verifyCompat: async () => ({ version: '1.0.0', compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
@@ -819,14 +851,14 @@ describe('bar install: post-extract app-exists assertion (#12)', () => {
|
|||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
||||||
version: '1.0.0',
|
|
||||||
}),
|
}),
|
||||||
downloadAndExtract: async (_url: string, dest: string) => {
|
downloadAndExtract: async (_url: string, dest: string) => {
|
||||||
// Places a wrongly-named artifact (simulates a bad archive).
|
// Places a wrongly-named artifact (simulates a bad archive).
|
||||||
fs.mkdirSync(dest, { recursive: true });
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
fs.writeFileSync(path.join(dest, 'WrongName.app'), 'dummy');
|
fs.writeFileSync(path.join(dest, 'WrongName.app'), 'dummy');
|
||||||
},
|
},
|
||||||
verifyCompat: async () => ({ version: '1.0.0', compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
@@ -844,13 +876,13 @@ describe('bar install: post-extract app-exists assertion (#12)', () => {
|
|||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
||||||
version: '1.0.0',
|
|
||||||
}),
|
}),
|
||||||
downloadAndExtract: async (_url: string, dest: string) => {
|
downloadAndExtract: async (_url: string, dest: string) => {
|
||||||
// Nothing extracted
|
// Nothing extracted
|
||||||
fs.mkdirSync(dest, { recursive: true });
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
},
|
},
|
||||||
verifyCompat: async () => ({ version: '1.0.0', compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => '1.0.0',
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
@@ -861,6 +893,179 @@ describe('bar install: post-extract app-exists assertion (#12)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 }),
|
||||||
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
// readAppBundleVersion returns the real version from Info.plist
|
||||||
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
|
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 }),
|
||||||
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => '1.4.0',
|
||||||
|
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 }),
|
||||||
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
// Unreadable Info.plist — returns null
|
||||||
|
readAppBundleVersion: (_appPath: string) => null,
|
||||||
|
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)
|
||||||
|
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) => {
|
||||||
|
// App bundle already created above — no actual download needed
|
||||||
|
},
|
||||||
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
// readAppBundleVersion intentionally omitted → uses production default
|
||||||
|
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();
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// App bundle already created — no plist inside
|
||||||
|
},
|
||||||
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
// readAppBundleVersion omitted → uses production default
|
||||||
|
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
|
// 5. Port discovery fallback
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1101,10 +1306,10 @@ describe('bar install: redirect host re-validation (fix #6)', () => {
|
|||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({
|
||||||
downloadUrl: INITIAL_URL,
|
downloadUrl: INITIAL_URL,
|
||||||
version: FAKE_VERSION,
|
|
||||||
}),
|
}),
|
||||||
downloadAndExtract: redirectFollowingExtract,
|
downloadAndExtract: redirectFollowingExtract,
|
||||||
verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => path.join(tempHome, 'Applications'),
|
getAppsDir: () => path.join(tempHome, 'Applications'),
|
||||||
});
|
});
|
||||||
@@ -1151,10 +1356,10 @@ describe('bar install: zip-slip guard (fix #14)', () => {
|
|||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
||||||
version: FAKE_VERSION,
|
|
||||||
}),
|
}),
|
||||||
downloadAndExtract: zipSlipExtract,
|
downloadAndExtract: zipSlipExtract,
|
||||||
verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => path.join(tempHome, 'Applications'),
|
getAppsDir: () => path.join(tempHome, 'Applications'),
|
||||||
});
|
});
|
||||||
@@ -1180,10 +1385,10 @@ describe('bar install: zip-slip guard (fix #14)', () => {
|
|||||||
await handleBarInstall([], {
|
await handleBarInstall([], {
|
||||||
fetchReleaseAsset: async () => ({
|
fetchReleaseAsset: async () => ({
|
||||||
downloadUrl: FAKE_DOWNLOAD_URL,
|
downloadUrl: FAKE_DOWNLOAD_URL,
|
||||||
version: FAKE_VERSION,
|
|
||||||
}),
|
}),
|
||||||
downloadAndExtract: safeExtract,
|
downloadAndExtract: safeExtract,
|
||||||
verifyCompat: async () => ({ version: FAKE_VERSION, compatible: true }),
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
readAppBundleVersion: (_appPath: string) => FAKE_VERSION,
|
||||||
getCcsDir: () => path.join(tempHome, '.ccs'),
|
getCcsDir: () => path.join(tempHome, '.ccs'),
|
||||||
getAppsDir: () => appsDir,
|
getAppsDir: () => appsDir,
|
||||||
});
|
});
|
||||||
@@ -1193,3 +1398,154 @@ describe('bar install: zip-slip guard (fix #14)', () => {
|
|||||||
expect(allOutput).not.toMatch(/\[X\]/);
|
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 }),
|
||||||
|
downloadAndExtract: fakeExtract(appsDir),
|
||||||
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
// Simulate unreadable Info.plist
|
||||||
|
readAppBundleVersion: (_appPath: string) => null,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// App bundle already created above
|
||||||
|
},
|
||||||
|
verifyCompat: async () => ({ compatible: true, reason: 'ok' }),
|
||||||
|
// readAppBundleVersion intentionally omitted → uses production default
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user