Files
windmill/cli/test/sync_push_auto_metadata_repro.test.ts
Ruben Fiszel 40dbab531e fix(cli): resolve cross-folder relative imports during lockgen on fresh DB (#9048)
* fix(cli): resolve cross-folder relative imports during lockgen on fresh DB

On a fresh workspace, lockfile generation for scripts that imported other
scripts via cross-folder relative imports (or barrel re-exporters) failed with
"Failed to find relative import" because the dep job's bun build hit the
server before any helper was deployed. Three independent bugs combined to
produce this:

1. wmill sync push --auto-metadata regenerated locks per script without
   building a DoubleLinkedDependencyTree or calling uploadScripts, so
   temp_script_refs was never sent to dependencies_async.

2. wmill script generate-metadata (the deprecated alias) had its own old
   in-line implementation that bypassed the tree entirely.

3. The TypeScript WASM parser dropped re-exports (export * from, export { x }
   from) when called with skip_type_only=false — the path used by
   parse_relative_imports — so barrel files looked like leaves to the CLI's
   dependency tree and their sibling helpers were missing from
   temp_script_refs.

Fix:
- sync.ts: --auto-metadata mirrors generate-metadata's flow (dryRun pass to
  populate tree → propagateStaleness → uploadScripts → real pass with tree).
- script.ts: deprecated wmill script generate-metadata now delegates to the
  canonical generateMetadata, which already does the tree+upload dance.
- parser-ts: visit_export_all and visit_named_export had inverted skip_type_only
  guards; aligned with visit_import_decl's pattern.

Includes 4 E2E tests reproducing each customer-hit failure path and a Rust
unit test for the re-export parser fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump windmill-parser-wasm-ts to 1.695.0

Pin the parser package to the version published with the re-export fix
(visit_export_all / visit_named_export skip_type_only=false) so the CLI
and frontend pick it up at the next release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): restore legacy stale-check in deprecated alias, add tree to gen pass

Delegating wmill script generate-metadata fully to the canonical handler
broke 4 workspace_deps_filter tests that rely on the legacy hash-with-deps
formula and the "No metadata to update" output string.

Restore the original in-line implementation (legacy stale-check preserved),
but add a DoubleLinkedDependencyTree + uploadScripts pass before the actual
generation step. The customer's bug only manifests on real lockgen, not on
the dry-run staleness check, so this preserves the existing test contract
while still fixing cross-folder relative imports for the deprecated alias.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:06:53 +00:00

271 lines
9.0 KiB
TypeScript

/**
* Reproduction for `sync push --auto-metadata` cross-folder relative-import bug.
*
* `--auto-metadata` regenerates lockfiles client-side before pushing, but on a
* fresh workspace none of the imported scripts are deployed yet. The fix must
* route lockgen through `DoubleLinkedDependencyTree` + `uploadScripts` so the
* dep job can resolve relative imports via `temp_script_refs`.
*
* Without the fix, `wmill sync push --auto-metadata --yes` aborts with
* "Failed to find relative import" / "Non-zero exit status for bun build".
*/
import { expect, test } from "bun:test";
import { writeFile, readFile } from "node:fs/promises";
import { withTestBackend } from "./test_backend.ts";
import { createLocalScript } from "./test_fixtures.ts";
const wmillYaml = `defaultTs: bun
includes: ["**"]
excludes: []
`;
// The importer path (f/aaa/...) sorts before the imported path (f/bbb/...)
// alphabetically. The CLI sorts changes by path within the script bucket, so
// without the fix the importer is processed first and its lockgen tries to
// fetch a not-yet-uploaded helper from the server.
//
// One `../` from f/aaa/consumer.ts steps out of f/aaa/ to f/, then `bbb/helper.ts`
// resolves to f/bbb/helper.ts.
const consumerScript = `import { helper } from "../bbb/helper.ts";
export async function main() { return helper(); }
`;
const helperScript = `export function helper() { return "ok"; }
`;
test(
"sync push --auto-metadata succeeds for cross-folder relative imports on a fresh workspace",
{ timeout: 120000 },
async () => {
await withTestBackend(async (backend, tempDir) => {
await writeFile(`${tempDir}/wmill.yaml`, wmillYaml);
// Cross-folder relative import where the importer's path comes
// alphabetically before the import target.
await createLocalScript(tempDir, "f/aaa", "consumer", "bun", consumerScript);
await createLocalScript(tempDir, "f/bbb", "helper", "bun", helperScript);
const result = await backend.runCLICommand(
["sync", "push", "--yes", "--auto-metadata"],
tempDir,
);
if (result.code !== 0) {
console.log("STDOUT:", result.stdout);
console.log("STDERR:", result.stderr);
}
// The exit code must be zero — `--auto-metadata` should not abort on a
// fresh workspace just because the importer is alphabetically first.
expect(result.code).toBe(0);
const combined = result.stdout + result.stderr;
expect(combined).not.toContain("Failed to find relative import");
expect(combined).not.toContain("Failed to generate lockfile");
// Consumer's lockfile should exist and be non-empty (i.e. lockgen
// actually produced a valid lock, not a sentinel/error string).
const consumerLock = await readFile(
`${tempDir}/f/aaa/consumer.script.lock`,
"utf-8",
).catch(() => "");
expect(consumerLock.length).toBeGreaterThan(0);
});
},
);
// Helper: build a multi-folder topology mimicking the customer's failure
// shape. Importers (analytics, webhooks) reach helpers in f/lib via deep
// cross-folder relative imports.
async function setupCustomerLikeTopology(tempDir: string) {
await writeFile(`${tempDir}/wmill.yaml`, wmillYaml);
await createLocalScript(
tempDir,
"f/lib",
"log_event",
"bun",
`export function logEvent(msg: string) { return msg; }\n`,
);
await createLocalScript(
tempDir,
"f/lib",
"errors",
"bun",
`export class AppError extends Error {}\n`,
);
await createLocalScript(
tempDir,
"f/integrations/snowflake",
"client",
"bun",
`export function client() { return "snowflake"; }\n`,
);
await createLocalScript(
tempDir,
"f/analytics/claims_operations",
"bulk",
"bun",
`import { client } from "../../integrations/snowflake/client.ts";
import { AppError } from "../../lib/errors.ts";
export async function main() { try { return client(); } catch (e) { throw new AppError(); } }
`,
);
await createLocalScript(
tempDir,
"f/webhooks/stripe",
"handle_webhook",
"bun",
`import { logEvent } from "../../lib/log_event.ts";
export async function main() { return logEvent("ok"); }
`,
);
}
test(
"generate-metadata succeeds across many folders with deep cross-folder relative imports",
{ timeout: 180000 },
async () => {
await withTestBackend(async (backend, tempDir) => {
await setupCustomerLikeTopology(tempDir);
const result = await backend.runCLICommand(
["generate-metadata", "--yes"],
tempDir,
);
if (result.code !== 0) {
console.log("STDOUT:", result.stdout);
console.log("STDERR:", result.stderr);
}
expect(result.code).toBe(0);
const combined = result.stdout + result.stderr;
expect(combined).not.toContain("Failed to find relative import");
expect(combined).not.toContain("Failed to generate lockfile");
const bulkLock = await readFile(
`${tempDir}/f/analytics/claims_operations/bulk.script.lock`,
"utf-8",
).catch(() => "");
const webhookLock = await readFile(
`${tempDir}/f/webhooks/stripe/handle_webhook.script.lock`,
"utf-8",
).catch(() => "");
expect(bulkLock.length).toBeGreaterThan(0);
expect(webhookLock.length).toBeGreaterThan(0);
});
},
);
// `wmill script generate-metadata` is a deprecated alias defined in
// commands/script/script.ts. Its action handler used to be a separate
// implementation that didn't go through DoubleLinkedDependencyTree +
// uploadScripts, so on a fresh DB it hit the same out-of-order failure as
// `sync push --auto-metadata`. The fix delegates the alias to the canonical
// generateMetadata implementation.
// Customer scenario: a barrel file (f/lib/errors/index.ts) re-exports from
// siblings (./types.ts, ./WorkflowError.ts, ...). An importer in a different
// folder imports from the barrel. On a fresh DB, the dep job for the importer
// fetches index.ts via raw_unpinned + temp_script_hash, but bun's resolver
// then has to resolve the barrel's *sibling* imports — and those need to be
// in TEMP_SCRIPT_REFS too.
test(
"generate-metadata succeeds when importer reaches helpers via a barrel re-exporter",
{ timeout: 180000 },
async () => {
await withTestBackend(async (backend, tempDir) => {
await writeFile(`${tempDir}/wmill.yaml`, wmillYaml);
await createLocalScript(
tempDir,
"f/lib/errors",
"types",
"bun",
`export type ErrorKind = "fatal" | "warn";\n` +
`export function _typesAnchor() { return null as unknown; }\n`,
);
await createLocalScript(
tempDir,
"f/lib/errors",
"WorkflowError",
"bun",
`export class WorkflowError extends Error { kind = "fatal" as const; }\n` +
`export function _wfeAnchor() { return new WorkflowError(); }\n`,
);
await createLocalScript(
tempDir,
"f/lib/errors",
"index",
"bun",
`export * from "./types.ts";\n` +
`export * from "./WorkflowError.ts";\n` +
`export function main() { return "barrel"; }\n`,
);
await createLocalScript(
tempDir,
"f/analytics/claims_operations",
"bulk",
"bun",
`import { WorkflowError } from "../../lib/errors/index.ts";
export async function main() { return new WorkflowError().message; }
`,
);
const result = await backend.runCLICommand(
["generate-metadata", "--yes"],
tempDir,
);
if (result.code !== 0) {
console.log("STDOUT:", result.stdout);
console.log("STDERR:", result.stderr);
}
expect(result.code).toBe(0);
const combined = result.stdout + result.stderr;
expect(combined).not.toContain("Failed to find relative import");
expect(combined).not.toContain("Failed to generate lockfile");
});
},
);
test(
"deprecated `wmill script generate-metadata` succeeds for cross-folder imports on a fresh workspace",
{ timeout: 180000 },
async () => {
await withTestBackend(async (backend, tempDir) => {
await setupCustomerLikeTopology(tempDir);
const result = await backend.runCLICommand(
["script", "generate-metadata", "--yes"],
tempDir,
);
if (result.code !== 0) {
console.log("STDOUT:", result.stdout);
console.log("STDERR:", result.stderr);
}
expect(result.code).toBe(0);
const combined = result.stdout + result.stderr;
expect(combined).not.toContain("Failed to find relative import");
expect(combined).not.toContain("Failed to generate lockfile");
const bulkLock = await readFile(
`${tempDir}/f/analytics/claims_operations/bulk.script.lock`,
"utf-8",
).catch(() => "");
const webhookLock = await readFile(
`${tempDir}/f/webhooks/stripe/handle_webhook.script.lock`,
"utf-8",
).catch(() => "");
expect(bulkLock.length).toBeGreaterThan(0);
expect(webhookLock.length).toBeGreaterThan(0);
});
},
);