mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
test(dsh): preserve availability mutation failures on Windows
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
b92ccb00f6
commit
3ab53d1740
3 changed files with 105 additions and 9 deletions
|
|
@ -122,10 +122,12 @@ export async function selectTarball(input) {
|
|||
export function safeAvailabilityCheckpoint(value) {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const scenarios = ['setup', 'user-install', 'user-winner', 'project-install', 'project-winner', 'user-shadowed', 'remote', 'by-name', 'content-modified', 'skill-missing', 'directory-missing', 'policy', 'absent', 'incomplete-provider', 'failed-provider'];
|
||||
const checkpoints = ['setup', 'install', 'install-poll', 'reference', 'before-digest', 'execute', 'assertion', 'after-digest', 'mutation'];
|
||||
const checkpoints = ['setup', 'install', 'install-poll', 'reference', 'before-digest', 'execute', 'assertion', 'after-digest', 'mutation', 'mutation-apply', 'mutation-restore'];
|
||||
const assertions = ['none', 'isError', 'kind', 'schemaVersion', 'association', 'scope', 'integrity', 'discovery', 'name', 'reason', 'invocation', 'installation', 'namespace', 'slug', 'version', 'fingerprint', 'render', 'privacy', 'unchanged', 'accepted', 'completed', 'reference'];
|
||||
if (!scenarios.includes(value.scenario) || !checkpoints.includes(value.checkpoint) || !assertions.includes(value.assertion)) return undefined;
|
||||
const result = { scenario: value.scenario, checkpoint: value.checkpoint, assertion: value.assertion };
|
||||
const osCodes = ['EACCES', 'EPERM', 'EBUSY', 'ENOENT', 'ENOTEMPTY', 'EEXIST', 'EXDEV', 'EIO', 'ETIMEDOUT'];
|
||||
for (const field of ['osCode', 'restoreOsCode']) if (osCodes.includes(value[field])) result[field] = value[field];
|
||||
const statuses = ['accepted', 'not-confirmed', 'awaiting-confirmation', 'cancelled'];
|
||||
// Mirror the public installationFailureCodes contract; never forward error messages or IDs.
|
||||
const codes = ['CONTEXT_STALE', 'REQUEST_CONFLICT', 'SOURCE_NOT_CONFIGURED', 'SOURCE_DENIED',
|
||||
|
|
|
|||
74
plugins/dsh/test/availability-mutation-fixture.test.ts
Normal file
74
plugins/dsh/test/availability-mutation-fixture.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { expect, test } from 'bun:test';
|
||||
import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
// @ts-expect-error Development-only observer fixture has no TypeScript declarations.
|
||||
import { withRenamedAvailabilityPath } from './integration/skill-availability-fixture.mjs';
|
||||
// @ts-expect-error Development-only Node script has no TypeScript declarations.
|
||||
import { safeAvailabilityCheckpoint } from '../scripts/smoke-native-host.mjs';
|
||||
|
||||
function checkpoint(): { scenario: string; checkpoint: string; assertion: string; restoreOsCode?: string } { return { scenario: 'directory-missing', checkpoint: 'mutation', assertion: 'none' }; }
|
||||
|
||||
test('failed initial rename never restores an absent backup or replaces the original error', async () => {
|
||||
const state = checkpoint();
|
||||
const original = Object.assign(new Error('/private/locked/path'), { code: 'EPERM' });
|
||||
const calls: string[][] = [];
|
||||
let inspected = false;
|
||||
await expect(withRenamedAvailabilityPath(state, '/source', '/backup', async () => { inspected = true; }, async (...paths: string[]) => {
|
||||
calls.push(paths); throw original;
|
||||
})).rejects.toBe(original);
|
||||
expect(calls).toEqual([['/source', '/backup']]);
|
||||
expect(inspected).toBe(false);
|
||||
expect(state.checkpoint).toBe('mutation-apply');
|
||||
});
|
||||
|
||||
test('failed restoration preserves the primary inspection error and its checkpoint', async () => {
|
||||
const state = checkpoint();
|
||||
const original = Object.assign(new Error('/private/assertion'), { code: 'ERR_ASSERTION' });
|
||||
let calls = 0;
|
||||
await expect(withRenamedAvailabilityPath(state, '/source', '/backup', async () => {
|
||||
Object.assign(state, { checkpoint: 'assertion', assertion: 'integrity' });
|
||||
throw original;
|
||||
}, async () => { if (++calls === 2) throw Object.assign(new Error('/private/restore'), { code: 'EBUSY' }); })).rejects.toBe(original);
|
||||
expect(calls).toBe(2);
|
||||
expect(state).toEqual({ scenario: 'directory-missing', checkpoint: 'assertion', assertion: 'integrity', restoreOsCode: 'EBUSY' });
|
||||
});
|
||||
|
||||
test('restoration failure after successful inspection identifies the restore phase', async () => {
|
||||
const state = checkpoint();
|
||||
const original = Object.assign(new Error('/private/restore'), { code: 'EACCES' });
|
||||
let calls = 0;
|
||||
await expect(withRenamedAvailabilityPath(state, '/source', '/backup', async () => {}, async () => {
|
||||
if (++calls === 2) throw original;
|
||||
})).rejects.toBe(original);
|
||||
expect(state.checkpoint).toBe('mutation-restore');
|
||||
});
|
||||
|
||||
test('real directory rename and restore preserve fixture files without skipping inspection', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'skillhub-availability-mutation-'));
|
||||
const source = join(root, 'skill');
|
||||
const backup = join(root, 'backup');
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, 'SKILL.md'), 'original');
|
||||
try {
|
||||
let inspected = false;
|
||||
await withRenamedAvailabilityPath(checkpoint(), source, backup, async () => {
|
||||
inspected = true;
|
||||
expect(await readFile(join(backup, 'SKILL.md'), 'utf8')).toBe('original');
|
||||
await expect(readFile(join(source, 'SKILL.md'))).rejects.toHaveProperty('code', 'ENOENT');
|
||||
}, rename);
|
||||
expect(inspected).toBe(true);
|
||||
expect(await readFile(join(source, 'SKILL.md'), 'utf8')).toBe('original');
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('mutation summary keeps only fixed OS codes and excludes paths or arbitrary error strings', () => {
|
||||
const state = { ...checkpoint(), checkpoint: 'mutation-apply', osCode: 'EPERM', restoreOsCode: 'ENOENT', message: '/private/token' };
|
||||
expect(safeAvailabilityCheckpoint(state)).toEqual({ scenario: 'directory-missing', checkpoint: 'mutation-apply', assertion: 'none', osCode: 'EPERM', restoreOsCode: 'ENOENT' });
|
||||
for (const value of ['/private/token', 'secret', 'constructor', {}, undefined]) {
|
||||
const safe = safeAvailabilityCheckpoint({ ...state, osCode: value, restoreOsCode: value });
|
||||
expect(safe.osCode).toBeUndefined();
|
||||
expect(safe.restoreOsCode).toBeUndefined();
|
||||
expect(JSON.stringify(safe)).not.toMatch(/private|token|secret/);
|
||||
}
|
||||
});
|
||||
|
|
@ -15,10 +15,29 @@ export function apply(ctx, config) {
|
|||
const checkpoint = { scenario: 'setup', checkpoint: 'setup', assertion: 'none' };
|
||||
const abort = new AbortController();
|
||||
const timer = setTimeout(() => run(ctx, root, AbortSignal.any([abort.signal, AbortSignal.timeout(70000)]), checkpoint)
|
||||
.catch(error => writeFile(join(root, 'skill-availability-result.json'), JSON.stringify({ passed: false, error: error.message, checkpoint, operation: checkpoint.operation }))), 1000);
|
||||
.catch(error => writeFile(join(root, 'skill-availability-result.json'), JSON.stringify({ passed: false, error: error.message, checkpoint: { ...checkpoint, osCode: error.code }, operation: checkpoint.operation }))), 1000);
|
||||
return () => { clearTimeout(timer); abort.abort(); };
|
||||
}
|
||||
|
||||
/** Preserve the failing mutation/inspection when cleanup also fails. No retry hides a Host failure. */
|
||||
export async function withRenamedAvailabilityPath(checkpoint, from, to, inspect, move = rename) {
|
||||
Object.assign(checkpoint, { checkpoint: 'mutation-apply', assertion: 'none' });
|
||||
// A failed rename leaves no backup owned by this fixture; do not try to restore it.
|
||||
await move(from, to);
|
||||
let inspectionFailed = false;
|
||||
try { return await inspect(); }
|
||||
catch (error) { inspectionFailed = true; throw error; }
|
||||
finally {
|
||||
if (inspectionFailed) {
|
||||
try { await move(to, from); }
|
||||
catch (error) { checkpoint.restoreOsCode = error.code; }
|
||||
} else {
|
||||
Object.assign(checkpoint, { checkpoint: 'mutation-restore', assertion: 'none' });
|
||||
await move(to, from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function digest(directory) {
|
||||
const hash = createHash('sha256');
|
||||
async function visit(path, relative = '') {
|
||||
|
|
@ -151,13 +170,14 @@ async function run(ctx, root, signal, checkpoint) {
|
|||
checkpoint.scenario = mode;
|
||||
mark('mutation');
|
||||
const backup = join(root, 'availability-backup-' + randomUUID());
|
||||
try {
|
||||
if (mode === 'content-modified') await writeFile(skillPath, original + '\nAVAILABILITY_PRIVATE_SENTINEL');
|
||||
else await rename(mode === 'skill-missing' ? skillPath : target, backup);
|
||||
await check(await reference('project'), { integrity: mode === 'content-modified' ? 'changed' : 'missing' });
|
||||
} finally {
|
||||
if (mode === 'content-modified') await writeFile(skillPath, original);
|
||||
else await rename(backup, mode === 'skill-missing' ? skillPath : target);
|
||||
if (mode === 'content-modified') {
|
||||
try {
|
||||
await writeFile(skillPath, original + '\nAVAILABILITY_PRIVATE_SENTINEL');
|
||||
await check(await reference('project'), { integrity: 'changed' });
|
||||
} finally { await writeFile(skillPath, original); }
|
||||
} else {
|
||||
await withRenamedAvailabilityPath(checkpoint, mode === 'skill-missing' ? skillPath : target, backup,
|
||||
async () => check(await reference('project'), { integrity: 'missing' }));
|
||||
}
|
||||
}
|
||||
checkpoint.scenario = 'policy';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue