feat(testfile-directives): Add per-test file directives to control runtime permissions and flags (Deno, Node, Bun, Chromium)

This commit is contained in:
2026-03-06 08:12:28 +00:00
parent 4b4ec78328
commit 69263b3efc
10 changed files with 593 additions and 114 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@git.zone/tstest',
version: '3.2.0',
description: 'a test utility to run tests that match test/**/*.ts'
version: '3.3.0',
description: 'A powerful, modern test runner for TypeScript with multi-runtime support (Node.js, Deno, Bun, Chromium) and a batteries-included test framework.'
}

View File

@@ -10,6 +10,20 @@ import { TapParser } from './tstest.classes.tap.parser.js';
import { TsTestLogger } from './tstest.logging.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
/**
* Default Deno permissions used when no directives override them.
*/
export const DENO_DEFAULT_PERMISSIONS = [
'--allow-read',
'--allow-env',
'--allow-net',
'--allow-write',
'--allow-sys',
'--allow-import',
'--node-modules-dir',
'--sloppy-imports',
];
/**
* Deno runtime adapter
* Executes tests using the Deno runtime
@@ -45,16 +59,7 @@ export class DenoRuntimeAdapter extends RuntimeAdapter {
return {
...super.getDefaultOptions(),
configPath,
permissions: [
'--allow-read',
'--allow-env',
'--allow-net',
'--allow-write',
'--allow-sys', // Allow system info access
'--allow-import', // Allow npm/node imports
'--node-modules-dir', // Enable Node.js compatibility mode
'--sloppy-imports', // Allow .js imports to resolve to .ts files
],
permissions: [...DENO_DEFAULT_PERMISSIONS],
};
}
@@ -102,16 +107,7 @@ export class DenoRuntimeAdapter extends RuntimeAdapter {
const args: string[] = ['run'];
// Add permissions
const permissions = mergedOptions.permissions || [
'--allow-read',
'--allow-env',
'--allow-net',
'--allow-write',
'--allow-sys',
'--allow-import',
'--node-modules-dir',
'--sloppy-imports',
];
const permissions = mergedOptions.permissions || [...DENO_DEFAULT_PERMISSIONS];
args.push(...permissions);
// Add config file if specified

View File

@@ -0,0 +1,226 @@
import * as plugins from './tstest.plugins.js';
import type { DenoOptions, RuntimeOptions } from './tstest.classes.runtime.adapter.js';
import type { Runtime } from './tstest.classes.runtime.parser.js';
import { DENO_DEFAULT_PERMISSIONS } from './tstest.classes.runtime.deno.js';
type DirectiveScope = Runtime | 'global';
export interface ITestFileDirective {
scope: DirectiveScope;
key: string;
value?: string;
}
export interface IParsedDirectives {
deno: ITestFileDirective[];
node: ITestFileDirective[];
bun: ITestFileDirective[];
chromium: ITestFileDirective[];
global: ITestFileDirective[];
}
const VALID_SCOPES = new Set<string>(['deno', 'node', 'bun', 'chromium']);
const DENO_PERMISSION_MAP: Record<string, string> = {
allowAll: '--allow-all',
allowRun: '--allow-run',
allowFfi: '--allow-ffi',
allowHrtime: '--allow-hrtime',
allowRead: '--allow-read',
allowWrite: '--allow-write',
allowNet: '--allow-net',
allowEnv: '--allow-env',
allowSys: '--allow-sys',
};
function createEmptyDirectives(): IParsedDirectives {
return { deno: [], node: [], bun: [], chromium: [], global: [] };
}
/**
* Parse tstest directives from file content.
* Scans comments at the top of the file (before any code).
*/
export function parseDirectivesFromContent(content: string): IParsedDirectives {
const result = createEmptyDirectives();
const lines = content.split('\n');
const maxLines = Math.min(lines.length, 30);
for (let i = 0; i < maxLines; i++) {
const line = lines[i].trim();
// Skip empty lines
if (line === '') continue;
// Stop at first non-comment line
if (!line.startsWith('//')) break;
// Match tstest directive: // tstest:<rest>
const match = line.match(/^\/\/\s*tstest:(.+)$/);
if (!match) continue;
const parts = match[1].split(':');
if (parts.length < 2) {
console.warn(`Warning: malformed tstest directive: "${line}"`);
continue;
}
const scopeStr = parts[0].trim();
const key = parts[1].trim();
const value = parts.length > 2 ? parts.slice(2).join(':').trim() : undefined;
// Handle global directives (env, timeout)
if (scopeStr === 'env' || scopeStr === 'timeout') {
result.global.push({
scope: 'global',
key: scopeStr,
value: key + (value !== undefined ? ':' + value : ''),
});
continue;
}
if (!VALID_SCOPES.has(scopeStr)) {
console.warn(`Warning: unknown tstest directive scope "${scopeStr}" in: "${line}"`);
continue;
}
const scope = scopeStr as Runtime;
result[scope].push({ scope, key, value });
}
return result;
}
/**
* Parse directives from a test file on disk.
*/
export async function parseDirectivesFromFile(filePath: string): Promise<IParsedDirectives> {
try {
const content = plugins.fs.readFileSync(filePath, 'utf8');
return parseDirectivesFromContent(content);
} catch {
return createEmptyDirectives();
}
}
/**
* Merge directives from 00init.ts and the test file.
* Test file directives are appended (take effect after init directives).
*/
export function mergeDirectives(init: IParsedDirectives, testFile: IParsedDirectives): IParsedDirectives {
return {
deno: [...init.deno, ...testFile.deno],
node: [...init.node, ...testFile.node],
bun: [...init.bun, ...testFile.bun],
chromium: [...init.chromium, ...testFile.chromium],
global: [...init.global, ...testFile.global],
};
}
/**
* Check if any directives exist for any scope.
*/
export function hasDirectives(directives: IParsedDirectives): boolean {
return (
directives.deno.length > 0 ||
directives.node.length > 0 ||
directives.bun.length > 0 ||
directives.chromium.length > 0 ||
directives.global.length > 0
);
}
/**
* Convert parsed directives into DenoOptions.
*/
function directivesToDenoOptions(directives: IParsedDirectives): DenoOptions | undefined {
const denoDirectives = directives.deno;
if (denoDirectives.length === 0 && directives.global.length === 0) return undefined;
const options: DenoOptions = {};
const extraPermissions: string[] = [];
const extraArgs: string[] = [];
const env: Record<string, string> = {};
let useAllowAll = false;
for (const d of denoDirectives) {
if (d.key === 'allowAll') {
useAllowAll = true;
} else if (DENO_PERMISSION_MAP[d.key]) {
extraPermissions.push(DENO_PERMISSION_MAP[d.key]);
} else if (d.key === 'flag' && d.value) {
extraArgs.push(d.value);
}
}
// Process global directives
for (const d of directives.global) {
if (d.key === 'env' && d.value) {
const eqIndex = d.value.indexOf('=');
if (eqIndex > 0) {
env[d.value.substring(0, eqIndex)] = d.value.substring(eqIndex + 1);
}
}
}
if (useAllowAll) {
// --allow-all replaces individual permissions, but keep compatibility flags
options.permissions = ['--allow-all', '--node-modules-dir', '--sloppy-imports'];
} else if (extraPermissions.length > 0) {
// Start with defaults and add extra permissions (deduplicated)
const allPermissions = [...DENO_DEFAULT_PERMISSIONS];
for (const p of extraPermissions) {
if (!allPermissions.includes(p)) {
allPermissions.push(p);
}
}
options.permissions = allPermissions;
}
if (extraArgs.length > 0) options.extraArgs = extraArgs;
if (Object.keys(env).length > 0) options.env = env;
// Return undefined if nothing was set
if (!options.permissions && !options.extraArgs && !options.env) return undefined;
return options;
}
/**
* Convert parsed directives into RuntimeOptions for Node/Bun (flag directives only).
*/
function directivesToGenericOptions(directives: ITestFileDirective[], globalDirectives: ITestFileDirective[]): RuntimeOptions | undefined {
const extraArgs: string[] = [];
const env: Record<string, string> = {};
for (const d of directives) {
if (d.key === 'flag' && d.value) {
extraArgs.push(d.value);
}
}
for (const d of globalDirectives) {
if (d.key === 'env' && d.value) {
const eqIndex = d.value.indexOf('=');
if (eqIndex > 0) {
env[d.value.substring(0, eqIndex)] = d.value.substring(eqIndex + 1);
}
}
}
if (extraArgs.length === 0 && Object.keys(env).length === 0) return undefined;
const options: RuntimeOptions = {};
if (extraArgs.length > 0) options.extraArgs = extraArgs;
if (Object.keys(env).length > 0) options.env = env;
return options;
}
/**
* Convert parsed directives into RuntimeOptions for a specific runtime.
*/
export function directivesToRuntimeOptions(directives: IParsedDirectives, runtime: Runtime): RuntimeOptions | undefined {
if (runtime === 'deno') {
return directivesToDenoOptions(directives);
}
return directivesToGenericOptions(directives[runtime] || [], directives.global);
}

View File

@@ -19,6 +19,14 @@ import { DenoRuntimeAdapter } from './tstest.classes.runtime.deno.js';
import { BunRuntimeAdapter } from './tstest.classes.runtime.bun.js';
import { DockerRuntimeAdapter } from './tstest.classes.runtime.docker.js';
// Test file directives
import {
parseDirectivesFromFile,
mergeDirectives,
directivesToRuntimeOptions,
hasDirectives,
} from './tstest.classes.testfile.directives.js';
export class TsTest {
public testDir: TestDirectory;
public executionMode: TestExecutionMode;
@@ -256,18 +264,32 @@ export class TsTest {
return;
}
// Parse directives from the test file (e.g., // tstest:deno:allowAll)
let directives = await parseDirectivesFromFile(fileNameArg);
// Also check for directives in 00init.ts
const testDir = plugins.path.dirname(fileNameArg);
const initFile = plugins.path.join(testDir, '00init.ts');
const initFileExists = await plugins.smartfsInstance.file(initFile).exists();
if (initFileExists) {
const initDirectives = await parseDirectivesFromFile(initFile);
directives = mergeDirectives(initDirectives, directives);
}
// Execute tests for each runtime
if (adapters.length === 1) {
// Single runtime - no sections needed
const adapter = adapters[0];
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles);
const options = hasDirectives(directives) ? directivesToRuntimeOptions(directives, adapter.id) : undefined;
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles, options);
tapCombinator.addTapParser(tapParser);
} else {
// Multiple runtimes - use sections
for (let i = 0; i < adapters.length; i++) {
const adapter = adapters[i];
this.logger.sectionStart(`Part ${i + 1}: ${adapter.displayName}`);
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles);
const options = hasDirectives(directives) ? directivesToRuntimeOptions(directives, adapter.id) : undefined;
const tapParser = await adapter.run(fileNameArg, fileIndex, totalFiles, options);
tapCombinator.addTapParser(tapParser);
this.logger.sectionEnd();
}