@git.zone/tstest 3.1.8 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist_ts/00_commitinfo_data.js +3 -3
  2. package/dist_ts/tstest.classes.migration.js +4 -4
  3. package/dist_ts/tstest.classes.runtime.bun.js +5 -5
  4. package/dist_ts/tstest.classes.runtime.chromium.js +12 -10
  5. package/dist_ts/tstest.classes.runtime.deno.d.ts +4 -0
  6. package/dist_ts/tstest.classes.runtime.deno.js +22 -27
  7. package/dist_ts/tstest.classes.runtime.docker.js +2 -2
  8. package/dist_ts/tstest.classes.runtime.node.js +5 -5
  9. package/dist_ts/tstest.classes.tap.parser.js +6 -7
  10. package/dist_ts/tstest.classes.testdirectory.d.ts +1 -1
  11. package/dist_ts/tstest.classes.testdirectory.js +11 -12
  12. package/dist_ts/tstest.classes.testfile.directives.d.ts +38 -0
  13. package/dist_ts/tstest.classes.testfile.directives.js +191 -0
  14. package/dist_ts/tstest.classes.tstest.js +48 -33
  15. package/dist_ts/tstest.plugins.d.ts +6 -3
  16. package/dist_ts/tstest.plugins.js +7 -4
  17. package/dist_ts_tapbundle_serverside/classes.tapnodetools.d.ts +30 -0
  18. package/dist_ts_tapbundle_serverside/classes.tapnodetools.js +100 -3
  19. package/dist_ts_tapbundle_serverside/classes.testfileprovider.js +3 -3
  20. package/dist_ts_tapbundle_serverside/plugins.d.ts +4 -1
  21. package/dist_ts_tapbundle_serverside/plugins.js +5 -2
  22. package/npmextra.json +1 -1
  23. package/package.json +17 -16
  24. package/readme.hints.md +1 -1
  25. package/readme.md +329 -860
  26. package/ts/00_commitinfo_data.ts +2 -2
  27. package/ts/tstest.classes.migration.ts +3 -6
  28. package/ts/tstest.classes.runtime.bun.ts +4 -4
  29. package/ts/tstest.classes.runtime.chromium.ts +8 -12
  30. package/ts/tstest.classes.runtime.deno.ts +22 -26
  31. package/ts/tstest.classes.runtime.docker.ts +1 -1
  32. package/ts/tstest.classes.runtime.node.ts +4 -4
  33. package/ts/tstest.classes.tap.parser.ts +5 -7
  34. package/ts/tstest.classes.testdirectory.ts +19 -20
  35. package/ts/tstest.classes.testfile.directives.ts +226 -0
  36. package/ts/tstest.classes.tstest.ts +60 -43
  37. package/ts/tstest.plugins.ts +8 -3
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@git.zone/tstest',
6
- version: '3.1.8',
7
- description: 'a test utility to run tests that match test/**/*.ts'
6
+ version: '3.3.0',
7
+ description: 'A powerful, modern test runner for TypeScript with multi-runtime support (Node.js, Deno, Bun, Chromium) and a batteries-included test framework.'
8
8
  }
@@ -111,10 +111,7 @@ export class Migration {
111
111
  * Find all legacy test files in the base directory
112
112
  */
113
113
  async findLegacyFiles(): Promise<string[]> {
114
- const files = await plugins.smartfile.fs.listFileTree(
115
- this.options.baseDir,
116
- this.options.pattern
117
- );
114
+ const files = plugins.fs.globSync(this.options.pattern, { cwd: this.options.baseDir }) as string[];
118
115
 
119
116
  const legacyFiles: string[] = [];
120
117
 
@@ -154,7 +151,7 @@ export class Migration {
154
151
  const newPath = plugins.path.join(dirName, newFileName);
155
152
 
156
153
  // Check if target file already exists
157
- if (await plugins.smartfile.fs.fileExists(newPath)) {
154
+ if (await plugins.smartfsInstance.file(newPath).exists()) {
158
155
  return {
159
156
  oldPath: filePath,
160
157
  newPath,
@@ -206,7 +203,7 @@ export class Migration {
206
203
  private async isGitRepository(dir: string): Promise<boolean> {
207
204
  try {
208
205
  const gitDir = plugins.path.join(dir, '.git');
209
- return await plugins.smartfile.fs.isDirectory(gitDir);
206
+ return await plugins.smartfsInstance.directory(gitDir).exists();
210
207
  } catch {
211
208
  return false;
212
209
  }
@@ -121,7 +121,7 @@ export class BunRuntimeAdapter extends RuntimeAdapter {
121
121
  // Check for 00init.ts file in test directory
122
122
  const testDir = plugins.path.dirname(testFile);
123
123
  const initFile = plugins.path.join(testDir, '00init.ts');
124
- const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
124
+ const initFileExists = await plugins.smartfsInstance.file(initFile).exists();
125
125
 
126
126
  let runCommand = fullCommand;
127
127
  let loaderPath: string | null = null;
@@ -135,7 +135,7 @@ import '${absoluteInitFile.replace(/\\/g, '/')}';
135
135
  import '${absoluteTestFile.replace(/\\/g, '/')}';
136
136
  `;
137
137
  loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
138
- await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
138
+ await plugins.smartfsInstance.file(loaderPath).write(loaderContent);
139
139
 
140
140
  // Rebuild command with loader file
141
141
  const loaderCommand = this.createCommand(loaderPath, mergedOptions);
@@ -148,8 +148,8 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
148
148
  if (loaderPath) {
149
149
  const cleanup = () => {
150
150
  try {
151
- if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
152
- plugins.smartfile.fs.removeSync(loaderPath);
151
+ if (plugins.fs.existsSync(loaderPath)) {
152
+ plugins.fs.rmSync(loaderPath, { force: true });
153
153
  }
154
154
  } catch (e) {
155
155
  // Ignore cleanup errors
@@ -107,7 +107,8 @@ export class ChromiumRuntimeAdapter extends RuntimeAdapter {
107
107
  const bundleFilePath = plugins.path.join(tsbundleCacheDirPath, bundleFileName);
108
108
 
109
109
  // lets bundle the test
110
- await plugins.smartfile.fs.ensureEmptyDir(tsbundleCacheDirPath);
110
+ try { await plugins.smartfsInstance.directory(tsbundleCacheDirPath).recursive().delete(); } catch (e) { /* may not exist */ }
111
+ await plugins.smartfsInstance.directory(tsbundleCacheDirPath).recursive().create();
111
112
  await this.tsbundleInstance.build(process.cwd(), testFile, bundleFilePath, {
112
113
  bundler: 'esbuild',
113
114
  });
@@ -116,15 +117,13 @@ export class ChromiumRuntimeAdapter extends RuntimeAdapter {
116
117
  const { httpPort, wsPort } = await this.findFreePorts();
117
118
 
118
119
  // lets create a server
119
- const server = new plugins.typedserver.servertools.Server({
120
+ const server = new plugins.typedserver.TypedServer({
120
121
  cors: true,
121
122
  port: httpPort,
123
+ serveDir: tsbundleCacheDirPath,
122
124
  });
123
- server.addRoute(
124
- '/test',
125
- new plugins.typedserver.servertools.Handler('GET', async (_req, res) => {
126
- res.type('.html');
127
- res.write(`
125
+ server.addRoute('/test', 'GET', async () => {
126
+ return new Response(`
128
127
  <html>
129
128
  <head>
130
129
  <script>
@@ -134,11 +133,8 @@ export class ChromiumRuntimeAdapter extends RuntimeAdapter {
134
133
  </head>
135
134
  <body></body>
136
135
  </html>
137
- `);
138
- res.end();
139
- })
140
- );
141
- server.addRoute('/*splat', new plugins.typedserver.servertools.HandlerStatic(tsbundleCacheDirPath));
136
+ `, { headers: { 'Content-Type': 'text/html' } });
137
+ });
142
138
  await server.start();
143
139
 
144
140
  // lets handle realtime comms
@@ -10,6 +10,20 @@ import { TapParser } from './tstest.classes.tap.parser.js';
10
10
  import { TsTestLogger } from './tstest.logging.js';
11
11
  import type { Runtime } from './tstest.classes.runtime.parser.js';
12
12
 
13
+ /**
14
+ * Default Deno permissions used when no directives override them.
15
+ */
16
+ export const DENO_DEFAULT_PERMISSIONS = [
17
+ '--allow-read',
18
+ '--allow-env',
19
+ '--allow-net',
20
+ '--allow-write',
21
+ '--allow-sys',
22
+ '--allow-import',
23
+ '--node-modules-dir',
24
+ '--sloppy-imports',
25
+ ];
26
+
13
27
  /**
14
28
  * Deno runtime adapter
15
29
  * Executes tests using the Deno runtime
@@ -36,25 +50,16 @@ export class DenoRuntimeAdapter extends RuntimeAdapter {
36
50
  const denoJsonPath = plugins.path.join(process.cwd(), 'deno.json');
37
51
  const denoJsoncPath = plugins.path.join(process.cwd(), 'deno.jsonc');
38
52
 
39
- if (plugins.smartfile.fs.fileExistsSync(denoJsonPath)) {
53
+ if (plugins.fs.existsSync(denoJsonPath)) {
40
54
  configPath = denoJsonPath;
41
- } else if (plugins.smartfile.fs.fileExistsSync(denoJsoncPath)) {
55
+ } else if (plugins.fs.existsSync(denoJsoncPath)) {
42
56
  configPath = denoJsoncPath;
43
57
  }
44
58
 
45
59
  return {
46
60
  ...super.getDefaultOptions(),
47
61
  configPath,
48
- permissions: [
49
- '--allow-read',
50
- '--allow-env',
51
- '--allow-net',
52
- '--allow-write',
53
- '--allow-sys', // Allow system info access
54
- '--allow-import', // Allow npm/node imports
55
- '--node-modules-dir', // Enable Node.js compatibility mode
56
- '--sloppy-imports', // Allow .js imports to resolve to .ts files
57
- ],
62
+ permissions: [...DENO_DEFAULT_PERMISSIONS],
58
63
  };
59
64
  }
60
65
 
@@ -102,16 +107,7 @@ export class DenoRuntimeAdapter extends RuntimeAdapter {
102
107
  const args: string[] = ['run'];
103
108
 
104
109
  // Add permissions
105
- const permissions = mergedOptions.permissions || [
106
- '--allow-read',
107
- '--allow-env',
108
- '--allow-net',
109
- '--allow-write',
110
- '--allow-sys',
111
- '--allow-import',
112
- '--node-modules-dir',
113
- '--sloppy-imports',
114
- ];
110
+ const permissions = mergedOptions.permissions || [...DENO_DEFAULT_PERMISSIONS];
115
111
  args.push(...permissions);
116
112
 
117
113
  // Add config file if specified
@@ -173,7 +169,7 @@ export class DenoRuntimeAdapter extends RuntimeAdapter {
173
169
  // Check for 00init.ts file in test directory
174
170
  const testDir = plugins.path.dirname(testFile);
175
171
  const initFile = plugins.path.join(testDir, '00init.ts');
176
- const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
172
+ const initFileExists = await plugins.smartfsInstance.file(initFile).exists();
177
173
 
178
174
  let runCommand = fullCommand;
179
175
  let loaderPath: string | null = null;
@@ -187,7 +183,7 @@ import '${absoluteInitFile.replace(/\\/g, '/')}';
187
183
  import '${absoluteTestFile.replace(/\\/g, '/')}';
188
184
  `;
189
185
  loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
190
- await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
186
+ await plugins.smartfsInstance.file(loaderPath).write(loaderContent);
191
187
 
192
188
  // Rebuild command with loader file
193
189
  const loaderCommand = this.createCommand(loaderPath, mergedOptions);
@@ -200,8 +196,8 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
200
196
  if (loaderPath) {
201
197
  const cleanup = () => {
202
198
  try {
203
- if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
204
- plugins.smartfile.fs.removeSync(loaderPath);
199
+ if (plugins.fs.existsSync(loaderPath)) {
200
+ plugins.fs.rmSync(loaderPath, { force: true });
205
201
  }
206
202
  } catch (e) {
207
203
  // Ignore cleanup errors
@@ -102,7 +102,7 @@ export class DockerRuntimeAdapter extends RuntimeAdapter {
102
102
  }
103
103
 
104
104
  // Check if Dockerfile exists
105
- if (!await plugins.smartfile.fs.fileExists(dockerfilePath)) {
105
+ if (!await plugins.smartfsInstance.file(dockerfilePath).exists()) {
106
106
  throw new Error(
107
107
  `Dockerfile not found: ${dockerfilePath}\n` +
108
108
  `Expected Dockerfile for Docker test variant.`
@@ -123,7 +123,7 @@ export class NodeRuntimeAdapter extends RuntimeAdapter {
123
123
  // Check for 00init.ts file in test directory
124
124
  const testDir = plugins.path.dirname(testFile);
125
125
  const initFile = plugins.path.join(testDir, '00init.ts');
126
- const initFileExists = await plugins.smartfile.fs.fileExists(initFile);
126
+ const initFileExists = await plugins.smartfsInstance.file(initFile).exists();
127
127
 
128
128
  // Determine which file to run
129
129
  let fileToRun = testFile;
@@ -138,7 +138,7 @@ import '${absoluteInitFile.replace(/\\/g, '/')}';
138
138
  import '${absoluteTestFile.replace(/\\/g, '/')}';
139
139
  `;
140
140
  loaderPath = plugins.path.join(testDir, `.loader_${plugins.path.basename(testFile)}`);
141
- await plugins.smartfile.memory.toFs(loaderContent, loaderPath);
141
+ await plugins.smartfsInstance.file(loaderPath).write(loaderContent);
142
142
  fileToRun = loaderPath;
143
143
  }
144
144
 
@@ -150,8 +150,8 @@ import '${absoluteTestFile.replace(/\\/g, '/')}';
150
150
  if (loaderPath) {
151
151
  const cleanup = () => {
152
152
  try {
153
- if (plugins.smartfile.fs.fileExistsSync(loaderPath)) {
154
- plugins.smartfile.fs.removeSync(loaderPath);
153
+ if (plugins.fs.existsSync(loaderPath)) {
154
+ plugins.fs.rmSync(loaderPath, { force: true });
155
155
  }
156
156
  } catch (e) {
157
157
  // Ignore cleanup errors
@@ -497,12 +497,10 @@ export class TapParser {
497
497
  */
498
498
  private async handleSnapshot(snapshotData: { path: string; content: string; action: string }) {
499
499
  try {
500
- const smartfile = await import('@push.rocks/smartfile');
501
-
502
500
  if (snapshotData.action === 'compare') {
503
501
  // Try to read existing snapshot
504
502
  try {
505
- const existingSnapshot = await smartfile.fs.toStringSync(snapshotData.path);
503
+ const existingSnapshot = await plugins.smartfsInstance.file(snapshotData.path).encoding('utf8').read() as string;
506
504
  if (existingSnapshot !== snapshotData.content) {
507
505
  // Snapshot mismatch
508
506
  if (this.logger) {
@@ -520,8 +518,8 @@ export class TapParser {
520
518
  if (error.code === 'ENOENT') {
521
519
  // Snapshot doesn't exist, create it
522
520
  const dirPath = snapshotData.path.substring(0, snapshotData.path.lastIndexOf('/'));
523
- await smartfile.fs.ensureDir(dirPath);
524
- await smartfile.memory.toFs(snapshotData.content, snapshotData.path);
521
+ await plugins.smartfsInstance.directory(dirPath).recursive().create();
522
+ await plugins.smartfsInstance.file(snapshotData.path).write(snapshotData.content);
525
523
  if (this.logger) {
526
524
  this.logger.testConsoleOutput(`Snapshot created: ${snapshotData.path}`);
527
525
  }
@@ -532,8 +530,8 @@ export class TapParser {
532
530
  } else if (snapshotData.action === 'update') {
533
531
  // Update snapshot
534
532
  const dirPath = snapshotData.path.substring(0, snapshotData.path.lastIndexOf('/'));
535
- await smartfile.fs.ensureDir(dirPath);
536
- await smartfile.memory.toFs(snapshotData.content, snapshotData.path);
533
+ await plugins.smartfsInstance.directory(dirPath).recursive().create();
534
+ await plugins.smartfsInstance.file(snapshotData.path).write(snapshotData.content);
537
535
  if (this.logger) {
538
536
  this.logger.testConsoleOutput(`Snapshot updated: ${snapshotData.path}`);
539
537
  }
@@ -1,8 +1,10 @@
1
1
  import * as plugins from './tstest.plugins.js';
2
2
  import * as paths from './tstest.paths.js';
3
- import { SmartFile } from '@push.rocks/smartfile';
3
+ import { type SmartFile, SmartFileFactory } from '@push.rocks/smartfile';
4
4
  import { TestExecutionMode } from './index.js';
5
5
 
6
+ const smartFileFactory = SmartFileFactory.nodeFs();
7
+
6
8
  // tap related stuff
7
9
  import { TapCombinator } from './tstest.classes.tap.combinator.js';
8
10
  import { TapParser } from './tstest.classes.tap.parser.js';
@@ -45,28 +47,28 @@ export class TestDirectory {
45
47
  switch (this.executionMode) {
46
48
  case TestExecutionMode.FILE:
47
49
  // Single file mode
48
- const filePath = plugins.path.isAbsolute(this.testPath)
49
- ? this.testPath
50
+ const filePath = plugins.path.isAbsolute(this.testPath)
51
+ ? this.testPath
50
52
  : plugins.path.join(this.cwd, this.testPath);
51
-
52
- if (await plugins.smartfile.fs.fileExists(filePath)) {
53
- this.testfileArray = [await plugins.smartfile.SmartFile.fromFilePath(filePath)];
53
+
54
+ if (await plugins.smartfsInstance.file(filePath).exists()) {
55
+ this.testfileArray = [await smartFileFactory.fromFilePath(filePath)];
54
56
  } else {
55
57
  throw new Error(`Test file not found: ${filePath}`);
56
58
  }
57
59
  break;
58
60
 
59
61
  case TestExecutionMode.GLOB:
60
- // Glob pattern mode - use listFileTree which supports glob patterns
62
+ // Glob pattern mode - use Node.js fs.globSync for full glob support
61
63
  const globPattern = this.testPath;
62
- const matchedFiles = await plugins.smartfile.fs.listFileTree(this.cwd, globPattern);
63
-
64
+ const matchedFiles = plugins.fs.globSync(globPattern, { cwd: this.cwd });
65
+
64
66
  this.testfileArray = await Promise.all(
65
- matchedFiles.map(async (filePath) => {
66
- const absolutePath = plugins.path.isAbsolute(filePath)
67
- ? filePath
67
+ matchedFiles.map(async (filePath: string) => {
68
+ const absolutePath = plugins.path.isAbsolute(filePath)
69
+ ? filePath
68
70
  : plugins.path.join(this.cwd, filePath);
69
- return await plugins.smartfile.SmartFile.fromFilePath(absolutePath);
71
+ return await smartFileFactory.fromFilePath(absolutePath);
70
72
  })
71
73
  );
72
74
  break;
@@ -79,19 +81,16 @@ export class TestDirectory {
79
81
  const tsPattern = '**/test*.ts';
80
82
  const dockerPattern = '**/*.docker.sh';
81
83
 
82
- const [tsFiles, dockerFiles] = await Promise.all([
83
- plugins.smartfile.fs.listFileTree(dirPath, tsPattern),
84
- plugins.smartfile.fs.listFileTree(dirPath, dockerPattern),
85
- ]);
86
-
87
- const allTestFiles = [...tsFiles, ...dockerFiles];
84
+ const tsFiles = plugins.fs.globSync(tsPattern, { cwd: dirPath });
85
+ const dockerFiles = plugins.fs.globSync(dockerPattern, { cwd: dirPath });
86
+ const allTestFiles = [...tsFiles, ...dockerFiles] as string[];
88
87
 
89
88
  this.testfileArray = await Promise.all(
90
89
  allTestFiles.map(async (filePath) => {
91
90
  const absolutePath = plugins.path.isAbsolute(filePath)
92
91
  ? filePath
93
92
  : plugins.path.join(dirPath, filePath);
94
- return await plugins.smartfile.SmartFile.fromFilePath(absolutePath);
93
+ return await smartFileFactory.fromFilePath(absolutePath);
95
94
  })
96
95
  );
97
96
  break;
@@ -0,0 +1,226 @@
1
+ import * as plugins from './tstest.plugins.js';
2
+ import type { DenoOptions, RuntimeOptions } from './tstest.classes.runtime.adapter.js';
3
+ import type { Runtime } from './tstest.classes.runtime.parser.js';
4
+ import { DENO_DEFAULT_PERMISSIONS } from './tstest.classes.runtime.deno.js';
5
+
6
+ type DirectiveScope = Runtime | 'global';
7
+
8
+ export interface ITestFileDirective {
9
+ scope: DirectiveScope;
10
+ key: string;
11
+ value?: string;
12
+ }
13
+
14
+ export interface IParsedDirectives {
15
+ deno: ITestFileDirective[];
16
+ node: ITestFileDirective[];
17
+ bun: ITestFileDirective[];
18
+ chromium: ITestFileDirective[];
19
+ global: ITestFileDirective[];
20
+ }
21
+
22
+ const VALID_SCOPES = new Set<string>(['deno', 'node', 'bun', 'chromium']);
23
+
24
+ const DENO_PERMISSION_MAP: Record<string, string> = {
25
+ allowAll: '--allow-all',
26
+ allowRun: '--allow-run',
27
+ allowFfi: '--allow-ffi',
28
+ allowHrtime: '--allow-hrtime',
29
+ allowRead: '--allow-read',
30
+ allowWrite: '--allow-write',
31
+ allowNet: '--allow-net',
32
+ allowEnv: '--allow-env',
33
+ allowSys: '--allow-sys',
34
+ };
35
+
36
+ function createEmptyDirectives(): IParsedDirectives {
37
+ return { deno: [], node: [], bun: [], chromium: [], global: [] };
38
+ }
39
+
40
+ /**
41
+ * Parse tstest directives from file content.
42
+ * Scans comments at the top of the file (before any code).
43
+ */
44
+ export function parseDirectivesFromContent(content: string): IParsedDirectives {
45
+ const result = createEmptyDirectives();
46
+ const lines = content.split('\n');
47
+ const maxLines = Math.min(lines.length, 30);
48
+
49
+ for (let i = 0; i < maxLines; i++) {
50
+ const line = lines[i].trim();
51
+
52
+ // Skip empty lines
53
+ if (line === '') continue;
54
+
55
+ // Stop at first non-comment line
56
+ if (!line.startsWith('//')) break;
57
+
58
+ // Match tstest directive: // tstest:<rest>
59
+ const match = line.match(/^\/\/\s*tstest:(.+)$/);
60
+ if (!match) continue;
61
+
62
+ const parts = match[1].split(':');
63
+ if (parts.length < 2) {
64
+ console.warn(`Warning: malformed tstest directive: "${line}"`);
65
+ continue;
66
+ }
67
+
68
+ const scopeStr = parts[0].trim();
69
+ const key = parts[1].trim();
70
+ const value = parts.length > 2 ? parts.slice(2).join(':').trim() : undefined;
71
+
72
+ // Handle global directives (env, timeout)
73
+ if (scopeStr === 'env' || scopeStr === 'timeout') {
74
+ result.global.push({
75
+ scope: 'global',
76
+ key: scopeStr,
77
+ value: key + (value !== undefined ? ':' + value : ''),
78
+ });
79
+ continue;
80
+ }
81
+
82
+ if (!VALID_SCOPES.has(scopeStr)) {
83
+ console.warn(`Warning: unknown tstest directive scope "${scopeStr}" in: "${line}"`);
84
+ continue;
85
+ }
86
+
87
+ const scope = scopeStr as Runtime;
88
+ result[scope].push({ scope, key, value });
89
+ }
90
+
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Parse directives from a test file on disk.
96
+ */
97
+ export async function parseDirectivesFromFile(filePath: string): Promise<IParsedDirectives> {
98
+ try {
99
+ const content = plugins.fs.readFileSync(filePath, 'utf8');
100
+ return parseDirectivesFromContent(content);
101
+ } catch {
102
+ return createEmptyDirectives();
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Merge directives from 00init.ts and the test file.
108
+ * Test file directives are appended (take effect after init directives).
109
+ */
110
+ export function mergeDirectives(init: IParsedDirectives, testFile: IParsedDirectives): IParsedDirectives {
111
+ return {
112
+ deno: [...init.deno, ...testFile.deno],
113
+ node: [...init.node, ...testFile.node],
114
+ bun: [...init.bun, ...testFile.bun],
115
+ chromium: [...init.chromium, ...testFile.chromium],
116
+ global: [...init.global, ...testFile.global],
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Check if any directives exist for any scope.
122
+ */
123
+ export function hasDirectives(directives: IParsedDirectives): boolean {
124
+ return (
125
+ directives.deno.length > 0 ||
126
+ directives.node.length > 0 ||
127
+ directives.bun.length > 0 ||
128
+ directives.chromium.length > 0 ||
129
+ directives.global.length > 0
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Convert parsed directives into DenoOptions.
135
+ */
136
+ function directivesToDenoOptions(directives: IParsedDirectives): DenoOptions | undefined {
137
+ const denoDirectives = directives.deno;
138
+ if (denoDirectives.length === 0 && directives.global.length === 0) return undefined;
139
+
140
+ const options: DenoOptions = {};
141
+ const extraPermissions: string[] = [];
142
+ const extraArgs: string[] = [];
143
+ const env: Record<string, string> = {};
144
+ let useAllowAll = false;
145
+
146
+ for (const d of denoDirectives) {
147
+ if (d.key === 'allowAll') {
148
+ useAllowAll = true;
149
+ } else if (DENO_PERMISSION_MAP[d.key]) {
150
+ extraPermissions.push(DENO_PERMISSION_MAP[d.key]);
151
+ } else if (d.key === 'flag' && d.value) {
152
+ extraArgs.push(d.value);
153
+ }
154
+ }
155
+
156
+ // Process global directives
157
+ for (const d of directives.global) {
158
+ if (d.key === 'env' && d.value) {
159
+ const eqIndex = d.value.indexOf('=');
160
+ if (eqIndex > 0) {
161
+ env[d.value.substring(0, eqIndex)] = d.value.substring(eqIndex + 1);
162
+ }
163
+ }
164
+ }
165
+
166
+ if (useAllowAll) {
167
+ // --allow-all replaces individual permissions, but keep compatibility flags
168
+ options.permissions = ['--allow-all', '--node-modules-dir', '--sloppy-imports'];
169
+ } else if (extraPermissions.length > 0) {
170
+ // Start with defaults and add extra permissions (deduplicated)
171
+ const allPermissions = [...DENO_DEFAULT_PERMISSIONS];
172
+ for (const p of extraPermissions) {
173
+ if (!allPermissions.includes(p)) {
174
+ allPermissions.push(p);
175
+ }
176
+ }
177
+ options.permissions = allPermissions;
178
+ }
179
+
180
+ if (extraArgs.length > 0) options.extraArgs = extraArgs;
181
+ if (Object.keys(env).length > 0) options.env = env;
182
+
183
+ // Return undefined if nothing was set
184
+ if (!options.permissions && !options.extraArgs && !options.env) return undefined;
185
+ return options;
186
+ }
187
+
188
+ /**
189
+ * Convert parsed directives into RuntimeOptions for Node/Bun (flag directives only).
190
+ */
191
+ function directivesToGenericOptions(directives: ITestFileDirective[], globalDirectives: ITestFileDirective[]): RuntimeOptions | undefined {
192
+ const extraArgs: string[] = [];
193
+ const env: Record<string, string> = {};
194
+
195
+ for (const d of directives) {
196
+ if (d.key === 'flag' && d.value) {
197
+ extraArgs.push(d.value);
198
+ }
199
+ }
200
+
201
+ for (const d of globalDirectives) {
202
+ if (d.key === 'env' && d.value) {
203
+ const eqIndex = d.value.indexOf('=');
204
+ if (eqIndex > 0) {
205
+ env[d.value.substring(0, eqIndex)] = d.value.substring(eqIndex + 1);
206
+ }
207
+ }
208
+ }
209
+
210
+ if (extraArgs.length === 0 && Object.keys(env).length === 0) return undefined;
211
+
212
+ const options: RuntimeOptions = {};
213
+ if (extraArgs.length > 0) options.extraArgs = extraArgs;
214
+ if (Object.keys(env).length > 0) options.env = env;
215
+ return options;
216
+ }
217
+
218
+ /**
219
+ * Convert parsed directives into RuntimeOptions for a specific runtime.
220
+ */
221
+ export function directivesToRuntimeOptions(directives: IParsedDirectives, runtime: Runtime): RuntimeOptions | undefined {
222
+ if (runtime === 'deno') {
223
+ return directivesToDenoOptions(directives);
224
+ }
225
+ return directivesToGenericOptions(directives[runtime] || [], directives.global);
226
+ }