mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
346 lines
11 KiB
TypeScript
346 lines
11 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 + 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);
|
|
});
|
|
},
|
|
);
|
|
|
|
// The importer and the leaf change; the barrel between them does not, so it is
|
|
// absent from the push's change set. See `resolvePlaceholdersFromLocal`.
|
|
test(
|
|
"sync push --auto-metadata succeeds when a changed leaf sits behind an unchanged barrel",
|
|
{ timeout: 180000 },
|
|
async () => {
|
|
await withTestBackend(async (backend, tempDir) => {
|
|
await writeFile(`${tempDir}/wmill.yaml`, wmillYaml);
|
|
|
|
await createLocalScript(
|
|
tempDir,
|
|
"f/barrel",
|
|
"helper",
|
|
"bun",
|
|
`export function add(a: number, b: number) { return a + b; }\n`,
|
|
);
|
|
await createLocalScript(
|
|
tempDir,
|
|
"f/barrel",
|
|
"index",
|
|
"bun",
|
|
`export * from "./helper.ts";\n`,
|
|
);
|
|
await createLocalScript(
|
|
tempDir,
|
|
"f/app",
|
|
"consumer",
|
|
"bun",
|
|
`import { add } from "../barrel/index.ts";
|
|
export async function main() { return add(1, 2); }
|
|
`,
|
|
);
|
|
|
|
const deploy = await backend.runCLICommand(
|
|
["sync", "push", "--yes", "--auto-metadata"],
|
|
tempDir,
|
|
);
|
|
if (deploy.code !== 0) {
|
|
console.log("STDOUT:", deploy.stdout);
|
|
console.log("STDERR:", deploy.stderr);
|
|
}
|
|
expect(deploy.code).toBe(0);
|
|
|
|
// Add an export to the leaf and use it from the importer. The barrel
|
|
// re-exports it already, so it stays byte-identical and out of the push.
|
|
await writeFile(
|
|
`${tempDir}/f/barrel/helper.ts`,
|
|
`export function add(a: number, b: number) { return a + b; }
|
|
export function subtract(a: number, b: number) { return a - b; }
|
|
`,
|
|
);
|
|
await writeFile(
|
|
`${tempDir}/f/app/consumer.ts`,
|
|
`import { subtract } from "../barrel/index.ts";
|
|
export async function main() { return subtract(3, 1); }
|
|
`,
|
|
);
|
|
|
|
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);
|
|
}
|
|
expect(result.code).toBe(0);
|
|
|
|
const combined = result.stdout + result.stderr;
|
|
expect(combined).not.toContain("No matching export");
|
|
expect(combined).not.toContain("Failed to generate lockfile");
|
|
});
|
|
},
|
|
);
|