Files
windmill/cli/test/lockfile_format_pinning_unit.test.ts
hugocasa 0b959b8ec6 feat(cli): wmill-lock.yaml auto-fill + --rehash-only + path-prefix dedup (#8978)
* fix(cli): canonical lockfile hashes + lock upgrade migration to v3

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): use __app_hash subpath in rehash missing-entry check

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): run sync pull lockfile auto-fill regardless of changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: regenerate system prompts for new lock and rehash-only commands

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): address review feedback on lock upgrade

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): drop v3 marker; always run fallback; fail-fast on unknown lockfile version

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): drop yaml-round-trip legacy hash variant; recover via --rehash-only

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): include legacy hash in script push staleness warning check

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* revert(cli): drop canonical hash formula; keep raw-bytes hashing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* perf(cli): reuse change-tracker map for sync pull lockfile auto-fill

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): address review feedback on rehash-only

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(cli): pin lockfile hash + yaml format and cover regression cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(cli): byte-stable snapshot tests for flow.yaml format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(cli): add app and script-metadata yaml snapshot fixtures

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): address claude review on rehash-only

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(cli): factorize script-path to remote-path derivation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): address claude + cubic review (dry-run mutation, rehash short-circuit)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(cli): make rehash a subcommand and factorize fs walks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): normalize line endings in yaml snapshot tests for windows ci

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(cli): address review feedback on rehash + auto-fill

- Flat-layout scripts now clearGlobalLock before rehash write so legacy
  ./-prefixed duplicates get cleaned up (matches flow/app behavior).
- Add MalformedLockfileError; sync pull auto-fill re-throws it alongside
  UnknownLockVersionError instead of silently warning + continuing.
- Document the legacy step-removal false-negative in
  isFlowDirectlyStale / isAppDirectlyStale and the categorizeLocalFiles
  ignore-filter invariant.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-01 15:11:56 +00:00

109 lines
4.8 KiB
TypeScript

/**
* Lockfile Format Pinning Tests
*
* Hard-coded golden values for the wire format of `wmill-lock.yaml` and the
* staleness-detection hash inputs. These tests will FAIL if any of the
* following changes:
*
* - The hash formula in `generateScriptHash` (raw concatenation order, JSON
* stringify of workspace deps) — used to compare against the hash stored
* in `wmill-lock.yaml` and on the deployed script.
* - The SHA-256 algorithm or hex encoding in `generateHash`.
* - The `yaml` library version's serialization output for the canonical
* `Lock` shape (sortMapEntries, singleQuote, etc.).
*
* Past regressions this catches:
* - Migration from Deno `@std/yaml` to npm `yaml` (#8041) silently changed
* indent/quote choices, producing different bytes for the same logical
* `wmill-lock.yaml` content. With these tests in place the byte change
* would have been caught at PR time.
* - The unsorted-keys top-hash issue in #8480 — a one-line `Object.keys().sort()`
* addition that silently invalidated every existing flow/app top hash.
*
* **If you intentionally change the format**: bump `version` in `metadata.ts`,
* add a migration path (e.g. `wmill lock upgrade`), AND update the golden
* values below. Don't just bump the goldens — that defeats the purpose.
*/
import { expect, test, describe } from "bun:test";
import { stringify as yamlStringify } from "yaml";
import { generateHash } from "../src/utils/utils.ts";
import { generateScriptHash } from "../src/utils/metadata.ts";
import { yamlOptions } from "../src/commands/sync/sync.ts";
describe("hash formula pinning", () => {
test("generateHash: SHA-256 hex of UTF-8 bytes", async () => {
expect(await generateHash("")).toEqual(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
expect(await generateHash("hello")).toEqual(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
// Multi-byte UTF-8 must hash bytes (not codepoints) — pin this
expect(await generateHash("é")).toEqual(
"4a99557e4033c3539de2eb65472017cad5f9557f7a0625a09f1c3f6e2ba69c4c"
);
});
test("generateScriptHash: order = JSON.stringify(deps) + content + metadata", async () => {
// Empty deps + empty content + empty metadata
expect(await generateScriptHash({}, "", "")).toEqual(
await generateHash("{}")
);
// Pin the canonical case used in real lockfiles
const deps = { "dependencies/package.json": "abc123" };
const content = "export async function main() { return 1; }\n";
const metadata = "summary: test\n";
expect(await generateScriptHash(deps, content, metadata)).toEqual(
await generateHash(JSON.stringify(deps) + content + metadata)
);
// Hard-pin one full byte sequence — if the formula changes (e.g. swap
// order, switch hash algo) this will diverge.
expect(await generateScriptHash(deps, content, metadata)).toEqual(
"69b0f1c596e65ef5e75c74aa2980484f1cae80bcb429132ce3906dd319ec28f1"
);
});
test("generateScriptHash: deps with different key orders produce different hashes", async () => {
// Documents existing behavior: workspace-deps map is NOT sorted before
// JSON.stringify, so insertion order matters. If you sort here, you must
// bump lock version.
const a = await generateScriptHash({ a: "1", b: "2" }, "", "");
const b = await generateScriptHash({ b: "2", a: "1" }, "", "");
expect(a).not.toEqual(b);
});
});
describe("wmill-lock.yaml byte-format pinning", () => {
test("yamlOptions: canonical Lock serializes to a stable byte sequence", () => {
// A representative Lock with both flat and ./-prefixed entries — the
// shape we produce in the real CLI. If the yaml lib changes
// indentation, quote style, or sort order, this snapshot will break.
// Note on ordering: `./`-prefixed keys sort BEFORE non-prefixed because
// `localeCompare` puts `.` before alphanumerics. If a future change
// normalizes away `./` on write (i.e. `clearGlobalLock` deletes them
// before any new entry is added), update this snapshot.
const lock = {
version: "v2",
locks: {
"f/scripts/utility": "hash1",
"./f/legacy/dotted": "hash_legacy",
"f/flows/main.flow+inline/step1.ts": "hash2",
},
};
const expected =
"version: v2\n" +
"locks:\n" +
" ./f/legacy/dotted: hash_legacy\n" +
" f/flows/main.flow+inline/step1.ts: hash2\n" +
" f/scripts/utility: hash1\n";
expect(yamlStringify(lock as Record<string, unknown>, yamlOptions)).toEqual(expected);
});
test("yamlOptions: empty Lock", () => {
const lock = { version: "v2", locks: {} };
const expected = "version: v2\nlocks: {}\n";
expect(yamlStringify(lock as Record<string, unknown>, yamlOptions)).toEqual(expected);
});
});