feat(testfile-directives): Add per-test file directives to control runtime permissions and flags (Deno, Node, Bun, Chromium)
This commit is contained in:
226
ts/tstest.classes.testfile.directives.ts
Normal file
226
ts/tstest.classes.testfile.directives.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user