fix: codebase preview in standalone mode

- Fix standalone bundle path lookup in worker to not add redundant file
  extension (the path already contains .tar/.esm suffixes from the API)
- Fix CLI preview tar bundle handling to preserve binary data correctly
  (was using btoa(blob.text()) which corrupted binary tar data)
- Add integration tests for script/flow preview commands covering:
  - Regular scripts (non-codebase)
  - Codebase scripts (CJS and ESM formats)
  - Codebase scripts with assets (tar bundles)
  - Flow preview

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-01-28 22:54:38 +00:00
parent f955496dc1
commit c59699acd7
3 changed files with 357 additions and 10 deletions
+2 -3
View File
@@ -635,10 +635,9 @@ async fn pull_codebase(w_id: &str, id: &str, job_dir: &str) -> Result<PulledCode
&& object_store.is_none()
{
let bun_cache_path = format!(
"{}/{}.{}",
"{}/{}",
*windmill_common::worker::ROOT_STANDALONE_BUNDLE_DIR,
path,
if is_tar { "tar" } else { "js" }
path
);
if std::fs::metadata(&bun_cache_path).is_ok() {
tracing::info!("loading {bun_cache_path} from standalone bundle cache");
+8 -7
View File
@@ -1125,7 +1125,7 @@ async function preview(
const codebase =
language == "bun" ? findCodebase(filePath, codebases) : undefined;
let bundledContent: string | undefined = undefined;
let bundledContent: string | Blob | undefined = undefined;
let isTar = false;
if (codebase) {
@@ -1179,8 +1179,7 @@ async function preview(
const fil = new File([file.contents as any], file.path.substring(1));
tarball.append(fil);
}
const blob = await streamToBlob(tarball.stream());
bundledContent = btoa(await blob.text());
bundledContent = await streamToBlob(tarball.stream());
isTar = true;
} else if (Array.isArray(codebase.assets) && codebase.assets.length > 0) {
// Handle assets
@@ -1196,14 +1195,14 @@ async function preview(
const file = new File([blob], asset.to);
tarball.append(file);
}
const blob = await streamToBlob(tarball.stream());
bundledContent = btoa(await blob.text());
bundledContent = await streamToBlob(tarball.stream());
isTar = true;
}
if (!opts.silent) {
const size = typeof bundledContent === "string" ? bundledContent.length : bundledContent.size;
log.info(
`Bundled ${filePath}: ${(bundledContent.length / 1024).toFixed(0)}kB (${(
`Bundled ${filePath}: ${(size / 1024).toFixed(0)}kB (${(
endTime - startTime
).toFixed(0)}ms)`
);
@@ -1229,7 +1228,9 @@ async function preview(
form.append("preview", JSON.stringify(previewPayload));
form.append(
"file",
new Blob([bundledContent], { type: "application/javascript" })
typeof bundledContent === "string"
? new Blob([bundledContent], { type: "application/javascript" })
: bundledContent
);
const url =
+347
View File
@@ -0,0 +1,347 @@
import { assertEquals, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { withTestBackend, cleanupTestBackend } from "./test_backend.ts";
// =============================================================================
// PREVIEW COMMAND INTEGRATION TESTS
// Tests for `wmill script preview` and `wmill flow preview` commands
//
// These tests require a backend to run. Run with:
// deno test --allow-all test/preview.test.ts
//
// The tests cover:
// - Regular script preview (non-codebase)
// - Codebase script preview (CJS format)
// - Codebase script preview (ESM format)
// - Codebase script preview with assets (tar bundle)
// - Codebase script preview with ESM + tar
// - Flow preview
// =============================================================================
// Helper to create a wmill.yaml config file
async function createWmillConfig(
tempDir: string,
config: {
defaultTs?: "bun" | "deno";
codebases?: Array<{
relative_path: string;
includes?: string[];
format?: "cjs" | "esm";
assets?: Array<{ from: string; to: string }>;
}>;
}
): Promise<void> {
let yamlContent = `defaultTs: ${config.defaultTs ?? "bun"}\n`;
if (config.codebases && config.codebases.length > 0) {
yamlContent += "codebases:\n";
for (const cb of config.codebases) {
yamlContent += ` - relative_path: ${cb.relative_path}\n`;
yamlContent += " includes:\n";
for (const inc of cb.includes ?? ["**"]) {
yamlContent += ` - "${inc}"\n`;
}
if (cb.format) {
yamlContent += ` format: ${cb.format}\n`;
}
if (cb.assets && cb.assets.length > 0) {
yamlContent += " assets:\n";
for (const asset of cb.assets) {
yamlContent += ` - from: ${asset.from}\n`;
yamlContent += ` to: ${asset.to}\n`;
}
}
}
}
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, yamlContent);
}
// Helper to create a script file with metadata
async function createScript(
tempDir: string,
path: string,
content: string,
metadata?: {
summary?: string;
description?: string;
}
): Promise<void> {
const dir = `${tempDir}/${path.substring(0, path.lastIndexOf("/"))}`;
await Deno.mkdir(dir, { recursive: true });
await Deno.writeTextFile(`${tempDir}/${path}`, content);
// Create metadata file
const metaPath = path.replace(/\.[^.]+$/, ".script.yaml");
const metaContent = `summary: "${metadata?.summary ?? "Test script"}"
description: "${metadata?.description ?? "Test script description"}"
lock: ""
schema:
$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
properties:
name:
type: string
default: "World"
required: []
`;
await Deno.writeTextFile(`${tempDir}/${metaPath}`, metaContent);
}
// Helper to create a flow directory with flow.yaml
async function createFlow(
tempDir: string,
flowPath: string,
options: {
summary: string;
scriptContent: string;
}
): Promise<void> {
const dir = `${tempDir}/${flowPath}`;
await Deno.mkdir(dir, { recursive: true });
const flowYaml = `summary: "${options.summary}"
description: "Test flow"
value:
modules:
- id: "a"
value:
type: "rawscript"
language: "bun"
content: |
${options.scriptContent.split("\n").map(line => ` ${line}`).join("\n")}
schema:
$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
properties:
name:
type: string
default: "World"
required: []
`;
await Deno.writeTextFile(`${dir}/flow.yaml`, flowYaml);
}
// =============================================================================
// SCRIPT PREVIEW TESTS
// =============================================================================
Deno.test({
name: "script preview: regular script (non-codebase)",
async fn() {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, { defaultTs: "bun" });
await createScript(
tempDir,
"f/test/simple_script.ts",
`export function main(name: string = "World") {
return \`Hello, \${name}!\`;
}`
);
const result = await backend.runCLICommand(
["script", "preview", "f/test/simple_script.ts"],
tempDir
);
assertEquals(result.code, 0, `Preview failed: ${result.stderr}\n${result.stdout}`);
assertStringIncludes(result.stdout + result.stderr, "Hello, World!");
});
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "script preview: codebase script (CJS)",
async fn() {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, {
defaultTs: "bun",
codebases: [{ relative_path: "f/codebase", includes: ["**"] }],
});
await createScript(
tempDir,
"f/codebase/cjs_script.ts",
`export function main(name: string = "World") {
console.log("CJS codebase script running");
return \`Hello from CJS codebase, \${name}!\`;
}`
);
const result = await backend.runCLICommand(
["script", "preview", "f/codebase/cjs_script.ts"],
tempDir
);
assertEquals(result.code, 0, `Preview failed: ${result.stderr}\n${result.stdout}`);
assertStringIncludes(result.stdout + result.stderr, "Hello from CJS codebase, World!");
});
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "script preview: codebase script (ESM)",
async fn() {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, {
defaultTs: "bun",
codebases: [{ relative_path: "f/codebase_esm", includes: ["**"], format: "esm" }],
});
await createScript(
tempDir,
"f/codebase_esm/esm_script.ts",
`export function main(name: string = "World") {
console.log("ESM codebase script running");
return \`Hello from ESM codebase, \${name}!\`;
}`
);
const result = await backend.runCLICommand(
["script", "preview", "f/codebase_esm/esm_script.ts"],
tempDir
);
assertEquals(result.code, 0, `Preview failed: ${result.stderr}\n${result.stdout}`);
assertStringIncludes(result.stdout + result.stderr, "Hello from ESM codebase, World!");
});
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "script preview: codebase script with assets (tar)",
async fn() {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, {
defaultTs: "bun",
codebases: [{
relative_path: "f/codebase_tar",
includes: ["**"],
assets: [{ from: "f/codebase_tar/data.json", to: "data.json" }],
}],
});
// Create asset file
await Deno.mkdir(`${tempDir}/f/codebase_tar`, { recursive: true });
await Deno.writeTextFile(
`${tempDir}/f/codebase_tar/data.json`,
JSON.stringify({ message: "Hello from asset!" })
);
await createScript(
tempDir,
"f/codebase_tar/tar_script.ts",
`import * as fs from "fs";
export function main(name: string = "World") {
console.log("Tar codebase script running");
const data = fs.readFileSync("data.json", "utf-8");
const parsed = JSON.parse(data);
return \`Hello \${name}! Asset says: \${parsed.message}\`;
}`
);
const result = await backend.runCLICommand(
["script", "preview", "f/codebase_tar/tar_script.ts"],
tempDir
);
assertEquals(result.code, 0, `Preview failed: ${result.stderr}\n${result.stdout}`);
assertStringIncludes(result.stdout + result.stderr, "Hello World! Asset says: Hello from asset!");
});
},
sanitizeResources: false,
sanitizeOps: false,
});
Deno.test({
name: "script preview: codebase script ESM + tar (assets)",
async fn() {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, {
defaultTs: "bun",
codebases: [{
relative_path: "f/codebase_esm_tar",
includes: ["**"],
format: "esm",
assets: [{ from: "f/codebase_esm_tar/config.json", to: "config.json" }],
}],
});
// Create asset file
await Deno.mkdir(`${tempDir}/f/codebase_esm_tar`, { recursive: true });
await Deno.writeTextFile(
`${tempDir}/f/codebase_esm_tar/config.json`,
JSON.stringify({ setting: "esm_tar_value" })
);
await createScript(
tempDir,
"f/codebase_esm_tar/esm_tar_script.ts",
`import * as fs from "fs";
export function main(name: string = "World") {
console.log("ESM + tar codebase script running");
const config = fs.readFileSync("config.json", "utf-8");
const parsed = JSON.parse(config);
return \`Hello \${name}! Config setting: \${parsed.setting}\`;
}`
);
const result = await backend.runCLICommand(
["script", "preview", "f/codebase_esm_tar/esm_tar_script.ts"],
tempDir
);
assertEquals(result.code, 0, `Preview failed: ${result.stderr}\n${result.stdout}`);
assertStringIncludes(result.stdout + result.stderr, "Hello World! Config setting: esm_tar_value");
});
},
sanitizeResources: false,
sanitizeOps: false,
});
// =============================================================================
// FLOW PREVIEW TESTS
// =============================================================================
Deno.test({
name: "flow preview: simple flow",
async fn() {
await withTestBackend(async (backend, tempDir) => {
await createWmillConfig(tempDir, { defaultTs: "bun" });
await createFlow(tempDir, "f/test/simple_flow.flow", {
summary: "Test flow",
scriptContent: `export function main(name: string = "World") { return \`Flow says: Hello, \${name}!\`; }`,
});
const result = await backend.runCLICommand(
["flow", "preview", "f/test/simple_flow.flow"],
tempDir
);
assertEquals(result.code, 0, `Flow preview failed: ${result.stderr}\n${result.stdout}`);
assertStringIncludes(result.stdout + result.stderr, "Flow says: Hello, World!");
});
},
sanitizeResources: false,
sanitizeOps: false,
});
// =============================================================================
// CLEANUP
// =============================================================================
Deno.test({
name: "cleanup test backend",
async fn() {
await cleanupTestBackend();
},
sanitizeResources: false,
sanitizeOps: false,
});