Files
windmill/cli/test/workspaces_config.test.ts
Ruben Fiszel 5b97092997 feat: unify CLI config to workspaces, deprecate gitBranches/environments (#8767)
* refactor: unify CLI config to workspaces, deprecate gitBranches/environments

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

* chore: update frontend examples and regenerate system prompts for workspaces config

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

* test: update test files to use workspaces config instead of gitBranches

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

* fix: handle --branch with --base-url correctly in sync pull/push

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

* feat: warn when --workspace overrides auto-detected branch or misses config entry

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

* feat: show reason why workspace was selected in log message

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

* docs: clarify specificItems file naming uses gitBranch as suffix

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

* refactor: rename branch-specific to workspace-specific, use workspace name as file suffix

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

* refactor: rename branch-specific to workspace-specific, add comprehensive integration tests

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

* refactor: simplify bind and init to be workspace-centric

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

* refactor: make bind/unbind interactive with --workspace and --branch flags

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

* refactor: make bind interactive with profile selection, workspace name, and optional branch

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

* refactor: init offers to bind workspace using same flow as wmill workspace bind

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

* fix: skip backend git-sync check in init when no workspace was bound

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

* fix: skip all API calls in init when no workspace was bound

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

* feat: log when RT namespace is skipped, offer to generate it after bind

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

* fix: warn when no workspace bound during init

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

* fix: init git-sync check uses bound workspace, not active profile

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

* fix: init uses selected profile directly, avoids re-resolving and duplicate prompt

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

* fix: init skips requireLogin, uses bound profile token directly

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

* feat: auto-pick or prompt workspace from config when no branch matches

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

* fix: show configured workspaces list and bind hint in resolution messages

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

* fix: cache bound profile to avoid duplicate profile selection prompts in init

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

* fix: hoist boundProfile scope, add 2 comprehensive integration tests covering all flows

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

* fix: rt.d.ts prompt defaults to no when file exists, better description

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

* fix: remove empty overrides from generated config, add specificItems hint

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

* fix: add inline comments for non-trivial fields, add overrides/promotionOverrides hints to bound workspaces

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

* chore: regenerate system prompts

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-09 19:46:34 +00:00

521 lines
18 KiB
TypeScript

import { expect, test, describe } from "bun:test";
import { writeFile, mkdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { withTestBackend } from "./test_backend.ts";
import { addWorkspace } from "../workspace.ts";
import { parseJsonFromCLIOutput } from "./test_config_helpers.ts";
import { getEffectiveSettings, readConfigFile, convertGitBranchesToWorkspaces, findWorkspaceByGitBranch, getEffectiveWorkspaceId, getEffectiveGitBranch, getWorkspaceNames, type SyncOptions } from "../src/core/conf.ts";
import { getSpecificItemsForCurrentBranch } from "../src/core/specific_items.ts";
// =============================================================================
// Helper
// =============================================================================
async function setupWorkspaceProfile(backend: any, workspaceName: string): Promise<void> {
await addWorkspace({
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: workspaceName,
token: backend.token,
}, { force: true, configDir: backend.testConfigDir });
}
// =============================================================================
// UNIT TESTS: workspaces config helpers
// =============================================================================
describe("findWorkspaceByGitBranch", () => {
test("finds workspace by key when gitBranch not set (default)", () => {
const ws = { staging: { baseUrl: "https://staging.wm.dev" } };
const match = findWorkspaceByGitBranch(ws as any, "staging");
expect(match).toBeDefined();
expect(match![0]).toEqual("staging");
});
test("finds workspace by explicit gitBranch", () => {
const ws = { production: { gitBranch: "main", baseUrl: "https://app.wm.dev" } };
const match = findWorkspaceByGitBranch(ws as any, "main");
expect(match).toBeDefined();
expect(match![0]).toEqual("production");
});
test("does not find workspace when gitBranch differs", () => {
const ws = { production: { gitBranch: "main", baseUrl: "https://app.wm.dev" } };
const match = findWorkspaceByGitBranch(ws as any, "production");
expect(match).toBeUndefined();
});
test("returns undefined for undefined workspaces", () => {
expect(findWorkspaceByGitBranch(undefined, "main")).toBeUndefined();
});
test("skips commonSpecificItems", () => {
const ws = { commonSpecificItems: { variables: ["f/**"] }, staging: { baseUrl: "x" } };
const match = findWorkspaceByGitBranch(ws as any, "commonSpecificItems");
expect(match).toBeUndefined();
});
test("returns first match when multiple map to same gitBranch", () => {
const ws = {
alpha: { gitBranch: "develop", baseUrl: "a" },
beta: { gitBranch: "develop", baseUrl: "b" },
};
const match = findWorkspaceByGitBranch(ws as any, "develop");
expect(match![0]).toEqual("alpha");
});
});
describe("getEffectiveWorkspaceId", () => {
test("returns workspaceId when set", () => {
expect(getEffectiveWorkspaceId("prod", { workspaceId: "production" })).toEqual("production");
});
test("defaults to workspace name when workspaceId not set", () => {
expect(getEffectiveWorkspaceId("staging", {})).toEqual("staging");
});
});
describe("getEffectiveGitBranch", () => {
test("returns gitBranch when set", () => {
expect(getEffectiveGitBranch("production", { gitBranch: "main" })).toEqual("main");
});
test("defaults to workspace name when gitBranch not set", () => {
expect(getEffectiveGitBranch("staging", {})).toEqual("staging");
});
});
describe("getWorkspaceNames", () => {
test("excludes commonSpecificItems", () => {
const ws = { staging: {}, production: {}, commonSpecificItems: { variables: [] } };
expect(getWorkspaceNames(ws as any)).toEqual(["staging", "production"]);
});
test("returns empty for undefined", () => {
expect(getWorkspaceNames(undefined)).toEqual([]);
});
});
// =============================================================================
// UNIT TESTS: convertGitBranchesToWorkspaces
// =============================================================================
describe("convertGitBranchesToWorkspaces", () => {
test("preserves all fields from old branch entries", () => {
const old = {
main: {
baseUrl: "https://app.wm.dev",
workspaceId: "production",
overrides: { skipSecrets: false },
promotionOverrides: { skipSecrets: true },
specificItems: { variables: ["f/**"] },
stateful: true,
message: "auto",
},
};
const ws = convertGitBranchesToWorkspaces(old as any);
const entry = (ws as any).main;
expect(entry.baseUrl).toEqual("https://app.wm.dev");
expect(entry.workspaceId).toEqual("production");
expect(entry.overrides).toEqual({ skipSecrets: false });
expect(entry.promotionOverrides).toEqual({ skipSecrets: true });
expect(entry.specificItems).toEqual({ variables: ["f/**"] });
expect(entry.stateful).toEqual(true);
expect(entry.message).toEqual("auto");
});
test("preserves commonSpecificItems", () => {
const old = {
commonSpecificItems: { variables: ["f/shared/**"], settings: true },
main: { overrides: {} },
};
const ws = convertGitBranchesToWorkspaces(old as any);
expect(ws.commonSpecificItems).toEqual({ variables: ["f/shared/**"], settings: true });
});
test("handles empty entries", () => {
const ws = convertGitBranchesToWorkspaces({ main: {} } as any);
expect((ws as any).main).toEqual({});
});
test("does not set gitBranch (defaults to key name)", () => {
const ws = convertGitBranchesToWorkspaces({ main: { baseUrl: "x" } } as any);
expect((ws as any).main.gitBranch).toBeUndefined();
});
});
// =============================================================================
// UNIT TESTS: getEffectiveSettings with workspaces config
// =============================================================================
describe("getEffectiveSettings with workspaces", () => {
test("applies overrides by workspace name", async () => {
const config: SyncOptions = {
includes: ["f/**"],
workspaces: {
staging: { overrides: { includes: ["staging/**"], skipVariables: true } },
production: { overrides: { skipSecrets: false } },
},
};
const s = await getEffectiveSettings(config, undefined, true, true, "staging");
expect(s.includes).toEqual(["staging/**"]);
expect(s.skipVariables).toEqual(true);
});
test("returns top-level settings for unknown workspace", async () => {
const config: SyncOptions = { includes: ["f/**"], workspaces: { staging: { overrides: {} } } };
const s = await getEffectiveSettings(config, undefined, true, true, "nonexistent");
expect(s.includes).toEqual(["f/**"]);
});
test("promotion resolves by gitBranch", async () => {
const config: SyncOptions = {
workspaces: {
production: {
gitBranch: "main",
promotionOverrides: { skipSecrets: false },
},
},
};
// --promotion main should find workspace "production" via gitBranch match
const s = await getEffectiveSettings(config, "main", true, true);
expect(s.skipSecrets).toEqual(false);
});
});
// =============================================================================
// UNIT TESTS: getSpecificItemsForCurrentBranch with workspaces
// =============================================================================
describe("getSpecificItemsForCurrentBranch with workspaces", () => {
test("looks up by workspace name override", () => {
const config: SyncOptions = {
workspaces: {
staging: { specificItems: { variables: ["f/staging/**"] } },
production: { specificItems: { variables: ["f/prod/**"] } },
commonSpecificItems: { resources: ["f/shared/**"] },
},
};
const items = getSpecificItemsForCurrentBranch(config, "staging");
expect(items?.variables).toEqual(["f/staging/**"]);
expect(items?.resources).toEqual(["f/shared/**"]);
});
test("returns undefined for unknown workspace", () => {
const config: SyncOptions = {
workspaces: { staging: { specificItems: { variables: ["f/**"] } } },
};
const items = getSpecificItemsForCurrentBranch(config, "nonexistent");
expect(items).toBeUndefined();
});
test("returns undefined when no workspaces config", () => {
expect(getSpecificItemsForCurrentBranch({}, "staging")).toBeUndefined();
});
test("merges common and workspace-specific items", () => {
const config: SyncOptions = {
workspaces: {
commonSpecificItems: { variables: ["common/**"], resources: ["shared/**"] },
dev: { specificItems: { variables: ["dev/**"], triggers: ["dev/triggers/**"] } },
},
};
const items = getSpecificItemsForCurrentBranch(config, "dev");
expect(items?.variables).toEqual(["common/**", "dev/**"]);
expect(items?.resources).toEqual(["shared/**"]);
expect(items?.triggers).toEqual(["dev/triggers/**"]);
});
});
// =============================================================================
// INTEGRATION TESTS: new workspaces config format
// =============================================================================
test("Integration: workspaces config with --branch applies correct overrides", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "ws_test");
// New workspaces config with workspace name ≠ gitBranch
await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
skipVariables: false
workspaces:
production:
gitBranch: prod_branch
overrides:
skipVariables: true
staging:
overrides:
skipVariables: false`, "utf-8");
// --branch prod_branch should resolve to workspace "production" and skip variables
const result = await backend.runCLICommand([
'sync', 'pull',
'--branch', 'prod_branch',
'--dry-run',
'--json-output'
], tempDir);
expect(result.code).toEqual(0);
const output = parseJsonFromCLIOutput(result.stdout);
const paths = (output.changes || []).map((c: any) => c.path);
const hasVariables = paths.some((p: string) => p.includes('.variable.yaml'));
expect(hasVariables).toEqual(false);
});
});
test("Integration: workspaces config with --branch resolves default gitBranch", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "ws_test");
// Workspace name = "staging", gitBranch defaults to "staging"
await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
skipVariables: false
workspaces:
staging:
overrides:
skipVariables: true`, "utf-8");
// --branch staging resolves to workspace "staging"
const result = await backend.runCLICommand([
'sync', 'pull',
'--branch', 'staging',
'--dry-run',
'--json-output'
], tempDir);
expect(result.code).toEqual(0);
const output = parseJsonFromCLIOutput(result.stdout);
const paths = (output.changes || []).map((c: any) => c.path);
const hasVariables = paths.some((p: string) => p.includes('.variable.yaml'));
expect(hasVariables).toEqual(false);
});
});
test("Integration: different workspaces have different overrides", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "ws_test");
await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
skipVariables: false
skipResources: false
workspaces:
production:
gitBranch: prod
overrides:
skipVariables: true
skipResources: true
development:
gitBranch: dev
overrides:
skipVariables: false
skipResources: false`, "utf-8");
// prod should skip both
const prodResult = await backend.runCLICommand([
'sync', 'pull', '--branch', 'prod', '--dry-run', '--json-output'
], tempDir);
expect(prodResult.code).toEqual(0);
const prodPaths = (parseJsonFromCLIOutput(prodResult.stdout).changes || []).map((c: any) => c.path);
expect(prodPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false);
expect(prodPaths.some((p: string) => p.includes('.resource.yaml'))).toEqual(false);
// dev should include both
const devResult = await backend.runCLICommand([
'sync', 'pull', '--branch', 'dev', '--dry-run', '--json-output'
], tempDir);
expect(devResult.code).toEqual(0);
const devPaths = (parseJsonFromCLIOutput(devResult.stdout).changes || []).map((c: any) => c.path);
expect(devPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(true);
expect(devPaths.some((p: string) => p.includes('.resource.yaml'))).toEqual(true);
});
});
test("Integration: legacy gitBranches config still works via normalization", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "legacy_test");
// Old gitBranches format on disk
await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
skipVariables: false
gitBranches:
legacy_branch:
overrides:
skipVariables: true`, "utf-8");
const result = await backend.runCLICommand([
'sync', 'pull', '--branch', 'legacy_branch', '--dry-run', '--json-output'
], tempDir);
expect(result.code).toEqual(0);
const paths = (parseJsonFromCLIOutput(result.stdout).changes || []).map((c: any) => c.path);
expect(paths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false);
});
});
test("Integration: legacy environments config still works via normalization", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "env_test");
// Old environments format on disk
await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
skipVariables: false
environments:
env_branch:
overrides:
skipVariables: true`, "utf-8");
const result = await backend.runCLICommand([
'sync', 'pull', '--branch', 'env_branch', '--dry-run', '--json-output'
], tempDir);
expect(result.code).toEqual(0);
const paths = (parseJsonFromCLIOutput(result.stdout).changes || []).map((c: any) => c.path);
expect(paths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false);
});
});
test("Integration: workspace-specific files use workspace name as suffix", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "specific_test");
// Workspace "production" with gitBranch "main" and specificItems
await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
workspaces:
production:
gitBranch: prod_branch
specificItems:
variables:
- "f/**"`, "utf-8");
// Pull with --branch prod_branch → resolves to workspace "production"
// workspace-specific files should use "production" as suffix (the workspace name)
const result = await backend.runCLICommand([
'sync', 'pull', '--branch', 'prod_branch', '--dry-run', '--json-output'
], tempDir);
expect(result.code).toEqual(0);
const output = parseJsonFromCLIOutput(result.stdout);
const changes = output.changes || [];
// Check that workspace-specific paths use the workspace name "production" as suffix
const wsSpecificPaths = changes
.filter((c: any) => c.workspace_specific_path)
.map((c: any) => c.workspace_specific_path);
for (const p of wsSpecificPaths) {
expect(p).toContain(".production.");
expect(p).not.toContain(".prod_branch.");
}
});
});
test("Integration: promotion with workspaces config", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "promo_test");
await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
skipVariables: false
workspaces:
staging:
gitBranch: staging_branch
overrides:
skipVariables: false
promotionOverrides:
skipVariables: true`, "utf-8");
// --promotion staging_branch should find workspace "staging" via gitBranch
// and apply promotionOverrides (skipVariables: true)
const result = await backend.runCLICommand([
'sync', 'pull',
'--branch', 'staging_branch',
'--promotion', 'staging_branch',
'--dry-run',
'--json-output'
], tempDir);
expect(result.code).toEqual(0);
const paths = (parseJsonFromCLIOutput(result.stdout).changes || []).map((c: any) => c.path);
expect(paths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false);
});
});
test("Integration: config migrate converts gitBranches to workspaces", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "migrate_test");
// Write old format
const yamlPath = `${tempDir}/wmill.yaml`;
await writeFile(yamlPath, `defaultTs: bun
includes:
- "f/**"
gitBranches:
main:
baseUrl: https://app.windmill.dev
workspaceId: production
overrides:
skipSecrets: false
staging:
baseUrl: https://staging.windmill.dev
overrides:
includeSchedules: true
commonSpecificItems:
variables:
- "f/shared/**"`, "utf-8");
// Run migrate
const result = await backend.runCLICommand([
'config', 'migrate',
], tempDir);
expect(result.code).toEqual(0);
// Read back and verify
const migrated = await readFile(yamlPath, "utf-8");
expect(migrated).toContain("workspaces:");
expect(migrated).not.toContain("gitBranches:");
expect(migrated).toContain("production");
expect(migrated).toContain("staging");
expect(migrated).toContain("commonSpecificItems");
expect(migrated).toContain("f/shared/**");
});
});
test("Integration: config migrate is idempotent", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "idempotent_test");
const yamlPath = `${tempDir}/wmill.yaml`;
await writeFile(yamlPath, `defaultTs: bun
workspaces:
staging:
baseUrl: https://staging.wm.dev
overrides: {}`, "utf-8");
const result = await backend.runCLICommand(['config', 'migrate'], tempDir);
expect(result.code).toEqual(0);
// File should be unchanged
const content = await readFile(yamlPath, "utf-8");
expect(content).toContain("workspaces:");
expect(content).not.toContain("gitBranches:");
});
});