tryscript 0.0.1 → 0.1.1

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.
@@ -1,15 +1,33 @@
1
1
 
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { existsSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { parse } from "yaml";
6
6
  import { spawn } from "node:child_process";
7
- import { mkdtemp, realpath, rm } from "node:fs/promises";
7
+ import { cp, mkdtemp, realpath, rm } from "node:fs/promises";
8
8
  import { tmpdir } from "node:os";
9
9
  import treeKill from "tree-kill";
10
10
  import stripAnsi from "strip-ansi";
11
11
 
12
12
  //#region src/lib/config.ts
13
+ /** Default coverage configuration values. */
14
+ const DEFAULT_COVERAGE_CONFIG = {
15
+ reportsDir: "coverage-tryscript",
16
+ reporters: ["text", "html"],
17
+ include: ["dist/**"],
18
+ src: "src"
19
+ };
20
+ /**
21
+ * Resolve coverage options by merging user config with defaults.
22
+ */
23
+ function resolveCoverageConfig(config) {
24
+ return {
25
+ reportsDir: config?.reportsDir ?? DEFAULT_COVERAGE_CONFIG.reportsDir,
26
+ reporters: config?.reporters ?? DEFAULT_COVERAGE_CONFIG.reporters,
27
+ include: config?.include ?? DEFAULT_COVERAGE_CONFIG.include,
28
+ src: config?.src ?? DEFAULT_COVERAGE_CONFIG.src
29
+ };
30
+ }
13
31
  const CONFIG_FILES = [
14
32
  "tryscript.config.ts",
15
33
  "tryscript.config.js",
@@ -44,7 +62,8 @@ function mergeConfig(base, frontmatter) {
44
62
  patterns: {
45
63
  ...base.patterns,
46
64
  ...frontmatter.patterns
47
- }
65
+ },
66
+ fixtures: [...base.fixtures ?? [], ...frontmatter.fixtures ?? []]
48
67
  };
49
68
  }
50
69
  /**
@@ -62,6 +81,10 @@ const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
62
81
  const CODE_BLOCK_REGEX = /```(console|bash)\r?\n([\s\S]*?)```/g;
63
82
  /** Regex to match markdown headings (for test names) */
64
83
  const HEADING_REGEX = /^#+\s+(?:Test:\s*)?(.+)$/m;
84
+ /** Regex to match skip annotation in heading or nearby HTML comment */
85
+ const SKIP_ANNOTATION_REGEX = /<!--\s*skip\s*-->/i;
86
+ /** Regex to match only annotation in heading or nearby HTML comment */
87
+ const ONLY_ANNOTATION_REGEX = /<!--\s*only\s*-->/i;
65
88
  /**
66
89
  * Parse a .tryscript.md file into structured test data.
67
90
  */
@@ -81,15 +104,23 @@ function parseTestFile(content, filePath) {
81
104
  const blockContent = match[2] ?? "";
82
105
  const blockStart = match.index;
83
106
  const lineNumber = content.slice(0, content.indexOf(match[0])).split("\n").length;
84
- const name = [...body.slice(0, blockStart).matchAll(new RegExp(HEADING_REGEX.source, "gm"))].pop()?.[1]?.trim();
107
+ const contentBefore = body.slice(0, blockStart);
108
+ const lastHeadingMatch = [...contentBefore.matchAll(new RegExp(HEADING_REGEX.source, "gm"))].pop();
109
+ const name = lastHeadingMatch?.[1]?.trim();
110
+ const headingContext = lastHeadingMatch ? contentBefore.slice(contentBefore.lastIndexOf(lastHeadingMatch[0])) : "";
111
+ const skip = SKIP_ANNOTATION_REGEX.test(headingContext);
112
+ const only = ONLY_ANNOTATION_REGEX.test(headingContext);
85
113
  const parsed = parseBlockContent(blockContent);
86
114
  if (parsed) blocks.push({
87
115
  name,
88
116
  command: parsed.command,
89
117
  expectedOutput: parsed.expectedOutput,
118
+ expectedStderr: parsed.expectedStderr,
90
119
  expectedExitCode: parsed.expectedExitCode,
91
120
  lineNumber,
92
- rawContent: match[0]
121
+ rawContent: match[0],
122
+ skip,
123
+ only
93
124
  });
94
125
  }
95
126
  return {
@@ -106,6 +137,7 @@ function parseBlockContent(content) {
106
137
  const lines = content.split("\n");
107
138
  const commandLines = [];
108
139
  const outputLines = [];
140
+ const stderrLines = [];
109
141
  let expectedExitCode = 0;
110
142
  let inCommand = false;
111
143
  for (const line of lines) if (line.startsWith("$ ")) {
@@ -115,6 +147,9 @@ function parseBlockContent(content) {
115
147
  else if (line.startsWith("? ")) {
116
148
  inCommand = false;
117
149
  expectedExitCode = parseInt(line.slice(2).trim(), 10);
150
+ } else if (line.startsWith("! ")) {
151
+ inCommand = false;
152
+ stderrLines.push(line.slice(2));
118
153
  } else {
119
154
  inCommand = false;
120
155
  outputLines.push(line);
@@ -132,9 +167,16 @@ function parseBlockContent(content) {
132
167
  let expectedOutput = outputLines.join("\n");
133
168
  expectedOutput = expectedOutput.replace(/\n+$/, "");
134
169
  if (expectedOutput) expectedOutput += "\n";
170
+ let expectedStderr;
171
+ if (stderrLines.length > 0) {
172
+ expectedStderr = stderrLines.join("\n");
173
+ expectedStderr = expectedStderr.replace(/\n+$/, "");
174
+ if (expectedStderr) expectedStderr += "\n";
175
+ }
135
176
  return {
136
177
  command: command.trim(),
137
178
  expectedOutput,
179
+ expectedStderr,
138
180
  expectedExitCode
139
181
  };
140
182
  }
@@ -144,25 +186,59 @@ function parseBlockContent(content) {
144
186
  /** Default timeout in milliseconds */
145
187
  const DEFAULT_TIMEOUT = 3e4;
146
188
  /**
189
+ * Normalize fixture config to Fixture object.
190
+ */
191
+ function normalizeFixture(fixture) {
192
+ if (typeof fixture === "string") return { source: fixture };
193
+ return fixture;
194
+ }
195
+ /**
196
+ * Setup fixtures by copying files to sandbox directory.
197
+ */
198
+ async function setupFixtures(fixtures, testDir, sandboxDir) {
199
+ if (!fixtures || fixtures.length === 0) return;
200
+ for (const f of fixtures) {
201
+ const fixture = normalizeFixture(f);
202
+ await cp(resolve(testDir, fixture.source), resolve(sandboxDir, fixture.dest ?? basename(fixture.source)), { recursive: true });
203
+ }
204
+ }
205
+ /**
147
206
  * Create an execution context for a test file.
207
+ * @param config - Test configuration
208
+ * @param testFilePath - Path to the test file
209
+ * @param coverageEnv - Optional coverage environment variables (e.g., NODE_V8_COVERAGE)
148
210
  */
149
- async function createExecutionContext(config, testFilePath) {
211
+ async function createExecutionContext(config, testFilePath, coverageEnv) {
150
212
  const tempDir = await realpath(await mkdtemp(join(tmpdir(), "tryscript-")));
151
213
  const testDir = resolve(dirname(testFilePath));
152
- let binPath = config.bin ?? "";
153
- if (binPath && !binPath.startsWith("/")) binPath = join(testDir, binPath);
214
+ let cwd;
215
+ let sandbox = false;
216
+ if (config.sandbox === true) {
217
+ cwd = tempDir;
218
+ sandbox = true;
219
+ } else if (typeof config.sandbox === "string") {
220
+ await cp(resolve(testDir, config.sandbox), tempDir, { recursive: true });
221
+ cwd = tempDir;
222
+ sandbox = true;
223
+ } else if (config.cwd) cwd = resolve(testDir, config.cwd);
224
+ else cwd = testDir;
225
+ if (sandbox && config.fixtures) await setupFixtures(config.fixtures, testDir, tempDir);
154
226
  return {
155
227
  tempDir,
156
228
  testDir,
157
- binPath,
229
+ cwd,
230
+ sandbox,
158
231
  env: {
159
232
  ...process.env,
160
233
  ...config.env,
234
+ ...coverageEnv,
161
235
  NO_COLOR: config.env?.NO_COLOR ?? "1",
162
236
  FORCE_COLOR: "0",
163
237
  TRYSCRIPT_TEST_DIR: testDir
164
238
  },
165
- timeout: config.timeout ?? DEFAULT_TIMEOUT
239
+ timeout: config.timeout ?? DEFAULT_TIMEOUT,
240
+ before: config.before,
241
+ after: config.after
166
242
  };
167
243
  }
168
244
  /**
@@ -175,16 +251,42 @@ async function cleanupExecutionContext(ctx) {
175
251
  });
176
252
  }
177
253
  /**
254
+ * Run the before hook if it hasn't run yet.
255
+ */
256
+ async function runBeforeHook(ctx) {
257
+ if (ctx.before && !ctx.beforeRan) {
258
+ ctx.beforeRan = true;
259
+ await executeCommand(ctx.before, ctx);
260
+ }
261
+ }
262
+ /**
263
+ * Run the after hook.
264
+ */
265
+ async function runAfterHook(ctx) {
266
+ if (ctx.after) await executeCommand(ctx.after, ctx);
267
+ }
268
+ /**
178
269
  * Run a single test block and return the result.
179
270
  */
180
271
  async function runBlock(block, ctx) {
181
272
  const startTime = Date.now();
273
+ if (block.skip) return {
274
+ block,
275
+ passed: true,
276
+ actualOutput: "",
277
+ actualExitCode: 0,
278
+ duration: 0,
279
+ skipped: true
280
+ };
182
281
  try {
183
- const { output, exitCode } = await executeCommand(block.command, ctx);
282
+ await runBeforeHook(ctx);
283
+ const { output, stdout, stderr, exitCode } = await executeCommand(block.command, ctx);
184
284
  return {
185
285
  block,
186
286
  passed: true,
187
287
  actualOutput: output,
288
+ actualStdout: stdout,
289
+ actualStderr: stderr,
188
290
  actualExitCode: exitCode,
189
291
  duration: Date.now() - startTime
190
292
  };
@@ -206,7 +308,7 @@ async function executeCommand(command, ctx) {
206
308
  return new Promise((resolve$1, reject) => {
207
309
  const proc = spawn(command, {
208
310
  shell: true,
209
- cwd: ctx.tempDir,
311
+ cwd: ctx.cwd,
210
312
  env: ctx.env,
211
313
  stdio: [
212
314
  "ignore",
@@ -214,9 +316,23 @@ async function executeCommand(command, ctx) {
214
316
  "pipe"
215
317
  ]
216
318
  });
217
- const chunks = [];
218
- proc.stdout.on("data", (data) => chunks.push(data));
219
- proc.stderr.on("data", (data) => chunks.push(data));
319
+ const combinedChunks = [];
320
+ const stdoutChunks = [];
321
+ const stderrChunks = [];
322
+ proc.stdout.on("data", (data) => {
323
+ combinedChunks.push({
324
+ data,
325
+ type: "stdout"
326
+ });
327
+ stdoutChunks.push(data);
328
+ });
329
+ proc.stderr.on("data", (data) => {
330
+ combinedChunks.push({
331
+ data,
332
+ type: "stderr"
333
+ });
334
+ stderrChunks.push(data);
335
+ });
220
336
  const timeoutId = setTimeout(() => {
221
337
  if (proc.pid) treeKill(proc.pid, "SIGKILL");
222
338
  reject(/* @__PURE__ */ new Error(`Command timed out after ${ctx.timeout}ms`));
@@ -224,7 +340,9 @@ async function executeCommand(command, ctx) {
224
340
  proc.on("close", (code) => {
225
341
  clearTimeout(timeoutId);
226
342
  resolve$1({
227
- output: Buffer.concat(chunks).toString("utf-8"),
343
+ output: Buffer.concat(combinedChunks.map((c) => c.data)).toString("utf-8"),
344
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
345
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
228
346
  exitCode: code ?? 0
229
347
  });
230
348
  });
@@ -321,8 +439,8 @@ function matchOutput(actual, expected, context, customPatterns = {}) {
321
439
 
322
440
  //#endregion
323
441
  //#region src/index.ts
324
- const VERSION = "0.0.1";
442
+ const VERSION = "0.1.1";
325
443
 
326
444
  //#endregion
327
- export { createExecutionContext as a, defineConfig as c, cleanupExecutionContext as i, loadConfig as l, matchOutput as n, runBlock as o, normalizeOutput as r, parseTestFile as s, VERSION as t, mergeConfig as u };
328
- //# sourceMappingURL=src-UjaSQrqA.mjs.map
445
+ export { createExecutionContext as a, parseTestFile as c, mergeConfig as d, resolveCoverageConfig as f, cleanupExecutionContext as i, defineConfig as l, matchOutput as n, runAfterHook as o, normalizeOutput as r, runBlock as s, VERSION as t, loadConfig as u };
446
+ //# sourceMappingURL=src-CndHSuTT.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"src-CndHSuTT.mjs","names":["DEFAULT_COVERAGE_CONFIG: Required<CoverageConfig>","config: TestConfig","parseYaml","blocks: TestBlock[]","match: RegExpExecArray | null","commandLines: string[]","outputLines: string[]","stderrLines: string[]","expectedStderr: string | undefined","cwd: string","combinedChunks: { data: Buffer; type: 'stdout' | 'stderr' }[]","stdoutChunks: Buffer[]","stderrChunks: Buffer[]","VERSION: string"],"sources":["../src/lib/config.ts","../src/lib/parser.ts","../src/lib/runner.ts","../src/lib/matcher.ts","../src/index.ts"],"sourcesContent":["import { pathToFileURL } from 'node:url';\nimport { existsSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport type { TestConfig, CoverageConfig } from './types.js';\n\n/** Fixture configuration for copying files to sandbox directory */\nexport interface Fixture {\n /** Source path (resolved relative to test file) */\n source: string;\n /** Destination path (resolved relative to sandbox dir) */\n dest?: string;\n}\n\nexport interface TryscriptConfig {\n /** Working directory for commands (default: test file directory) */\n cwd?: string;\n /** Run in isolated sandbox: true = empty temp, path = copy to temp */\n sandbox?: boolean | string;\n /** Fixtures to copy to sandbox directory before tests */\n fixtures?: (string | Fixture)[];\n /** Script to run before first test block */\n before?: string;\n /** Script to run after all test blocks */\n after?: string;\n env?: Record<string, string>;\n timeout?: number;\n patterns?: Record<string, RegExp | string>;\n tests?: string[];\n /** Coverage configuration (used with --coverage flag) */\n coverage?: CoverageConfig;\n}\n\n/** Default coverage configuration values. */\nexport const DEFAULT_COVERAGE_CONFIG: Required<CoverageConfig> = {\n reportsDir: 'coverage-tryscript',\n reporters: ['text', 'html'],\n include: ['dist/**'],\n src: 'src',\n};\n\n/**\n * Resolve coverage options by merging user config with defaults.\n */\nexport function resolveCoverageConfig(config?: CoverageConfig): Required<CoverageConfig> {\n return {\n reportsDir: config?.reportsDir ?? DEFAULT_COVERAGE_CONFIG.reportsDir,\n reporters: config?.reporters ?? DEFAULT_COVERAGE_CONFIG.reporters,\n include: config?.include ?? DEFAULT_COVERAGE_CONFIG.include,\n src: config?.src ?? DEFAULT_COVERAGE_CONFIG.src,\n };\n}\n\nconst CONFIG_FILES = ['tryscript.config.ts', 'tryscript.config.js', 'tryscript.config.mjs'];\n\n/**\n * Load config file using dynamic import.\n * Supports both TypeScript (via tsx/ts-node) and JavaScript configs.\n */\nexport async function loadConfig(baseDir: string): Promise<TryscriptConfig> {\n for (const filename of CONFIG_FILES) {\n const configPath = resolve(baseDir, filename);\n if (existsSync(configPath)) {\n const configUrl = pathToFileURL(configPath).href;\n const module = (await import(configUrl)) as { default?: TryscriptConfig } | TryscriptConfig;\n return (module as { default?: TryscriptConfig }).default ?? (module as TryscriptConfig);\n }\n }\n return {};\n}\n\n/**\n * Merge config with frontmatter overrides.\n * Frontmatter takes precedence over config file.\n */\nexport function mergeConfig(base: TryscriptConfig, frontmatter: TestConfig): TryscriptConfig {\n return {\n ...base,\n ...frontmatter,\n env: { ...base.env, ...frontmatter.env },\n patterns: { ...base.patterns, ...frontmatter.patterns },\n fixtures: [...(base.fixtures ?? []), ...(frontmatter.fixtures ?? [])],\n };\n}\n\n/**\n * Helper for typed config files.\n */\nexport function defineConfig(config: TryscriptConfig): TryscriptConfig {\n return config;\n}\n","import { parse as parseYaml } from 'yaml';\nimport type { TestConfig, TestBlock, TestFile } from './types.js';\n\n/** Regex to match YAML frontmatter at the start of a file */\nconst FRONTMATTER_REGEX = /^---\\r?\\n([\\s\\S]*?)\\r?\\n---\\r?\\n/;\n\n/** Regex to match fenced code blocks with console/bash info string */\nconst CODE_BLOCK_REGEX = /```(console|bash)\\r?\\n([\\s\\S]*?)```/g;\n\n/** Regex to match markdown headings (for test names) */\nconst HEADING_REGEX = /^#+\\s+(?:Test:\\s*)?(.+)$/m;\n\n/** Regex to match skip annotation in heading or nearby HTML comment */\nconst SKIP_ANNOTATION_REGEX = /<!--\\s*skip\\s*-->/i;\n\n/** Regex to match only annotation in heading or nearby HTML comment */\nconst ONLY_ANNOTATION_REGEX = /<!--\\s*only\\s*-->/i;\n\n/**\n * Parse a .tryscript.md file into structured test data.\n */\nexport function parseTestFile(content: string, filePath: string): TestFile {\n const rawContent = content;\n let config: TestConfig = {};\n let body = content;\n\n // Extract frontmatter if present\n const frontmatterMatch = FRONTMATTER_REGEX.exec(content);\n if (frontmatterMatch) {\n const yamlContent = frontmatterMatch[1] ?? '';\n config = parseYaml(yamlContent) as TestConfig;\n body = content.slice(frontmatterMatch[0].length);\n }\n\n // Parse all console blocks\n const blocks: TestBlock[] = [];\n\n // Reset regex lastIndex\n CODE_BLOCK_REGEX.lastIndex = 0;\n\n let match: RegExpExecArray | null;\n while ((match = CODE_BLOCK_REGEX.exec(body)) !== null) {\n const blockContent = match[2] ?? '';\n const blockStart = match.index;\n\n // Find the line number (1-indexed)\n const precedingContent = content.slice(0, content.indexOf(match[0]));\n const lineNumber = precedingContent.split('\\n').length;\n\n // Look for a heading before this block (for test name)\n const contentBefore = body.slice(0, blockStart);\n const lastHeadingMatch = [\n ...contentBefore.matchAll(new RegExp(HEADING_REGEX.source, 'gm')),\n ].pop();\n const name = lastHeadingMatch?.[1]?.trim();\n\n // Check for skip/only annotations in the heading line or nearby comments\n const headingContext = lastHeadingMatch\n ? contentBefore.slice(contentBefore.lastIndexOf(lastHeadingMatch[0]))\n : '';\n const skip = SKIP_ANNOTATION_REGEX.test(headingContext);\n const only = ONLY_ANNOTATION_REGEX.test(headingContext);\n\n // Parse the block content\n const parsed = parseBlockContent(blockContent);\n if (parsed) {\n blocks.push({\n name,\n command: parsed.command,\n expectedOutput: parsed.expectedOutput,\n expectedStderr: parsed.expectedStderr,\n expectedExitCode: parsed.expectedExitCode,\n lineNumber,\n rawContent: match[0],\n skip,\n only,\n });\n }\n }\n\n return { path: filePath, config, blocks, rawContent };\n}\n\n/**\n * Parse the content of a single console block.\n */\nfunction parseBlockContent(content: string): {\n command: string;\n expectedOutput: string;\n expectedStderr?: string;\n expectedExitCode: number;\n} | null {\n const lines = content.split('\\n');\n const commandLines: string[] = [];\n const outputLines: string[] = [];\n const stderrLines: string[] = [];\n let expectedExitCode = 0;\n let inCommand = false;\n\n for (const line of lines) {\n if (line.startsWith('$ ')) {\n // Start of a command\n inCommand = true;\n commandLines.push(line.slice(2));\n } else if (line.startsWith('> ') && inCommand) {\n // Continuation of a multi-line command\n commandLines.push(line.slice(2));\n } else if (line.startsWith('? ')) {\n // Exit code specification\n inCommand = false;\n expectedExitCode = parseInt(line.slice(2).trim(), 10);\n } else if (line.startsWith('! ')) {\n // Stderr line (prefixed with !)\n inCommand = false;\n stderrLines.push(line.slice(2));\n } else {\n // Output line (stdout or combined)\n inCommand = false;\n outputLines.push(line);\n }\n }\n\n if (commandLines.length === 0) {\n return null;\n }\n\n // Join command lines, handling shell continuations\n let command = '';\n for (let i = 0; i < commandLines.length; i++) {\n const line = commandLines[i] ?? '';\n if (line.endsWith('\\\\')) {\n command += line.slice(0, -1) + ' ';\n } else {\n command += line;\n if (i < commandLines.length - 1) {\n command += ' ';\n }\n }\n }\n\n // Join output lines, preserving blank lines but trimming trailing empty lines\n let expectedOutput = outputLines.join('\\n');\n expectedOutput = expectedOutput.replace(/\\n+$/, '');\n if (expectedOutput) {\n expectedOutput += '\\n';\n }\n\n // Join stderr lines if any\n let expectedStderr: string | undefined;\n if (stderrLines.length > 0) {\n expectedStderr = stderrLines.join('\\n');\n expectedStderr = expectedStderr.replace(/\\n+$/, '');\n if (expectedStderr) {\n expectedStderr += '\\n';\n }\n }\n\n return { command: command.trim(), expectedOutput, expectedStderr, expectedExitCode };\n}\n","import { spawn } from 'node:child_process';\nimport { mkdtemp, realpath, rm, cp } from 'node:fs/promises';\nimport { tmpdir } from 'node:os';\nimport { join, dirname, resolve, basename } from 'node:path';\nimport treeKill from 'tree-kill';\nimport type { TestBlock, TestBlockResult } from './types.js';\nimport type { TryscriptConfig, Fixture } from './config.js';\n\n/** Default timeout in milliseconds */\nconst DEFAULT_TIMEOUT = 30_000;\n\n/**\n * Execution context for a test file.\n * Created once per file, contains directory paths and config.\n */\nexport interface ExecutionContext {\n /** Temporary directory for this test file (resolved, no symlinks) */\n tempDir: string;\n /** Directory containing the test file */\n testDir: string;\n /** Working directory for command execution */\n cwd: string;\n /** Whether running in sandbox mode */\n sandbox: boolean;\n /** Environment variables */\n env: Record<string, string>;\n /** Timeout per command */\n timeout: number;\n /** Before hook script */\n before?: string;\n /** After hook script */\n after?: string;\n /** Whether before hook has been run */\n beforeRan?: boolean;\n}\n\n/**\n * Normalize fixture config to Fixture object.\n */\nfunction normalizeFixture(fixture: string | Fixture): Fixture {\n if (typeof fixture === 'string') {\n return { source: fixture };\n }\n return fixture;\n}\n\n/**\n * Setup fixtures by copying files to sandbox directory.\n */\nasync function setupFixtures(\n fixtures: (string | Fixture)[] | undefined,\n testDir: string,\n sandboxDir: string,\n): Promise<void> {\n if (!fixtures || fixtures.length === 0) {\n return;\n }\n\n for (const f of fixtures) {\n const fixture = normalizeFixture(f);\n const src = resolve(testDir, fixture.source);\n const destName = fixture.dest ?? basename(fixture.source);\n const dst = resolve(sandboxDir, destName);\n await cp(src, dst, { recursive: true });\n }\n}\n\n/**\n * Create an execution context for a test file.\n * @param config - Test configuration\n * @param testFilePath - Path to the test file\n * @param coverageEnv - Optional coverage environment variables (e.g., NODE_V8_COVERAGE)\n */\nexport async function createExecutionContext(\n config: TryscriptConfig,\n testFilePath: string,\n coverageEnv?: Record<string, string>,\n): Promise<ExecutionContext> {\n // Create temp directory and resolve symlinks (e.g., /var -> /private/var on macOS)\n // This ensures [CWD] and [ROOT] patterns match pwd output\n const rawTempDir = await mkdtemp(join(tmpdir(), 'tryscript-'));\n const tempDir = await realpath(rawTempDir);\n\n // Resolve test file directory for portable test commands\n const testDir = resolve(dirname(testFilePath));\n\n // Determine working directory based on sandbox config\n let cwd: string;\n let sandbox = false;\n\n if (config.sandbox === true) {\n // Empty sandbox: run in temp directory\n cwd = tempDir;\n sandbox = true;\n } else if (typeof config.sandbox === 'string') {\n // Copy directory to sandbox: copy source to temp, run in temp\n const srcPath = resolve(testDir, config.sandbox);\n await cp(srcPath, tempDir, { recursive: true });\n cwd = tempDir;\n sandbox = true;\n } else if (config.cwd) {\n // Run in specified directory (relative to test file)\n cwd = resolve(testDir, config.cwd);\n } else {\n // Default: run in test file directory\n cwd = testDir;\n }\n\n // Copy additional fixtures to sandbox (only if sandbox enabled)\n if (sandbox && config.fixtures) {\n await setupFixtures(config.fixtures, testDir, tempDir);\n }\n\n const ctx: ExecutionContext = {\n tempDir,\n testDir,\n cwd,\n sandbox,\n env: {\n ...process.env,\n ...config.env,\n ...coverageEnv,\n // Disable colors by default for deterministic output\n NO_COLOR: config.env?.NO_COLOR ?? '1',\n FORCE_COLOR: '0',\n // Provide test directory for portable test commands\n TRYSCRIPT_TEST_DIR: testDir,\n } as Record<string, string>,\n timeout: config.timeout ?? DEFAULT_TIMEOUT,\n before: config.before,\n after: config.after,\n };\n\n return ctx;\n}\n\n/**\n * Clean up execution context (remove temp directory).\n */\nexport async function cleanupExecutionContext(ctx: ExecutionContext): Promise<void> {\n await rm(ctx.tempDir, { recursive: true, force: true });\n}\n\n/**\n * Run the before hook if it hasn't run yet.\n */\nexport async function runBeforeHook(ctx: ExecutionContext): Promise<void> {\n if (ctx.before && !ctx.beforeRan) {\n ctx.beforeRan = true;\n await executeCommand(ctx.before, ctx);\n }\n}\n\n/**\n * Run the after hook.\n */\nexport async function runAfterHook(ctx: ExecutionContext): Promise<void> {\n if (ctx.after) {\n await executeCommand(ctx.after, ctx);\n }\n}\n\n/**\n * Run a single test block and return the result.\n */\nexport async function runBlock(block: TestBlock, ctx: ExecutionContext): Promise<TestBlockResult> {\n const startTime = Date.now();\n\n // Handle skip annotation\n if (block.skip) {\n return {\n block,\n passed: true,\n actualOutput: '',\n actualExitCode: 0,\n duration: 0,\n skipped: true,\n };\n }\n\n try {\n // Run before hook if this is the first test\n await runBeforeHook(ctx);\n\n // Execute command directly (shell handles $VAR expansion)\n const { output, stdout, stderr, exitCode } = await executeCommand(block.command, ctx);\n\n const duration = Date.now() - startTime;\n\n return {\n block,\n passed: true, // Matching handled separately\n actualOutput: output,\n actualStdout: stdout,\n actualStderr: stderr,\n actualExitCode: exitCode,\n duration,\n };\n } catch (error) {\n const duration = Date.now() - startTime;\n const message = error instanceof Error ? error.message : String(error);\n\n return {\n block,\n passed: false,\n actualOutput: '',\n actualExitCode: -1,\n duration,\n error: message,\n };\n }\n}\n\n/** Command execution result with separate stdout/stderr */\ninterface CommandResult {\n output: string;\n stdout: string;\n stderr: string;\n exitCode: number;\n}\n\n/**\n * Execute a command and capture output.\n */\nasync function executeCommand(command: string, ctx: ExecutionContext): Promise<CommandResult> {\n return new Promise((resolve, reject) => {\n const proc = spawn(command, {\n shell: true,\n cwd: ctx.cwd,\n env: ctx.env as NodeJS.ProcessEnv,\n // Pipe both to capture\n stdio: ['ignore', 'pipe', 'pipe'],\n });\n\n const combinedChunks: { data: Buffer; type: 'stdout' | 'stderr' }[] = [];\n const stdoutChunks: Buffer[] = [];\n const stderrChunks: Buffer[] = [];\n\n // Capture data as it comes in to preserve order\n proc.stdout.on('data', (data: Buffer) => {\n combinedChunks.push({ data, type: 'stdout' });\n stdoutChunks.push(data);\n });\n proc.stderr.on('data', (data: Buffer) => {\n combinedChunks.push({ data, type: 'stderr' });\n stderrChunks.push(data);\n });\n\n const timeoutId = setTimeout(() => {\n if (proc.pid) {\n treeKill(proc.pid, 'SIGKILL');\n }\n reject(new Error(`Command timed out after ${ctx.timeout}ms`));\n }, ctx.timeout);\n\n proc.on('close', (code) => {\n clearTimeout(timeoutId);\n const output = Buffer.concat(combinedChunks.map((c) => c.data)).toString('utf-8');\n const stdout = Buffer.concat(stdoutChunks).toString('utf-8');\n const stderr = Buffer.concat(stderrChunks).toString('utf-8');\n resolve({\n output,\n stdout,\n stderr,\n exitCode: code ?? 0,\n });\n });\n\n proc.on('error', (err) => {\n clearTimeout(timeoutId);\n reject(err);\n });\n });\n}\n","import stripAnsi from 'strip-ansi';\n\n/**\n * Escape special regex characters in a string.\n */\nfunction escapeRegex(str: string): string {\n return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n// Marker prefix for patterns (uses Unicode private use chars that won't appear in normal output)\nconst MARKER = '\\uE000';\n\n/**\n * Convert expected output with elision patterns to a regex.\n *\n * Handles (matching trycmd):\n * - [..] — matches any characters on the same line (trycmd: [^\\n]*?)\n * - ... — matches zero or more complete lines (trycmd: \\n(([^\\n]*\\n)*)?)\n * - [EXE] — matches .exe on Windows, empty otherwise\n * - [ROOT] — replaced with test root directory (pre-processed)\n * - [CWD] — replaced with current working directory (pre-processed)\n * - Custom [NAME] patterns from config (trycmd: TestCases::insert_var)\n */\nfunction patternToRegex(\n expected: string,\n customPatterns: Record<string, string | RegExp> = {},\n): RegExp {\n // Build a map of markers to their regex replacements\n const replacements = new Map<string, string>();\n let markerIndex = 0;\n\n const getMarker = (): string => {\n return `${MARKER}${markerIndex++}${MARKER}`;\n };\n\n let processed = expected;\n\n // Replace [..] with marker\n const dotdotMarker = getMarker();\n replacements.set(dotdotMarker, '[^\\\\n]*');\n processed = processed.replaceAll('[..]', dotdotMarker);\n\n // Replace ... (followed by newline) with marker\n const ellipsisMarker = getMarker();\n replacements.set(ellipsisMarker, '(?:[^\\\\n]*\\\\n)*');\n processed = processed.replace(/\\.\\.\\.\\n/g, ellipsisMarker);\n\n // Replace [EXE] with marker\n const exeMarker = getMarker();\n const exe = process.platform === 'win32' ? '\\\\.exe' : '';\n replacements.set(exeMarker, exe);\n processed = processed.replaceAll('[EXE]', exeMarker);\n\n // Replace custom patterns with markers\n for (const [name, pattern] of Object.entries(customPatterns)) {\n const placeholder = `[${name}]`;\n const patternStr = pattern instanceof RegExp ? pattern.source : pattern;\n const marker = getMarker();\n replacements.set(marker, `(${patternStr})`);\n processed = processed.replaceAll(placeholder, marker);\n }\n\n // Escape special regex characters\n let regex = escapeRegex(processed);\n\n // Restore markers to their regex replacements\n for (const [marker, replacement] of replacements) {\n regex = regex.replaceAll(escapeRegex(marker), replacement);\n }\n\n // Match the entire string (dotall mode for . to match newlines if needed)\n return new RegExp(`^${regex}$`, 's');\n}\n\n/**\n * Pre-process expected output to replace path placeholders with actual paths.\n * This happens BEFORE pattern matching.\n */\nfunction preprocessPaths(expected: string, context: { root: string; cwd: string }): string {\n let result = expected;\n // Normalize paths for comparison (use forward slashes)\n const normalizedRoot = context.root.replace(/\\\\/g, '/');\n const normalizedCwd = context.cwd.replace(/\\\\/g, '/');\n result = result.replaceAll('[ROOT]', normalizedRoot);\n result = result.replaceAll('[CWD]', normalizedCwd);\n return result;\n}\n\n/**\n * Normalize actual output for comparison.\n * - Remove ANSI escape codes (colors, etc.)\n * - Normalize line endings to \\n\n * - Normalize paths (Windows backslashes to forward slashes)\n * - Trim trailing whitespace from lines\n * - Ensure single trailing newline\n */\nexport function normalizeOutput(output: string): string {\n // Remove ANSI escape codes first\n let normalized = stripAnsi(output);\n\n normalized = normalized\n .replace(/\\r\\n/g, '\\n')\n .replace(/\\r/g, '\\n')\n .split('\\n')\n .map((line) => line.trimEnd())\n .join('\\n')\n .replace(/\\n+$/, '\\n');\n\n // Handle empty output\n if (normalized === '\\n') {\n normalized = '';\n }\n\n return normalized;\n}\n\n/**\n * Check if actual output matches expected pattern.\n */\nexport function matchOutput(\n actual: string,\n expected: string,\n context: { root: string; cwd: string },\n customPatterns: Record<string, string | RegExp> = {},\n): boolean {\n const normalizedActual = normalizeOutput(actual);\n const normalizedExpected = normalizeOutput(expected);\n\n // Empty expected matches empty actual\n if (normalizedExpected === '' && normalizedActual === '') {\n return true;\n }\n\n const preprocessed = preprocessPaths(normalizedExpected, context);\n const regex = patternToRegex(preprocessed, customPatterns);\n return regex.test(normalizedActual);\n}\n","// Public API exports\n\n// Version constant (injected at build time)\ndeclare const __VERSION__: string;\nexport const VERSION: string = typeof __VERSION__ !== 'undefined' ? __VERSION__ : 'development';\n\n// Config helper\nexport { defineConfig } from './lib/config.js';\nexport type { TryscriptConfig } from './lib/config.js';\n\n// Types\nexport type {\n TestConfig,\n TestBlock,\n TestFile,\n TestBlockResult,\n TestFileResult,\n TestRunSummary,\n CoverageConfig,\n CoverageContext,\n} from './lib/types.js';\n\n// Core functions (for programmatic use)\nexport { parseTestFile } from './lib/parser.js';\nexport { runBlock, createExecutionContext, cleanupExecutionContext } from './lib/runner.js';\nexport type { ExecutionContext } from './lib/runner.js';\nexport { matchOutput, normalizeOutput } from './lib/matcher.js';\n"],"mappings":"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"}
@@ -39,6 +39,24 @@ let strip_ansi = require("strip-ansi");
39
39
  strip_ansi = __toESM(strip_ansi);
40
40
 
41
41
  //#region src/lib/config.ts
42
+ /** Default coverage configuration values. */
43
+ const DEFAULT_COVERAGE_CONFIG = {
44
+ reportsDir: "coverage-tryscript",
45
+ reporters: ["text", "html"],
46
+ include: ["dist/**"],
47
+ src: "src"
48
+ };
49
+ /**
50
+ * Resolve coverage options by merging user config with defaults.
51
+ */
52
+ function resolveCoverageConfig(config) {
53
+ return {
54
+ reportsDir: config?.reportsDir ?? DEFAULT_COVERAGE_CONFIG.reportsDir,
55
+ reporters: config?.reporters ?? DEFAULT_COVERAGE_CONFIG.reporters,
56
+ include: config?.include ?? DEFAULT_COVERAGE_CONFIG.include,
57
+ src: config?.src ?? DEFAULT_COVERAGE_CONFIG.src
58
+ };
59
+ }
42
60
  const CONFIG_FILES = [
43
61
  "tryscript.config.ts",
44
62
  "tryscript.config.js",
@@ -73,7 +91,8 @@ function mergeConfig(base, frontmatter) {
73
91
  patterns: {
74
92
  ...base.patterns,
75
93
  ...frontmatter.patterns
76
- }
94
+ },
95
+ fixtures: [...base.fixtures ?? [], ...frontmatter.fixtures ?? []]
77
96
  };
78
97
  }
79
98
  /**
@@ -91,6 +110,10 @@ const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
91
110
  const CODE_BLOCK_REGEX = /```(console|bash)\r?\n([\s\S]*?)```/g;
92
111
  /** Regex to match markdown headings (for test names) */
93
112
  const HEADING_REGEX = /^#+\s+(?:Test:\s*)?(.+)$/m;
113
+ /** Regex to match skip annotation in heading or nearby HTML comment */
114
+ const SKIP_ANNOTATION_REGEX = /<!--\s*skip\s*-->/i;
115
+ /** Regex to match only annotation in heading or nearby HTML comment */
116
+ const ONLY_ANNOTATION_REGEX = /<!--\s*only\s*-->/i;
94
117
  /**
95
118
  * Parse a .tryscript.md file into structured test data.
96
119
  */
@@ -110,15 +133,23 @@ function parseTestFile(content, filePath) {
110
133
  const blockContent = match[2] ?? "";
111
134
  const blockStart = match.index;
112
135
  const lineNumber = content.slice(0, content.indexOf(match[0])).split("\n").length;
113
- const name = [...body.slice(0, blockStart).matchAll(new RegExp(HEADING_REGEX.source, "gm"))].pop()?.[1]?.trim();
136
+ const contentBefore = body.slice(0, blockStart);
137
+ const lastHeadingMatch = [...contentBefore.matchAll(new RegExp(HEADING_REGEX.source, "gm"))].pop();
138
+ const name = lastHeadingMatch?.[1]?.trim();
139
+ const headingContext = lastHeadingMatch ? contentBefore.slice(contentBefore.lastIndexOf(lastHeadingMatch[0])) : "";
140
+ const skip = SKIP_ANNOTATION_REGEX.test(headingContext);
141
+ const only = ONLY_ANNOTATION_REGEX.test(headingContext);
114
142
  const parsed = parseBlockContent(blockContent);
115
143
  if (parsed) blocks.push({
116
144
  name,
117
145
  command: parsed.command,
118
146
  expectedOutput: parsed.expectedOutput,
147
+ expectedStderr: parsed.expectedStderr,
119
148
  expectedExitCode: parsed.expectedExitCode,
120
149
  lineNumber,
121
- rawContent: match[0]
150
+ rawContent: match[0],
151
+ skip,
152
+ only
122
153
  });
123
154
  }
124
155
  return {
@@ -135,6 +166,7 @@ function parseBlockContent(content) {
135
166
  const lines = content.split("\n");
136
167
  const commandLines = [];
137
168
  const outputLines = [];
169
+ const stderrLines = [];
138
170
  let expectedExitCode = 0;
139
171
  let inCommand = false;
140
172
  for (const line of lines) if (line.startsWith("$ ")) {
@@ -144,6 +176,9 @@ function parseBlockContent(content) {
144
176
  else if (line.startsWith("? ")) {
145
177
  inCommand = false;
146
178
  expectedExitCode = parseInt(line.slice(2).trim(), 10);
179
+ } else if (line.startsWith("! ")) {
180
+ inCommand = false;
181
+ stderrLines.push(line.slice(2));
147
182
  } else {
148
183
  inCommand = false;
149
184
  outputLines.push(line);
@@ -161,9 +196,16 @@ function parseBlockContent(content) {
161
196
  let expectedOutput = outputLines.join("\n");
162
197
  expectedOutput = expectedOutput.replace(/\n+$/, "");
163
198
  if (expectedOutput) expectedOutput += "\n";
199
+ let expectedStderr;
200
+ if (stderrLines.length > 0) {
201
+ expectedStderr = stderrLines.join("\n");
202
+ expectedStderr = expectedStderr.replace(/\n+$/, "");
203
+ if (expectedStderr) expectedStderr += "\n";
204
+ }
164
205
  return {
165
206
  command: command.trim(),
166
207
  expectedOutput,
208
+ expectedStderr,
167
209
  expectedExitCode
168
210
  };
169
211
  }
@@ -173,25 +215,59 @@ function parseBlockContent(content) {
173
215
  /** Default timeout in milliseconds */
174
216
  const DEFAULT_TIMEOUT = 3e4;
175
217
  /**
218
+ * Normalize fixture config to Fixture object.
219
+ */
220
+ function normalizeFixture(fixture) {
221
+ if (typeof fixture === "string") return { source: fixture };
222
+ return fixture;
223
+ }
224
+ /**
225
+ * Setup fixtures by copying files to sandbox directory.
226
+ */
227
+ async function setupFixtures(fixtures, testDir, sandboxDir) {
228
+ if (!fixtures || fixtures.length === 0) return;
229
+ for (const f of fixtures) {
230
+ const fixture = normalizeFixture(f);
231
+ await (0, node_fs_promises.cp)((0, node_path.resolve)(testDir, fixture.source), (0, node_path.resolve)(sandboxDir, fixture.dest ?? (0, node_path.basename)(fixture.source)), { recursive: true });
232
+ }
233
+ }
234
+ /**
176
235
  * Create an execution context for a test file.
236
+ * @param config - Test configuration
237
+ * @param testFilePath - Path to the test file
238
+ * @param coverageEnv - Optional coverage environment variables (e.g., NODE_V8_COVERAGE)
177
239
  */
178
- async function createExecutionContext(config, testFilePath) {
240
+ async function createExecutionContext(config, testFilePath, coverageEnv) {
179
241
  const tempDir = await (0, node_fs_promises.realpath)(await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "tryscript-")));
180
242
  const testDir = (0, node_path.resolve)((0, node_path.dirname)(testFilePath));
181
- let binPath = config.bin ?? "";
182
- if (binPath && !binPath.startsWith("/")) binPath = (0, node_path.join)(testDir, binPath);
243
+ let cwd;
244
+ let sandbox = false;
245
+ if (config.sandbox === true) {
246
+ cwd = tempDir;
247
+ sandbox = true;
248
+ } else if (typeof config.sandbox === "string") {
249
+ await (0, node_fs_promises.cp)((0, node_path.resolve)(testDir, config.sandbox), tempDir, { recursive: true });
250
+ cwd = tempDir;
251
+ sandbox = true;
252
+ } else if (config.cwd) cwd = (0, node_path.resolve)(testDir, config.cwd);
253
+ else cwd = testDir;
254
+ if (sandbox && config.fixtures) await setupFixtures(config.fixtures, testDir, tempDir);
183
255
  return {
184
256
  tempDir,
185
257
  testDir,
186
- binPath,
258
+ cwd,
259
+ sandbox,
187
260
  env: {
188
261
  ...process.env,
189
262
  ...config.env,
263
+ ...coverageEnv,
190
264
  NO_COLOR: config.env?.NO_COLOR ?? "1",
191
265
  FORCE_COLOR: "0",
192
266
  TRYSCRIPT_TEST_DIR: testDir
193
267
  },
194
- timeout: config.timeout ?? DEFAULT_TIMEOUT
268
+ timeout: config.timeout ?? DEFAULT_TIMEOUT,
269
+ before: config.before,
270
+ after: config.after
195
271
  };
196
272
  }
197
273
  /**
@@ -204,16 +280,42 @@ async function cleanupExecutionContext(ctx) {
204
280
  });
205
281
  }
206
282
  /**
283
+ * Run the before hook if it hasn't run yet.
284
+ */
285
+ async function runBeforeHook(ctx) {
286
+ if (ctx.before && !ctx.beforeRan) {
287
+ ctx.beforeRan = true;
288
+ await executeCommand(ctx.before, ctx);
289
+ }
290
+ }
291
+ /**
292
+ * Run the after hook.
293
+ */
294
+ async function runAfterHook(ctx) {
295
+ if (ctx.after) await executeCommand(ctx.after, ctx);
296
+ }
297
+ /**
207
298
  * Run a single test block and return the result.
208
299
  */
209
300
  async function runBlock(block, ctx) {
210
301
  const startTime = Date.now();
302
+ if (block.skip) return {
303
+ block,
304
+ passed: true,
305
+ actualOutput: "",
306
+ actualExitCode: 0,
307
+ duration: 0,
308
+ skipped: true
309
+ };
211
310
  try {
212
- const { output, exitCode } = await executeCommand(block.command, ctx);
311
+ await runBeforeHook(ctx);
312
+ const { output, stdout, stderr, exitCode } = await executeCommand(block.command, ctx);
213
313
  return {
214
314
  block,
215
315
  passed: true,
216
316
  actualOutput: output,
317
+ actualStdout: stdout,
318
+ actualStderr: stderr,
217
319
  actualExitCode: exitCode,
218
320
  duration: Date.now() - startTime
219
321
  };
@@ -235,7 +337,7 @@ async function executeCommand(command, ctx) {
235
337
  return new Promise((resolve$2, reject) => {
236
338
  const proc = (0, node_child_process.spawn)(command, {
237
339
  shell: true,
238
- cwd: ctx.tempDir,
340
+ cwd: ctx.cwd,
239
341
  env: ctx.env,
240
342
  stdio: [
241
343
  "ignore",
@@ -243,9 +345,23 @@ async function executeCommand(command, ctx) {
243
345
  "pipe"
244
346
  ]
245
347
  });
246
- const chunks = [];
247
- proc.stdout.on("data", (data) => chunks.push(data));
248
- proc.stderr.on("data", (data) => chunks.push(data));
348
+ const combinedChunks = [];
349
+ const stdoutChunks = [];
350
+ const stderrChunks = [];
351
+ proc.stdout.on("data", (data) => {
352
+ combinedChunks.push({
353
+ data,
354
+ type: "stdout"
355
+ });
356
+ stdoutChunks.push(data);
357
+ });
358
+ proc.stderr.on("data", (data) => {
359
+ combinedChunks.push({
360
+ data,
361
+ type: "stderr"
362
+ });
363
+ stderrChunks.push(data);
364
+ });
249
365
  const timeoutId = setTimeout(() => {
250
366
  if (proc.pid) (0, tree_kill.default)(proc.pid, "SIGKILL");
251
367
  reject(/* @__PURE__ */ new Error(`Command timed out after ${ctx.timeout}ms`));
@@ -253,7 +369,9 @@ async function executeCommand(command, ctx) {
253
369
  proc.on("close", (code) => {
254
370
  clearTimeout(timeoutId);
255
371
  resolve$2({
256
- output: Buffer.concat(chunks).toString("utf-8"),
372
+ output: Buffer.concat(combinedChunks.map((c) => c.data)).toString("utf-8"),
373
+ stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
374
+ stderr: Buffer.concat(stderrChunks).toString("utf-8"),
257
375
  exitCode: code ?? 0
258
376
  });
259
377
  });
@@ -350,7 +468,7 @@ function matchOutput(actual, expected, context, customPatterns = {}) {
350
468
 
351
469
  //#endregion
352
470
  //#region src/index.ts
353
- const VERSION = "0.0.1";
471
+ const VERSION = "0.1.1";
354
472
 
355
473
  //#endregion
356
474
  Object.defineProperty(exports, 'VERSION', {
@@ -413,10 +531,22 @@ Object.defineProperty(exports, 'parseTestFile', {
413
531
  return parseTestFile;
414
532
  }
415
533
  });
534
+ Object.defineProperty(exports, 'resolveCoverageConfig', {
535
+ enumerable: true,
536
+ get: function () {
537
+ return resolveCoverageConfig;
538
+ }
539
+ });
540
+ Object.defineProperty(exports, 'runAfterHook', {
541
+ enumerable: true,
542
+ get: function () {
543
+ return runAfterHook;
544
+ }
545
+ });
416
546
  Object.defineProperty(exports, 'runBlock', {
417
547
  enumerable: true,
418
548
  get: function () {
419
549
  return runBlock;
420
550
  }
421
551
  });
422
- //# sourceMappingURL=src-CeUA446P.cjs.map
552
+ //# sourceMappingURL=src-CxUUK92Q.cjs.map