diff --git a/cli/deno.lock b/cli/deno.lock index 59a1002364..536c8960c8 100644 --- a/cli/deno.lock +++ b/cli/deno.lock @@ -1553,6 +1553,8 @@ "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", "https://deno.land/std@0.224.0/cli/parse_args.ts": "5250832fb7c544d9111e8a41ad272c016f5a53f975ef84d5a9fe5fcb70566ece", + "https://deno.land/std@0.224.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376", + "https://deno.land/std@0.224.0/encoding/hex.ts": "6270f25e5d85f99fcf315278670ba012b04b7c94b67715b53f30d03249687c07", "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", "https://deno.land/std@0.224.0/fs/_create_walk_entry.ts": "5d9d2aaec05bcf09a06748b1684224d33eba7a4de24cf4cf5599991ca6b5b412", "https://deno.land/std@0.224.0/fs/_get_file_info_type.ts": "da7bec18a7661dba360a1db475b826b18977582ce6fc9b25f3d4ee0403fe8cbd", diff --git a/cli/src/utils/metadata.ts b/cli/src/utils/metadata.ts index 6a598e6d73..5ed715cc9d 100644 --- a/cli/src/utils/metadata.ts +++ b/cli/src/utils/metadata.ts @@ -211,6 +211,180 @@ export async function updateScriptSchema( } } +// --------------------------------------------------------------------------- +// Annotation parser — mirrors backend's WorkspaceDependenciesAnnotatedRefs::parse +// (windmill-common/src/workspace_dependencies.rs) so the cache key captures +// exactly the parts of scriptContent that affect lockfile generation. +// --------------------------------------------------------------------------- + +type AnnotationMode = "manual" | "extra"; + +interface WorkspaceDepsAnnotation { + mode: AnnotationMode; + external: string[]; + inline: string | null; +} + +const LANG_ANNOTATION_CONFIG: Partial< + Record +> = { + python3: { comment: "#", keyword: "requirements", validityRe: /^#\s?(\S+)\s*$/ }, + bun: { comment: "//", keyword: "package_json" }, + nativets: { comment: "//", keyword: "package_json" }, + go: { comment: "//", keyword: "go_mod" }, + php: { comment: "//", keyword: "composer_json" }, +}; + +export function extractWorkspaceDepsAnnotation( + scriptContent: string, + language: ScriptLanguage, +): WorkspaceDepsAnnotation | null { + const config = LANG_ANNOTATION_CONFIG[language]; + if (!config) return null; + + const { comment, keyword, validityRe } = config; + const extraMarker = `extra_${keyword}:`; + const manualMarker = `${keyword}:`; + + const lines = scriptContent.split("\n"); + + // Find first annotation line (mirrors Rust find_position) + let pos = -1; + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (l.startsWith(comment) && (l.includes(extraMarker) || l.includes(manualMarker))) { + pos = i; + break; + } + } + if (pos === -1) return null; + + const annotationLine = lines[pos]; + const mode: AnnotationMode = annotationLine.includes(extraMarker) ? "extra" : "manual"; + + // Parse external references from the annotation line + const marker = mode === "extra" ? extraMarker : manualMarker; + const unparsed = annotationLine.replaceAll(marker, "").replaceAll(comment, ""); + const external = unparsed + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + // Parse inline deps from subsequent lines + const inlineParts: string[] = []; + for (let i = pos + 1; i < lines.length; i++) { + const l = lines[i]; + if (validityRe) { + const match = validityRe.exec(l); + if (match && match[1]) { + inlineParts.push(match[1]); + } else { + break; + } + } else { + if (!l.startsWith(comment)) { + break; + } + inlineParts.push(l.substring(comment.length)); + } + } + + const inlineStr = inlineParts.join("\n"); + const inline = inlineStr.trim().length > 0 ? inlineStr : null; + + return { mode, external, inline }; +} + +export async function computeLockCacheKey( + scriptContent: string, + language: ScriptLanguage, + rawWorkspaceDependencies: Record, +): Promise { + const annotation = extractWorkspaceDepsAnnotation(scriptContent, language); + const annotationStr = annotation + ? `${annotation.mode}|${annotation.external.join(",")}|${annotation.inline ?? ""}` + : "none"; + const sortedDepsKeys = Object.keys(rawWorkspaceDependencies).sort(); + const depsStr = sortedDepsKeys.map((k) => `${k}=${rawWorkspaceDependencies[k]}`).join(";"); + return await generateHash(`${language}|${annotationStr}|${depsStr}`); +} + +const lockCache = new Map(); + +export function clearLockCache(): void { + lockCache.clear(); +} + +async function fetchScriptLock( + workspace: Workspace, + scriptContent: string, + language: ScriptLanguage, + remotePath: string, + rawWorkspaceDependencies: Record, +): Promise { + const hasRawDeps = Object.keys(rawWorkspaceDependencies).length > 0; + const cacheKey = hasRawDeps + ? await computeLockCacheKey(scriptContent, language, rawWorkspaceDependencies) + : undefined; + if (cacheKey && lockCache.has(cacheKey)) { + log.info(`Using cached lockfile for ${remotePath}`); + return lockCache.get(cacheKey)!; + } + + const extraHeaders = getHeaders(); + const rawResponse = await fetch( + `${workspace.remote}api/w/${workspace.workspaceId}/jobs/run/dependencies`, + { + method: "POST", + headers: { + Cookie: `token=${workspace.token}`, + "Content-Type": "application/json", + ...extraHeaders, + }, + body: JSON.stringify({ + raw_scripts: [ + { + raw_code: scriptContent, + language: language, + script_path: remotePath, + }, + ], + raw_workspace_dependencies: Object.keys(rawWorkspaceDependencies).length > 0 + ? rawWorkspaceDependencies : null, + entrypoint: remotePath, + }), + } + ); + + let responseText = "reading response failed"; + try { + responseText = await rawResponse.text(); + const response = JSON.parse(responseText); + const lock = response.lock; + if (lock === undefined) { + if (response?.["error"]?.["message"]) { + throw new LockfileGenerationError( + `Failed to generate lockfile: ${response?.["error"]?.["message"]}` + ); + } + throw new LockfileGenerationError( + `Failed to generate lockfile: ${JSON.stringify(response, null, 2)}` + ); + } + if (cacheKey) { + lockCache.set(cacheKey, lock); + } + return lock; + } catch (e) { + if (e instanceof LockfileGenerationError) { + throw e; + } + throw new LockfileGenerationError( + `Failed to generate lockfile:${rawResponse.statusText}, ${responseText}, ${e}` + ); + } +} + async function updateScriptLock( workspace: Workspace, scriptContent: string, @@ -235,70 +409,28 @@ async function updateScriptLock( const dependencyPaths = Object.keys(rawWorkspaceDependencies).join(', '); log.info(`Generating script lock for ${remotePath} with raw workspace dependencies: ${dependencyPaths}`); } - - // 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(); - const rawResponse = await fetch( - `${workspace.remote}api/w/${workspace.workspaceId}/jobs/run/dependencies`, - { - method: "POST", - headers: { - Cookie: `token=${workspace.token}`, - "Content-Type": "application/json", - ...extraHeaders, - }, - body: JSON.stringify({ - raw_scripts: [ - { - raw_code: scriptContent, - language: language, - script_path: remotePath, - }, - ], - raw_workspace_dependencies: Object.keys(rawWorkspaceDependencies).length > 0 - ? rawWorkspaceDependencies : null, - entrypoint: remotePath, - }), - } + + const lock = await fetchScriptLock( + workspace, + scriptContent, + language, + remotePath, + rawWorkspaceDependencies, ); - let responseText = "reading response failed"; - try { - responseText = await rawResponse.text(); - const response = JSON.parse(responseText); - const lock = response.lock; - if (lock === undefined) { - if (response?.["error"]?.["message"]) { - throw new LockfileGenerationError( - `Failed to generate lockfile: ${response?.["error"]?.["message"]}` - ); + const lockPath = remotePath + ".script.lock"; + if (lock != "") { + await Deno.writeTextFile(lockPath, lock); + metadataContent.lock = "!inline " + lockPath.replaceAll(SEP, "/"); + } else { + try { + if (await Deno.stat(lockPath)) { + await Deno.remove(lockPath); } - throw new LockfileGenerationError( - `Failed to generate lockfile: ${JSON.stringify(response, null, 2)}` - ); + } catch (e) { + log.info(colors.yellow(`Error removing lock file ${lockPath}: ${e}`)); } - const lockPath = remotePath + ".script.lock"; - if (lock != "") { - await Deno.writeTextFile(lockPath, lock); - metadataContent.lock = "!inline " + lockPath.replaceAll(SEP, "/"); - } else { - try { - if (await Deno.stat(lockPath)) { - await Deno.remove(lockPath); - } - } catch (e) { - log.info(colors.yellow(`Error removing lock file ${lockPath}: ${e}`)); - } - metadataContent.lock = ""; - } - } catch (e) { - if (e instanceof LockfileGenerationError) { - throw e; - } - throw new LockfileGenerationError( - `Failed to generate lockfile:${rawResponse.statusText}, ${responseText}, ${e}` - ); + metadataContent.lock = ""; } } diff --git a/cli/test/lock_cache.test.ts b/cli/test/lock_cache.test.ts new file mode 100644 index 0000000000..c217749c6c --- /dev/null +++ b/cli/test/lock_cache.test.ts @@ -0,0 +1,572 @@ +/** + * Lock Cache Tests + * + * Tests the in-memory lock cache used when fetching lockfiles for scripts with + * raw_workspace_dependencies. + * + * Part 1: Unit tests for annotation parsing (mirrors backend). + * Part 2: Unit tests for cache key computation. + * Part 3: Behavioral tests comparing old logic (no cache, always fetches) + * vs new logic (caches by key, skips duplicate fetches). + */ + +import { + assertEquals, + assertNotEquals, +} from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts"; + +// --------------------------------------------------------------------------- +// Mirrors extractWorkspaceDepsAnnotation + computeLockCacheKey from +// src/utils/metadata.ts so we can test the algorithm without pulling in the +// full (unresolvable-in-tests) module graph. +// --------------------------------------------------------------------------- + +type AnnotationMode = "manual" | "extra"; + +interface WorkspaceDepsAnnotation { + mode: AnnotationMode; + external: string[]; + inline: string | null; +} + +const LANG_ANNOTATION_CONFIG: Record< + string, + { comment: string; keyword: string; validityRe?: RegExp } | undefined +> = { + python3: { comment: "#", keyword: "requirements", validityRe: /^#\s?(\S+)\s*$/ }, + bun: { comment: "//", keyword: "package_json" }, + nativets: { comment: "//", keyword: "package_json" }, + go: { comment: "//", keyword: "go_mod" }, + php: { comment: "//", keyword: "composer_json" }, +}; + +function extractWorkspaceDepsAnnotation( + scriptContent: string, + language: string, +): WorkspaceDepsAnnotation | null { + const config = LANG_ANNOTATION_CONFIG[language]; + if (!config) return null; + + const { comment, keyword, validityRe } = config; + const extraMarker = `extra_${keyword}:`; + const manualMarker = `${keyword}:`; + + const lines = scriptContent.split("\n"); + + let pos = -1; + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (l.startsWith(comment) && (l.includes(extraMarker) || l.includes(manualMarker))) { + pos = i; + break; + } + } + if (pos === -1) return null; + + const annotationLine = lines[pos]; + const mode: AnnotationMode = annotationLine.includes(extraMarker) ? "extra" : "manual"; + + const marker = mode === "extra" ? extraMarker : manualMarker; + const unparsed = annotationLine.replaceAll(marker, "").replaceAll(comment, ""); + const external = unparsed + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + const inlineParts: string[] = []; + for (let i = pos + 1; i < lines.length; i++) { + const l = lines[i]; + if (validityRe) { + const match = validityRe.exec(l); + if (match && match[1]) { + inlineParts.push(match[1]); + } else { + break; + } + } else { + if (!l.startsWith(comment)) { + break; + } + inlineParts.push(l.substring(comment.length)); + } + } + + const inlineStr = inlineParts.join("\n"); + const inline = inlineStr.trim().length > 0 ? inlineStr : null; + + return { mode, external, inline }; +} + +async function computeLockCacheKey( + scriptContent: string, + language: string, + rawWorkspaceDependencies: Record, +): Promise { + const annotation = extractWorkspaceDepsAnnotation(scriptContent, language); + const annotationStr = annotation + ? `${annotation.mode}|${annotation.external.join(",")}|${annotation.inline ?? ""}` + : "none"; + const sortedDepsKeys = Object.keys(rawWorkspaceDependencies).sort(); + const depsStr = sortedDepsKeys + .map((k) => `${k}=${rawWorkspaceDependencies[k]}`) + .join(";"); + const content = `${language}|${annotationStr}|${depsStr}`; + const buf = new TextEncoder().encode(content); + return encodeHex(await crypto.subtle.digest("SHA-256", buf)); +} + +// --------------------------------------------------------------------------- +// Helpers that mirror the two fetch strategies (old / new). +// --------------------------------------------------------------------------- + +interface ScriptInput { + scriptContent: string; + language: string; + remotePath: string; + rawWorkspaceDependencies: Record; +} + +/** Old logic: always calls the remote for every script. */ +async function fetchScriptLockOld( + input: ScriptInput, + remoteFn: (input: ScriptInput) => Promise, +): Promise { + return await remoteFn(input); +} + +/** New logic: only caches when raw_workspace_dependencies are non-empty. */ +async function fetchScriptLockNew( + input: ScriptInput, + remoteFn: (input: ScriptInput) => Promise, + cache: Map, +): Promise { + const hasRawDeps = Object.keys(input.rawWorkspaceDependencies).length > 0; + const cacheKey = hasRawDeps + ? await computeLockCacheKey( + input.scriptContent, + input.language, + input.rawWorkspaceDependencies, + ) + : undefined; + if (cacheKey && cache.has(cacheKey)) { + return cache.get(cacheKey)!; + } + const lock = await remoteFn(input); + if (cacheKey) { + cache.set(cacheKey, lock); + } + return lock; +} + +// ============================================================================= +// Part 1 — Annotation parsing +// ============================================================================= + +Deno.test("python: manual requirements with external refs + inline deps", () => { + const code = `# requirements: default, base +#requests==2.31.0 +#pandas>=1.5.0 + +def main(): + pass`; + const r = extractWorkspaceDepsAnnotation(code, "python3")!; + assertEquals(r.mode, "manual"); + assertEquals(r.external, ["default", "base"]); + assertEquals(r.inline, "requests==2.31.0\npandas>=1.5.0"); +}); + +Deno.test("python: extra_requirements mode", () => { + const code = `# extra_requirements: utils +#numpy>=1.24.0 + +def main(): + pass`; + const r = extractWorkspaceDepsAnnotation(code, "python3")!; + assertEquals(r.mode, "extra"); + assertEquals(r.external, ["utils"]); + assertEquals(r.inline, "numpy>=1.24.0"); +}); + +Deno.test("python: empty requirements (opt-out)", () => { + const code = `# requirements: +def main(): + pass`; + const r = extractWorkspaceDepsAnnotation(code, "python3")!; + assertEquals(r.mode, "manual"); + assertEquals(r.external, []); + assertEquals(r.inline, null); +}); + +Deno.test("python: no annotation → null", () => { + const code = `def main(): + print("hello")`; + assertEquals(extractWorkspaceDepsAnnotation(code, "python3"), null); +}); + +Deno.test("bun: package_json annotation with inline", () => { + const code = `// package_json: utils, base +//{ +// "dependencies": { +// "axios": "^1.6.0" +// } +//} + +export function main() {}`; + const r = extractWorkspaceDepsAnnotation(code, "bun")!; + assertEquals(r.mode, "manual"); + assertEquals(r.external, ["utils", "base"]); + assertEquals(r.inline, `{ + "dependencies": { + "axios": "^1.6.0" + } +}`); +}); + +Deno.test("go: go_mod annotation", () => { + const code = `// go_mod: base, +//github.com/gin-gonic/gin v1.9.1 + +package main +func main() {}`; + const r = extractWorkspaceDepsAnnotation(code, "go")!; + assertEquals(r.mode, "manual"); + assertEquals(r.external, ["base"]); + assertEquals(r.inline, "github.com/gin-gonic/gin v1.9.1"); +}); + +Deno.test("unsupported language → null", () => { + assertEquals(extractWorkspaceDepsAnnotation("print(1)", "deno"), null); + assertEquals(extractWorkspaceDepsAnnotation("print(1)", "bash"), null); +}); + +// ============================================================================= +// Part 2 — Cache key computation +// ============================================================================= + +Deno.test("same annotation + language + deps → same key", async () => { + const code = `# requirements: default +#requests==2.31.0 +print("hello")`; + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + const a = await computeLockCacheKey(code, "python3", deps); + const b = await computeLockCacheKey(code, "python3", deps); + assertEquals(a, b); +}); + +Deno.test("different code, same annotation → same key", async () => { + const codeA = `# requirements: default +#requests==2.31.0 +print("hello")`; + const codeB = `# requirements: default +#requests==2.31.0 +print("world")`; + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + assertEquals( + await computeLockCacheKey(codeA, "python3", deps), + await computeLockCacheKey(codeB, "python3", deps), + ); +}); + +Deno.test("different annotation inline → different key", async () => { + const codeA = `# requirements: default +#requests==2.31.0 +print("hello")`; + const codeB = `# requirements: default +#flask==3.0.0 +print("hello")`; + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + assertNotEquals( + await computeLockCacheKey(codeA, "python3", deps), + await computeLockCacheKey(codeB, "python3", deps), + ); +}); + +Deno.test("different annotation external refs → different key", async () => { + const codeA = `# requirements: default +print("hello")`; + const codeB = `# requirements: base +print("hello")`; + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + assertNotEquals( + await computeLockCacheKey(codeA, "python3", deps), + await computeLockCacheKey(codeB, "python3", deps), + ); +}); + +Deno.test("manual vs extra mode → different key", async () => { + const codeA = `# requirements: default +print("hello")`; + const codeB = `# extra_requirements: default +print("hello")`; + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + assertNotEquals( + await computeLockCacheKey(codeA, "python3", deps), + await computeLockCacheKey(codeB, "python3", deps), + ); +}); + +Deno.test("no annotation, same code → same key", async () => { + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + assertEquals( + await computeLockCacheKey("print('a')", "python3", deps), + await computeLockCacheKey("print('b')", "python3", deps), + ); +}); + +Deno.test("different deps → different key", async () => { + const code = `# requirements: default +print("hello")`; + assertNotEquals( + await computeLockCacheKey(code, "python3", { d: "a" }), + await computeLockCacheKey(code, "python3", { d: "b" }), + ); +}); + +Deno.test("different language → different key", async () => { + const deps = { d: "v" }; + assertNotEquals( + await computeLockCacheKey("x", "bun", deps), + await computeLockCacheKey("x", "python3", deps), + ); +}); + +Deno.test("dep key order does not matter", async () => { + const code = "print('hello')"; + assertEquals( + await computeLockCacheKey(code, "python3", { a: "1", b: "2" }), + await computeLockCacheKey(code, "python3", { b: "2", a: "1" }), + ); +}); + +// ============================================================================= +// Part 3 — Multi-script fetch behavior: old logic vs new logic +// ============================================================================= + +function makeRemoteFn(): { + remoteFn: (input: ScriptInput) => Promise; + callCount: () => number; +} { + const calls: ScriptInput[] = []; + return { + remoteFn: async (input: ScriptInput) => { + calls.push(input); + const depsStr = Object.entries(input.rawWorkspaceDependencies).sort().map(([k,v]) => `${k}=${v}`).join(","); + return `lock for ${input.language}:${input.scriptContent}:${depsStr}`; + }, + callCount: () => calls.length, + }; +} + +// -- Two scripts, same annotation + language + deps ------------------------- + +Deno.test("old logic: two scripts same annotation → 2 remote calls", async () => { + const { remoteFn, callCount } = makeRemoteFn(); + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + + const scripts: ScriptInput[] = [ + { scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps }, + { scriptContent: "# requirements: default\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps }, + ]; + + for (const s of scripts) await fetchScriptLockOld(s, remoteFn); + assertEquals(callCount(), 2); +}); + +Deno.test("new logic: two scripts same annotation → 1 remote call (cache shared)", async () => { + const { remoteFn, callCount } = makeRemoteFn(); + const cache = new Map(); + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + + const scripts: ScriptInput[] = [ + { scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps }, + { scriptContent: "# requirements: default\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps }, + ]; + + const results: string[] = []; + for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); + assertEquals(callCount(), 1); + assertEquals(results[0], results[1]); +}); + +// -- Two scripts, different annotations + same deps ------------------------- + +Deno.test("new logic: different annotations same deps → 2 remote calls", async () => { + const { remoteFn, callCount } = makeRemoteFn(); + const cache = new Map(); + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + + const scripts: ScriptInput[] = [ + { scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps }, + { scriptContent: "# requirements: base\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps }, + ]; + + const results: string[] = []; + for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); + assertEquals(callCount(), 2); + assertNotEquals(results[0], results[1]); +}); + +// -- Two scripts, same annotation + different deps -------------------------- + +Deno.test("new logic: same annotation different deps → 2 remote calls", async () => { + const { remoteFn, callCount } = makeRemoteFn(); + const cache = new Map(); + + const scripts: ScriptInput[] = [ + { scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", + rawWorkspaceDependencies: { "dependencies/requirements.in": "requests==2.31.0" } }, + { scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "b", + rawWorkspaceDependencies: { "dependencies/requirements.in": "requests==2.32.0" } }, + ]; + + const results: string[] = []; + for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); + assertEquals(callCount(), 2); + assertNotEquals(results[0], results[1]); +}); + +// -- Many scripts, same annotation + deps ----------------------------------- + +Deno.test("old logic: 5 scripts same annotation+deps → 5 remote calls", async () => { + const { remoteFn, callCount } = makeRemoteFn(); + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + const ann = "# requirements: default\n"; + + const scripts: ScriptInput[] = [ + { scriptContent: ann + "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps }, + { scriptContent: ann + "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps }, + { scriptContent: ann + "print(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps }, + { scriptContent: ann + "print(1)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps }, + { scriptContent: ann + "print(2)", language: "python3", remotePath: "e", rawWorkspaceDependencies: deps }, + ]; + + for (const s of scripts) await fetchScriptLockOld(s, remoteFn); + assertEquals(callCount(), 5); +}); + +Deno.test("new logic: 5 scripts same annotation+deps → 1 remote call", async () => { + const { remoteFn, callCount } = makeRemoteFn(); + const cache = new Map(); + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + const ann = "# requirements: default\n"; + + const scripts: ScriptInput[] = [ + { scriptContent: ann + "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps }, + { scriptContent: ann + "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps }, + { scriptContent: ann + "print(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps }, + { scriptContent: ann + "print(1)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps }, + { scriptContent: ann + "print(2)", language: "python3", remotePath: "e", rawWorkspaceDependencies: deps }, + ]; + + const results: string[] = []; + for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); + assertEquals(callCount(), 1); + for (let i = 1; i < results.length; i++) { + assertEquals(results[0], results[i]); + } +}); + +// -- Many scripts, 2 annotation groups + same deps ------------------------- + +Deno.test("new logic: 4 scripts with 2 annotation groups → 2 remote calls", async () => { + const { remoteFn, callCount } = makeRemoteFn(); + const cache = new Map(); + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + + const scripts: ScriptInput[] = [ + { scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps }, + { scriptContent: "# requirements: base\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps }, + { scriptContent: "# requirements: default\nprint(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps }, + { scriptContent: "# requirements: base\nprint(4)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps }, + ]; + + const results: string[] = []; + for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); + assertEquals(callCount(), 2); + assertEquals(results[0], results[2]); // same annotation "default" + assertEquals(results[1], results[3]); // same annotation "base" + assertNotEquals(results[0], results[1]); +}); + +// -- Scripts with no workspace deps (empty) --------------------------------- + +Deno.test("new logic: empty deps → no caching", async () => { + const { remoteFn, callCount } = makeRemoteFn(); + const cache = new Map(); + + const scripts: ScriptInput[] = [ + { scriptContent: "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: {} }, + { scriptContent: "print(1)", language: "python3", remotePath: "b", rawWorkspaceDependencies: {} }, + ]; + + for (const s of scripts) await fetchScriptLockNew(s, remoteFn, cache); + assertEquals(callCount(), 2); + assertEquals(cache.size, 0); +}); + +// -- No annotation scripts with raw deps → share cache --------------------- + +Deno.test("new logic: no annotation + same deps → 1 remote call", async () => { + const { remoteFn, callCount } = makeRemoteFn(); + const cache = new Map(); + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + + const scripts: ScriptInput[] = [ + { scriptContent: "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps }, + { scriptContent: "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps }, + ]; + + const results: string[] = []; + for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); + assertEquals(callCount(), 1); + assertEquals(results[0], results[1]); +}); + +// -- Mix of annotated and non-annotated scripts ----------------------------- + +Deno.test("new logic: mix of annotated and non-annotated → separate cache groups", async () => { + const { remoteFn, callCount } = makeRemoteFn(); + const cache = new Map(); + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + + const scripts: ScriptInput[] = [ + { scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps }, + { scriptContent: "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps }, + { scriptContent: "# requirements: default\nprint(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps }, + { scriptContent: "print(4)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps }, + ]; + + const results: string[] = []; + for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); + assertEquals(callCount(), 2); // one for annotated group, one for no-annotation group + assertEquals(results[0], results[2]); // both annotated "default" + assertEquals(results[1], results[3]); // both no annotation + assertNotEquals(results[0], results[1]); // annotated ≠ non-annotated +}); + +// -- Cache returns correct lock value --------------------------------------- + +Deno.test("new logic: cached value matches original remote response", async () => { + const cache = new Map(); + const deps = { "dependencies/requirements.in": "requests==2.31.0" }; + + let callIdx = 0; + const remoteFn = async (_input: ScriptInput) => { + callIdx++; + return "resolved-lock-content-abc123"; + }; + + const r1 = await fetchScriptLockNew( + { scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps }, + remoteFn, cache, + ); + const r2 = await fetchScriptLockNew( + { scriptContent: "# requirements: default\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps }, + remoteFn, cache, + ); + + assertEquals(callIdx, 1); + assertEquals(r1, "resolved-lock-content-abc123"); + assertEquals(r2, "resolved-lock-content-abc123"); +});