Files
windmill/cli/test/shared_ui_diff_unit.test.ts
Ruben Fiszel 14c29b77e9 fix(cli): surface shared UI changes in sync push dry-run preview (#10278)
* fix(cli): surface shared UI (ui/) changes in sync push dry-run preview

The git-sync "Pull from repo" preview never showed shared UI (ui/) changes,
so users thought the shared-UI folder was not syncing. The apply step does
sync it (pushSharedUi on dryRun=false); only the dry-run preview was blind.

Shared UI maps a single top-level ui/ folder to the workspace_shared_ui store
and is handled out-of-band from the normal file diff (isNotWmillFile excludes
ui/). The dry-run path returns before pushSharedUi runs, so the `changes` list
the modal consumes never contained any ui/ entry and read as "no changes".

- Add exported diffSharedUi(workspace) computing added/edited/deleted ui/<rel>
  entries (push direction), and refactor pushSharedUi to reuse it so preview
  and apply never diverge.
- Fold the diff into `changes` in the dry-run path (both JSON and terminal),
  guarded by try/catch. Apply path is unchanged.
- Label ui/ paths as "shared UI" in prettyChanges (getTypeStrFromPath throws
  on non-wmill paths like ui/config.json).
- Do not run pushSharedUi in the zero-changes branch during a dry-run.

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

* fix(cli): report shared-UI-only push in sync JSON output

Address local review: when a real apply has only ui/ changes it reaches the
zero-file-changes branch, pushes the shared-UI store, then printed
"No changes to push" in --json-output. Surface pushSharedUi's result so the
message no longer claims no changes when the store was written. Also correct
the pushSharedUi docstring (empty-but-existing folder still clears a
non-empty remote store).

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

* test(cli): trim shared_ui diff test header to the durable invariant

Address Codex nit: replace the narrative regression header with a 4-line
statement of the invariant (diffSharedUi mirrors pushSharedUi's apply
semantics so preview and apply never diverge).

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

* fix(cli): own-property shared-UI diff and count ui/ in dry-run summary

Address Codex review:
- diffSharedUi used `rel in remote`/`rel in files`, so a file named after an
  Object.prototype member (e.g. ui/toString) always registered as present and
  was misdiffed; pushSharedUi could then skip deleting it. Use Object.hasOwn.
- The dry-run "N changes to apply" summary logged before the shared UI fold,
  so a shared-UI-only dry-run printed "0 changes to apply" then listed the
  changes. Fold before the summary so the count includes ui/.
- Add a unit test for the ui/toString inherited-property filename.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:26:33 +02:00

93 lines
3.3 KiB
TypeScript

/**
* diffSharedUi must mirror pushSharedUi's apply semantics so the git-sync
* dry-run preview and the real apply never diverge: it emits ui/<rel> entries
* exactly when the local ui/ folder differs from the remote store, and nothing
* (including no local folder) when the apply would be a no-op.
*/
import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
let remoteFiles: Record<string, string> = {};
mock.module("../gen/services.gen.ts", () => ({
getSharedUi: async (_args: { workspace: string }) => ({ files: remoteFiles }),
}));
const { diffSharedUi } = await import("../src/commands/shared_ui.ts");
describe("diffSharedUi", () => {
const ws = "test-workspace";
let tmpDir: string;
let prevCwd: string;
beforeEach(() => {
remoteFiles = {};
prevCwd = process.cwd();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wm-shared-ui-"));
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(prevCwd);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeUi(rel: string, content: string) {
const full = path.join(tmpDir, "ui", rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content, "utf-8");
}
test("emits an added ui/<rel> entry when local has a file the remote lacks", async () => {
writeUi("theme.json", "{}");
const changes = await diffSharedUi(ws);
expect(changes).toEqual([{ type: "added", path: "ui/theme.json" }]);
});
test("emits an edited ui/<rel> entry when local content differs", async () => {
remoteFiles = { "theme.json": "{}" };
writeUi("theme.json", '{"a":1}');
const changes = await diffSharedUi(ws);
expect(changes).toEqual([
{ type: "edited", path: "ui/theme.json", before: "{}", after: '{"a":1}' },
]);
});
test("emits a deleted ui/<rel> entry when remote has a file local lacks", async () => {
remoteFiles = { "theme.json": "{}", "extra.json": "1" };
writeUi("theme.json", "{}");
const changes = await diffSharedUi(ws);
expect(changes).toEqual([{ type: "deleted", path: "ui/extra.json" }]);
});
test("emits nothing when local matches the remote store", async () => {
remoteFiles = { "theme.json": "{}" };
writeUi("theme.json", "{}");
const changes = await diffSharedUi(ws);
expect(changes).toEqual([]);
});
test("diffs files named after Object.prototype members (e.g. toString)", async () => {
// Own-property check, not `in`: a local-only ui/toString is an add, and a
// remote-only ui/toString is a delete, despite Object.prototype.toString.
writeUi("toString", "x");
let changes = await diffSharedUi(ws);
expect(changes).toEqual([{ type: "added", path: "ui/toString" }]);
fs.rmSync(path.join(tmpDir, "ui", "toString"));
fs.mkdirSync(path.join(tmpDir, "ui"), { recursive: true });
remoteFiles = { toString: "x" };
changes = await diffSharedUi(ws);
expect(changes).toEqual([{ type: "deleted", path: "ui/toString" }]);
});
test("emits nothing when there is no local ui/ folder (apply is a no-op)", async () => {
remoteFiles = { "theme.json": "{}" };
const changes = await diffSharedUi(ws);
expect(changes).toEqual([]);
});
});