diff --git a/backend/windmill-worker/loader.bun.js b/backend/windmill-worker/loader.bun.js index a697ba1d5334a..43a9279adc34c 100644 --- a/backend/windmill-worker/loader.bun.js +++ b/backend/windmill-worker/loader.bun.js @@ -54,6 +54,32 @@ const p = { build.onLoad({ filter: /.*\.url$/ }, async (args) => { const url = readFileSync(args.path, "utf8"); + + // Extract the script path from the URL to check for local file + // URL format: {base_url}/api/w/{w_id}/scripts/raw_unpinned/p/{script_path} + try { + const urlObj = new URL(url); + const pathMatch = urlObj.pathname.match(/\/scripts\/raw_unpinned\/p\/(.+)$/); + + if (pathMatch) { + const scriptPath = pathMatch[1]; + // Check if we have this file locally (from raw_scripts) + const localPath = resolve("./" + scriptPath); + try { + const localContent = readFileSync(localPath, "utf8"); + return { + contents: replaceRelativeImports(localContent).contents, + loader: "tsx", + }; + } catch { + // File not found locally, fall through to fetch from server + } + } + } catch { + // URL parsing failed, fall through to fetch from server + } + + // Fallback: fetch from server const req = await fetch(url, { method: "GET", headers: { diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 404e22148606e..789672949ad33 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -50,6 +50,14 @@ use windmill_common::{ use windmill_common::s3_helpers::attempt_fetch_bytes; use windmill_parser::Typ; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RawScriptForDependencies { + pub script_path: String, + pub raw_code: Option, + pub language: ScriptLang, +} const RELATIVE_BUN_LOADER: &str = include_str!("../loader.bun.js"); @@ -106,11 +114,42 @@ pub async fn gen_bun_lockfile( workspace_dependencies: &WorkspaceDependenciesPrefetched, npm_mode: bool, occupancy_metrics: &mut Option<&mut OccupancyMetrics>, + raw_scripts: Option<&Vec>, ) -> Result> { let common_bun_proc_envs: HashMap = get_common_bun_proc_envs(None).await; let mut empty_deps = false; + // Write raw scripts to job_dir so loader can find them locally + if let Some(scripts) = raw_scripts { + for script in scripts { + if let Some(ref raw_code) = script.raw_code { + // Normalize path: remove leading slash and u/ or f/ prefixes + let path = script.script_path + .trim_start_matches('/') + .trim_start_matches("u/") + .trim_start_matches("f/"); + + // Ensure .ts extension + let file_path = if path.ends_with(".ts") { + path.to_string() + } else { + format!("{}.ts", path) + }; + + // Create parent directories if needed + let script_file_path = format!("{}/{}", job_dir, file_path); + if let Some(parent) = std::path::Path::new(&script_file_path).parent() { + std::fs::create_dir_all(parent)?; + } + + // Write the script file + write_file(job_dir, &file_path, raw_code)?; + tracing::debug!("Wrote raw_script to {}/{}", job_dir, file_path); + } + } + } + if let Some(package_json_content) = workspace_dependencies.get_bun()? { gen_bunfig(job_dir).await?; write_file(job_dir, "package.json", package_json_content.as_str())?; @@ -987,6 +1026,7 @@ pub async fn handle_bun_job( workspace_dependencies, annotation.npm, &mut Some(occupancy_metrics), + None, // raw_scripts - will be provided by worker.rs later ) .await?; @@ -1700,6 +1740,7 @@ pub async fn start_worker( .await?, annotation.npm, &mut None, + None, // raw_scripts - will be provided by worker.rs later ) .await?; } diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 0e6e19bd6b453..0196d688a6908 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -2743,6 +2743,7 @@ async fn capture_dependency_job( &workspace_dependencies, windmill_common::worker::TypeScriptAnnotations::parse(job_raw_code).npm, &mut Some(occupancy_metrics), + None, // raw_scripts - will be provided by worker.rs later ) .await? { diff --git a/cli/src/utils/metadata.ts b/cli/src/utils/metadata.ts index e52830ba6a1ed..c7e729232d98c 100644 --- a/cli/src/utils/metadata.ts +++ b/cli/src/utils/metadata.ts @@ -27,23 +27,121 @@ export class LockfileGenerationError extends Error { export async function generateAllMetadata() {} +// ============================================================================= +// CONSTANTS - Import Patterns and File Extensions +// ============================================================================= + +const TS_IMPORT_PATTERNS = { + ES6_IMPORT: /import\s+(?:(?:\*\s+as\s+\w+)|(?:\{[^}]*\})|(?:\w+))\s+from\s+['"]([^'"]+)['"]/g, + DYNAMIC_IMPORT: /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + REQUIRE: /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g, +} as const; + +const PYTHON_IMPORT_PATTERNS = { + RELATIVE: /from\s+(\.+\w*(?:\.\w+)*)\s+import/g, + ABSOLUTE: /from\s+([fu]\.\w+(?:\.\w+)*)\s+import/g, +} as const; + +const TS_EXTENSION_PATTERN = /\.ts$/; +const PATH_SEPARATOR = '/'; + +const LANGUAGE_EXTENSIONS: Record = { + python3: ['.py'], + bun: ['.ts', '.js'], + deno: ['.ts', '.js'], + nativets: ['.ts', '.js'], +}; + +// ============================================================================= +// UTILITY FUNCTIONS - Path Operations +// ============================================================================= + +/** + * Extract directory path from a script path + */ +function getScriptDirectory(scriptPath: string): string { + const lastSlashIndex = scriptPath.lastIndexOf(PATH_SEPARATOR); + return lastSlashIndex >= 0 ? scriptPath.substring(0, lastSlashIndex) : ''; +} + +/** + * Convert file path to remote path (remove extension, normalize separators) + */ +function toRemotePath(scriptPath: string): string { + const lastDotIndex = scriptPath.lastIndexOf('.'); + const pathWithoutExtension = lastDotIndex >= 0 + ? scriptPath.substring(0, lastDotIndex) + : scriptPath; + return pathWithoutExtension.replaceAll(SEP, PATH_SEPARATOR); +} + +/** + * Resolve a relative import path against a base script path + */ +function resolveImportPath( + importPath: string, + scriptPath: string +): string | null { + if (importPath.startsWith(PATH_SEPARATOR)) { + return importPath.substring(1); // Absolute import - strip leading slash + } else if (importPath.startsWith('.')) { + const scriptDir = getScriptDirectory(scriptPath); + return normalizePath(`${scriptDir}${PATH_SEPARATOR}${importPath}`); + } + return null; // Not a local import +} + +/** + * Get file extensions for a given language + */ +function getExtensionsForLanguage(language: ScriptLanguage): string[] { + return LANGUAGE_EXTENSIONS[language] ?? ['.ts', '.js']; +} + +/** + * Normalize a path by resolving .. and . segments + */ +function normalizePath(path: string): string | null { + const parts = path.split(PATH_SEPARATOR); + const result: string[] = []; + + for (const part of parts) { + if (part === '' || part === '.') { + continue; + } else if (part === '..') { + if (result.length === 0) { + return null; // Invalid path going above root + } + result.pop(); + } else { + result.push(part); + } + } + + return result.join(PATH_SEPARATOR); +} + +// ============================================================================= +// RAW WORKSPACE DEPENDENCIES +// ============================================================================= + export async function getRawWorkspaceDependencies(): Promise> { const rawWorkspaceDeps: Record = {}; - + try { for await (const entry of Deno.readDir("dependencies")) { if (entry.isDirectory) continue; - + const filePath = `dependencies/${entry.name}`; const content = await Deno.readTextFile(filePath); - + // Find matching language for (const lang of workspaceDependenciesLanguages) { if (entry.name.endsWith(lang.filename)) { // Check if out of sync const contentHash = await generateHash(content + filePath); const isUpToDate = await checkifMetadataUptodate(filePath, contentHash, undefined); - + if (!isUpToDate) { rawWorkspaceDeps[filePath] = content; } @@ -54,7 +152,232 @@ export async function getRawWorkspaceDependencies(): Promise p.length > 0); + + // Go up the specified number of levels + const targetParts = pathParts.slice(0, Math.max(0, pathParts.length - levelsUp)); + + // Add the module path + if (module) { + targetParts.push(...module.split('.')); + } + + return targetParts.length > 0 ? targetParts.join(PATH_SEPARATOR) : null; +} + +/** + * Parse Python absolute import (f.* or u.*) + */ +function parsePythonAbsoluteImport(importMatch: string): string { + return importMatch.replace(/\./g, PATH_SEPARATOR); +} + +/** + * Extract relative imports from Python code + * Matches imports like: + * - from .module import something + * - from ..module import something + * - from f.folder.script import something + * - from u.folder.script import something + */ +function extractPythonRelativeImports(code: string, scriptPath: string): string[] { + const imports: string[] = []; + + // Process relative imports + for (const match of code.matchAll(PYTHON_IMPORT_PATTERNS.RELATIVE)) { + const resolved = parsePythonRelativeImport(match[1], scriptPath); + if (resolved) { + imports.push(resolved); + } + } + + // Process absolute folder imports + for (const match of code.matchAll(PYTHON_IMPORT_PATTERNS.ABSOLUTE)) { + imports.push(parsePythonAbsoluteImport(match[1])); + } + + return imports; +} + +// ============================================================================= +// SCRIPT COLLECTION HELPERS +// ============================================================================= + +/** + * Try to read an imported script file with multiple extension attempts + */ +async function tryReadImportedScript( + importPath: string, + extensions: string[] +): Promise<{ content: string; filePath: string; language: ScriptLanguage } | null> { + const basePath = importPath.replaceAll(PATH_SEPARATOR, SEP); + + for (const ext of extensions) { + const filePath = basePath + ext; + + try { + const content = await Deno.readTextFile(filePath); + const language = inferContentTypeFromFilePath(filePath, 'bun'); + return { content, filePath, language }; + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) { + // Log unexpected errors (permissions, etc.) + log.debug(`Error reading ${filePath}: ${e}`); + } + continue; + } + } + + log.debug(`Could not find import '${importPath}' with extensions [${extensions.join(', ')}]`); + return null; +} + +/** + * Process a single import and collect its dependencies recursively + */ +async function processImport( + importPath: string, + language: ScriptLanguage, + visited: Set, + localScripts: Record +): Promise { + const extensions = getExtensionsForLanguage(language); + + const scriptData = await tryReadImportedScript(importPath, extensions); + if (!scriptData) { + return; // File not found, already logged + } + + // Store the imported script + localScripts[importPath] = { + content: scriptData.content, + language: scriptData.language + }; + + // Recursively collect imports from this script + const nestedScripts = await collectLocalScripts( + scriptData.filePath, + scriptData.language, + visited + ); + + // Merge nested scripts (avoid overwriting) + for (const [path, data] of Object.entries(nestedScripts)) { + localScripts[path] ??= data; // Use nullish coalescing assignment + } +} + +/** + * Extract imports based on script language + */ +function extractImportsForLanguage( + content: string, + remotePath: string, + language: ScriptLanguage +): string[] { + if (language === 'bun' || language === 'deno' || language === 'nativets') { + return extractTSRelativeImports(content, remotePath); + } else if (language === 'python3') { + return extractPythonRelativeImports(content, remotePath); + } + return []; +} + +/** + * Collect local scripts that are imported by the given script + * This function recursively traverses imports to find all dependencies + * + * @param scriptPath The path to the script file to analyze + * @param language The language of the script + * @param visited Set of already visited script paths to avoid cycles + * @returns Record of script path -> script content for all imported local scripts + */ +export async function collectLocalScripts( + scriptPath: string, + language: ScriptLanguage, + visited: Set = new Set() +): Promise> { + const localScripts: Record = {}; + + const remotePath = toRemotePath(scriptPath); + + if (visited.has(remotePath)) { + return localScripts; + } + visited.add(remotePath); + + let scriptContent: string; + try { + scriptContent = await Deno.readTextFile(scriptPath); + } catch (e) { + log.debug(`Could not read script file ${scriptPath}: ${e}`); + return localScripts; + } + + // Extract imports based on language + const imports = extractImportsForLanguage(scriptContent, remotePath, language); + + // Process each import + for (const importPath of imports) { + await processImport(importPath, language, visited, localScripts); + } + + return localScripts; } export function workspaceDependenciesPathToLanguageAndFilename(path: string): { name: string | undefined, language: ScriptLanguage } | undefined { @@ -155,7 +478,8 @@ export async function generateScriptMetadataInternal( language, remotePath, metadataParsedContent, - filteredRawWorkspaceDependencies + filteredRawWorkspaceDependencies, + scriptPath ); } else { metadataParsedContent.lock = ""; @@ -211,13 +535,48 @@ export async function updateScriptSchema( } } +// ============================================================================= +// LOCKFILE GENERATION HELPERS +// ============================================================================= + +/** + * Raw script entry for API payload + */ +interface RawScriptEntry { + raw_code: string; + language: ScriptLanguage; + script_path: string; +} + +/** + * Build the raw_scripts payload for lockfile generation + */ +function buildRawScriptsPayload( + mainScript: { content: string; language: ScriptLanguage; path: string }, + localScripts: Record +): RawScriptEntry[] { + return [ + { + raw_code: mainScript.content, + language: mainScript.language, + script_path: mainScript.path, + }, + ...Object.entries(localScripts).map(([importPath, scriptData]) => ({ + raw_code: scriptData.content, + language: scriptData.language, + script_path: importPath, + })) + ]; +} + async function updateScriptLock( workspace: Workspace, scriptContent: string, language: ScriptLanguage, remotePath: string, metadataContent: Record, - rawWorkspaceDependencies: Record + rawWorkspaceDependencies: Record, + scriptPath: string ): Promise { if ( !( @@ -230,12 +589,27 @@ async function updateScriptLock( return; } + // Collect local scripts that are imported by this script + const localScripts = await collectLocalScripts(scriptPath, language); + const localScriptCount = Object.keys(localScripts).length; + // Log workspace dependencies and local scripts if (Object.keys(rawWorkspaceDependencies).length > 0) { const dependencyPaths = Object.keys(rawWorkspaceDependencies).join(', '); log.info(`Generating script lock for ${remotePath} with raw workspace dependencies: ${dependencyPaths}`); } - + + if (localScriptCount > 0) { + const scriptPaths = Object.keys(localScripts).join(', '); + log.info(`Found ${localScriptCount} local script(s) imported by ${remotePath}: ${scriptPaths}`); + } + + // Build raw_scripts payload with main script and all its local imports + const rawScripts = buildRawScriptsPayload( + { content: scriptContent, language, path: remotePath }, + localScripts + ); + // generate the script lock running a dependency job in Windmill and update it inplace // TODO: update this once the client is released const extraHeaders = getHeaders(); @@ -249,14 +623,8 @@ async function updateScriptLock( ...extraHeaders, }, body: JSON.stringify({ - raw_scripts: [ - { - raw_code: scriptContent, - language: language, - script_path: remotePath, - }, - ], - raw_workspace_dependencies: Object.keys(rawWorkspaceDependencies).length > 0 + raw_scripts: rawScripts, + raw_workspace_dependencies: Object.keys(rawWorkspaceDependencies).length > 0 ? rawWorkspaceDependencies : null, entrypoint: remotePath, }), diff --git a/cli/test/local_script_dependencies.test.ts b/cli/test/local_script_dependencies.test.ts new file mode 100644 index 0000000000000..af3cad5d7f0a4 --- /dev/null +++ b/cli/test/local_script_dependencies.test.ts @@ -0,0 +1,274 @@ +/** + * Tests for local script dependency resolution + * Verifies that generate-metadata correctly handles relative imports in local development + */ + +import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { withContainerizedBackend } from "./containerized_backend.ts"; +import { addWorkspace } from "../workspace.ts"; + +Deno.test("generate-metadata: basic relative imports", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "local_script_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** +excludes: []`); + + await Deno.mkdir(`${tempDir}/f`, { recursive: true }); + await Deno.mkdir(`${tempDir}/f/utils`, { recursive: true }); + + const utilScript = `export function greet(name: string): string { + return \`Hello, \${name}!\`; +} + +export function add(a: number, b: number): number { + return a + b; +}`; + await Deno.writeTextFile(`${tempDir}/f/utils/helpers.ts`, utilScript); + await Deno.writeTextFile(`${tempDir}/f/utils/helpers.script.yaml`, `summary: Utility helpers +description: Helper functions +schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + properties: {} +lock: ""`); + + const mainScript = `import { greet, add } from "./utils/helpers"; + +export async function main(name: string = "World", x: number = 1, y: number = 2) { + const greeting = greet(name); + const sum = add(x, y); + return \`\${greeting} The sum is \${sum}\`; +}`; + await Deno.writeTextFile(`${tempDir}/f/main.ts`, mainScript); + await Deno.writeTextFile(`${tempDir}/f/main.script.yaml`, `summary: Main script +description: Script with imports +schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + required: [] + properties: + name: + type: string + default: World + x: + type: number + default: 1 + y: + type: number + default: 2 +lock: ""`); + + const result = await backend.runCLICommand(['script', 'generate-metadata', 'f/main.ts'], tempDir); + + assertEquals(result.code, 0, `generate-metadata should succeed: ${result.stderr}`); + assertStringIncludes( + result.stdout.toLowerCase() + result.stderr.toLowerCase(), + "local script", + "Should mention local scripts in output" + ); + + const lockFileExists = await Deno.stat(`${tempDir}/f/main.script.lock`) + .then(() => true) + .catch(() => false); + assert(lockFileExists, "Lockfile should be generated"); + }); +}); + +Deno.test("generate-metadata: nested imports", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "nested_imports_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** +excludes: []`); + + await Deno.mkdir(`${tempDir}/f`, { recursive: true }); + await Deno.mkdir(`${tempDir}/f/lib`, { recursive: true }); + await Deno.mkdir(`${tempDir}/f/lib/math`, { recursive: true }); + + const mathScript = `export function multiply(a: number, b: number): number { + return a * b; +}`; + await Deno.writeTextFile(`${tempDir}/f/lib/math/operations.ts`, mathScript); + await Deno.writeTextFile(`${tempDir}/f/lib/math/operations.script.yaml`, `summary: Math operations +description: Math operations +schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + properties: {} +lock: ""`); + + const utilScript = `import { multiply } from "./math/operations"; + +export function square(x: number): number { + return multiply(x, x); +}`; + await Deno.writeTextFile(`${tempDir}/f/lib/helpers.ts`, utilScript); + await Deno.writeTextFile(`${tempDir}/f/lib/helpers.script.yaml`, `summary: Helper functions +description: Helper functions +schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + properties: {} +lock: ""`); + + const mainScript = `import { square } from "./lib/helpers"; + +export async function main(num: number = 5) { + return \`The square of \${num} is \${square(num)}\`; +}`; + await Deno.writeTextFile(`${tempDir}/f/calculator.ts`, mainScript); + await Deno.writeTextFile(`${tempDir}/f/calculator.script.yaml`, `summary: Calculator +description: Calculator script +schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + required: [] + properties: + num: + type: number + default: 5 +lock: ""`); + + const result = await backend.runCLICommand(['script', 'generate-metadata', 'f/calculator.ts'], tempDir); + + assertEquals(result.code, 0, `generate-metadata should succeed: ${result.stderr}`); + + const output = result.stdout.toLowerCase() + result.stderr.toLowerCase(); + if (output.includes("local script")) { + const scriptMatches = output.match(/local script/gi); + assert(scriptMatches && scriptMatches.length >= 1, "Should find at least 1 local script"); + } + + const lockFileExists = await Deno.stat(`${tempDir}/f/calculator.script.lock`) + .then(() => true) + .catch(() => false); + assert(lockFileExists, "Lockfile should be generated"); + }); +}); + +Deno.test("generate-metadata: python relative imports", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "python_imports_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** +excludes: []`); + + await Deno.mkdir(`${tempDir}/f`, { recursive: true }); + await Deno.mkdir(`${tempDir}/f/utils`, { recursive: true }); + + const utilScript = `def format_message(msg: str) -> str: + return f"[INFO] {msg}" + +def calculate(x: int, y: int) -> int: + return x + y +`; + await Deno.writeTextFile(`${tempDir}/f/utils/helpers.py`, utilScript); + await Deno.writeTextFile(`${tempDir}/f/utils/helpers.script.yaml`, `summary: Python helpers +description: Python utilities +schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + properties: {} +lock: ""`); + + const mainScript = `from .utils.helpers import format_message, calculate + +def main(x: int = 5, y: int = 10): + result = calculate(x, y) + return format_message(f"Sum is {result}") +`; + await Deno.writeTextFile(`${tempDir}/f/processor.py`, mainScript); + await Deno.writeTextFile(`${tempDir}/f/processor.script.yaml`, `summary: Python processor +description: Python script +schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + required: [] + properties: + x: + type: integer + default: 5 + y: + type: integer + default: 10 +lock: ""`); + + const result = await backend.runCLICommand(['script', 'generate-metadata', 'f/processor.py'], tempDir); + + assertEquals(result.code, 0, `generate-metadata should succeed: ${result.stderr}`); + + const lockFileExists = await Deno.stat(`${tempDir}/f/processor.script.lock`) + .then(() => true) + .catch(() => false); + assert(lockFileExists, "Lockfile should be generated"); + }); +}); + +Deno.test("generate-metadata: no imports baseline", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "no_imports_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** +excludes: []`); + + await Deno.mkdir(`${tempDir}/f`, { recursive: true }); + + const simpleScript = `export async function main(name: string = "World") { + return \`Hello, \${name}!\`; +}`; + await Deno.writeTextFile(`${tempDir}/f/simple.ts`, simpleScript); + await Deno.writeTextFile(`${tempDir}/f/simple.script.yaml`, `summary: Simple script +description: No imports +schema: + $schema: https://json-schema.org/draft/2020-12/schema + type: object + required: [] + properties: + name: + type: string + default: World +lock: ""`); + + const result = await backend.runCLICommand(['script', 'generate-metadata', 'f/simple.ts'], tempDir); + + assertEquals(result.code, 0, `generate-metadata should succeed: ${result.stderr}`); + + const lockFileExists = await Deno.stat(`${tempDir}/f/simple.script.lock`) + .then(() => true) + .catch(() => false); + assert(lockFileExists, "Lockfile should be generated"); + }); +});