Files
windmill/cli/test/init_template_unit.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

260 lines
9.4 KiB
TypeScript

/**
* Unit tests for wmill.yaml template generation, config reference, and JSON Schema.
*/
import { expect, test, describe } from "bun:test";
import { parse } from "yaml";
import Ajv from "ajv";
import {
generateCommentedTemplate,
generateJsonSchema,
formatConfigReference,
formatConfigReferenceJson,
CONFIG_REFERENCE,
} from "../src/commands/init/template.ts";
// =============================================================================
// generateCommentedTemplate
// =============================================================================
describe("generateCommentedTemplate", () => {
test("produces valid YAML that parses without errors", () => {
const yaml = generateCommentedTemplate("main");
const config = parse(yaml);
expect(config).toBeDefined();
expect(typeof config).toBe("object");
});
test("uses provided branch name in workspaces", () => {
const config = parse(generateCommentedTemplate("my-feature"));
expect(config.workspaces["my-feature"]).toBeDefined();
});
test("defaults to 'main' when no branch name given", () => {
const config = parse(generateCommentedTemplate());
expect(config.workspaces["main"]).toBeDefined();
});
test("quotes branch names with YAML-special characters", () => {
const specialBranches = ["fix: something", "feat/my branch", "release#1"];
for (const branch of specialBranches) {
const yaml = generateCommentedTemplate(branch);
const config = parse(yaml);
expect(config.workspaces[branch]).toBeDefined();
}
});
test("contains yaml-language-server schema directive", () => {
const yaml = generateCommentedTemplate("main");
expect(yaml.startsWith("# yaml-language-server: $schema=wmill.schema.json")).toBe(true);
});
test("includes all non-commented, non-skipped CONFIG_REFERENCE entries as active YAML keys", () => {
const config = parse(generateCommentedTemplate("main"));
for (const opt of CONFIG_REFERENCE) {
if (!opt.commented && !opt.skipInTemplate) {
expect(config).toHaveProperty(opt.name);
}
}
});
test("does not include commented or skipped entries as active YAML keys", () => {
const config = parse(generateCommentedTemplate("main"));
for (const opt of CONFIG_REFERENCE) {
if (opt.commented || opt.skipInTemplate) {
expect(config[opt.name]).toBeUndefined();
}
}
});
test("default values match expected defaults", () => {
const config = parse(generateCommentedTemplate("main"));
expect(config.defaultTs).toBe("bun");
expect(config.skipSecrets).toBe(true);
expect(config.nonDottedPaths).toBe(true);
expect(config.codebases).toEqual([]);
expect(config.excludes).toEqual([]);
expect(config.includes).toEqual(["f/**"]);
});
});
// =============================================================================
// generateJsonSchema
// =============================================================================
describe("generateJsonSchema", () => {
const schema = generateJsonSchema();
test("is a valid JSON Schema draft-07", () => {
expect(schema.$schema).toBe("http://json-schema.org/draft-07/schema#");
expect(schema.type).toBe("object");
expect(schema.properties).toBeDefined();
});
test("validates the generated YAML template", () => {
const config = parse(generateCommentedTemplate("main"));
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
expect(validate(config)).toBe(true);
});
test("rejects unknown keys", () => {
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
expect(validate({ unknownOption: true })).toBe(false);
});
test("rejects invalid enum values", () => {
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
expect(validate({ defaultTs: "python" })).toBe(false);
});
test("rejects wrong types", () => {
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
expect(validate({ skipSecrets: "yes" })).toBe(false);
});
test("includes codebases array schema with item properties", () => {
expect(schema.properties.codebases.type).toBe("array");
expect(schema.properties.codebases.items.properties.relative_path).toBeDefined();
expect(schema.properties.codebases.items.required).toContain("relative_path");
});
test("includes workspaces with workspace config schema", () => {
const wsSchema = schema.properties.workspaces.additionalProperties;
expect(wsSchema.properties.gitBranch).toBeDefined();
expect(wsSchema.properties.baseUrl).toBeDefined();
expect(wsSchema.properties.workspaceId).toBeDefined();
expect(wsSchema.properties.specificItems).toBeDefined();
expect(wsSchema.properties.specificItems.properties.variables).toBeDefined();
});
test("includes gitBranches as deprecated alias for workspaces", () => {
expect(schema.properties.gitBranches).toBeDefined();
expect(schema.properties.gitBranches.additionalProperties).toEqual(
schema.properties.workspaces.additionalProperties
);
});
test("includes environments as deprecated alias for workspaces", () => {
expect(schema.properties.environments).toBeDefined();
expect(schema.properties.environments.additionalProperties).toEqual(
schema.properties.workspaces.additionalProperties
);
});
test("does not contain template-only keys in schema output", () => {
const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote", "skipInTemplate"];
const json = JSON.stringify(schema);
for (const key of templateKeys) {
expect(json).not.toContain(`"${key}"`);
}
});
});
// =============================================================================
// formatConfigReference
// =============================================================================
describe("formatConfigReference", () => {
const output = formatConfigReference();
test("includes header row", () => {
expect(output).toContain("OPTION");
expect(output).toContain("DESCRIPTION");
expect(output).toContain("DEFAULT");
});
test("includes all top-level CONFIG_REFERENCE entries", () => {
for (const opt of CONFIG_REFERENCE) {
expect(output).toContain(opt.name);
}
});
test("auto-expands codebases sub-fields", () => {
expect(output).toContain("codebases[].relative_path");
expect(output).toContain("codebases[].format");
expect(output).toContain("codebases[].external");
});
test("auto-expands workspaces sub-fields", () => {
expect(output).toContain("workspaces.<workspace>.gitBranch");
expect(output).toContain("workspaces.<workspace>.baseUrl");
expect(output).toContain("workspaces.<workspace>.workspaceId");
expect(output).toContain("workspaces.<workspace>.specificItems.variables");
});
test("auto-expands commonSpecificItems sub-fields", () => {
expect(output).toContain("workspaces.commonSpecificItems.variables");
expect(output).toContain("workspaces.commonSpecificItems.settings");
});
test("deprecated entries are listed but not expanded", () => {
expect(output).toContain("gitBranches");
expect(output).toContain("[Deprecated]");
// Should NOT have expanded sub-fields for deprecated entries
expect(output).not.toContain("gitBranches.<workspace>");
});
});
// =============================================================================
// formatConfigReferenceJson
// =============================================================================
describe("formatConfigReferenceJson", () => {
test("produces valid JSON", () => {
const parsed = JSON.parse(formatConfigReferenceJson());
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBe(CONFIG_REFERENCE.length);
});
test("each entry has name, type, default, description", () => {
const parsed = JSON.parse(formatConfigReferenceJson());
for (const entry of parsed) {
expect(entry).toHaveProperty("name");
expect(entry).toHaveProperty("type");
expect(entry).toHaveProperty("default");
expect(entry).toHaveProperty("description");
}
});
test("does not contain template-only keys", () => {
const parsed = JSON.parse(formatConfigReferenceJson());
const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote", "skipInTemplate"];
for (const entry of parsed) {
for (const key of templateKeys) {
expect(entry).not.toHaveProperty(key);
}
}
});
});
// =============================================================================
// CONFIG_REFERENCE integrity
// =============================================================================
describe("CONFIG_REFERENCE integrity", () => {
test("all entries have required fields", () => {
for (const opt of CONFIG_REFERENCE) {
expect(opt.name).toBeTruthy();
expect(opt.type).toBeTruthy();
expect(opt.description).toBeTruthy();
expect(opt.default).toBeDefined();
}
});
test("no duplicate names", () => {
const names = CONFIG_REFERENCE.map((o) => o.name);
expect(new Set(names).size).toBe(names.length);
});
test("type field uses valid JSON Schema types", () => {
const validTypes = new Set(["boolean", "string", "integer", "number", "array", "object"]);
for (const opt of CONFIG_REFERENCE) {
expect(validTypes.has(opt.type)).toBe(true);
}
});
});