- Added a new Archify skill, enabling users to create polished architecture, workflow, sequence, data-flow, and lifecycle diagrams. - Implemented comprehensive functionality including rendering, validation, and delivery of diagrams in various formats. - Integrated a user-friendly command-line interface for generating and previewing diagrams. - Developed supporting files including package.json, LICENSE, and SKILL.md for documentation and licensing. - Added unit tests to ensure reliability and functionality of the new skill. These changes enhance the application by providing a structured approach to visualizing system architecture and workflows, improving user experience and data representation.
3318 lines
121 KiB
JavaScript
3318 lines
121 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import fsPromises from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import test from 'node:test';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
import {
|
|
acknowledgeUpdate,
|
|
checkForUpdate,
|
|
} from '../scripts/check-update.mjs';
|
|
import { DEFAULT_MANIFEST_URL, compareSemver, parseSemver } from '../scripts/update-contract.mjs';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const skillRoot = path.resolve(here, '..');
|
|
const checkerPath = path.join(skillRoot, 'scripts', 'check-update.mjs');
|
|
const contractPath = path.join(skillRoot, 'scripts', 'update-contract.mjs');
|
|
const expectedRepository = 'https://github.com/tt-a1i/archify';
|
|
const expectedManifestUrl = 'https://tt-a1i.github.io/archify/skill-updates/archify/stable.json';
|
|
const baseTime = Date.parse('2026-08-28T08:00:00Z');
|
|
const childCheckTimeoutMs = 2_000;
|
|
const parentCheckTimeoutMs = 5_000;
|
|
const maxCacheStateBytes = 64 * 1_024;
|
|
|
|
function writeJson(target, value) {
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function compactStateSource(state) {
|
|
return `${JSON.stringify(state)}\n`;
|
|
}
|
|
|
|
function metadataWithOverrides(metadata, overrides) {
|
|
return new Proxy(metadata, {
|
|
get(target, property) {
|
|
if (Object.hasOwn(overrides, property)) return overrides[property];
|
|
const value = Reflect.get(target, property, target);
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
});
|
|
}
|
|
|
|
function historyDigest(index) {
|
|
return `sha256:${crypto.createHash('sha256').update(`history-${index}`).digest('hex')}`;
|
|
}
|
|
|
|
function cachedStateWithHistory({ offeredDigests = [], acknowledgedDigests = [] } = {}) {
|
|
return {
|
|
schemaVersion: 1,
|
|
skillId: 'archify',
|
|
installedVersion: '2.15.0',
|
|
check: {
|
|
nextCheckAt: '2020-01-01T00:00:00.000Z',
|
|
consecutiveFailures: 0,
|
|
},
|
|
notification: {
|
|
offeredDigests: [...offeredDigests],
|
|
acknowledgedDigests: [...acknowledgedDigests],
|
|
},
|
|
};
|
|
}
|
|
|
|
function candidateStateForDigest(state, digest) {
|
|
return {
|
|
...state,
|
|
check: {
|
|
nextCheckAt: new Date(baseTime + (72 * 60 * 60 * 1_000)).toISOString(),
|
|
consecutiveFailures: 0,
|
|
},
|
|
notification: {
|
|
offeredDigests: [...state.notification.offeredDigests, digest],
|
|
acknowledgedDigests: [...state.notification.acknowledgedDigests],
|
|
},
|
|
candidate: {
|
|
version: '2.16.0',
|
|
targetDigest: digest,
|
|
severity: 'normal',
|
|
releaseNotes: 'https://github.com/tt-a1i/archify/releases/tag/v2.16.0',
|
|
},
|
|
};
|
|
}
|
|
|
|
function writeCompactCommittedState(testFixture, state, generation = 1n) {
|
|
const target = path.join(operationPath(testFixture, 'committed', generation), 'state.json');
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, compactStateSource(state));
|
|
return target;
|
|
}
|
|
|
|
function committedStateFiles(testFixture) {
|
|
return fs.readdirSync(stateDirectory(testFixture), { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && /^committed-\d+$/.test(entry.name))
|
|
.map((entry) => path.join(stateDirectory(testFixture), entry.name, 'state.json'));
|
|
}
|
|
|
|
function localRelease(version = '2.15.0') {
|
|
return {
|
|
schemaVersion: 1,
|
|
skillId: 'archify',
|
|
channel: version.includes('-') ? 'development' : 'stable',
|
|
version,
|
|
source: { repository: expectedRepository },
|
|
updateManifestUrl: expectedManifestUrl,
|
|
};
|
|
}
|
|
|
|
function remoteRelease(overrides = {}) {
|
|
return {
|
|
schemaVersion: 1,
|
|
skillId: 'archify',
|
|
channel: 'stable',
|
|
version: '2.16.0',
|
|
publishedAt: '2026-08-28T07:00:00Z',
|
|
source: {
|
|
repository: expectedRepository,
|
|
ref: 'v2.16.0',
|
|
treeSha: 'a'.repeat(40),
|
|
},
|
|
artifact: {
|
|
sha256: 'b'.repeat(64),
|
|
},
|
|
summary: 'Improve large-repository scanning and diagram layout.',
|
|
releaseNotes: 'https://github.com/tt-a1i/archify/releases/tag/v2.16.0',
|
|
severity: 'normal',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function remoteReleaseForVersion(version, digest = 'b'.repeat(64)) {
|
|
const release = remoteRelease();
|
|
return {
|
|
...release,
|
|
version,
|
|
source: { ...release.source, ref: `v${version}` },
|
|
artifact: { sha256: digest },
|
|
releaseNotes: `https://github.com/tt-a1i/archify/releases/tag/v${version}`,
|
|
};
|
|
}
|
|
|
|
function response(body, { status = 200, etag = '"archify-2.16.0"' } = {}) {
|
|
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
|
status,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
etag,
|
|
},
|
|
});
|
|
}
|
|
|
|
function fixture(version = '2.15.0') {
|
|
const root = fs.realpathSync(fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'archify-update-notifier-'),
|
|
));
|
|
const releasePath = path.join(root, 'skill-release.json');
|
|
const cacheDirectory = path.join(root, 'cache');
|
|
writeJson(releasePath, localRelease(version));
|
|
return { root, releasePath, cacheDirectory };
|
|
}
|
|
|
|
function stateDirectory(testFixture, version = '2.15.0') {
|
|
const partition = crypto.createHash('sha256').update(version).digest('hex').slice(0, 24);
|
|
return path.join(testFixture.cacheDirectory, `version-${partition}`);
|
|
}
|
|
|
|
function statePath(testFixture, version = '2.15.0') {
|
|
const directory = stateDirectory(testFixture, version);
|
|
let committed = [];
|
|
try {
|
|
committed = fs.readdirSync(directory)
|
|
.filter((name) => /^committed-\d+$/.test(name))
|
|
.sort((left, right) => {
|
|
const leftGeneration = BigInt(left.slice('committed-'.length));
|
|
const rightGeneration = BigInt(right.slice('committed-'.length));
|
|
return leftGeneration > rightGeneration ? -1 : 1;
|
|
});
|
|
} catch {
|
|
// A seed may be written before the cache partition exists.
|
|
}
|
|
return committed[0]
|
|
? path.join(directory, committed[0], 'state.json')
|
|
: path.join(operationPath(testFixture, 'committed', 1n, version), 'state.json');
|
|
}
|
|
|
|
function operationPath(testFixture, kind, generation, version = '2.15.0') {
|
|
return path.join(
|
|
stateDirectory(testFixture, version),
|
|
`${kind}-${BigInt(generation).toString().padStart(20, '0')}`,
|
|
);
|
|
}
|
|
|
|
function writePendingOperation(testFixture, {
|
|
generation = 1n,
|
|
owner = { pid: process.pid, token: 'e'.repeat(32) },
|
|
modifiedAt = new Date(),
|
|
} = {}) {
|
|
const directory = operationPath(testFixture, 'pending', generation);
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
const ownerPath = path.join(directory, 'owner.json');
|
|
fs.writeFileSync(ownerPath, typeof owner === 'string' ? owner : `${JSON.stringify(owner)}\n`);
|
|
fs.utimesSync(ownerPath, modifiedAt, modifiedAt);
|
|
return directory;
|
|
}
|
|
|
|
function pauseMkdirOnce(targetPath) {
|
|
const originalMkdir = fsPromises.mkdir;
|
|
let release;
|
|
let markReached;
|
|
let intercepted = false;
|
|
const reached = new Promise((resolve) => { markReached = resolve; });
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
fsPromises.mkdir = async (target, ...args) => {
|
|
if (!intercepted && path.resolve(target) === path.resolve(targetPath)) {
|
|
intercepted = true;
|
|
markReached();
|
|
await gate;
|
|
}
|
|
return originalMkdir(target, ...args);
|
|
};
|
|
return {
|
|
reached,
|
|
release,
|
|
restore() {
|
|
release();
|
|
fsPromises.mkdir = originalMkdir;
|
|
},
|
|
};
|
|
}
|
|
|
|
function pauseReaddirSnapshot(targetDirectory, callNumber) {
|
|
const originalReaddir = fsPromises.readdir;
|
|
let release;
|
|
let markReached;
|
|
let calls = 0;
|
|
const reached = new Promise((resolve) => { markReached = resolve; });
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
fsPromises.readdir = async (target, ...args) => {
|
|
const result = await originalReaddir(target, ...args);
|
|
if (path.resolve(target) === path.resolve(targetDirectory) && ++calls === callNumber) {
|
|
markReached();
|
|
await gate;
|
|
}
|
|
return result;
|
|
};
|
|
return {
|
|
reached,
|
|
release,
|
|
restore() {
|
|
release();
|
|
fsPromises.readdir = originalReaddir;
|
|
},
|
|
};
|
|
}
|
|
|
|
function pauseReaddirSnapshotOnce(targetDirectory, predicate) {
|
|
const originalReaddir = fsPromises.readdir;
|
|
let release;
|
|
let markReached;
|
|
let intercepted = false;
|
|
const reached = new Promise((resolve) => { markReached = resolve; });
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
fsPromises.readdir = async (target, ...args) => {
|
|
const result = await originalReaddir(target, ...args);
|
|
if (!intercepted
|
|
&& path.resolve(target) === path.resolve(targetDirectory)
|
|
&& predicate(result)) {
|
|
intercepted = true;
|
|
markReached();
|
|
await gate;
|
|
}
|
|
return result;
|
|
};
|
|
return {
|
|
reached,
|
|
release,
|
|
restore() {
|
|
release();
|
|
fsPromises.readdir = originalReaddir;
|
|
},
|
|
};
|
|
}
|
|
|
|
function pauseOpenOnce(predicate) {
|
|
const originalOpen = fsPromises.open;
|
|
let release;
|
|
let markReached;
|
|
let intercepted = false;
|
|
const reached = new Promise((resolve) => { markReached = resolve; });
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
fsPromises.open = async (target, ...args) => {
|
|
if (!intercepted && predicate(target)) {
|
|
intercepted = true;
|
|
markReached();
|
|
await gate;
|
|
}
|
|
return originalOpen(target, ...args);
|
|
};
|
|
return {
|
|
reached,
|
|
release,
|
|
restore() {
|
|
release();
|
|
fsPromises.open = originalOpen;
|
|
},
|
|
};
|
|
}
|
|
|
|
function pauseWriteOnce(basename) {
|
|
return pauseOpenOnce((target) => path.basename(target) === basename);
|
|
}
|
|
|
|
function pauseRenameMatchingOnce(predicate) {
|
|
const originalRename = fsPromises.rename;
|
|
let release;
|
|
let markReached;
|
|
let intercepted = false;
|
|
const reached = new Promise((resolve) => { markReached = resolve; });
|
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
fsPromises.rename = async (source, destination, ...args) => {
|
|
if (!intercepted && predicate(source, destination)) {
|
|
intercepted = true;
|
|
markReached();
|
|
await gate;
|
|
}
|
|
return originalRename(source, destination, ...args);
|
|
};
|
|
return {
|
|
reached,
|
|
release,
|
|
restore() {
|
|
release();
|
|
fsPromises.rename = originalRename;
|
|
},
|
|
};
|
|
}
|
|
|
|
function pauseRenameOnce(sourcePath, destinationPath) {
|
|
return pauseRenameMatchingOnce((source, destination) => (
|
|
path.resolve(source) === path.resolve(sourcePath)
|
|
&& path.resolve(destination) === path.resolve(destinationPath)
|
|
));
|
|
}
|
|
|
|
function options(testFixture, fetchImpl, overrides = {}) {
|
|
return {
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
fetchImpl,
|
|
now: () => baseTime,
|
|
random: () => 0.5,
|
|
timeoutMs: 50,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function cachedUpdateState() {
|
|
return {
|
|
schemaVersion: 1,
|
|
skillId: 'archify',
|
|
installedVersion: '2.15.0',
|
|
check: {
|
|
nextCheckAt: new Date(baseTime + (24 * 60 * 60 * 1_000)).toISOString(),
|
|
consecutiveFailures: 0,
|
|
},
|
|
notification: {
|
|
offeredDigests: [`sha256:${'b'.repeat(64)}`],
|
|
acknowledgedDigests: [],
|
|
},
|
|
candidate: {
|
|
version: '2.16.0',
|
|
targetDigest: `sha256:${'b'.repeat(64)}`,
|
|
severity: 'normal',
|
|
releaseNotes: 'https://github.com/tt-a1i/archify/releases/tag/v2.16.0',
|
|
},
|
|
};
|
|
}
|
|
|
|
function createFifo(target) {
|
|
const created = spawnSync('mkfifo', [target], { encoding: 'utf8' });
|
|
if (created.error?.code === 'ENOENT') return false;
|
|
assert.equal(created.status, 0, created.stderr || created.error?.message);
|
|
return true;
|
|
}
|
|
|
|
function runCheckInChild(testFixture) {
|
|
const manifest = remoteReleaseForVersion('2.15.0', 'd'.repeat(64));
|
|
const childSource = `
|
|
import { checkForUpdate } from ${JSON.stringify(pathToFileURL(checkerPath).href)};
|
|
const watchdog = setTimeout(() => {
|
|
process.stderr.write('child update check timed out\\n');
|
|
process.exit(124);
|
|
}, ${childCheckTimeoutMs});
|
|
let requests = 0;
|
|
try {
|
|
const result = await checkForUpdate({
|
|
releasePath: ${JSON.stringify(testFixture.releasePath)},
|
|
cacheDirectory: ${JSON.stringify(testFixture.cacheDirectory)},
|
|
fetchImpl: async () => {
|
|
requests += 1;
|
|
return new Response(${JSON.stringify(JSON.stringify(manifest))}, {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
},
|
|
now: () => ${baseTime},
|
|
random: () => 0.5,
|
|
timeoutMs: 50,
|
|
});
|
|
process.stdout.write(JSON.stringify({ result, requests }));
|
|
} finally {
|
|
clearTimeout(watchdog);
|
|
}
|
|
`;
|
|
const child = spawnSync(process.execPath, ['--input-type=module', '--eval', childSource], {
|
|
cwd: skillRoot,
|
|
encoding: 'utf8',
|
|
timeout: parentCheckTimeoutMs,
|
|
});
|
|
const diagnostics = [child.error?.stack, child.stderr, child.stdout].filter(Boolean).join('\n');
|
|
assert.equal(child.error, undefined, diagnostics);
|
|
assert.equal(child.status, 0, diagnostics);
|
|
return JSON.parse(child.stdout);
|
|
}
|
|
|
|
function assertUnsafeCacheStateIsIgnored(testFixture) {
|
|
assert.deepEqual(runCheckInChild(testFixture), {
|
|
result: { status: 'silent', reason: 'current' },
|
|
requests: 1,
|
|
});
|
|
}
|
|
|
|
test('production manifest URL is a fixed trusted GitHub Pages resource', () => {
|
|
assert.equal(DEFAULT_MANIFEST_URL, expectedManifestUrl);
|
|
const local = JSON.parse(fs.readFileSync(path.join(skillRoot, 'skill-release.json'), 'utf8'));
|
|
assert.equal(local.updateManifestUrl, expectedManifestUrl);
|
|
assert.equal(local.source.repository, expectedRepository);
|
|
});
|
|
|
|
test('SemVer comparison handles stable, prerelease, and downgrade ordering', () => {
|
|
assert.equal(compareSemver('2.16.0', '2.15.0'), 1);
|
|
assert.equal(compareSemver('2.16.0-dev.0', '2.15.0'), 1);
|
|
assert.equal(compareSemver('2.16.0', '2.16.0-dev.9'), 1);
|
|
assert.equal(compareSemver('2.16.0-dev.2', '2.16.0-dev.10'), -1);
|
|
assert.equal(compareSemver('2.16.0', '2.16.0'), 0);
|
|
assert.equal(compareSemver('2.16.0+build.9', '2.16.0+build.1'), 0);
|
|
assert.equal(compareSemver('9007199254740993.0.0', '9007199254740992.0.0'), 1);
|
|
assert.equal(compareSemver('2.16.0-dev.9007199254740993', '2.16.0-dev.9007199254740992'), 1);
|
|
assert.throws(() => compareSemver('2.16.0-dev.01', '2.16.0'));
|
|
});
|
|
|
|
test('development installs never treat the older stable release as an update', async (t) => {
|
|
const testFixture = fixture('2.16.0-dev.0');
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease({
|
|
version: '2.15.0',
|
|
source: {
|
|
repository: expectedRepository,
|
|
ref: 'v2.15.0',
|
|
treeSha: 'c'.repeat(40),
|
|
},
|
|
artifact: { sha256: 'd'.repeat(64) },
|
|
releaseNotes: 'https://github.com/tt-a1i/archify/releases/tag/v2.15.0',
|
|
})),
|
|
));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'current' });
|
|
});
|
|
|
|
test('a changed digest never bypasses same-version or downgrade protection', async () => {
|
|
for (const [version, digest] of [
|
|
['2.15.0', 'c'.repeat(64)],
|
|
['2.14.9', 'd'.repeat(64)],
|
|
]) {
|
|
const testFixture = fixture();
|
|
try {
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteReleaseForVersion(version, digest)),
|
|
));
|
|
assert.deepEqual(result, { status: 'silent', reason: 'current' });
|
|
} finally {
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
test('a successful refresh withdraws a previously offered higher candidate', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(requests === 1
|
|
? remoteReleaseForVersion('3.0.0', 'c'.repeat(64))
|
|
: remoteReleaseForVersion('2.15.0', 'd'.repeat(64)));
|
|
};
|
|
|
|
const offered = await checkForUpdate(options(testFixture, fetchImpl));
|
|
assert.equal(offered.status, 'update_available');
|
|
assert.equal(offered.latestVersion, '3.0.0');
|
|
|
|
const withdrawn = await checkForUpdate(options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}));
|
|
assert.deepEqual(withdrawn, { status: 'silent', reason: 'current' });
|
|
assert.equal(JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8')).candidate.version, '2.15.0');
|
|
assert.equal(requests, 2);
|
|
});
|
|
|
|
test('a newer immutable candidate is re-offered until the visible notice is acknowledged', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
};
|
|
|
|
const first = await checkForUpdate(options(testFixture, fetchImpl));
|
|
assert.equal(first.status, 'update_available');
|
|
assert.equal(first.installedVersion, '2.15.0');
|
|
assert.equal(first.latestVersion, '2.16.0');
|
|
assert.equal(first.eventKey, `archify@sha256:${'b'.repeat(64)}`);
|
|
assert.equal(first.targetDigest, `sha256:${'b'.repeat(64)}`);
|
|
assert.equal(Object.hasOwn(first, 'updateCommand'), false);
|
|
assert.equal(first.summary, 'Archify 2.16.0 is available; see the official release notes for details.');
|
|
const persisted = JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8'));
|
|
assert.deepEqual(Object.keys(persisted.check).sort(), [
|
|
'consecutiveFailures', 'nextCheckAt',
|
|
]);
|
|
assert.deepEqual(Object.keys(persisted.notification).sort(), [
|
|
'acknowledgedDigests', 'offeredDigests',
|
|
]);
|
|
assert.deepEqual(Object.keys(persisted.candidate).sort(), [
|
|
'releaseNotes', 'severity', 'targetDigest', 'version',
|
|
]);
|
|
|
|
const second = await checkForUpdate(options(testFixture, fetchImpl));
|
|
assert.equal(second.status, 'update_available');
|
|
assert.equal(second.eventKey, first.eventKey);
|
|
assert.equal(requests, 1, 'fresh cached candidates must not make another request');
|
|
|
|
const acknowledgement = await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: first.eventKey,
|
|
now: () => baseTime + 1_000,
|
|
});
|
|
assert.deepEqual(acknowledgement, { status: 'acknowledged', eventKey: first.eventKey });
|
|
assert.deepEqual(
|
|
JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8')).notification.acknowledgedDigests,
|
|
[first.targetDigest],
|
|
);
|
|
|
|
const third = await checkForUpdate(options(testFixture, fetchImpl));
|
|
assert.deepEqual(third, { status: 'silent', reason: 'already-notified' });
|
|
assert.equal(requests, 1);
|
|
});
|
|
|
|
test('an acknowledged candidate stays suppressed after a later candidate and manifest rollback', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const releases = [
|
|
remoteReleaseForVersion('2.16.0', 'b'.repeat(64)),
|
|
remoteReleaseForVersion('2.17.0', 'c'.repeat(64)),
|
|
remoteReleaseForVersion('2.16.0', 'b'.repeat(64)),
|
|
];
|
|
let requests = 0;
|
|
const fetchImpl = async () => response(releases[requests++]);
|
|
|
|
const first = await checkForUpdate(options(testFixture, fetchImpl));
|
|
assert.equal(first.status, 'update_available');
|
|
assert.deepEqual(await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: first.eventKey,
|
|
}), { status: 'acknowledged', eventKey: first.eventKey });
|
|
|
|
const second = await checkForUpdate(options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}));
|
|
assert.equal(second.status, 'update_available');
|
|
assert.deepEqual(await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: second.eventKey,
|
|
}), { status: 'acknowledged', eventKey: second.eventKey });
|
|
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (146 * 60 * 60 * 1_000),
|
|
})), { status: 'silent', reason: 'already-notified' });
|
|
assert.equal(requests, 3);
|
|
assert.deepEqual(
|
|
JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8')).notification.acknowledgedDigests,
|
|
[first.targetDigest, second.targetDigest],
|
|
);
|
|
});
|
|
|
|
test('a 64 KiB multi-offer cache only returns an event whose acknowledgement closure can commit', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const earlierDigest = `sha256:${'a'.repeat(64)}`;
|
|
const currentDigest = `sha256:${'b'.repeat(64)}`;
|
|
const replacementDigest = `sha256:${'c'.repeat(64)}`;
|
|
const cachedVersion = `3.16.${'9'.repeat(35)}`;
|
|
const state = cachedStateWithHistory({
|
|
offeredDigests: [earlierDigest, currentDigest],
|
|
acknowledgedDigests: Array.from({ length: 877 }, (_, index) => historyDigest(index)),
|
|
});
|
|
state.check.nextCheckAt = 'Mon, 31 Aug 2026 08:00:00 GMT';
|
|
state.candidate = {
|
|
version: cachedVersion,
|
|
targetDigest: currentDigest,
|
|
severity: 'normal',
|
|
releaseNotes: `https://github.com/tt-a1i/archify/releases/tag/v${cachedVersion}`,
|
|
};
|
|
assert.equal(Buffer.byteLength(compactStateSource(state)), maxCacheStateBytes);
|
|
writeCompactCommittedState(testFixture, state);
|
|
let requests = 0;
|
|
|
|
const offered = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteReleaseForVersion('2.16.0', 'c'.repeat(64)));
|
|
}));
|
|
assert.equal(offered.status, 'update_available');
|
|
const acknowledgement = await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: offered.eventKey,
|
|
});
|
|
|
|
assert.deepEqual(acknowledgement, {
|
|
status: 'acknowledged',
|
|
eventKey: offered.eventKey,
|
|
});
|
|
assert.equal(requests, 1, 'an unrecoverable cached offer must be rebuilt before exposure');
|
|
assert.equal(offered.targetDigest, replacementDigest);
|
|
});
|
|
|
|
test('a recoverable multi-offer boundary acknowledges every event without pruning history', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const earlierDigest = `sha256:${'a'.repeat(64)}`;
|
|
const currentDigest = `sha256:${'b'.repeat(64)}`;
|
|
const cachedVersion = `3.16.${'9'.repeat(35)}`;
|
|
const exactHistory = Array.from({ length: 877 }, (_, index) => historyDigest(index));
|
|
const state = cachedStateWithHistory({
|
|
offeredDigests: [earlierDigest, currentDigest],
|
|
acknowledgedDigests: exactHistory,
|
|
});
|
|
state.check.nextCheckAt = 'Mon, 31 Aug 2026 8:00:00 GMT';
|
|
state.candidate = {
|
|
version: cachedVersion,
|
|
targetDigest: currentDigest,
|
|
severity: 'normal',
|
|
releaseNotes: `https://github.com/tt-a1i/archify/releases/tag/v${cachedVersion}`,
|
|
};
|
|
assert.equal(Buffer.byteLength(compactStateSource(state)), maxCacheStateBytes - 1);
|
|
writeCompactCommittedState(testFixture, state);
|
|
|
|
const current = await checkForUpdate(options(testFixture, async () => {
|
|
throw new Error('a fresh recoverable cache must not use the network');
|
|
}));
|
|
assert.equal(current.targetDigest, currentDigest);
|
|
assert.deepEqual(await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: current.eventKey,
|
|
}), { status: 'acknowledged', eventKey: current.eventKey });
|
|
const earlierEventKey = `archify@${earlierDigest}`;
|
|
assert.deepEqual(await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: earlierEventKey,
|
|
}), { status: 'acknowledged', eventKey: earlierEventKey });
|
|
|
|
const persisted = JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8'));
|
|
assert.deepEqual(persisted.notification, {
|
|
offeredDigests: [],
|
|
acknowledgedDigests: [...exactHistory, currentDigest, earlierDigest],
|
|
});
|
|
assert.equal(fs.statSync(statePath(testFixture)).size, maxCacheStateBytes);
|
|
});
|
|
|
|
test('a near-capacity null schedule does not retry the network on every activation', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const state = cachedStateWithHistory({
|
|
acknowledgedDigests: Array.from({ length: 883 }, (_, index) => historyDigest(index)),
|
|
});
|
|
state.check.nextCheckAt = null;
|
|
assert.equal(Buffer.byteLength(compactStateSource(state)), 65_524);
|
|
writeCompactCommittedState(testFixture, state);
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
};
|
|
|
|
const first = await checkForUpdate(options(testFixture, fetchImpl));
|
|
const second = await checkForUpdate(options(testFixture, fetchImpl));
|
|
|
|
assert.equal(first.status, 'update_available');
|
|
assert.deepEqual(second, first);
|
|
assert.equal(requests, 1, 'the first check must commit a bounded retry or reusable candidate');
|
|
});
|
|
|
|
test('a saturated exact acknowledgement history never returns an unacknowledgeable offer', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const targetDigest = `sha256:${'f'.repeat(64)}`;
|
|
const state = cachedStateWithHistory();
|
|
let projected = candidateStateForDigest(state, targetDigest);
|
|
let index = 0;
|
|
while (Buffer.byteLength(compactStateSource(projected)) <= maxCacheStateBytes) {
|
|
state.notification.acknowledgedDigests.push(historyDigest(index));
|
|
index += 1;
|
|
projected = candidateStateForDigest(state, targetDigest);
|
|
}
|
|
assert.ok(Buffer.byteLength(compactStateSource(state)) <= maxCacheStateBytes);
|
|
assert.ok(Buffer.byteLength(compactStateSource(projected)) > maxCacheStateBytes);
|
|
const exactHistory = [...state.notification.acknowledgedDigests];
|
|
writeCompactCommittedState(testFixture, state);
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteReleaseForVersion('2.16.0', 'f'.repeat(64)));
|
|
}));
|
|
const acknowledgement = result.status === 'update_available'
|
|
? await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: result.eventKey,
|
|
})
|
|
: null;
|
|
|
|
assert.deepEqual({ result, acknowledgement }, {
|
|
result: { status: 'silent', reason: 'cache-unavailable' },
|
|
acknowledgement: null,
|
|
});
|
|
assert.equal(requests, 1);
|
|
assert.ok(committedStateFiles(testFixture).every(
|
|
(target) => fs.statSync(target).size <= maxCacheStateBytes,
|
|
));
|
|
assert.deepEqual(
|
|
JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8')).notification.acknowledgedDigests,
|
|
exactHistory,
|
|
);
|
|
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteReleaseForVersion('2.16.0', 'f'.repeat(64)));
|
|
})), { status: 'silent', reason: 'cache-valid' });
|
|
assert.equal(requests, 1, 'capacity rejection should commit a bounded retry delay');
|
|
});
|
|
|
|
test('capacity backoff withdraws a stale candidate while preserving its late acknowledgement', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const staleDigest = `sha256:${'b'.repeat(64)}`;
|
|
const replacementDigest = `sha256:${'c'.repeat(64)}`;
|
|
const state = cachedStateWithHistory({ offeredDigests: [staleDigest] });
|
|
state.candidate = {
|
|
version: '2.16.0',
|
|
targetDigest: staleDigest,
|
|
severity: 'normal',
|
|
releaseNotes: 'https://github.com/tt-a1i/archify/releases/tag/v2.16.0',
|
|
};
|
|
let replacement = candidateStateForDigest(state, replacementDigest);
|
|
let index = 0;
|
|
while (Buffer.byteLength(compactStateSource(replacement)) <= maxCacheStateBytes) {
|
|
state.notification.acknowledgedDigests.push(historyDigest(index));
|
|
index += 1;
|
|
replacement = candidateStateForDigest(state, replacementDigest);
|
|
}
|
|
assert.ok(Buffer.byteLength(compactStateSource(state)) <= maxCacheStateBytes);
|
|
assert.ok(Buffer.byteLength(compactStateSource(replacement)) > maxCacheStateBytes);
|
|
const exactHistory = [...state.notification.acknowledgedDigests];
|
|
writeCompactCommittedState(testFixture, state);
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteReleaseForVersion('2.16.0', 'c'.repeat(64)));
|
|
};
|
|
|
|
const refresh = await checkForUpdate(options(testFixture, fetchImpl));
|
|
const cached = await checkForUpdate(options(testFixture, fetchImpl));
|
|
const staleEventKey = `archify@${staleDigest}`;
|
|
const lateAcknowledgement = await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: staleEventKey,
|
|
});
|
|
|
|
assert.deepEqual({ refresh, cached, lateAcknowledgement }, {
|
|
refresh: { status: 'silent', reason: 'cache-unavailable' },
|
|
cached: { status: 'silent', reason: 'cache-valid' },
|
|
lateAcknowledgement: { status: 'acknowledged', eventKey: staleEventKey },
|
|
});
|
|
assert.equal(requests, 1);
|
|
assert.ok(committedStateFiles(testFixture).every(
|
|
(target) => fs.statSync(target).size <= maxCacheStateBytes,
|
|
));
|
|
const persisted = JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8'));
|
|
assert.equal(Object.hasOwn(persisted, 'candidate'), false);
|
|
assert.deepEqual(persisted.notification, {
|
|
offeredDigests: [],
|
|
acknowledgedDigests: [...exactHistory, staleDigest],
|
|
});
|
|
});
|
|
|
|
test('a 64 KiB state can be acknowledged but a 64 KiB plus one state is ignored', async () => {
|
|
const targetDigest = `sha256:${'e'.repeat(64)}`;
|
|
const exactState = cachedStateWithHistory({ offeredDigests: [targetDigest] });
|
|
exactState.candidate = {
|
|
version: '2.16.0',
|
|
targetDigest,
|
|
severity: 'normal',
|
|
releaseNotes: 'https://github.com/tt-a1i/archify/releases/tag/v2.16.0',
|
|
};
|
|
const initialBytes = Buffer.byteLength(compactStateSource(exactState));
|
|
exactState.check.nextCheckAt += 'x'.repeat(maxCacheStateBytes - initialBytes);
|
|
assert.equal(Buffer.byteLength(compactStateSource(exactState)), maxCacheStateBytes);
|
|
|
|
const exactFixture = fixture();
|
|
try {
|
|
writeCompactCommittedState(exactFixture, exactState);
|
|
const eventKey = `archify@${targetDigest}`;
|
|
assert.deepEqual(await acknowledgeUpdate({
|
|
releasePath: exactFixture.releasePath,
|
|
cacheDirectory: exactFixture.cacheDirectory,
|
|
eventKey,
|
|
}), { status: 'acknowledged', eventKey });
|
|
assert.equal(fs.statSync(statePath(exactFixture)).size, maxCacheStateBytes);
|
|
assert.deepEqual(
|
|
JSON.parse(fs.readFileSync(statePath(exactFixture), 'utf8')).notification,
|
|
{ offeredDigests: [], acknowledgedDigests: [targetDigest] },
|
|
);
|
|
} finally {
|
|
fs.rmSync(exactFixture.root, { recursive: true, force: true });
|
|
}
|
|
|
|
const oversizedFixture = fixture();
|
|
try {
|
|
const target = writeCompactCommittedState(oversizedFixture, exactState);
|
|
fs.appendFileSync(target, ' ');
|
|
assert.equal(fs.statSync(target).size, maxCacheStateBytes + 1);
|
|
assert.deepEqual(await acknowledgeUpdate({
|
|
releasePath: oversizedFixture.releasePath,
|
|
cacheDirectory: oversizedFixture.cacheDirectory,
|
|
eventKey: `archify@${targetDigest}`,
|
|
}), { status: 'silent', reason: 'invalid-acknowledgement' });
|
|
} finally {
|
|
fs.rmSync(oversizedFixture.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('a boundary offer remains acknowledgeable without pruning exact history', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const targetDigest = `sha256:${'d'.repeat(64)}`;
|
|
const state = cachedStateWithHistory();
|
|
let index = 0;
|
|
while (true) {
|
|
state.notification.acknowledgedDigests.push(historyDigest(index));
|
|
if (Buffer.byteLength(compactStateSource(
|
|
candidateStateForDigest(state, targetDigest),
|
|
)) > maxCacheStateBytes) {
|
|
state.notification.acknowledgedDigests.pop();
|
|
break;
|
|
}
|
|
index += 1;
|
|
}
|
|
const projected = candidateStateForDigest(state, targetDigest);
|
|
assert.ok(Buffer.byteLength(compactStateSource(projected)) <= maxCacheStateBytes);
|
|
const oneMoreAcknowledgement = cachedStateWithHistory({
|
|
acknowledgedDigests: [...state.notification.acknowledgedDigests, historyDigest(index)],
|
|
});
|
|
assert.ok(Buffer.byteLength(compactStateSource(
|
|
candidateStateForDigest(oneMoreAcknowledgement, targetDigest),
|
|
)) > maxCacheStateBytes);
|
|
const exactHistory = [...state.notification.acknowledgedDigests];
|
|
writeCompactCommittedState(testFixture, state);
|
|
const fetchImpl = async () => response(remoteReleaseForVersion('2.16.0', 'd'.repeat(64)));
|
|
|
|
const offered = await checkForUpdate(options(testFixture, fetchImpl));
|
|
assert.equal(offered.status, 'update_available');
|
|
assert.deepEqual(await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: offered.eventKey,
|
|
}), { status: 'acknowledged', eventKey: offered.eventKey });
|
|
|
|
assert.ok(committedStateFiles(testFixture).every(
|
|
(target) => fs.statSync(target).size <= maxCacheStateBytes,
|
|
));
|
|
assert.deepEqual(
|
|
JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8')).notification,
|
|
{
|
|
offeredDigests: [],
|
|
acknowledgedDigests: [...exactHistory, targetDigest],
|
|
},
|
|
);
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, fetchImpl)), {
|
|
status: 'silent',
|
|
reason: 'already-notified',
|
|
});
|
|
});
|
|
|
|
test('opaque response validators are neither persisted nor replayed after the check TTL', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
let requests = 0;
|
|
const fetchImpl = async (_url, init) => {
|
|
requests += 1;
|
|
assert.equal(init.headers['if-none-match'], undefined);
|
|
return response(remoteRelease(), { etag: `"per-client-${requests}"` });
|
|
};
|
|
|
|
const first = await checkForUpdate(options(testFixture, fetchImpl));
|
|
assert.equal(first.status, 'update_available');
|
|
|
|
const second = await checkForUpdate(options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}));
|
|
assert.equal(second.status, 'update_available');
|
|
assert.equal(second.eventKey, first.eventKey);
|
|
assert.equal(requests, 2);
|
|
assert.equal(Object.hasOwn(JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8')).check, 'etag'), false);
|
|
});
|
|
|
|
test('an HTTP 304 is always a failed unconditional refresh', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
if (requests === 1) {
|
|
return new Response(JSON.stringify(remoteRelease()), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(null, { status: 304 });
|
|
};
|
|
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl))).status, 'update_available');
|
|
const refresh = await checkForUpdate(options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}));
|
|
|
|
assert.deepEqual(refresh, { status: 'silent', reason: 'check-failed' });
|
|
assert.equal(requests, 2);
|
|
});
|
|
|
|
test('a failed refresh preserves the last-good unacknowledged candidate', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
if (requests === 1) return response(remoteRelease());
|
|
throw new Error('offline');
|
|
};
|
|
|
|
const first = await checkForUpdate(options(testFixture, fetchImpl));
|
|
assert.equal(first.status, 'update_available');
|
|
const failed = await checkForUpdate(options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}));
|
|
assert.deepEqual(failed, { status: 'silent', reason: 'check-failed' });
|
|
|
|
const cached = await checkForUpdate(options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (74 * 60 * 60 * 1_000),
|
|
}));
|
|
assert.equal(cached.status, 'update_available');
|
|
assert.equal(cached.eventKey, first.eventKey);
|
|
assert.equal(requests, 2);
|
|
});
|
|
|
|
test('failure backoff saturates safely instead of overflowing the cache counter', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
writeJson(statePath(testFixture), {
|
|
schemaVersion: 1,
|
|
skillId: 'archify',
|
|
installedVersion: '2.15.0',
|
|
check: {
|
|
nextCheckAt: new Date(baseTime - 1_000).toISOString(),
|
|
consecutiveFailures: Number.MAX_SAFE_INTEGER,
|
|
},
|
|
notification: {
|
|
offeredDigests: [],
|
|
acknowledgedDigests: [],
|
|
},
|
|
});
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
throw new Error('offline');
|
|
};
|
|
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, fetchImpl)), {
|
|
status: 'silent',
|
|
reason: 'check-failed',
|
|
});
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (60 * 60 * 1_000),
|
|
})), { status: 'silent', reason: 'cache-valid' });
|
|
assert.equal(requests, 1);
|
|
assert.equal(JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8')).check.consecutiveFailures, 2);
|
|
});
|
|
|
|
test('a committed cache FIFO is ignored without blocking the update check', (t) => {
|
|
if (process.platform === 'win32') {
|
|
t.skip('FIFO files are unavailable on Windows');
|
|
return;
|
|
}
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const cacheStatePath = statePath(testFixture);
|
|
fs.mkdirSync(path.dirname(cacheStatePath), { recursive: true });
|
|
if (!createFifo(cacheStatePath)) {
|
|
t.skip('mkfifo is unavailable');
|
|
return;
|
|
}
|
|
|
|
assertUnsafeCacheStateIsIgnored(testFixture);
|
|
});
|
|
|
|
test('a committed cache state symlink is ignored without following its target', (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const cacheStatePath = statePath(testFixture);
|
|
const linkedTarget = path.join(testFixture.root, 'linked-state-target');
|
|
fs.mkdirSync(path.dirname(cacheStatePath), { recursive: true });
|
|
const targetIsFifo = process.platform !== 'win32' && createFifo(linkedTarget);
|
|
if (!targetIsFifo) writeJson(linkedTarget, cachedUpdateState());
|
|
fs.symlinkSync(linkedTarget, cacheStatePath, process.platform === 'win32' ? 'file' : undefined);
|
|
|
|
assertUnsafeCacheStateIsIgnored(testFixture);
|
|
});
|
|
|
|
test('an oversized committed cache state is ignored without an unbounded read', (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const oversizedState = `${JSON.stringify(cachedUpdateState())}${' '.repeat(256 * 1_024)}`;
|
|
const cacheStatePath = statePath(testFixture);
|
|
fs.mkdirSync(path.dirname(cacheStatePath), { recursive: true });
|
|
fs.writeFileSync(cacheStatePath, oversizedState);
|
|
|
|
assertUnsafeCacheStateIsIgnored(testFixture);
|
|
});
|
|
|
|
test('a cache ancestor replaced before a cached read cannot inject a reminder', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalReaddir = fsPromises.readdir;
|
|
t.after(() => {
|
|
fsPromises.readdir = originalReaddir;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
writeCompactCommittedState(testFixture, cachedStateWithHistory());
|
|
const replacementRoot = path.join(testFixture.root, 'replacement-cache');
|
|
writeJson(
|
|
path.join(replacementRoot, path.basename(stateDirectory(testFixture)),
|
|
path.basename(operationPath(testFixture, 'committed', 1n)), 'state.json'),
|
|
cachedUpdateState(),
|
|
);
|
|
const detachedCache = path.join(testFixture.root, 'detached-cache');
|
|
const probe = path.join(testFixture.root, 'symlink-probe');
|
|
try {
|
|
fs.symlinkSync(
|
|
replacementRoot,
|
|
probe,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
fs.unlinkSync(probe);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`directory symlinks are unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
let replaced = false;
|
|
fsPromises.readdir = async (target, ...args) => {
|
|
if (!replaced && path.resolve(target) === path.resolve(stateDirectory(testFixture))) {
|
|
replaced = true;
|
|
fs.renameSync(testFixture.cacheDirectory, detachedCache);
|
|
fs.symlinkSync(
|
|
replacementRoot,
|
|
testFixture.cacheDirectory,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
}
|
|
return originalReaddir(target, ...args);
|
|
};
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(replaced, true);
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 0);
|
|
});
|
|
|
|
test('a committed directory replaced by a symlink cannot inject a reminder', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalReaddir = fsPromises.readdir;
|
|
t.after(() => {
|
|
fsPromises.readdir = originalReaddir;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
writeCompactCommittedState(testFixture, cachedStateWithHistory());
|
|
const committed = operationPath(testFixture, 'committed', 1n);
|
|
const replacement = path.join(testFixture.root, 'replacement-committed');
|
|
writeJson(path.join(replacement, 'state.json'), cachedUpdateState());
|
|
const probe = path.join(testFixture.root, 'nested-symlink-probe');
|
|
try {
|
|
fs.symlinkSync(
|
|
replacement,
|
|
probe,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
fs.unlinkSync(probe);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`directory symlinks are unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
const detached = path.join(stateDirectory(testFixture), 'detached-committed');
|
|
let replaced = false;
|
|
fsPromises.readdir = async (target, ...args) => {
|
|
const entries = await originalReaddir(target, ...args);
|
|
if (!replaced && path.resolve(target) === path.resolve(stateDirectory(testFixture))) {
|
|
replaced = true;
|
|
fs.renameSync(committed, detached);
|
|
fs.symlinkSync(
|
|
replacement,
|
|
committed,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
}
|
|
return entries;
|
|
};
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(replaced, true);
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 0);
|
|
});
|
|
|
|
test('a pending directory replaced by a symlink cannot be ignored as inactive', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalReaddir = fsPromises.readdir;
|
|
t.after(() => {
|
|
fsPromises.readdir = originalReaddir;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
const pending = writePendingOperation(testFixture);
|
|
const replacement = path.join(testFixture.root, 'replacement-pending');
|
|
writePendingOperation({
|
|
...testFixture,
|
|
cacheDirectory: path.join(testFixture.root, 'replacement-cache'),
|
|
}, { modifiedAt: new Date(Date.now() - 60_000) });
|
|
const replacementPending = operationPath({
|
|
...testFixture,
|
|
cacheDirectory: path.join(testFixture.root, 'replacement-cache'),
|
|
}, 'pending', 1n);
|
|
fs.renameSync(replacementPending, replacement);
|
|
const probe = path.join(testFixture.root, 'pending-symlink-probe');
|
|
try {
|
|
fs.symlinkSync(
|
|
replacement,
|
|
probe,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
fs.unlinkSync(probe);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`directory symlinks are unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
const detached = path.join(stateDirectory(testFixture), 'detached-pending');
|
|
let replaced = false;
|
|
fsPromises.readdir = async (target, ...args) => {
|
|
const entries = await originalReaddir(target, ...args);
|
|
if (!replaced && path.resolve(target) === path.resolve(stateDirectory(testFixture))) {
|
|
replaced = true;
|
|
fs.renameSync(pending, detached);
|
|
fs.symlinkSync(
|
|
replacement,
|
|
pending,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
}
|
|
return entries;
|
|
};
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(replaced, true);
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 0);
|
|
});
|
|
|
|
test('zero-inode cache metadata uses timestamp fallback to reject a replaced ancestor', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalLstat = fsPromises.lstat;
|
|
const originalReaddir = fsPromises.readdir;
|
|
t.after(() => {
|
|
fsPromises.lstat = originalLstat;
|
|
fsPromises.readdir = originalReaddir;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
writeCompactCommittedState(testFixture, cachedStateWithHistory());
|
|
const replacementRoot = path.join(testFixture.root, 'replacement-cache');
|
|
writeJson(
|
|
path.join(replacementRoot, path.basename(stateDirectory(testFixture)),
|
|
path.basename(operationPath(testFixture, 'committed', 1n)), 'state.json'),
|
|
cachedUpdateState(),
|
|
);
|
|
const detachedCache = path.join(testFixture.root, 'detached-cache');
|
|
fsPromises.lstat = async (target, ...args) => {
|
|
const metadata = await originalLstat(target, ...args);
|
|
const resolved = path.resolve(target);
|
|
const isCachePath = resolved === path.resolve(testFixture.cacheDirectory)
|
|
|| resolved.startsWith(`${path.resolve(testFixture.cacheDirectory)}${path.sep}`);
|
|
if (args[0]?.bigint && isCachePath && metadata.isDirectory()) {
|
|
return metadataWithOverrides(metadata, {
|
|
ino: 0n,
|
|
birthtimeNs: replaced ? 2_000n : 1_000n,
|
|
});
|
|
}
|
|
return metadata;
|
|
};
|
|
let replaced = false;
|
|
fsPromises.readdir = async (target, ...args) => {
|
|
if (!replaced && path.resolve(target) === path.resolve(stateDirectory(testFixture))) {
|
|
replaced = true;
|
|
fs.renameSync(testFixture.cacheDirectory, detachedCache);
|
|
fs.renameSync(replacementRoot, testFixture.cacheDirectory);
|
|
}
|
|
return originalReaddir(target, ...args);
|
|
};
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(replaced, true);
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 0);
|
|
});
|
|
|
|
test('zero-inode cache metadata still supports a normal check and acknowledgement', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalLstat = fsPromises.lstat;
|
|
const originalOpen = fsPromises.open;
|
|
t.after(() => {
|
|
fsPromises.lstat = originalLstat;
|
|
fsPromises.open = originalOpen;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
const cacheRoot = path.resolve(testFixture.cacheDirectory);
|
|
const isCachePath = (target) => {
|
|
const resolved = path.resolve(target);
|
|
return resolved === cacheRoot || resolved.startsWith(`${cacheRoot}${path.sep}`);
|
|
};
|
|
fsPromises.lstat = async (target, ...args) => {
|
|
const metadata = await originalLstat(target, ...args);
|
|
if (args[0]?.bigint && isCachePath(target)) {
|
|
return metadataWithOverrides(metadata, { ino: 0n, birthtimeNs: 1_000n });
|
|
}
|
|
return metadata;
|
|
};
|
|
fsPromises.open = async (target, ...args) => {
|
|
const handle = await originalOpen(target, ...args);
|
|
if (!isCachePath(target)) return handle;
|
|
return new Proxy(handle, {
|
|
get(fileHandle, property) {
|
|
if (property === 'stat') {
|
|
return async (...statArguments) => {
|
|
const metadata = await fileHandle.stat(...statArguments);
|
|
return statArguments[0]?.bigint
|
|
? metadataWithOverrides(metadata, { ino: 0n, birthtimeNs: 1_000n })
|
|
: metadataWithOverrides(metadata, { ino: 0 });
|
|
};
|
|
}
|
|
const value = Reflect.get(fileHandle, property, fileHandle);
|
|
return typeof value === 'function' ? value.bind(fileHandle) : value;
|
|
},
|
|
});
|
|
};
|
|
let requests = 0;
|
|
|
|
const offered = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
const acknowledged = await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: offered.eventKey,
|
|
});
|
|
|
|
assert.equal(offered.status, 'update_available');
|
|
assert.deepEqual(acknowledged, { status: 'acknowledged', eventKey: offered.eventKey });
|
|
assert.equal(requests, 1);
|
|
});
|
|
|
|
test('zero-inode cache metadata without birthtime fails closed', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalLstat = fsPromises.lstat;
|
|
t.after(() => {
|
|
fsPromises.lstat = originalLstat;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
const cacheRoot = path.resolve(testFixture.cacheDirectory);
|
|
fsPromises.lstat = async (target, ...args) => {
|
|
const metadata = await originalLstat(target, ...args);
|
|
const resolved = path.resolve(target);
|
|
if (args[0]?.bigint
|
|
&& (resolved === cacheRoot || resolved.startsWith(`${cacheRoot}${path.sep}`))) {
|
|
return metadataWithOverrides(metadata, { ino: 0n, birthtimeNs: 0n });
|
|
}
|
|
return metadata;
|
|
};
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 0);
|
|
});
|
|
|
|
test('a state write replaced before post-verification is never reported as committed', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalOpen = fsPromises.open;
|
|
const originalWriteFile = fsPromises.writeFile;
|
|
t.after(() => {
|
|
fsPromises.open = originalOpen;
|
|
fsPromises.writeFile = originalWriteFile;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
let replaced = false;
|
|
const replaceStateFile = (target) => {
|
|
if (replaced) return;
|
|
replaced = true;
|
|
fs.renameSync(target, `${target}.written-by-checker`);
|
|
fs.writeFileSync(target, '{}\n', { flag: 'wx', mode: 0o600 });
|
|
};
|
|
fsPromises.writeFile = async (target, ...args) => {
|
|
const result = await originalWriteFile(target, ...args);
|
|
if (path.basename(target).startsWith('.state.')) replaceStateFile(target);
|
|
return result;
|
|
};
|
|
fsPromises.open = async (target, ...args) => {
|
|
const handle = await originalOpen(target, ...args);
|
|
if (!path.basename(target).startsWith('.state.')) return handle;
|
|
return {
|
|
stat: handle.stat.bind(handle),
|
|
writeFile: handle.writeFile.bind(handle),
|
|
close: async () => {
|
|
const result = await handle.close();
|
|
replaceStateFile(target);
|
|
return result;
|
|
},
|
|
};
|
|
};
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => response(remoteRelease())));
|
|
|
|
assert.equal(replaced, true);
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
for (const committed of committedStateFiles(testFixture)) {
|
|
assert.notDeepEqual(JSON.parse(fs.readFileSync(committed, 'utf8')), {});
|
|
}
|
|
});
|
|
|
|
test('a pending directory replaced by a symlink cannot be reported as committed', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalRename = fsPromises.rename;
|
|
t.after(() => {
|
|
fsPromises.rename = originalRename;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
const probe = path.join(testFixture.root, 'symlink-probe');
|
|
try {
|
|
fs.mkdirSync(probe);
|
|
const probeLink = `${probe}-link`;
|
|
fs.symlinkSync(probe, probeLink, process.platform === 'win32' ? 'junction' : 'dir');
|
|
fs.unlinkSync(probeLink);
|
|
fs.rmdirSync(probe);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`directory symlinks are unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
const detachedPending = path.join(testFixture.root, 'detached-pending');
|
|
let pending = null;
|
|
let replaced = false;
|
|
fsPromises.rename = async (source, destination, ...args) => {
|
|
if (!pending
|
|
&& path.basename(source).startsWith('.state.')
|
|
&& path.basename(destination) === 'state.json') {
|
|
const result = await originalRename(source, destination, ...args);
|
|
pending = path.dirname(destination);
|
|
return result;
|
|
}
|
|
if (!replaced
|
|
&& pending
|
|
&& path.resolve(source) === path.resolve(pending)
|
|
&& path.basename(destination).startsWith('committed-')) {
|
|
replaced = true;
|
|
fs.renameSync(pending, detachedPending);
|
|
fs.symlinkSync(
|
|
detachedPending,
|
|
pending,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
}
|
|
return originalRename(source, destination, ...args);
|
|
};
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(replaced, true);
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 1);
|
|
assert.equal(fs.existsSync(path.join(detachedPending, 'state.json')), true);
|
|
});
|
|
|
|
test('a cache trust failure while cancelling cannot return a cached reminder', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalRename = fsPromises.rename;
|
|
t.after(() => {
|
|
fsPromises.rename = originalRename;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
const stale = cachedUpdateState();
|
|
stale.check.nextCheckAt = new Date(baseTime - 1_000).toISOString();
|
|
const committed = writeCompactCommittedState(testFixture, stale);
|
|
const detachedCache = path.join(testFixture.root, 'detached-cache');
|
|
let refreshed = false;
|
|
let replaced = false;
|
|
fsPromises.rename = async (source, destination, ...args) => {
|
|
const sourceName = path.basename(source);
|
|
const destinationName = path.basename(destination);
|
|
if (!refreshed && sourceName.startsWith('claim-') && destinationName === 'active-claim') {
|
|
const result = await originalRename(source, destination, ...args);
|
|
const fresh = cachedUpdateState();
|
|
writeJson(committed, fresh);
|
|
refreshed = true;
|
|
return result;
|
|
}
|
|
if (!replaced && sourceName.startsWith('pending-') && destinationName.startsWith('cancelled-')) {
|
|
replaced = true;
|
|
fs.renameSync(testFixture.cacheDirectory, detachedCache);
|
|
fs.symlinkSync(
|
|
detachedCache,
|
|
testFixture.cacheDirectory,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
}
|
|
return originalRename(source, destination, ...args);
|
|
};
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(refreshed, true);
|
|
assert.equal(replaced, true);
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 0);
|
|
});
|
|
|
|
test('prepared claim cleanup never recursively deletes through a replaced cache ancestor', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalOpen = fsPromises.open;
|
|
const originalRm = fsPromises.rm;
|
|
const originalWriteFile = fsPromises.writeFile;
|
|
t.after(() => {
|
|
fsPromises.open = originalOpen;
|
|
fsPromises.rm = originalRm;
|
|
fsPromises.writeFile = originalWriteFile;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
const activeClaim = path.join(stateDirectory(testFixture), 'active-claim');
|
|
let injectedActiveClaim = false;
|
|
const injectActiveClaim = () => {
|
|
if (injectedActiveClaim) return;
|
|
injectedActiveClaim = true;
|
|
fs.mkdirSync(activeClaim);
|
|
};
|
|
fsPromises.writeFile = async (target, ...args) => {
|
|
const result = await originalWriteFile(target, ...args);
|
|
if (path.basename(path.dirname(target)).startsWith('claim-')
|
|
&& path.basename(target) === 'owner.json') injectActiveClaim();
|
|
return result;
|
|
};
|
|
fsPromises.open = async (target, ...args) => {
|
|
const handle = await originalOpen(target, ...args);
|
|
if (path.basename(path.dirname(target)).startsWith('claim-')
|
|
&& path.basename(target) === 'owner.json') {
|
|
return {
|
|
stat: handle.stat.bind(handle),
|
|
writeFile: handle.writeFile.bind(handle),
|
|
close: async () => {
|
|
const result = await handle.close();
|
|
injectActiveClaim();
|
|
return result;
|
|
},
|
|
};
|
|
}
|
|
return handle;
|
|
};
|
|
const victimRoot = path.join(testFixture.root, 'victim-cache');
|
|
const victimClaim = path.join(
|
|
victimRoot,
|
|
path.basename(stateDirectory(testFixture)),
|
|
path.basename(operationPath(testFixture, 'claim', 1n)),
|
|
);
|
|
const victimFile = path.join(victimClaim, 'important.txt');
|
|
fs.mkdirSync(victimClaim, { recursive: true });
|
|
fs.writeFileSync(victimFile, 'must survive\n');
|
|
const detachedCache = path.join(testFixture.root, 'detached-cache');
|
|
let interceptedRemoval = false;
|
|
fsPromises.rm = async (target, ...args) => {
|
|
if (!interceptedRemoval && path.basename(target).startsWith('claim-')) {
|
|
interceptedRemoval = true;
|
|
fs.renameSync(testFixture.cacheDirectory, detachedCache);
|
|
fs.symlinkSync(
|
|
victimRoot,
|
|
testFixture.cacheDirectory,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
}
|
|
return originalRm(target, ...args);
|
|
};
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(injectedActiveClaim, true);
|
|
assert.equal(requests, 0);
|
|
assert.equal(result.status, 'silent');
|
|
assert.equal(interceptedRemoval, false);
|
|
assert.equal(fs.readFileSync(victimFile, 'utf8'), 'must survive\n');
|
|
const coordinationEntries = fs.readdirSync(stateDirectory(testFixture));
|
|
assert.deepEqual(
|
|
coordinationEntries.filter((name) => name.startsWith('claim-')),
|
|
[],
|
|
);
|
|
const discardedClaims = coordinationEntries
|
|
.filter((name) => name.startsWith('discarded-claim-'));
|
|
assert.equal(discardedClaims.length, 1);
|
|
const discardedOwnerPath = path.join(
|
|
stateDirectory(testFixture),
|
|
discardedClaims[0],
|
|
'owner.json',
|
|
);
|
|
const discardedOwner = JSON.parse(fs.readFileSync(discardedOwnerPath, 'utf8'));
|
|
assert.deepEqual(Object.keys(discardedOwner).sort(), ['generation', 'pid', 'token']);
|
|
assert.equal(discardedOwner.generation, '1');
|
|
assert.match(discardedOwner.token, /^[a-f0-9]{32}$/);
|
|
assert.equal(discardedClaims[0].endsWith(`-${discardedOwner.token}`), true);
|
|
});
|
|
|
|
test('a symlink cache root cannot write into its target', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const protectedTarget = path.join(testFixture.root, 'simulated-project-config');
|
|
fs.mkdirSync(protectedTarget);
|
|
fs.writeFileSync(path.join(protectedTarget, 'settings.json'), '{"protected":true}\n');
|
|
try {
|
|
fs.symlinkSync(
|
|
protectedTarget,
|
|
testFixture.cacheDirectory,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`directory symlinks are unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 0);
|
|
assert.deepEqual(fs.readdirSync(protectedTarget), ['settings.json']);
|
|
});
|
|
|
|
test('a symlink cache ancestor cannot redirect cache writes', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const protectedTarget = path.join(testFixture.root, 'simulated-project-config');
|
|
const linkedAncestor = path.join(testFixture.root, 'cache-parent');
|
|
const cacheDirectory = path.join(linkedAncestor, 'nested', 'cache');
|
|
fs.mkdirSync(protectedTarget);
|
|
fs.writeFileSync(path.join(protectedTarget, 'settings.json'), '{"protected":true}\n');
|
|
try {
|
|
fs.symlinkSync(
|
|
protectedTarget,
|
|
linkedAncestor,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`directory symlinks are unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}, { cacheDirectory }));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 0);
|
|
assert.deepEqual(fs.readdirSync(protectedTarget), ['settings.json']);
|
|
});
|
|
|
|
test('a cache ancestor replaced during preparation cannot redirect later writes', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalLstat = fsPromises.lstat;
|
|
t.after(() => {
|
|
fsPromises.lstat = originalLstat;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
const protectedTarget = path.join(testFixture.root, 'simulated-project-config');
|
|
const protectedVersion = path.join(protectedTarget, path.basename(stateDirectory(testFixture)));
|
|
const detachedCache = path.join(testFixture.root, 'detached-cache');
|
|
const probe = path.join(testFixture.root, 'symlink-probe');
|
|
fs.mkdirSync(testFixture.cacheDirectory);
|
|
fs.mkdirSync(protectedVersion, { recursive: true });
|
|
fs.writeFileSync(path.join(protectedVersion, 'settings.json'), '{"protected":true}\n');
|
|
try {
|
|
fs.symlinkSync(
|
|
protectedTarget,
|
|
probe,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
fs.unlinkSync(probe);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`directory symlinks are unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
let replaced = false;
|
|
const versionDirectoryName = path.basename(stateDirectory(testFixture));
|
|
fsPromises.lstat = async (target, ...args) => {
|
|
if (!replaced && path.basename(target) === versionDirectoryName) {
|
|
replaced = true;
|
|
fs.renameSync(testFixture.cacheDirectory, detachedCache);
|
|
fs.symlinkSync(
|
|
protectedTarget,
|
|
testFixture.cacheDirectory,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
}
|
|
return originalLstat(target, ...args);
|
|
};
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(replaced, true);
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 0);
|
|
assert.deepEqual(fs.readdirSync(protectedVersion), ['settings.json']);
|
|
});
|
|
|
|
test('a cache ancestor replaced before reservation creation fails closed', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalMkdir = fsPromises.mkdir;
|
|
t.after(() => {
|
|
fsPromises.mkdir = originalMkdir;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
const replacementRoot = path.join(testFixture.root, 'replacement-cache');
|
|
const replacementVersion = path.join(
|
|
replacementRoot,
|
|
path.basename(stateDirectory(testFixture)),
|
|
);
|
|
const detachedCache = path.join(testFixture.root, 'detached-cache');
|
|
const probe = path.join(testFixture.root, 'symlink-probe');
|
|
fs.mkdirSync(replacementVersion, { recursive: true });
|
|
fs.writeFileSync(path.join(replacementVersion, 'settings.json'), '{"protected":true}\n');
|
|
try {
|
|
fs.symlinkSync(
|
|
replacementRoot,
|
|
probe,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
fs.unlinkSync(probe);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`directory symlinks are unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
let replaced = false;
|
|
fsPromises.mkdir = async (target, ...args) => {
|
|
if (!replaced && path.basename(target).startsWith('reserved-')) {
|
|
replaced = true;
|
|
fs.renameSync(testFixture.cacheDirectory, detachedCache);
|
|
fs.symlinkSync(
|
|
replacementRoot,
|
|
testFixture.cacheDirectory,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
}
|
|
return originalMkdir(target, ...args);
|
|
};
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(replaced, true);
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 0);
|
|
const replacementEntries = fs.readdirSync(replacementVersion).sort();
|
|
const reservations = replacementEntries.filter((entry) => entry.startsWith('reserved-'));
|
|
assert.equal(reservations.length, 1);
|
|
assert.deepEqual(
|
|
replacementEntries.filter((entry) => !entry.startsWith('reserved-')),
|
|
['settings.json'],
|
|
);
|
|
assert.deepEqual(fs.readdirSync(path.join(replacementVersion, reservations[0])), []);
|
|
});
|
|
|
|
test('a cache ancestor replacement restored after reservation creation fails closed', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalMkdir = fsPromises.mkdir;
|
|
t.after(() => {
|
|
fsPromises.mkdir = originalMkdir;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
const replacementRoot = path.join(testFixture.root, 'replacement-cache');
|
|
const replacementVersion = path.join(
|
|
replacementRoot,
|
|
path.basename(stateDirectory(testFixture)),
|
|
);
|
|
const detachedCache = path.join(testFixture.root, 'detached-cache');
|
|
const probe = path.join(testFixture.root, 'symlink-probe');
|
|
fs.mkdirSync(replacementVersion, { recursive: true });
|
|
fs.writeFileSync(path.join(replacementVersion, 'settings.json'), '{"protected":true}\n');
|
|
try {
|
|
fs.symlinkSync(
|
|
replacementRoot,
|
|
probe,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
fs.unlinkSync(probe);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`directory symlinks are unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
let restored = false;
|
|
fsPromises.mkdir = async (target, ...args) => {
|
|
if (!restored && path.basename(target).startsWith('reserved-')) {
|
|
fs.renameSync(testFixture.cacheDirectory, detachedCache);
|
|
fs.symlinkSync(
|
|
replacementRoot,
|
|
testFixture.cacheDirectory,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
try {
|
|
return await originalMkdir(target, ...args);
|
|
} finally {
|
|
fs.unlinkSync(testFixture.cacheDirectory);
|
|
fs.renameSync(detachedCache, testFixture.cacheDirectory);
|
|
restored = true;
|
|
}
|
|
}
|
|
return originalMkdir(target, ...args);
|
|
};
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(restored, true);
|
|
assert.deepEqual(result, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 0);
|
|
const replacementEntries = fs.readdirSync(replacementVersion).sort();
|
|
const reservations = replacementEntries.filter((entry) => entry.startsWith('reserved-'));
|
|
assert.equal(reservations.length, 1);
|
|
assert.deepEqual(
|
|
replacementEntries.filter((entry) => !entry.startsWith('reserved-')),
|
|
['settings.json'],
|
|
);
|
|
assert.deepEqual(fs.readdirSync(path.join(replacementVersion, reservations[0])), []);
|
|
});
|
|
|
|
test('a trusted cache prefix switched after validation cannot redirect later writes', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalHomedir = os.homedir;
|
|
const originalLstat = fsPromises.lstat;
|
|
t.after(() => {
|
|
os.homedir = originalHomedir;
|
|
fsPromises.lstat = originalLstat;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
const trustedAlias = path.join(testFixture.root, 'trusted-home');
|
|
const canonicalHome = path.join(testFixture.root, 'canonical-home');
|
|
const protectedTarget = path.join(testFixture.root, 'simulated-project-config');
|
|
const cacheDirectory = path.join(trustedAlias, 'cache');
|
|
fs.mkdirSync(canonicalHome);
|
|
fs.mkdirSync(protectedTarget);
|
|
fs.writeFileSync(path.join(protectedTarget, 'settings.json'), '{"protected":true}\n');
|
|
try {
|
|
fs.symlinkSync(
|
|
canonicalHome,
|
|
trustedAlias,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`directory symlinks are unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
os.homedir = () => trustedAlias;
|
|
const canonicalVersionDirectory = path.join(
|
|
fs.realpathSync(canonicalHome),
|
|
'cache',
|
|
path.basename(stateDirectory(testFixture)),
|
|
);
|
|
let targetVisits = 0;
|
|
let switched = false;
|
|
fsPromises.lstat = async (target, ...args) => {
|
|
const metadata = await originalLstat(target, ...args);
|
|
if (!switched && path.resolve(target) === canonicalVersionDirectory) {
|
|
targetVisits += 1;
|
|
if (targetVisits === 2) {
|
|
switched = true;
|
|
fs.unlinkSync(trustedAlias);
|
|
fs.symlinkSync(
|
|
protectedTarget,
|
|
trustedAlias,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
);
|
|
}
|
|
}
|
|
return metadata;
|
|
};
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => response(remoteRelease()), {
|
|
cacheDirectory,
|
|
}));
|
|
|
|
assert.equal(switched, true);
|
|
assert.equal(result.status, 'update_available');
|
|
assert.deepEqual(fs.readdirSync(protectedTarget), ['settings.json']);
|
|
assert.ok(fs.readdirSync(canonicalVersionDirectory).some(
|
|
(entry) => entry.startsWith('committed-'),
|
|
));
|
|
});
|
|
|
|
test('concurrent checks use one writer and leave a valid cache', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
let releaseFetch;
|
|
let markStarted;
|
|
const started = new Promise((resolve) => { markStarted = resolve; });
|
|
const fetchImpl = async () => {
|
|
markStarted();
|
|
return new Promise((resolve) => { releaseFetch = resolve; });
|
|
};
|
|
|
|
const firstCheck = checkForUpdate(options(testFixture, fetchImpl));
|
|
await started;
|
|
const overlapping = await checkForUpdate(options(testFixture, fetchImpl));
|
|
assert.deepEqual(overlapping, { status: 'silent', reason: 'check-in-progress' });
|
|
|
|
releaseFetch(response(remoteRelease()));
|
|
const first = await firstCheck;
|
|
assert.equal(first.status, 'update_available');
|
|
assert.doesNotThrow(() => JSON.parse(
|
|
fs.readFileSync(statePath(testFixture), 'utf8'),
|
|
));
|
|
});
|
|
|
|
test('an empty precheck snapshot cannot start a second concurrent network request', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
fs.mkdirSync(stateDirectory(testFixture), { recursive: true });
|
|
const pause = pauseReaddirSnapshot(stateDirectory(testFixture), 2);
|
|
t.after(() => pause.restore());
|
|
let releaseFetch;
|
|
let markFetchStarted;
|
|
let requests = 0;
|
|
const fetchStarted = new Promise((resolve) => { markFetchStarted = resolve; });
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
markFetchStarted();
|
|
return new Promise((resolve) => { releaseFetch = resolve; });
|
|
};
|
|
|
|
const delayedPrecheck = checkForUpdate(options(testFixture, fetchImpl));
|
|
await pause.reached;
|
|
const claimedCheck = checkForUpdate(options(testFixture, fetchImpl));
|
|
await fetchStarted;
|
|
pause.release();
|
|
assert.deepEqual(await delayedPrecheck, { status: 'silent', reason: 'check-in-progress' });
|
|
assert.equal(requests, 1);
|
|
|
|
releaseFetch(response(remoteRelease()));
|
|
assert.equal((await claimedCheck).status, 'update_available');
|
|
pause.restore();
|
|
assert.equal(requests, 1);
|
|
});
|
|
|
|
test('two promoters cannot replace and then steal a stale empty active claim', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const activeClaim = path.join(stateDirectory(testFixture), 'active-claim');
|
|
fs.mkdirSync(activeClaim, { recursive: true });
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(activeClaim, staleTime, staleTime);
|
|
const pause = pauseWriteOnce('.retirement-guard');
|
|
t.after(() => pause.restore());
|
|
let releaseFetch;
|
|
let markFetchStarted;
|
|
let requests = 0;
|
|
const fetchStarted = new Promise((resolve) => { markFetchStarted = resolve; });
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
markFetchStarted();
|
|
return new Promise((resolve) => { releaseFetch = resolve; });
|
|
};
|
|
|
|
const delayedRetirement = checkForUpdate(options(testFixture, fetchImpl));
|
|
await pause.reached;
|
|
fs.utimesSync(
|
|
path.join(operationPath(testFixture, 'pending', 1n), 'owner.json'),
|
|
staleTime,
|
|
staleTime,
|
|
);
|
|
const claimedCheck = checkForUpdate(options(testFixture, fetchImpl));
|
|
await fetchStarted;
|
|
pause.release();
|
|
assert.deepEqual(await delayedRetirement, { status: 'silent', reason: 'check-in-progress' });
|
|
assert.equal(requests, 1);
|
|
|
|
releaseFetch(response(remoteRelease()));
|
|
assert.equal((await claimedCheck).status, 'update_available');
|
|
pause.restore();
|
|
assert.equal(requests, 1);
|
|
});
|
|
|
|
test('a delayed lower-generation promoter cannot retire a completed higher claim', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const pause = pauseRenameOnce(
|
|
operationPath(testFixture, 'claim', 1n),
|
|
path.join(stateDirectory(testFixture), 'active-claim'),
|
|
);
|
|
t.after(() => pause.restore());
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
};
|
|
|
|
const delayedLower = checkForUpdate(options(testFixture, fetchImpl));
|
|
await pause.reached;
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(
|
|
path.join(operationPath(testFixture, 'pending', 1n), 'owner.json'),
|
|
staleTime,
|
|
staleTime,
|
|
);
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl))).status, 'update_available');
|
|
assert.equal(requests, 1);
|
|
|
|
pause.release();
|
|
assert.equal((await delayedLower).status, 'update_available');
|
|
pause.restore();
|
|
assert.equal(requests, 1);
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(
|
|
path.join(stateDirectory(testFixture), 'active-claim', 'owner.json'),
|
|
'utf8',
|
|
)).generation,
|
|
'2',
|
|
);
|
|
});
|
|
|
|
test('an expired contender cannot fetch after its delayed claim promotion succeeds', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const pause = pauseRenameOnce(
|
|
operationPath(testFixture, 'claim', 1n),
|
|
path.join(stateDirectory(testFixture), 'active-claim'),
|
|
);
|
|
t.after(() => pause.restore());
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
};
|
|
|
|
const delayedExpired = checkForUpdate(options(testFixture, fetchImpl));
|
|
await pause.reached;
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(
|
|
path.join(operationPath(testFixture, 'pending', 1n), 'owner.json'),
|
|
staleTime,
|
|
staleTime,
|
|
);
|
|
pause.release();
|
|
assert.deepEqual(await delayedExpired, { status: 'silent', reason: 'check-in-progress' });
|
|
assert.equal(requests, 0);
|
|
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl))).status, 'update_available');
|
|
pause.restore();
|
|
assert.equal(requests, 1);
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(
|
|
path.join(stateDirectory(testFixture), 'active-claim', 'owner.json'),
|
|
'utf8',
|
|
)).generation,
|
|
'2',
|
|
);
|
|
});
|
|
|
|
test('a stale supersession snapshot cannot let a lower promoter retire a higher claim', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const generationOneClaim = path.basename(operationPath(testFixture, 'claim', 1n));
|
|
const pause = pauseReaddirSnapshotOnce(
|
|
stateDirectory(testFixture),
|
|
(entries) => entries.some((entry) => entry.name === generationOneClaim)
|
|
&& !entries.some((entry) => /-(?:0{19}2)$/.test(entry.name)),
|
|
);
|
|
t.after(() => pause.restore());
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
};
|
|
|
|
const delayedLower = checkForUpdate(options(testFixture, fetchImpl));
|
|
await pause.reached;
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(
|
|
path.join(operationPath(testFixture, 'pending', 1n), 'owner.json'),
|
|
staleTime,
|
|
staleTime,
|
|
);
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl))).status, 'update_available');
|
|
assert.equal(requests, 1);
|
|
|
|
pause.release();
|
|
assert.equal((await delayedLower).status, 'update_available');
|
|
pause.restore();
|
|
assert.equal(requests, 1);
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(
|
|
path.join(stateDirectory(testFixture), 'active-claim', 'owner.json'),
|
|
'utf8',
|
|
)).generation,
|
|
'2',
|
|
);
|
|
});
|
|
|
|
test('an owner fenced while active ownership is read cannot issue a second GET', {
|
|
timeout: parentCheckTimeoutMs,
|
|
}, async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const directory = stateDirectory(testFixture);
|
|
const originalOpen = fsPromises.open;
|
|
let releaseVerification;
|
|
let markVerification;
|
|
let intercepted = false;
|
|
const verificationReached = new Promise((resolve) => { markVerification = resolve; });
|
|
const verificationGate = new Promise((resolve) => { releaseVerification = resolve; });
|
|
fsPromises.open = async (target, ...args) => {
|
|
const handle = await originalOpen(target, ...args);
|
|
if (intercepted
|
|
|| path.resolve(target) !== path.join(directory, 'active-claim', 'owner.json')) {
|
|
return handle;
|
|
}
|
|
intercepted = true;
|
|
return new Proxy(handle, {
|
|
get(fileHandle, property) {
|
|
if (property === 'close') {
|
|
return async () => {
|
|
const result = await fileHandle.close();
|
|
markVerification();
|
|
await verificationGate;
|
|
return result;
|
|
};
|
|
}
|
|
const value = Reflect.get(fileHandle, property, fileHandle);
|
|
return typeof value === 'function' ? value.bind(fileHandle) : value;
|
|
},
|
|
});
|
|
};
|
|
t.after(() => {
|
|
releaseVerification();
|
|
fsPromises.open = originalOpen;
|
|
});
|
|
let requests = 0;
|
|
const checkOptions = options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
});
|
|
|
|
const suspendedOwner = checkForUpdate(checkOptions);
|
|
await verificationReached;
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(
|
|
path.join(operationPath(testFixture, 'pending', 1n), 'owner.json'),
|
|
staleTime,
|
|
staleTime,
|
|
);
|
|
assert.equal((await checkForUpdate(checkOptions)).status, 'update_available');
|
|
assert.equal(requests, 1);
|
|
|
|
releaseVerification();
|
|
assert.deepEqual(await suspendedOwner, { status: 'silent', reason: 'cache-unavailable' });
|
|
assert.equal(requests, 1);
|
|
});
|
|
|
|
test('an expired active owner cannot start a network request', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const pendingOne = path.basename(operationPath(testFixture, 'pending', 1n));
|
|
const claimOne = path.basename(operationPath(testFixture, 'claim', 1n));
|
|
const pause = pauseReaddirSnapshotOnce(
|
|
stateDirectory(testFixture),
|
|
(entries) => entries.some((entry) => entry.name === 'active-claim')
|
|
&& entries.some((entry) => entry.name === pendingOne)
|
|
&& !entries.some((entry) => entry.name === claimOne),
|
|
);
|
|
t.after(() => pause.restore());
|
|
let requests = 0;
|
|
|
|
const check = checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
await pause.reached;
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(
|
|
path.join(operationPath(testFixture, 'pending', 1n), 'owner.json'),
|
|
staleTime,
|
|
staleTime,
|
|
);
|
|
pause.release();
|
|
|
|
assert.deepEqual(await check, { status: 'silent', reason: 'check-in-progress' });
|
|
pause.restore();
|
|
assert.equal(requests, 0);
|
|
});
|
|
|
|
test('a fenced owner rechecks its active claim immediately before network access', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const pendingOne = path.basename(operationPath(testFixture, 'pending', 1n));
|
|
const claimOne = path.basename(operationPath(testFixture, 'claim', 1n));
|
|
const pause = pauseReaddirSnapshotOnce(
|
|
stateDirectory(testFixture),
|
|
(entries) => entries.some((entry) => entry.name === 'active-claim')
|
|
&& entries.some((entry) => entry.name === pendingOne)
|
|
&& !entries.some((entry) => entry.name === claimOne),
|
|
);
|
|
t.after(() => pause.restore());
|
|
let requests = 0;
|
|
let releaseFetch;
|
|
let markFetchStarted;
|
|
const fetchStarted = new Promise((resolve) => { markFetchStarted = resolve; });
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
markFetchStarted();
|
|
return new Promise((resolve) => { releaseFetch = resolve; });
|
|
};
|
|
|
|
const delayedOwner = checkForUpdate(options(testFixture, fetchImpl));
|
|
await pause.reached;
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(
|
|
path.join(operationPath(testFixture, 'pending', 1n), 'owner.json'),
|
|
staleTime,
|
|
staleTime,
|
|
);
|
|
const successor = checkForUpdate(options(testFixture, fetchImpl));
|
|
await fetchStarted;
|
|
assert.equal(requests, 1);
|
|
|
|
pause.release();
|
|
assert.deepEqual(await delayedOwner, { status: 'silent', reason: 'check-in-progress' });
|
|
pause.restore();
|
|
assert.equal(requests, 1);
|
|
|
|
releaseFetch(response(remoteRelease()));
|
|
assert.equal((await successor).status, 'update_available');
|
|
assert.equal(requests, 1);
|
|
});
|
|
|
|
test('an overlapping check reads the last-good candidate while another process refreshes it', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
let requests = 0;
|
|
let releaseRefresh;
|
|
let markRefreshStarted;
|
|
const refreshStarted = new Promise((resolve) => { markRefreshStarted = resolve; });
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
if (requests === 1) return response(remoteRelease());
|
|
markRefreshStarted();
|
|
return new Promise((resolve) => { releaseRefresh = resolve; });
|
|
};
|
|
|
|
const first = await checkForUpdate(options(testFixture, fetchImpl));
|
|
const refreshOptions = options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
});
|
|
const refresh = checkForUpdate(refreshOptions);
|
|
await refreshStarted;
|
|
|
|
const overlapping = await checkForUpdate(refreshOptions);
|
|
assert.equal(overlapping.status, 'update_available');
|
|
assert.equal(overlapping.eventKey, first.eventKey);
|
|
assert.equal(requests, 2);
|
|
|
|
releaseRefresh(response(remoteRelease()));
|
|
assert.equal((await refresh).status, 'update_available');
|
|
});
|
|
|
|
test('acknowledgement waits briefly for an in-flight refresh instead of losing the notice', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const offered = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease()),
|
|
));
|
|
let releaseRefresh;
|
|
let markRefreshStarted;
|
|
const refreshStarted = new Promise((resolve) => { markRefreshStarted = resolve; });
|
|
const refresh = checkForUpdate(options(testFixture, async () => {
|
|
markRefreshStarted();
|
|
return new Promise((resolve) => { releaseRefresh = resolve; });
|
|
}, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}));
|
|
await refreshStarted;
|
|
|
|
const acknowledgement = acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: offered.eventKey,
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000) + 1,
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
releaseRefresh(response(remoteRelease()));
|
|
await refresh;
|
|
|
|
assert.deepEqual(await acknowledgement, {
|
|
status: 'acknowledged',
|
|
eventKey: offered.eventKey,
|
|
});
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, async () => response(remoteRelease()), {
|
|
now: () => baseTime + (74 * 60 * 60 * 1_000),
|
|
})), { status: 'silent', reason: 'already-notified' });
|
|
});
|
|
|
|
test('a last-good notice remains acknowledgeable after the refresh commits a new candidate', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const firstRelease = remoteReleaseForVersion('2.16.0', 'b'.repeat(64));
|
|
const secondRelease = remoteReleaseForVersion('2.17.0', 'c'.repeat(64));
|
|
const offered = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(firstRelease),
|
|
));
|
|
let releaseRefresh;
|
|
let markRefreshStarted;
|
|
const refreshStarted = new Promise((resolve) => { markRefreshStarted = resolve; });
|
|
const refresh = checkForUpdate(options(testFixture, async () => {
|
|
markRefreshStarted();
|
|
return new Promise((resolve) => { releaseRefresh = resolve; });
|
|
}, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}));
|
|
await refreshStarted;
|
|
|
|
const visibleDuringRefresh = await checkForUpdate(options(testFixture, async () => {
|
|
assert.fail('an overlapping reader must not start another network request');
|
|
}, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}));
|
|
assert.equal(visibleDuringRefresh.eventKey, offered.eventKey);
|
|
const acknowledgement = acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: visibleDuringRefresh.eventKey,
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
releaseRefresh(response(secondRelease));
|
|
assert.equal((await refresh).latestVersion, '2.17.0');
|
|
assert.deepEqual(await acknowledgement, {
|
|
status: 'acknowledged',
|
|
eventKey: offered.eventKey,
|
|
});
|
|
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, async () => response(firstRelease), {
|
|
now: () => baseTime + (146 * 60 * 60 * 1_000),
|
|
})), { status: 'silent', reason: 'already-notified' });
|
|
});
|
|
|
|
test('acknowledgement retry budget uses a monotonic clock', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const offered = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease()),
|
|
));
|
|
writePendingOperation(testFixture, { generation: 2n });
|
|
const ticks = [100, 1_301];
|
|
const started = Date.now();
|
|
|
|
assert.deepEqual(await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: offered.eventKey,
|
|
monotonicNow: () => ticks.shift() ?? 1_301,
|
|
}), { status: 'silent', reason: 'check-in-progress' });
|
|
assert.ok(Date.now() - started < 200, 'an exhausted monotonic budget should not sleep on wall time');
|
|
});
|
|
|
|
test('a delayed allocator cannot reuse a generation after its reservation name was taken', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const firstRelease = remoteReleaseForVersion('2.16.0', 'b'.repeat(64));
|
|
const secondRelease = remoteReleaseForVersion('2.17.0', 'c'.repeat(64));
|
|
const offered = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(firstRelease),
|
|
));
|
|
const pause = pauseMkdirOnce(operationPath(testFixture, 'reserved', 2n));
|
|
t.after(() => pause.restore());
|
|
|
|
const acknowledgement = acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: offered.eventKey,
|
|
});
|
|
await pause.reached;
|
|
assert.equal((await checkForUpdate(options(testFixture, async () => response(secondRelease), {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}))).latestVersion, '2.17.0');
|
|
|
|
pause.release();
|
|
assert.deepEqual(await acknowledgement, { status: 'acknowledged', eventKey: offered.eventKey });
|
|
pause.restore();
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'reserved', 2n)), true);
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'reserved', 3n)), true);
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, async () => response(firstRelease), {
|
|
now: () => baseTime + (146 * 60 * 60 * 1_000),
|
|
})), { status: 'silent', reason: 'already-notified' });
|
|
});
|
|
|
|
test('an oversized generation name cannot poison future allocation', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
fs.mkdirSync(stateDirectory(testFixture), { recursive: true });
|
|
fs.mkdirSync(path.join(stateDirectory(testFixture), `reserved-${'9'.repeat(246)}`));
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
};
|
|
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl))).status, 'update_available');
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl))).status, 'update_available');
|
|
assert.equal(requests, 1);
|
|
assert.ok(fs.existsSync(operationPath(testFixture, 'reserved', 1n)));
|
|
});
|
|
|
|
test('a lower generation stalled after reservation cannot commit behind a newer generation', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const firstRelease = remoteReleaseForVersion('2.16.0', 'b'.repeat(64));
|
|
const secondRelease = remoteReleaseForVersion('2.17.0', 'c'.repeat(64));
|
|
const offered = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(firstRelease),
|
|
));
|
|
const pause = pauseMkdirOnce(operationPath(testFixture, 'pending', 2n));
|
|
t.after(() => pause.restore());
|
|
|
|
const acknowledgement = acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: offered.eventKey,
|
|
});
|
|
await pause.reached;
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'reserved', 2n)), true);
|
|
assert.equal((await checkForUpdate(options(testFixture, async () => response(secondRelease), {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}))).latestVersion, '2.17.0');
|
|
|
|
pause.release();
|
|
assert.deepEqual(await acknowledgement, { status: 'acknowledged', eventKey: offered.eventKey });
|
|
pause.restore();
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'committed', 2n)), false);
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'cancelled', 2n)), true);
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'reserved', 3n)), true);
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'reserved', 4n)), true);
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, async () => response(firstRelease), {
|
|
now: () => baseTime + (146 * 60 * 60 * 1_000),
|
|
})), { status: 'silent', reason: 'already-notified' });
|
|
});
|
|
|
|
test('acknowledgement fences an expired refresh and the resumed owner cannot erase it', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const offered = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease()),
|
|
));
|
|
|
|
let resumeRefresh;
|
|
let markRefreshStarted;
|
|
const refreshStarted = new Promise((resolve) => { markRefreshStarted = resolve; });
|
|
const refresh = checkForUpdate(options(testFixture, async () => {
|
|
markRefreshStarted();
|
|
return new Promise((resolve) => { resumeRefresh = resolve; });
|
|
}, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
timeoutMs: 2_000,
|
|
}));
|
|
await refreshStarted;
|
|
|
|
const refreshPending = operationPath(testFixture, 'pending', 2n);
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(path.join(refreshPending, 'owner.json'), staleTime, staleTime);
|
|
assert.deepEqual(await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: offered.eventKey,
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000) + 1,
|
|
}), { status: 'acknowledged', eventKey: offered.eventKey });
|
|
|
|
resumeRefresh(response(remoteRelease()));
|
|
assert.deepEqual(await refresh, { status: 'silent', reason: 'check-in-progress' });
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, async () => response(remoteRelease()), {
|
|
now: () => baseTime + (71 * 60 * 60 * 1_000),
|
|
})), { status: 'silent', reason: 'already-notified' });
|
|
});
|
|
|
|
for (const pausePoint of ['pending owner creation', 'state temp creation']) {
|
|
test(`acknowledgement retries when fenced during ${pausePoint}`, async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const offered = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease()),
|
|
));
|
|
const pending = operationPath(testFixture, 'pending', 2n);
|
|
const pause = pauseOpenOnce((target) => {
|
|
if (pausePoint === 'pending owner creation') {
|
|
return path.resolve(target) === path.join(pending, 'owner.json');
|
|
}
|
|
return path.dirname(path.resolve(target)) === pending
|
|
&& /^\.state\..+\.tmp$/.test(path.basename(target));
|
|
});
|
|
t.after(() => pause.restore());
|
|
|
|
const acknowledgement = acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: offered.eventKey,
|
|
});
|
|
await pause.reached;
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(
|
|
pausePoint === 'pending owner creation' ? pending : path.join(pending, 'owner.json'),
|
|
staleTime,
|
|
staleTime,
|
|
);
|
|
|
|
assert.equal((await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease()),
|
|
{ now: () => baseTime + (73 * 60 * 60 * 1_000) },
|
|
))).status, 'update_available');
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'fenced', 2n)), true);
|
|
|
|
pause.release();
|
|
assert.deepEqual(await acknowledgement, {
|
|
status: 'acknowledged',
|
|
eventKey: offered.eventKey,
|
|
});
|
|
pause.restore();
|
|
});
|
|
}
|
|
|
|
test('a stale corrupt operation is fenced without exposing a failure', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
const pending = writePendingOperation(testFixture, {
|
|
owner: 'corrupt-owner',
|
|
modifiedAt: staleTime,
|
|
});
|
|
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease()),
|
|
));
|
|
|
|
assert.equal(result.status, 'update_available');
|
|
assert.equal(fs.existsSync(pending), false);
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'fenced', 1n)), true);
|
|
});
|
|
|
|
test('malformed active-claim shapes recover after the hard lease', async () => {
|
|
for (const shape of ['file', 'symlink', 'owner-directory']) {
|
|
const testFixture = fixture();
|
|
try {
|
|
const directory = stateDirectory(testFixture);
|
|
const activeClaim = path.join(directory, 'active-claim');
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
if (shape === 'file') {
|
|
fs.writeFileSync(activeClaim, 'corrupt claim');
|
|
} else if (shape === 'symlink') {
|
|
fs.symlinkSync(path.join(testFixture.root, 'missing-claim-target'), activeClaim);
|
|
} else {
|
|
fs.mkdirSync(path.join(activeClaim, 'owner.json'), { recursive: true });
|
|
}
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
};
|
|
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, fetchImpl)), {
|
|
status: 'silent',
|
|
reason: 'check-in-progress',
|
|
}, `${shape} should retain a fresh hard lease`);
|
|
assert.equal(requests, 0);
|
|
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
if (shape === 'symlink') fs.lutimesSync(activeClaim, staleTime, staleTime);
|
|
else if (shape === 'owner-directory') {
|
|
fs.utimesSync(path.join(activeClaim, 'owner.json'), staleTime, staleTime);
|
|
} else fs.utimesSync(activeClaim, staleTime, staleTime);
|
|
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl))).status, 'update_available');
|
|
assert.equal(requests, 1, `${shape} should be retired after its hard lease`);
|
|
} finally {
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
test('a delayed corrupt-claim retirement cannot move a successor active claim', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const directory = stateDirectory(testFixture);
|
|
const activeClaim = path.join(directory, 'active-claim');
|
|
fs.mkdirSync(activeClaim, { recursive: true });
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(activeClaim, staleTime, staleTime);
|
|
let firstRetirement = null;
|
|
const pause = pauseRenameMatchingOnce((source, destination) => {
|
|
if (path.resolve(source) !== activeClaim
|
|
|| !path.basename(path.dirname(destination)).startsWith('retired-claim-corrupt-')) {
|
|
return false;
|
|
}
|
|
firstRetirement = destination;
|
|
return true;
|
|
});
|
|
t.after(() => pause.restore());
|
|
|
|
const delayed = checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease()),
|
|
));
|
|
await pause.reached;
|
|
fs.utimesSync(activeClaim, staleTime, staleTime);
|
|
fs.utimesSync(
|
|
path.join(operationPath(testFixture, 'pending', 1n), 'owner.json'),
|
|
staleTime,
|
|
staleTime,
|
|
);
|
|
|
|
assert.equal((await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease()),
|
|
))).status, 'update_available');
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(path.join(activeClaim, 'owner.json'), 'utf8')).generation,
|
|
'2',
|
|
);
|
|
|
|
pause.release();
|
|
assert.equal((await delayed).status, 'update_available');
|
|
pause.restore();
|
|
assert.equal(firstRetirement !== null, true);
|
|
assert.equal(fs.existsSync(activeClaim), true);
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(path.join(activeClaim, 'owner.json'), 'utf8')).generation,
|
|
'2',
|
|
);
|
|
});
|
|
|
|
test('repeated corrupt claim contents receive distinct retirement identities', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const directory = stateDirectory(testFixture);
|
|
const activeClaim = path.join(directory, 'active-claim');
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
fs.writeFileSync(activeClaim, 'same corrupt claim');
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(activeClaim, staleTime, staleTime);
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
};
|
|
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl))).status, 'update_available');
|
|
fs.renameSync(activeClaim, path.join(directory, 'test-completed-active-claim'));
|
|
fs.writeFileSync(activeClaim, 'same corrupt claim');
|
|
fs.utimesSync(activeClaim, staleTime, staleTime);
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}))).status, 'update_available');
|
|
|
|
assert.equal(requests, 2);
|
|
assert.equal(
|
|
fs.readdirSync(directory).filter((name) => name.startsWith('retired-claim-corrupt-')).length,
|
|
2,
|
|
);
|
|
});
|
|
|
|
test('corrupt claim retirement preserves distinct 64-bit inode identities', async (t) => {
|
|
const testFixture = fixture();
|
|
const originalLstat = fsPromises.lstat;
|
|
t.after(() => {
|
|
fsPromises.lstat = originalLstat;
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
});
|
|
const directory = stateDirectory(testFixture);
|
|
const activeClaim = path.join(directory, 'active-claim');
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
fs.writeFileSync(activeClaim, 'corrupt claim');
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(activeClaim, staleTime, staleTime);
|
|
let instance = 1n;
|
|
fsPromises.lstat = async (target, ...args) => {
|
|
const metadata = await originalLstat(target, ...args);
|
|
const resolved = path.resolve(target);
|
|
const isCorruptClaimInstance = resolved === path.resolve(activeClaim)
|
|
|| (path.basename(resolved) === 'active'
|
|
&& path.basename(path.dirname(resolved)).startsWith('retired-claim-corrupt-'));
|
|
if (!isCorruptClaimInstance || !metadata.isFile()) return metadata;
|
|
const inode = 9_007_199_254_740_992n + (instance - 1n);
|
|
return args[0]?.bigint
|
|
? metadataWithOverrides(metadata, {
|
|
ino: inode,
|
|
birthtimeNs: 1_000n,
|
|
ctimeNs: 2_000n,
|
|
})
|
|
: metadataWithOverrides(metadata, {
|
|
ino: Number(inode),
|
|
birthtimeMs: 1,
|
|
ctimeMs: 2,
|
|
});
|
|
};
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
};
|
|
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl))).status, 'update_available');
|
|
fs.renameSync(activeClaim, path.join(directory, 'test-completed-active-claim'));
|
|
fs.writeFileSync(activeClaim, 'corrupt claim');
|
|
fs.utimesSync(activeClaim, staleTime, staleTime);
|
|
instance = 2n;
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
}))).status, 'update_available');
|
|
|
|
assert.equal(requests, 2);
|
|
assert.equal(
|
|
fs.readdirSync(directory).filter((name) => name.startsWith('retired-claim-corrupt-')).length,
|
|
2,
|
|
);
|
|
});
|
|
|
|
test('a forged retirement symlink cannot move an active claim outside its cache partition', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
};
|
|
assert.equal((await checkForUpdate(options(testFixture, fetchImpl))).status, 'update_available');
|
|
|
|
const outside = path.join(testFixture.root, 'outside');
|
|
fs.mkdirSync(outside);
|
|
fs.symlinkSync(outside, path.join(stateDirectory(testFixture), 'retired-claim-1'));
|
|
const refreshOptions = options(testFixture, fetchImpl, {
|
|
now: () => baseTime + (73 * 60 * 60 * 1_000),
|
|
});
|
|
assert.equal((await checkForUpdate(refreshOptions)).status, 'update_available');
|
|
assert.deepEqual(fs.readdirSync(outside), []);
|
|
assert.equal(requests, 1);
|
|
|
|
fs.unlinkSync(path.join(stateDirectory(testFixture), 'retired-claim-1'));
|
|
assert.equal((await checkForUpdate(refreshOptions)).status, 'update_available');
|
|
assert.deepEqual(fs.readdirSync(outside), []);
|
|
assert.equal(requests, 2);
|
|
});
|
|
|
|
test('an expired operation is fenced even when its PID was reused by a live process', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
const pending = writePendingOperation(testFixture, { modifiedAt: staleTime });
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(result.status, 'update_available');
|
|
assert.equal(requests, 1);
|
|
assert.equal(fs.existsSync(pending), false);
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'fenced', 1n)), true);
|
|
});
|
|
|
|
test('a corrupt future-dated operation cannot suppress checks indefinitely', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const futureTime = new Date(Date.now() + (365 * 24 * 60 * 60 * 1_000));
|
|
const pending = writePendingOperation(testFixture, {
|
|
owner: 'corrupt-owner',
|
|
modifiedAt: futureTime,
|
|
});
|
|
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease()),
|
|
));
|
|
|
|
assert.equal(result.status, 'update_available');
|
|
assert.equal(fs.existsSync(pending), false);
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'fenced', 1n)), true);
|
|
});
|
|
|
|
test('a newly-created operation with slight clock skew is never fenced as stale', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const nearFuture = new Date(Date.now() + 1_000);
|
|
const pending = writePendingOperation(testFixture, {
|
|
owner: 'writer-has-not-finished-the-record',
|
|
modifiedAt: nearFuture,
|
|
});
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'check-in-progress' });
|
|
assert.equal(requests, 0);
|
|
assert.equal(fs.existsSync(pending), true);
|
|
});
|
|
|
|
test('a fenced stale owner cannot overwrite a newer committed candidate after resuming', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
let resumeOldOwner;
|
|
let markOldStarted;
|
|
const oldStarted = new Promise((resolve) => { markOldStarted = resolve; });
|
|
const oldCheck = checkForUpdate(options(testFixture, async () => {
|
|
markOldStarted();
|
|
return new Promise((resolve) => { resumeOldOwner = resolve; });
|
|
}, { timeoutMs: 2_000 }));
|
|
await oldStarted;
|
|
|
|
const oldPending = operationPath(testFixture, 'pending', 1n);
|
|
const staleTime = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(path.join(oldPending, 'owner.json'), staleTime, staleTime);
|
|
|
|
const newer = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteReleaseForVersion('3.0.0', 'c'.repeat(64))),
|
|
));
|
|
assert.equal(newer.status, 'update_available');
|
|
assert.equal(newer.latestVersion, '3.0.0');
|
|
|
|
resumeOldOwner(response(remoteReleaseForVersion('2.16.0', 'b'.repeat(64))));
|
|
assert.deepEqual(await oldCheck, { status: 'silent', reason: 'check-in-progress' });
|
|
const persisted = JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8'));
|
|
assert.equal(persisted.candidate.version, '3.0.0');
|
|
assert.equal(fs.existsSync(operationPath(testFixture, 'fenced', 1n)), true);
|
|
});
|
|
|
|
test('different installed versions share no acknowledgement or cache-reset state', async (t) => {
|
|
const testFixture = fixture('2.15.0');
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const olderReleasePath = path.join(testFixture.root, 'older-skill-release.json');
|
|
writeJson(olderReleasePath, localRelease('2.14.0'));
|
|
let requests = 0;
|
|
const fetchImpl = async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
};
|
|
|
|
const newerInstall = await checkForUpdate(options(testFixture, fetchImpl));
|
|
assert.equal(newerInstall.status, 'update_available');
|
|
assert.deepEqual(await acknowledgeUpdate({
|
|
releasePath: testFixture.releasePath,
|
|
cacheDirectory: testFixture.cacheDirectory,
|
|
eventKey: newerInstall.eventKey,
|
|
now: () => baseTime + 1_000,
|
|
}), { status: 'acknowledged', eventKey: newerInstall.eventKey });
|
|
|
|
const olderInstallOptions = options(testFixture, fetchImpl, { releasePath: olderReleasePath });
|
|
const olderInstall = await checkForUpdate(olderInstallOptions);
|
|
assert.equal(olderInstall.status, 'update_available');
|
|
assert.equal(olderInstall.installedVersion, '2.14.0');
|
|
|
|
assert.deepEqual(await checkForUpdate(options(testFixture, fetchImpl)), {
|
|
status: 'silent',
|
|
reason: 'already-notified',
|
|
});
|
|
const olderAgain = await checkForUpdate(olderInstallOptions);
|
|
assert.equal(olderAgain.status, 'update_available');
|
|
assert.equal(requests, 2, 'each installed version should retain its own fresh cache');
|
|
assert.equal(fs.existsSync(statePath(testFixture, '2.15.0')), true);
|
|
assert.equal(fs.existsSync(statePath(testFixture, '2.14.0')), true);
|
|
});
|
|
|
|
test('an identity mismatch is rejected silently and never cached as a candidate', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease({ skillId: 'different-skill' })),
|
|
));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'invalid-manifest' });
|
|
const state = JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8'));
|
|
assert.equal(Object.hasOwn(state, 'candidate'), false);
|
|
});
|
|
|
|
test('release notes must byte-match the exact trusted GitHub URL', async () => {
|
|
const base = 'https://github.com/tt-a1i/archify/releases/tag/v2.16.0';
|
|
for (const releaseNotes of [
|
|
'https://github.com:443/tt-a1i/archify/releases/tag/v2.16.0',
|
|
'https://github.com:444/tt-a1i/archify/releases/tag/v2.16.0',
|
|
`${base}?`,
|
|
`${base}#`,
|
|
`${base}?source=manifest`,
|
|
'https://GITHUB.COM/tt-a1i/archify/releases/tag/v2.16.0',
|
|
]) {
|
|
const testFixture = fixture();
|
|
try {
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease({ releaseNotes })),
|
|
));
|
|
assert.deepEqual(result, { status: 'silent', reason: 'invalid-manifest' }, releaseNotes);
|
|
} finally {
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
test('malformed release notes are classified as an invalid manifest', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease({ releaseNotes: 'not-a-url' })),
|
|
));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'invalid-manifest' });
|
|
});
|
|
|
|
test('publishedAt must use canonical UTC seconds and a real calendar date', async () => {
|
|
for (const publishedAt of [
|
|
'2026-08-28T15:00:00+08:00',
|
|
'2026-08-28',
|
|
'2026-02-30T00:00:00Z',
|
|
]) {
|
|
const testFixture = fixture();
|
|
try {
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease({ publishedAt })),
|
|
));
|
|
assert.deepEqual(result, { status: 'silent', reason: 'invalid-manifest' }, publishedAt);
|
|
} finally {
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
test('stable candidates cannot use prerelease or build metadata', async () => {
|
|
for (const version of ['2.16.0-dev.1', '2.16.0+build.1']) {
|
|
const testFixture = fixture();
|
|
try {
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteReleaseForVersion(version)),
|
|
));
|
|
assert.deepEqual(result, { status: 'silent', reason: 'invalid-manifest' }, version);
|
|
} finally {
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
test('an invalid local identity fails before any network disclosure', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
writeJson(testFixture.releasePath, {
|
|
...localRelease(),
|
|
source: { repository: 'https://example.com/untrusted/archify' },
|
|
});
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'invalid-local-release' });
|
|
assert.equal(requests, 0);
|
|
});
|
|
|
|
test('a bounded timeout fails silently without retrying', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
let requests = 0;
|
|
const fetchImpl = (_url, init) => new Promise((_resolve, reject) => {
|
|
requests += 1;
|
|
init.signal.addEventListener('abort', () => {
|
|
const error = new Error('aborted');
|
|
error.name = 'AbortError';
|
|
reject(error);
|
|
}, { once: true });
|
|
});
|
|
|
|
const result = await checkForUpdate(options(testFixture, fetchImpl, { timeoutMs: 10 }));
|
|
assert.deepEqual(result, { status: 'silent', reason: 'check-failed' });
|
|
assert.equal(requests, 1);
|
|
});
|
|
|
|
test('non-success responses cancel their unread body before failing silently', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
let cancelled = false;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => ({
|
|
status: 503,
|
|
headers: new Headers(),
|
|
body: {
|
|
async cancel() {
|
|
cancelled = true;
|
|
},
|
|
},
|
|
})));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'check-failed' });
|
|
assert.equal(cancelled, true);
|
|
});
|
|
|
|
test('media-type and declared-size rejection cancel unread response bodies', async () => {
|
|
for (const headers of [
|
|
{ 'content-type': 'text/html' },
|
|
{ 'content-type': 'application/json', 'content-length': String((32 * 1024) + 1) },
|
|
]) {
|
|
const testFixture = fixture();
|
|
let cancelled = false;
|
|
try {
|
|
const result = await checkForUpdate(options(testFixture, async () => ({
|
|
status: 200,
|
|
headers: new Headers(headers),
|
|
body: {
|
|
async cancel() {
|
|
cancelled = true;
|
|
},
|
|
},
|
|
})));
|
|
assert.deepEqual(result, { status: 'silent', reason: 'invalid-manifest' });
|
|
assert.equal(cancelled, true);
|
|
} finally {
|
|
fs.rmSync(testFixture.root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
test('response stream failures are classified as network failures', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => ({
|
|
status: 200,
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
body: {
|
|
getReader() {
|
|
return {
|
|
async read() {
|
|
throw new TypeError('socket closed while reading');
|
|
},
|
|
releaseLock() {},
|
|
};
|
|
},
|
|
},
|
|
})));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'check-failed' });
|
|
});
|
|
|
|
test('invalid UTF-8 is classified as an invalid manifest rather than a network failure', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => new Response(new Uint8Array([0xff]), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
));
|
|
assert.deepEqual(result, { status: 'silent', reason: 'invalid-manifest' });
|
|
});
|
|
|
|
test('chunked responses above 32 KiB are rejected instead of fully trusted', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
const oversized = `${JSON.stringify(remoteRelease())}${' '.repeat(33 * 1024)}`;
|
|
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(oversized),
|
|
));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'invalid-manifest' });
|
|
});
|
|
|
|
test('a poisoned far-future cache timestamp cannot suppress checks indefinitely', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
await checkForUpdate(options(testFixture, async () => response(remoteRelease())));
|
|
const cacheStatePath = statePath(testFixture);
|
|
const state = JSON.parse(fs.readFileSync(cacheStatePath, 'utf8'));
|
|
state.check.nextCheckAt = '9999-12-31T23:59:59.000Z';
|
|
writeJson(cacheStatePath, state);
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(result.status, 'update_available');
|
|
assert.equal(requests, 1);
|
|
});
|
|
|
|
test('a semantically corrupt cached candidate discards legacy validators and rebuilds unconditionally', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
await checkForUpdate(options(testFixture, async () => response(remoteRelease())));
|
|
const cacheStatePath = statePath(testFixture);
|
|
const state = JSON.parse(fs.readFileSync(cacheStatePath, 'utf8'));
|
|
state.candidate.targetDigest = 'sha256:corrupt';
|
|
state.check.etag = '"legacy-tracker"';
|
|
state.check.nextCheckAt = '9999-12-31T23:59:59.000Z';
|
|
writeJson(cacheStatePath, state);
|
|
let conditionalHeader = 'not-observed';
|
|
|
|
const result = await checkForUpdate(options(testFixture, async (_url, init) => {
|
|
conditionalHeader = init.headers['if-none-match'];
|
|
return response(remoteRelease());
|
|
}));
|
|
|
|
assert.equal(result.status, 'update_available');
|
|
assert.equal(conditionalHeader, undefined);
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(statePath(testFixture), 'utf8')).candidate.targetDigest,
|
|
result.targetDigest,
|
|
);
|
|
});
|
|
|
|
test('a newer cached candidate without offered or acknowledged provenance is rebuilt', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
writeJson(statePath(testFixture), {
|
|
schemaVersion: 1,
|
|
skillId: 'archify',
|
|
installedVersion: '2.15.0',
|
|
check: {
|
|
nextCheckAt: new Date(baseTime + (24 * 60 * 60 * 1_000)).toISOString(),
|
|
consecutiveFailures: 0,
|
|
},
|
|
notification: {
|
|
offeredDigests: [],
|
|
acknowledgedDigests: [],
|
|
},
|
|
candidate: {
|
|
version: '2.16.0',
|
|
targetDigest: `sha256:${'b'.repeat(64)}`,
|
|
severity: 'normal',
|
|
releaseNotes: 'https://github.com/tt-a1i/archify/releases/tag/v2.16.0',
|
|
},
|
|
});
|
|
let requests = 0;
|
|
|
|
const result = await checkForUpdate(options(testFixture, async () => {
|
|
requests += 1;
|
|
return response(remoteReleaseForVersion('2.15.0', 'd'.repeat(64)));
|
|
}));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'current' });
|
|
assert.equal(requests, 1, 'semantic corruption must not produce an unacknowledgeable cached notice');
|
|
});
|
|
|
|
test('corrupt cache is rebuilt without exposing an error to the user', async (t) => {
|
|
const testFixture = fixture();
|
|
t.after(() => fs.rmSync(testFixture.root, { recursive: true, force: true }));
|
|
fs.mkdirSync(stateDirectory(testFixture), { recursive: true });
|
|
fs.mkdirSync(path.dirname(statePath(testFixture)), { recursive: true });
|
|
fs.writeFileSync(statePath(testFixture), '{not-json');
|
|
|
|
const result = await checkForUpdate(options(
|
|
testFixture,
|
|
async () => response(remoteRelease({ version: '2.15.0' })),
|
|
));
|
|
|
|
assert.deepEqual(result, { status: 'silent', reason: 'invalid-manifest' });
|
|
assert.doesNotThrow(() => JSON.parse(
|
|
fs.readFileSync(statePath(testFixture), 'utf8'),
|
|
));
|
|
});
|
|
|
|
test('disabled CLI returns one silent JSON line and never needs the network', () => {
|
|
const result = spawnSync(process.execPath, [checkerPath], {
|
|
cwd: skillRoot,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, ARCHIFY_UPDATE_CHECK_DISABLED: '1' },
|
|
});
|
|
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.deepEqual(JSON.parse(result.stdout), { status: 'silent', reason: 'disabled' });
|
|
assert.equal(result.stdout.trim().split('\n').length, 1);
|
|
});
|
|
|
|
test('CLI acknowledgement emits the documented one-line success schema', async (t) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-update-cli-ack-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const home = path.join(root, 'home');
|
|
const xdg = path.join(root, 'xdg-cache');
|
|
const localData = path.join(root, 'local-data');
|
|
for (const directory of [home, xdg, localData]) fs.mkdirSync(directory, { recursive: true });
|
|
const cacheDirectory = process.platform === 'win32'
|
|
? path.join(localData, 'archify-skill')
|
|
: process.platform === 'darwin'
|
|
? path.join(home, 'Library', 'Caches', 'archify-skill')
|
|
: path.join(xdg, 'archify-skill');
|
|
const releasePath = path.join(skillRoot, 'skill-release.json');
|
|
const installedRelease = JSON.parse(fs.readFileSync(releasePath, 'utf8'));
|
|
const [major, minor, patch] = parseSemver(installedRelease.version).core;
|
|
const candidateVersion = `${major}.${minor}.${BigInt(patch) + 1n}`;
|
|
const offered = await checkForUpdate({
|
|
releasePath,
|
|
cacheDirectory,
|
|
fetchImpl: async () => response(remoteReleaseForVersion(candidateVersion)),
|
|
now: () => baseTime,
|
|
random: () => 0.5,
|
|
timeoutMs: 50,
|
|
});
|
|
assert.equal(offered.status, 'update_available');
|
|
|
|
const acknowledgement = spawnSync(process.execPath, [checkerPath, '--ack', offered.eventKey], {
|
|
cwd: skillRoot,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
HOME: home,
|
|
USERPROFILE: home,
|
|
XDG_CACHE_HOME: xdg,
|
|
LOCALAPPDATA: localData,
|
|
},
|
|
});
|
|
assert.equal(acknowledgement.status, 0, acknowledgement.stderr);
|
|
assert.deepEqual(JSON.parse(acknowledgement.stdout), {
|
|
status: 'acknowledged',
|
|
eventKey: offered.eventKey,
|
|
});
|
|
assert.equal(acknowledgement.stdout.trim().split('\n').length, 1);
|
|
});
|
|
|
|
test('CLI entry detection survives a realpath or symlink alias', (t) => {
|
|
const aliasRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-update-cli-alias-'));
|
|
t.after(() => fs.rmSync(aliasRoot, { recursive: true, force: true }));
|
|
const aliasPath = path.join(aliasRoot, 'check-update-alias.mjs');
|
|
try {
|
|
fs.symlinkSync(checkerPath, aliasPath);
|
|
} catch (error) {
|
|
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
|
t.skip(`symlinks unavailable: ${error.code}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const result = spawnSync(process.execPath, [aliasPath], {
|
|
cwd: aliasRoot,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, ARCHIFY_UPDATE_CHECK_DISABLED: '1' },
|
|
});
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.deepEqual(JSON.parse(result.stdout), { status: 'silent', reason: 'disabled' });
|
|
});
|
|
|
|
test('notifier source has no process execution or remote-origin override surface', () => {
|
|
const checkerSource = fs.readFileSync(checkerPath, 'utf8');
|
|
const contractSource = fs.readFileSync(contractPath, 'utf8');
|
|
const combinedSource = `${checkerSource}\n${contractSource}`;
|
|
assert.doesNotMatch(combinedSource, /(?:node:)?child_process/);
|
|
assert.doesNotMatch(combinedSource, /(?:^|[^\w.])(?:spawn|exec|execFile|fork)(?:Sync)?\s*\(/m);
|
|
assert.doesNotMatch(contractSource, /\bfetch\s*\(/);
|
|
const environmentReads = [...combinedSource.matchAll(/process\.env\.([A-Z0-9_]+)/g)]
|
|
.map((match) => match[1])
|
|
.sort();
|
|
assert.deepEqual(environmentReads, [
|
|
'ARCHIFY_UPDATE_CHECK_DISABLED',
|
|
'LOCALAPPDATA',
|
|
'XDG_CACHE_HOME',
|
|
]);
|
|
assert.doesNotMatch(combinedSource, /updateCommand/);
|
|
});
|