Files
windmill/cli/test/fork_merge.test.ts
Ruben Fiszel 4342c18541 feat: add CLI workspace merge command and enhance fork with datatable/color support (#8756)
* feat: add CLI workspace merge command and enhance fork with datatable/color support

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

* fix: abort fork on git branch failure, per-datatable error handling, guard resetDiffTally

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

* test: add fork/merge integration tests covering full cycle

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

* fix: support deploying deletions during fork merge (archive/delete in target)

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

* refactor: share deploy logic between CLI and frontend via windmill-utils-internal

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

* fix: revert frontend to self-contained deploy, fix failure_module handling

The frontend imports windmill-utils-internal from npm (published v1.3.4)
which doesn't have the new deploy module yet. Revert frontend to its own
self-contained implementation with two improvements:
- Pass failure_module to getAllModules in flow deploy and getItemValue
- Add deleteItemInWorkspace for deploying deletions during merge

The shared deploy.ts in windmill-utils-internal remains for CLI use.

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

* feat: share deploy logic via published windmill-utils-internal, add comprehensive integration tests

- Publish windmill-utils-internal v1.3.8 with DeployProvider interface
- Frontend now uses shared deploy module (deployItem, deleteItemInWorkspace,
  checkItemExists, getOnBehalfOf, getItemValue) via provider adapter
- Add 4 new integration test sub-tests: all item types, secret variables,
  special characters, partial deploy + resetDiffTally

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

* fix: remove unused folderName function from frontend utils_workspace_deploy

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-08 05:29:48 +00:00

536 lines
28 KiB
TypeScript

import { expect, test } from "bun:test";
import { withTestBackend, type TestBackend } from "./test_backend.ts";
// =============================================================================
// FORK & MERGE INTEGRATION TESTS
//
// Tests the full fork/merge cycle using a single test backend instance.
// All sub-tests share the same backend to avoid workspace-limit issues
// (CE limits to 2 non-admins workspaces).
//
// workspace_diff tracking is an EE feature (populated by git sync).
// We manually insert rows into workspace_diff to simulate what git sync
// does, matching the pattern in backend/.../workspace_comparison.rs.
// =============================================================================
const FORK_ID = "wm-fork-merge-test";
async function api(
backend: TestBackend,
path: string,
options: RequestInit = {}
): Promise<Response> {
return backend.apiRequest!(path, options);
}
async function runSQL(backend: TestBackend, query: string): Promise<void> {
const dbUrl =
process.env["DATABASE_URL"] ||
"postgres://postgres:changeme@localhost:5432";
const proc = Bun.spawn(
[
"psql",
`${dbUrl}/postgres?sslmode=disable`,
"-t",
"-c",
`SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%' ORDER BY datname DESC LIMIT 1`,
],
{ stdout: "pipe", stderr: "pipe" }
);
const dbName = (await new Response(proc.stdout).text()).trim();
await proc.exited;
if (!dbName) throw new Error("Could not find test database");
const sqlProc = Bun.spawn(
["psql", `${dbUrl}/${dbName}?sslmode=disable`, "-c", query],
{ stdout: "pipe", stderr: "pipe" }
);
await sqlProc.exited;
}
async function populateWorkspaceDiff(
backend: TestBackend,
parentWs: string,
forkWs: string,
diffs: Array<{ path: string; kind: string; ahead: number; behind: number }>
): Promise<void> {
const values = diffs
.map(
(d) =>
`('${parentWs}', '${forkWs}', '${d.path}', '${d.kind}', ${d.ahead}, ${d.behind})`
)
.join(",\n");
await runSQL(
backend,
`INSERT INTO workspace_diff (source_workspace_id, fork_workspace_id, path, kind, ahead, behind)
VALUES ${values}
ON CONFLICT (source_workspace_id, fork_workspace_id, path, kind)
DO UPDATE SET ahead = EXCLUDED.ahead, behind = EXCLUDED.behind, has_changes = NULL`
);
}
async function removeFromSkipTally(backend: TestBackend, workspaceId: string) {
await runSQL(backend, `DELETE FROM skip_workspace_diff_tally WHERE workspace_id = '${workspaceId}'`);
}
async function deleteFork(backend: TestBackend, forkId: string) {
try {
await api(backend, `/api/w/${forkId}/workspaces/delete`, { method: "POST" });
} catch {}
// Force-clean via SQL to ensure workspace slot is freed (CE limits to 2)
const esc = forkId.replace(/'/g, "''");
await runSQL(backend, `
SET session_replication_role = replica;
DO $$ DECLARE r RECORD; BEGIN
FOR r IN SELECT c.table_name FROM information_schema.columns c
JOIN information_schema.tables t ON c.table_name = t.table_name AND c.table_schema = t.table_schema
WHERE c.column_name = 'workspace_id' AND c.table_schema = 'public' AND t.table_type = 'BASE TABLE'
GROUP BY c.table_name
LOOP EXECUTE format('DELETE FROM %I WHERE workspace_id = ''${esc}''', r.table_name);
END LOOP;
END $$;
DELETE FROM workspace WHERE id = '${esc}';
DELETE FROM workspace_diff WHERE fork_workspace_id = '${esc}';
DELETE FROM skip_workspace_diff_tally WHERE workspace_id = '${esc}';
SET session_replication_role = DEFAULT;
`);
}
async function createTestItems(backend: TestBackend, workspace: string) {
await api(backend, `/api/w/${workspace}/folders/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "merge_test" }),
});
await api(backend, `/api/w/${workspace}/scripts/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "f/merge_test/script_a",
content: "export function main() { return 'parent v1'; }",
language: "bun",
summary: "Script A",
schema: { type: "object", properties: {}, required: [] },
}),
});
await api(backend, `/api/w/${workspace}/variables/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/var_a", value: "parent_value", is_secret: false, description: "Variable A" }),
});
await api(backend, `/api/w/${workspace}/resources/type/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "merge_test_type", schema: { type: "object" }, description: "Test type" }),
});
await api(backend, `/api/w/${workspace}/resources/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/resource_a", resource_type: "merge_test_type", value: { key: "parent" }, description: "Resource A" }),
});
}
async function createFork(backend: TestBackend, parentWs: string, forkId: string, color?: string) {
const r = await api(backend, `/api/w/${parentWs}/workspaces/create_fork`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: forkId, name: "Test Fork", color, forked_datatables: [] }),
});
if (!r.ok) {
const err = await r.text();
throw new Error(`Fork creation failed: ${r.status} ${err}`);
}
await removeFromSkipTally(backend, forkId);
await new Promise((resolve) => setTimeout(resolve, 500));
}
// =====================================================================
// All fork/merge sub-tests run inside a single withTestBackend to share
// the backend instance and avoid CE workspace limits.
// =====================================================================
test(
"Fork/Merge: full cycle integration tests",
async () => {
await withTestBackend(async (backend, _tempDir) => {
const parentWs = backend.workspace;
// ---------------------------------------------------------------
// Sub-test 1: Deploy changes from fork to parent
// ---------------------------------------------------------------
console.log("\n--- Sub-test 1: fork→parent deploy ---");
await deleteFork(backend, FORK_ID);
await createTestItems(backend, parentWs);
await createFork(backend, parentWs, FORK_ID, "#ff5500");
// Make changes in fork
const forkScript = await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/f/merge_test/script_a`)).json();
await api(backend, `/api/w/${FORK_ID}/scripts/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...forkScript, content: "export function main() { return 'fork v2 - modified!'; }", summary: "Script A (fork)", parent_hash: forkScript.hash }),
});
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/new_fork_var", value: "fork_only", is_secret: false, description: "New from fork" }),
});
await api(backend, `/api/w/${FORK_ID}/resources/update/f/merge_test/resource_a`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/resource_a", value: { key: "fork_modified" }, description: "Resource A (fork)" }),
});
// Populate workspace_diff
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/script_a", kind: "script", ahead: 1, behind: 0 },
{ path: "f/merge_test/new_fork_var", kind: "variable", ahead: 1, behind: 0 },
{ path: "f/merge_test/resource_a", kind: "resource", ahead: 1, behind: 0 },
]);
// Compare
const comp1 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
expect(comp1.skipped_comparison).toBe(false);
expect(comp1.summary.total_diffs).toBeGreaterThanOrEqual(3);
expect(comp1.summary.conflicts).toBe(0);
// Deploy fork→parent
for (const diff of comp1.diffs.filter((d: any) => d.ahead > 0)) {
const { kind, path } = diff;
if (kind === "script") {
const s = await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/${path}`)).json();
let parentHash;
try { parentHash = (await (await api(backend, `/api/w/${parentWs}/scripts/get/p/${path}`)).json()).hash; } catch {}
expect((await api(backend, `/api/w/${parentWs}/scripts/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...s, lock: s.lock, parent_hash: parentHash }),
})).ok).toBe(true);
} else if (kind === "variable") {
const v = await (await api(backend, `/api/w/${FORK_ID}/variables/get/${path}?decrypt_secret=true`)).json();
const exists = await (await api(backend, `/api/w/${parentWs}/variables/exists/${path}`)).json();
if (exists) {
await api(backend, `/api/w/${parentWs}/variables/update/${path}?already_encrypted=false`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, value: v.value ?? "", is_secret: v.is_secret, description: v.description ?? "" }),
});
} else {
await api(backend, `/api/w/${parentWs}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, value: v.value ?? "", is_secret: v.is_secret, description: v.description ?? "" }),
});
}
} else if (kind === "resource") {
const res = await (await api(backend, `/api/w/${FORK_ID}/resources/get/${path}`)).json();
const exists = await (await api(backend, `/api/w/${parentWs}/resources/exists/${path}`)).json();
if (exists) {
await api(backend, `/api/w/${parentWs}/resources/update/${path}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, value: res.value, description: res.description ?? "" }),
});
} else {
await api(backend, `/api/w/${parentWs}/resources/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, value: res.value, resource_type: res.resource_type, description: res.description ?? "" }),
});
}
}
}
// Verify
expect((await (await api(backend, `/api/w/${parentWs}/scripts/get/p/f/merge_test/script_a`)).json()).content).toContain("fork v2");
expect((await api(backend, `/api/w/${parentWs}/variables/get/f/merge_test/new_fork_var`)).ok).toBe(true);
expect(JSON.stringify((await (await api(backend, `/api/w/${parentWs}/resources/get/f/merge_test/resource_a`)).json()).value)).toContain("fork_modified");
console.log(" ✓ Sub-test 1 passed: fork→parent deploy");
// ---------------------------------------------------------------
// Sub-test 2: Deploy parent→fork direction
// ---------------------------------------------------------------
console.log("\n--- Sub-test 2: parent→fork deploy ---");
// Create new script in parent
await api(backend, `/api/w/${parentWs}/scripts/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/script_b", content: "export function main() { return 'parent only'; }", language: "bun", summary: "Script B", schema: { type: "object", properties: {}, required: [] } }),
});
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/script_b", kind: "script", ahead: 0, behind: 1 },
]);
const comp2 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
const behindDiffs = comp2.diffs.filter((d: any) => d.behind > 0);
expect(behindDiffs.length).toBeGreaterThanOrEqual(1);
// Deploy parent→fork
for (const diff of behindDiffs) {
if (diff.kind === "script") {
const s = await (await api(backend, `/api/w/${parentWs}/scripts/get/p/${diff.path}`)).json();
let forkHash;
try { forkHash = (await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/${diff.path}`)).json()).hash; } catch {}
await api(backend, `/api/w/${FORK_ID}/scripts/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...s, lock: s.lock, parent_hash: forkHash }),
});
}
}
expect((await api(backend, `/api/w/${FORK_ID}/scripts/get/p/f/merge_test/script_b`)).ok).toBe(true);
console.log(" ✓ Sub-test 2 passed: parent→fork deploy");
// ---------------------------------------------------------------
// Sub-test 3: Conflict detection
// ---------------------------------------------------------------
console.log("\n--- Sub-test 3: conflict detection ---");
// Create actual divergence: modify script_a differently in both workspaces
const parentScriptA = await (await api(backend, `/api/w/${parentWs}/scripts/get/p/f/merge_test/script_a`)).json();
await api(backend, `/api/w/${parentWs}/scripts/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...parentScriptA, content: "export function main() { return 'parent conflict version'; }", parent_hash: parentScriptA.hash }),
});
const forkScriptA = await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/f/merge_test/script_a`)).json();
await api(backend, `/api/w/${FORK_ID}/scripts/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...forkScriptA, content: "export function main() { return 'fork conflict version'; }", parent_hash: forkScriptA.hash }),
});
// Now populate the conflict diff
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/script_a", kind: "script", ahead: 2, behind: 1 },
]);
const comp3 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
expect(comp3.summary.conflicts).toBeGreaterThanOrEqual(1);
const conflict = comp3.diffs.find((d: any) => d.path === "f/merge_test/script_a" && d.ahead > 0 && d.behind > 0);
expect(conflict).toBeDefined();
console.log(" ✓ Sub-test 3 passed: conflict detected");
// ---------------------------------------------------------------
// Sub-test 4: Fork has correct parent_workspace_id
// ---------------------------------------------------------------
console.log("\n--- Sub-test 4: parent_workspace_id ---");
// Check parent_workspace_id via direct SQL (no REST endpoint exposes this directly)
const dbUrl = process.env["DATABASE_URL"] || "postgres://postgres:changeme@localhost:5432";
const dbProc = Bun.spawn(
["psql", `${dbUrl}/postgres?sslmode=disable`, "-t", "-c",
`SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%' ORDER BY datname DESC LIMIT 1`],
{ stdout: "pipe", stderr: "pipe" }
);
const testDb = (await new Response(dbProc.stdout).text()).trim();
await dbProc.exited;
const parentProc = Bun.spawn(
["psql", `${dbUrl}/${testDb}?sslmode=disable`, "-t", "-c",
`SELECT parent_workspace_id FROM workspace WHERE id = '${FORK_ID}'`],
{ stdout: "pipe", stderr: "pipe" }
);
const parentId = (await new Response(parentProc.stdout).text()).trim();
await parentProc.exited;
expect(parentId).toBe(parentWs);
console.log(" ✓ Sub-test 4 passed: parent_workspace_id correct");
// ---------------------------------------------------------------
// Sub-test 5: resetDiffTally cleans unchanged items
// ---------------------------------------------------------------
console.log("\n--- Sub-test 5: resetDiffTally ---");
// Add a diff for an item that hasn't actually changed (var_a was deployed already)
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/var_a", kind: "variable", ahead: 1, behind: 0 },
]);
const resetResp = await api(backend, `/api/w/${parentWs}/workspaces/reset_diff_tally/${FORK_ID}`, { method: "POST" });
expect(resetResp.ok).toBe(true);
const comp5 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
const varDiff = comp5.diffs.find((d: any) => d.path === "f/merge_test/var_a" && d.kind === "variable");
// var_a was already deployed (same value in both), so it should be cleaned up
expect(varDiff).toBeUndefined();
console.log(" ✓ Sub-test 5 passed: resetDiffTally cleaned unchanged items");
// ---------------------------------------------------------------
// Sub-test 6: All item types in one merge
// ---------------------------------------------------------------
console.log("\n--- Sub-test 6: all item types (script, variable, resource, resource_type, flow, app) ---");
await deleteFork(backend, FORK_ID);
// Create resource type + resource in parent
await api(backend, `/api/w/${parentWs}/resources/type/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "merge_test_type", schema: { type: "object" }, description: "Type" }),
});
await api(backend, `/api/w/${parentWs}/resources/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/res_t6", resource_type: "merge_test_type", value: { key: "parent" }, description: "R" }),
});
// Create flow in parent
await api(backend, `/api/w/${parentWs}/flows/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "f/merge_test/flow_t6", summary: "Flow", schema: { type: "object", properties: {}, required: [] },
value: { modules: [{ id: "a", value: { type: "rawscript", content: "export function main() { return 1; }", language: "bun", input_transforms: {} } }], failure_module: null, same_worker: false },
}),
});
// Create app in parent
await api(backend, `/api/w/${parentWs}/apps/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "f/merge_test/app_t6", summary: "App",
value: { type: "rawscript", content: { v: 1 } },
policy: { on_behalf_of: "", on_behalf_of_email: "", extra_perms: {}, execution_mode: "publisher" },
}),
});
await createFork(backend, parentWs, FORK_ID);
// Modify all in fork
const forkRes = await (await api(backend, `/api/w/${FORK_ID}/resources/get/f/merge_test/res_t6`)).json();
await api(backend, `/api/w/${FORK_ID}/resources/update/f/merge_test/res_t6`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: forkRes.path, value: { key: "fork" }, description: "Fork" }),
});
const forkFlow = await (await api(backend, `/api/w/${FORK_ID}/flows/get/f/merge_test/flow_t6`)).json();
forkFlow.value.modules[0].value.content = "export function main() { return 2; }";
await api(backend, `/api/w/${FORK_ID}/flows/update/f/merge_test/flow_t6`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(forkFlow),
});
const forkApp = await (await api(backend, `/api/w/${FORK_ID}/apps/get/p/f/merge_test/app_t6`)).json();
await api(backend, `/api/w/${FORK_ID}/apps/update/f/merge_test/app_t6`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...forkApp, summary: "Fork App" }),
});
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/res_t6", kind: "resource", ahead: 1, behind: 0 },
{ path: "f/merge_test/flow_t6", kind: "flow", ahead: 1, behind: 0 },
{ path: "f/merge_test/app_t6", kind: "app", ahead: 1, behind: 0 },
]);
const comp6 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
const ahead6 = comp6.diffs.filter((d: any) => d.ahead > 0);
expect(ahead6.length).toBeGreaterThanOrEqual(3);
// Deploy all
for (const d of ahead6) {
if (d.kind === "resource") {
const r = await (await api(backend, `/api/w/${FORK_ID}/resources/get/${d.path}`)).json();
await api(backend, `/api/w/${parentWs}/resources/update/${d.path}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: d.path, value: r.value, description: r.description ?? "" }),
});
} else if (d.kind === "flow") {
const f = await (await api(backend, `/api/w/${FORK_ID}/flows/get/${d.path}`)).json();
for (const m of f.value?.modules ?? []) { if (m.value?.hash) m.value.hash = undefined; }
await api(backend, `/api/w/${parentWs}/flows/update/${d.path}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(f),
});
} else if (d.kind === "app") {
const a = await (await api(backend, `/api/w/${FORK_ID}/apps/get/p/${d.path}`)).json();
await api(backend, `/api/w/${parentWs}/apps/update/${d.path}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(a),
});
}
}
// Verify
const pRes = await (await api(backend, `/api/w/${parentWs}/resources/get/f/merge_test/res_t6`)).json();
expect(JSON.stringify(pRes.value)).toContain("fork");
const pFlow = await (await api(backend, `/api/w/${parentWs}/flows/get/f/merge_test/flow_t6`)).json();
expect(pFlow.value.modules[0].value.content).toContain("return 2");
const pApp = await (await api(backend, `/api/w/${parentWs}/apps/get/p/f/merge_test/app_t6`)).json();
expect(pApp.summary).toBe("Fork App");
console.log(" ✓ Sub-test 6 passed: all item types merged");
// ---------------------------------------------------------------
// Sub-test 7: Secret variables preserved across fork/merge
// ---------------------------------------------------------------
console.log("\n--- Sub-test 7: secret variable ---");
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/secret_fork", value: "s3cret!", is_secret: true, description: "Secret" }),
});
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/secret_fork", kind: "variable", ahead: 1, behind: 0 },
]);
const comp7 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
const secDiff = comp7.diffs.find((d: any) => d.path === "f/merge_test/secret_fork");
expect(secDiff).toBeDefined();
const secVar = await (await api(backend, `/api/w/${FORK_ID}/variables/get/f/merge_test/secret_fork?decrypt_secret=true`)).json();
await api(backend, `/api/w/${parentWs}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: secVar.path, value: secVar.value ?? "", is_secret: secVar.is_secret, description: secVar.description ?? "" }),
});
const pSec = await (await api(backend, `/api/w/${parentWs}/variables/get/f/merge_test/secret_fork?decrypt_secret=true`)).json();
expect(pSec.value).toBe("s3cret!");
expect(pSec.is_secret).toBe(true);
console.log(" ✓ Sub-test 7 passed: secret variable preserved");
// ---------------------------------------------------------------
// Sub-test 8: Special characters in variable values
// ---------------------------------------------------------------
console.log("\n--- Sub-test 8: special characters ---");
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/special", value: "hello\nworld\t\"quotes\" 'single' \\back 日本語 🎉", is_secret: false, description: "" }),
});
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/special", kind: "variable", ahead: 1, behind: 0 },
]);
const forkSpecial = await (await api(backend, `/api/w/${FORK_ID}/variables/get/f/merge_test/special?decrypt_secret=true`)).json();
await api(backend, `/api/w/${parentWs}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: forkSpecial.path, value: forkSpecial.value ?? "", is_secret: false, description: "" }),
});
const pSpecial = await (await api(backend, `/api/w/${parentWs}/variables/get/f/merge_test/special?decrypt_secret=true`)).json();
expect(pSpecial.value).toBe(forkSpecial.value);
console.log(" ✓ Sub-test 8 passed: special characters preserved");
// ---------------------------------------------------------------
// Sub-test 9: Partial deploy — only deployed items cleaned by resetDiffTally
// ---------------------------------------------------------------
console.log("\n--- Sub-test 9: partial deploy + resetDiffTally ---");
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/partial_a", value: "a", is_secret: false, description: "A" }),
});
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/partial_b", value: "b", is_secret: false, description: "B" }),
});
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/partial_a", kind: "variable", ahead: 1, behind: 0 },
{ path: "f/merge_test/partial_b", kind: "variable", ahead: 1, behind: 0 },
]);
// Deploy only partial_a
const va = await (await api(backend, `/api/w/${FORK_ID}/variables/get/f/merge_test/partial_a?decrypt_secret=true`)).json();
await api(backend, `/api/w/${parentWs}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: va.path, value: va.value ?? "", is_secret: false, description: "" }),
});
await api(backend, `/api/w/${parentWs}/workspaces/reset_diff_tally/${FORK_ID}`, { method: "POST" });
await new Promise(r => setTimeout(r, 500));
const comp9 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
const bAfter = comp9.diffs.find((d: any) => d.path === "f/merge_test/partial_b");
// partial_b was NOT deployed, so it must still appear in diffs
expect(bAfter).toBeDefined();
expect(bAfter?.ahead).toBeGreaterThan(0);
console.log(" ✓ Sub-test 9 passed: partial deploy + resetDiffTally");
// ---------------------------------------------------------------
// Cleanup
// ---------------------------------------------------------------
await deleteFork(backend, FORK_ID);
console.log("\n✅ All sub-tests passed!");
});
},
300_000
);