536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
buildVerificationCommand,
|
|
computeVerificationSnapshot,
|
|
isVerificationProfile,
|
|
runVerificationRequest,
|
|
} from './verification.js';
|
|
import {
|
|
buildWorkspaceCommandEnvironment,
|
|
ensureWorkspaceDependenciesInstalled,
|
|
hasInstalledNodeModules,
|
|
} from './workspace-package-manager.js';
|
|
|
|
describe('verification helpers', () => {
|
|
it('recognizes only fixed verification profiles', () => {
|
|
expect(isVerificationProfile('test')).toBe(true);
|
|
expect(isVerificationProfile('typecheck')).toBe(true);
|
|
expect(isVerificationProfile('build')).toBe(true);
|
|
expect(isVerificationProfile('lint')).toBe(true);
|
|
});
|
|
|
|
it('builds deterministic commands for each profile', () => {
|
|
const repoDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-verification-'),
|
|
);
|
|
fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({}));
|
|
|
|
expect(buildVerificationCommand('test', repoDir)).toMatchObject({
|
|
file: 'npm',
|
|
args: ['test'],
|
|
requiredScript: 'test',
|
|
});
|
|
expect(buildVerificationCommand('typecheck', repoDir)).toMatchObject({
|
|
file: 'npm',
|
|
args: ['run', 'typecheck'],
|
|
requiredScript: 'typecheck',
|
|
});
|
|
expect(buildVerificationCommand('build', repoDir)).toMatchObject({
|
|
file: 'npm',
|
|
args: ['run', 'build'],
|
|
requiredScript: 'build',
|
|
});
|
|
expect(buildVerificationCommand('lint', repoDir)).toMatchObject({
|
|
file: 'npm',
|
|
args: ['run', 'lint'],
|
|
requiredScript: 'lint',
|
|
});
|
|
});
|
|
|
|
it('prefers lint:ci and lint:check before plain lint', () => {
|
|
const repoDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-verification-lint-'),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'package.json'),
|
|
JSON.stringify({
|
|
scripts: {
|
|
lint: 'eslint .',
|
|
'lint:check': 'eslint --max-warnings 0 .',
|
|
'lint:ci': 'eslint --format compact .',
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(buildVerificationCommand('lint', repoDir)).toMatchObject({
|
|
file: 'npm',
|
|
args: ['run', 'lint:ci'],
|
|
requiredScript: 'lint:ci',
|
|
});
|
|
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'package.json'),
|
|
JSON.stringify({
|
|
scripts: {
|
|
lint: 'eslint .',
|
|
'lint:check': 'eslint --max-warnings 0 .',
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(buildVerificationCommand('lint', repoDir)).toMatchObject({
|
|
file: 'npm',
|
|
args: ['run', 'lint:check'],
|
|
requiredScript: 'lint:check',
|
|
});
|
|
});
|
|
|
|
it('selects the workspace package manager for verification commands', () => {
|
|
const repoDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-verification-pnpm-'),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'package.json'),
|
|
JSON.stringify({ packageManager: 'pnpm@10.11.0' }),
|
|
);
|
|
|
|
expect(buildVerificationCommand('typecheck', repoDir)).toMatchObject({
|
|
file: 'corepack',
|
|
args: ['pnpm', 'run', 'typecheck'],
|
|
commandText: 'corepack pnpm run typecheck',
|
|
requiredScript: 'typecheck',
|
|
});
|
|
});
|
|
|
|
it('builds nested pnpm verification commands without tripping corepack project specs', () => {
|
|
const parentDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-verification-corepack-parent-'),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(parentDir, 'package.json'),
|
|
JSON.stringify({ packageManager: 'bun@1.3.11' }),
|
|
);
|
|
|
|
const repoDir = path.join(parentDir, 'owner');
|
|
fs.mkdirSync(repoDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'nested-pnpm-workspace',
|
|
scripts: {
|
|
typecheck: 'tsc --noEmit',
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'pnpm-lock.yaml'),
|
|
'lockfileVersion: 9.0\n',
|
|
);
|
|
const command = buildVerificationCommand('typecheck', repoDir);
|
|
|
|
expect(command).toMatchObject({
|
|
file: 'corepack',
|
|
args: ['pnpm', 'run', 'typecheck'],
|
|
commandText: 'corepack pnpm run typecheck',
|
|
packageManager: 'pnpm',
|
|
requiredScript: 'typecheck',
|
|
});
|
|
expect(
|
|
buildWorkspaceCommandEnvironment(repoDir, command.packageManager, {
|
|
PATH: '/tmp/bin',
|
|
}),
|
|
).toMatchObject({
|
|
PATH: '/tmp/bin',
|
|
COREPACK_ENABLE_PROJECT_SPEC: '0',
|
|
});
|
|
});
|
|
|
|
it('computes a stable snapshot over the readable workspace inputs', () => {
|
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-snapshot-'));
|
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
|
fs.mkdirSync(path.join(repoDir, 'node_modules'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'src', 'index.ts'),
|
|
'export const x = 1;\n',
|
|
);
|
|
fs.mkdirSync(path.join(repoDir, 'dist'), { recursive: true });
|
|
fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=1\n');
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'dist', 'index.js'),
|
|
'export const x = 1;\n',
|
|
);
|
|
|
|
const first = computeVerificationSnapshot(repoDir).snapshotId;
|
|
fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=2\n');
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'dist', 'index.js'),
|
|
'export const x = 2;\n',
|
|
);
|
|
const second = computeVerificationSnapshot(repoDir).snapshotId;
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'src', 'index.ts'),
|
|
'export const x = 2;\n',
|
|
);
|
|
const third = computeVerificationSnapshot(repoDir).snapshotId;
|
|
|
|
expect(second).toBe(first);
|
|
expect(third).not.toBe(first);
|
|
});
|
|
|
|
it('ignores runtime state directories and local backup folders', () => {
|
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-snapshot-'));
|
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
|
fs.mkdirSync(path.join(repoDir, 'data'), { recursive: true });
|
|
fs.mkdirSync(path.join(repoDir, 'logs'), { recursive: true });
|
|
fs.mkdirSync(path.join(repoDir, 'cache'), { recursive: true });
|
|
fs.mkdirSync(path.join(repoDir, '.ejclaw-reviewer-runtime'), {
|
|
recursive: true,
|
|
});
|
|
fs.mkdirSync(path.join(repoDir, 'store.local-backup-20260408_024500'), {
|
|
recursive: true,
|
|
});
|
|
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'src', 'index.ts'),
|
|
'export const x = 1;\n',
|
|
);
|
|
fs.writeFileSync(path.join(repoDir, 'data', 'state.json'), '{"x":1}\n');
|
|
fs.writeFileSync(path.join(repoDir, 'logs', 'service.log'), 'line-1\n');
|
|
fs.writeFileSync(path.join(repoDir, 'cache', 'tmp.txt'), 'cache-a\n');
|
|
fs.writeFileSync(
|
|
path.join(repoDir, '.ejclaw-reviewer-runtime', 'runtime.json'),
|
|
'{"ok":true}\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'store.local-backup-20260408_024500', 'db.sqlite'),
|
|
'backup-a\n',
|
|
);
|
|
|
|
const first = computeVerificationSnapshot(repoDir).snapshotId;
|
|
|
|
fs.writeFileSync(path.join(repoDir, 'data', 'state.json'), '{"x":2}\n');
|
|
fs.writeFileSync(path.join(repoDir, 'logs', 'service.log'), 'line-2\n');
|
|
fs.writeFileSync(path.join(repoDir, 'cache', 'tmp.txt'), 'cache-b\n');
|
|
fs.writeFileSync(
|
|
path.join(repoDir, '.ejclaw-reviewer-runtime', 'runtime.json'),
|
|
'{"ok":false}\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'store.local-backup-20260408_024500', 'db.sqlite'),
|
|
'backup-b\n',
|
|
);
|
|
|
|
const second = computeVerificationSnapshot(repoDir).snapshotId;
|
|
|
|
expect(second).toBe(first);
|
|
});
|
|
|
|
it('still includes nested source directories named like excluded roots', () => {
|
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-snapshot-'));
|
|
fs.mkdirSync(path.join(repoDir, 'src', 'fixtures', 'data'), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'src', 'fixtures', 'data', 'sample.json'),
|
|
'{"v":1}\n',
|
|
);
|
|
|
|
const first = computeVerificationSnapshot(repoDir).snapshotId;
|
|
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'src', 'fixtures', 'data', 'sample.json'),
|
|
'{"v":2}\n',
|
|
);
|
|
|
|
const second = computeVerificationSnapshot(repoDir).snapshotId;
|
|
|
|
expect(second).not.toBe(first);
|
|
});
|
|
|
|
it('backfills legacy node_modules state before verification', async () => {
|
|
const repoDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-verification-legacy-'),
|
|
);
|
|
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'verification-legacy',
|
|
packageManager: 'bun@1.3.11',
|
|
scripts: {
|
|
test: 'vitest run',
|
|
typecheck: 'tsc --noEmit',
|
|
build: 'tsc',
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(path.join(repoDir, 'bun.lock'), '');
|
|
fs.writeFileSync(path.join(repoDir, 'node_modules', '.bin', 'tsc'), '');
|
|
|
|
expect(hasInstalledNodeModules(repoDir)).toBe(false);
|
|
|
|
const result = await runVerificationRequest(
|
|
{
|
|
requestId: 'req-legacy-node-modules',
|
|
profile: 'typecheck',
|
|
expectedSnapshotId: 'fs:mismatch',
|
|
},
|
|
{ repoDir },
|
|
);
|
|
|
|
expect(hasInstalledNodeModules(repoDir)).toBe(true);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toContain('Snapshot mismatch before verification');
|
|
});
|
|
|
|
it('runs typecheck directly on the host for non-build profiles', async () => {
|
|
const repoDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-verification-direct-'),
|
|
);
|
|
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
|
recursive: true,
|
|
});
|
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'verification-direct',
|
|
packageManager: 'bun@1.3.11',
|
|
scripts: {
|
|
typecheck:
|
|
"node -e \"process.stdout.write('typecheck:' + (process.env.CI || 'missing'))\"",
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(path.join(repoDir, 'bun.lock'), '');
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'node_modules', '.bin', 'placeholder'),
|
|
'',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'src', 'index.ts'),
|
|
'export const value = 1;\n',
|
|
);
|
|
ensureWorkspaceDependenciesInstalled(repoDir);
|
|
|
|
const expectedSnapshotId = computeVerificationSnapshot(repoDir).snapshotId;
|
|
const result = await runVerificationRequest(
|
|
{
|
|
requestId: 'req-direct-typecheck',
|
|
profile: 'typecheck',
|
|
expectedSnapshotId,
|
|
},
|
|
{ repoDir },
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toContain('typecheck:1');
|
|
expect(result.runtimeVersion).toMatch(/^host:bun@/);
|
|
expect(result.snapshotId).toBe(expectedSnapshotId);
|
|
});
|
|
|
|
it('runs lint verification using the preferred lint script', async () => {
|
|
const repoDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-verification-direct-lint-'),
|
|
);
|
|
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
|
recursive: true,
|
|
});
|
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'verification-direct-lint',
|
|
packageManager: 'bun@1.3.11',
|
|
scripts: {
|
|
'lint:check':
|
|
"node -e \"process.stdout.write('lint-check:' + (process.env.CI || 'missing'))\"",
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(path.join(repoDir, 'bun.lock'), '');
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'node_modules', '.bin', 'placeholder'),
|
|
'',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'src', 'index.ts'),
|
|
'export const value = 1;\n',
|
|
);
|
|
ensureWorkspaceDependenciesInstalled(repoDir);
|
|
|
|
const expectedSnapshotId = computeVerificationSnapshot(repoDir).snapshotId;
|
|
const result = await runVerificationRequest(
|
|
{
|
|
requestId: 'req-direct-lint',
|
|
profile: 'lint',
|
|
expectedSnapshotId,
|
|
},
|
|
{ repoDir },
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.command).toContain('lint:check');
|
|
expect(result.stdout).toContain('lint-check:1');
|
|
expect(result.snapshotId).toBe(expectedSnapshotId);
|
|
});
|
|
|
|
it('does not leak host secrets into direct verification commands', async () => {
|
|
const repoDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-verification-direct-secret-'),
|
|
);
|
|
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'verification-direct-secret',
|
|
packageManager: 'bun@1.3.11',
|
|
scripts: {
|
|
typecheck:
|
|
'node -e "process.stdout.write(process.env.EJCLAW_VERIFICATION_SECRET || \'missing\')"',
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(path.join(repoDir, 'bun.lock'), '');
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'node_modules', '.bin', 'placeholder'),
|
|
'',
|
|
);
|
|
ensureWorkspaceDependenciesInstalled(repoDir);
|
|
|
|
const previousSecret = process.env.EJCLAW_VERIFICATION_SECRET;
|
|
process.env.EJCLAW_VERIFICATION_SECRET = 'top-secret';
|
|
|
|
try {
|
|
const expectedSnapshotId =
|
|
computeVerificationSnapshot(repoDir).snapshotId;
|
|
const result = await runVerificationRequest(
|
|
{
|
|
requestId: 'req-direct-typecheck-secret',
|
|
profile: 'typecheck',
|
|
expectedSnapshotId,
|
|
},
|
|
{ repoDir },
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.stdout).toContain('missing');
|
|
expect(result.stdout).not.toContain('top-secret');
|
|
} finally {
|
|
if (previousSecret == null) {
|
|
delete process.env.EJCLAW_VERIFICATION_SECRET;
|
|
} else {
|
|
process.env.EJCLAW_VERIFICATION_SECRET = previousSecret;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('fails direct verification if the command mutates the workspace', async () => {
|
|
const repoDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-verification-direct-mutate-'),
|
|
);
|
|
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
|
recursive: true,
|
|
});
|
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'verification-direct-mutate',
|
|
packageManager: 'bun@1.3.11',
|
|
scripts: {
|
|
test: "node -e \"require('node:fs').writeFileSync('src/generated.txt', 'x\\\\n')\"",
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(path.join(repoDir, 'bun.lock'), '');
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'node_modules', '.bin', 'placeholder'),
|
|
'',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'src', 'index.ts'),
|
|
'export const value = 1;\n',
|
|
);
|
|
ensureWorkspaceDependenciesInstalled(repoDir);
|
|
|
|
const expectedSnapshotId = computeVerificationSnapshot(repoDir).snapshotId;
|
|
const result = await runVerificationRequest(
|
|
{
|
|
requestId: 'req-direct-test-mutate',
|
|
profile: 'test',
|
|
expectedSnapshotId,
|
|
},
|
|
{ repoDir },
|
|
);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.error).toContain('Workspace changed during verification');
|
|
expect(result.snapshotId).not.toBe(expectedSnapshotId);
|
|
expect(fs.existsSync(path.join(repoDir, 'src', 'generated.txt'))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('allows build verification to write excluded output directories', async () => {
|
|
const repoDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'ejclaw-verification-build-direct-'),
|
|
);
|
|
fs.mkdirSync(path.join(repoDir, 'node_modules', '.bin'), {
|
|
recursive: true,
|
|
});
|
|
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'package.json'),
|
|
JSON.stringify({
|
|
name: 'verification-build-direct',
|
|
packageManager: 'bun@1.3.11',
|
|
scripts: {
|
|
build:
|
|
"node -e \"require('node:fs').mkdirSync('dist', { recursive: true }); require('node:fs').writeFileSync('dist/output.js', 'ok\\\\n')\"",
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(path.join(repoDir, 'bun.lock'), '');
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'node_modules', '.bin', 'placeholder'),
|
|
'',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'src', 'index.ts'),
|
|
'export const value = 1;\n',
|
|
);
|
|
ensureWorkspaceDependenciesInstalled(repoDir);
|
|
|
|
const expectedSnapshotId = computeVerificationSnapshot(repoDir).snapshotId;
|
|
const result = await runVerificationRequest(
|
|
{
|
|
requestId: 'req-direct-build',
|
|
profile: 'build',
|
|
expectedSnapshotId,
|
|
},
|
|
{ repoDir },
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.runtimeVersion).toMatch(/^host:bun@/);
|
|
expect(result.snapshotId).toBe(expectedSnapshotId);
|
|
expect(
|
|
fs.readFileSync(path.join(repoDir, 'dist', 'output.js'), 'utf-8'),
|
|
).toBe('ok\n');
|
|
});
|
|
});
|