evOo-local/packages/core/src/compose-update.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

649 lines
23 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
COMPOSE_MANUAL_UPGRADE_COMMANDS,
chooseUpdateStrategy,
commitImageTag,
compareReleaseTags,
composePsArgv,
composePullArgv,
composeUpArgv,
composeUpdatePlan,
DEFAULT_COMPOSE_PROJECT_NAME,
DEFAULT_IMAGE_TAG,
forkImageTag,
gitIndexContentDiffArgv,
gitStatusArgv,
gitUntrackedFilesArgv,
gitWorktreeContentDiffArgv,
imageRef,
isValidComposeProjectName,
isValidImageName,
isValidImageTag,
manualUpgradeCommands,
OFFICIAL_SERVER_IMAGE,
parseGitNameOnly,
parseLsRemoteReleases,
parseLsRemoteTags,
parseReleaseTag,
RECREATED_SERVICES,
resolveComposeProjectName,
resolveExecutionMode,
resolveInstallKind,
resolveTrackedDirtyPaths,
rollbackTarget,
SOURCE_MANUAL_UPGRADE_COMMANDS,
selectLatestRelease,
selectLatestReleaseTag,
upsertEnvAssignments,
validateUpdateRequest,
} from "./compose-update.js";
const target = {
composeFiles: ["/srv/rakazo/infra/compose/docker-compose.prod.yml"],
envFiles: ["/srv/rakazo/.env"],
};
describe("image references", () => {
it("accepts the tags the publish workflow produces", () => {
expect(isValidImageTag("latest")).toBe(true);
expect(isValidImageTag("v1.2.3")).toBe(true);
expect(isValidImageTag("sha-0123456")).toBe(true);
expect(isValidImageTag("local-0123456789ab")).toBe(true);
});
it("rejects tags that could become a second argument or a shell fragment", () => {
expect(isValidImageTag("")).toBe(false);
expect(isValidImageTag("-rm")).toBe(false);
expect(isValidImageTag(".hidden")).toBe(false);
expect(isValidImageTag("v1 v2")).toBe(false);
expect(isValidImageTag("v1;rm -rf /")).toBe(false);
expect(isValidImageTag("v1\nv2")).toBe(false);
expect(isValidImageTag("v1/../../etc")).toBe(false);
expect(isValidImageTag(`v${"1".repeat(200)}`)).toBe(false);
});
it("accepts registry paths with a host and rejects anything else", () => {
expect(isValidImageName(OFFICIAL_SERVER_IMAGE)).toBe(true);
expect(isValidImageName("ghcr.io:5000/owner/app")).toBe(true);
expect(isValidImageName("rakazo/app")).toBe(true);
expect(isValidImageName("Ghcr.io/owner/app")).toBe(false);
expect(isValidImageName("owner/app:tag")).toBe(false);
expect(isValidImageName("owner/../app")).toBe(false);
expect(isValidImageName("")).toBe(false);
});
it("refuses to assemble a reference it could not validate", () => {
expect(imageRef(OFFICIAL_SERVER_IMAGE, "v1.0.0")).toBe(`${OFFICIAL_SERVER_IMAGE}:v1.0.0`);
expect(() => imageRef(OFFICIAL_SERVER_IMAGE, "-rm")).toThrow(/image tag/);
expect(() => imageRef("BAD NAME", DEFAULT_IMAGE_TAG)).toThrow(/image name/);
});
it("derives local and per-commit tags from a resolved commit only", () => {
expect(forkImageTag("0123456789abcdef0123456789abcdef01234567")).toBe(
"local-0123456789abcdef0123456789abcdef01234567",
);
expect(commitImageTag("0123456789abcdef0123456789abcdef01234567")).toBe(
"sha-0123456789abcdef0123456789abcdef01234567",
);
expect(() => forkImageTag("HEAD")).toThrow(/commit/);
expect(() => commitImageTag("")).toThrow(/commit/);
});
});
describe("release tag resolution", () => {
it("parses semver release tags and rejects other tag shapes", () => {
expect(parseReleaseTag("v1.2.3")).toMatchObject({ major: 1, minor: 2, patch: 3 });
expect(parseReleaseTag("v1.2.3-rc.1")).toMatchObject({ prerelease: "rc.1" });
expect(parseReleaseTag("1.2.3")).toBeNull();
expect(parseReleaseTag("v1.2")).toBeNull();
expect(parseReleaseTag("latest")).toBeNull();
});
it("orders releases numerically rather than lexically", () => {
const a = parseReleaseTag("v0.9.0");
const b = parseReleaseTag("v0.10.0");
expect(a && b && compareReleaseTags(a, b)).toBeLessThan(0);
const rc = parseReleaseTag("v1.0.0-rc.1");
const release = parseReleaseTag("v1.0.0");
expect(rc && release && compareReleaseTags(rc, release)).toBeLessThan(0);
});
it("reads ls-remote output, collapsing peeled refs", () => {
const first = "1".repeat(40);
const second = "2".repeat(40);
const tagObject = "a".repeat(40);
const stdout = [
`${tagObject}\trefs/tags/v0.1.0`,
`${first}\trefs/tags/v0.1.0^{}`,
`${second}\trefs/tags/v0.2.0`,
`${"b".repeat(40)}\trefs/heads/main`,
`${"c".repeat(40)}\trefs/tags/`,
"",
].join("\n");
expect(parseLsRemoteTags(stdout)).toEqual(["v0.1.0", "v0.2.0"]);
expect(parseLsRemoteReleases(stdout)).toEqual([
{ tag: "v0.1.0", commit: first },
{ tag: "v0.2.0", commit: second },
]);
expect(selectLatestRelease(parseLsRemoteReleases(stdout))).toEqual({
tag: "v0.2.0",
commit: second,
});
});
it("drops ls-remote tags that would not be usable as image tags", () => {
expect(parseLsRemoteTags("aa\trefs/tags/-dangerous\nbb\trefs/tags/v1.0.0")).toEqual(["v1.0.0"]);
});
it("picks the newest stable release and never a pre-release", () => {
expect(selectLatestReleaseTag(["v0.1.0", "v0.10.2", "v0.9.9"])).toBe("v0.10.2");
expect(selectLatestReleaseTag(["v1.0.0", "v1.1.0-rc.3"])).toBe("v1.0.0");
expect(selectLatestReleaseTag(["v2.0.0-rc.1"])).toBeNull();
expect(selectLatestReleaseTag(["main", "latest"])).toBeNull();
expect(selectLatestReleaseTag([])).toBeNull();
});
});
describe("strategy and mode selection", () => {
it("pulls for the official repository and builds for a fork", () => {
expect(chooseUpdateStrategy({ official: true }).strategy).toBe("pull");
expect(chooseUpdateStrategy({ official: false }).strategy).toBe("build");
expect(chooseUpdateStrategy({ official: false }).reason).toMatch(/minutes/);
});
it("prefers the sidecar, falls back to a checkout, and reports when neither exists", () => {
expect(resolveExecutionMode({ hasUpdater: true, hasCheckout: true })).toEqual({
mode: "sidecar",
reason: null,
});
expect(resolveExecutionMode({ hasUpdater: false, hasCheckout: true }).mode).toBe("checkout");
const none = resolveExecutionMode({ hasUpdater: false, hasCheckout: false });
expect(none.mode).toBe("unavailable");
expect(none.reason).toMatch(/updater/);
});
it("reports unavailable when self-update is switched off, whatever else is present", () => {
const off = resolveExecutionMode({ hasUpdater: true, disabled: true });
expect(off.mode).toBe("unavailable");
expect(off.reason).toMatch(/switched off/);
});
it("detects sidecar, compose-without-sidecar, and source install kinds", () => {
expect(
resolveInstallKind({ updaterUrlConfigured: true, updaterReachable: true, hasCheckout: true }),
).toEqual({ kind: "sidecar", mode: "sidecar", reason: null });
const compose = resolveInstallKind({
updaterUrlConfigured: true,
updaterReachable: false,
hasCheckout: true,
});
expect(compose.kind).toBe("compose");
expect(compose.mode).toBe("unavailable");
expect(compose.reason).toMatch(/sidecar/);
const source = resolveInstallKind({
updaterUrlConfigured: false,
updaterReachable: false,
hasCheckout: true,
});
expect(source.kind).toBe("source");
expect(source.mode).toBe("checkout");
expect(manualUpgradeCommands("compose")).toEqual([
"# Published release tag",
...COMPOSE_MANUAL_UPGRADE_COMMANDS,
"# Local tag (rebuild from checkout)",
"git pull",
"GIT_SHA=$(git rev-parse HEAD) docker compose --env-file .env -f infra/compose/docker-compose.prod.yml up -d --wait --pull never --build api worker web",
]);
expect(manualUpgradeCommands("compose", { imageTag: "local" })).toEqual([
"git pull",
"GIT_SHA=$(git rev-parse HEAD) docker compose --env-file .env -f infra/compose/docker-compose.prod.yml up -d --wait --pull never --build api worker web",
]);
expect(manualUpgradeCommands("compose", { imageTag: "sha-abc" })).toEqual([
...COMPOSE_MANUAL_UPGRADE_COMMANDS,
]);
expect(manualUpgradeCommands("source")).toEqual([...SOURCE_MANUAL_UPGRADE_COMMANDS]);
expect(manualUpgradeCommands("sidecar")).toEqual([]);
});
});
describe("compose argv construction", () => {
it("passes the compose file and env files as separate arguments", () => {
expect(composePullArgv(target)).toEqual({
command: "docker",
args: [
"compose",
"-p",
DEFAULT_COMPOSE_PROJECT_NAME,
"--env-file",
"/srv/rakazo/.env",
"--file",
...target.composeFiles,
"pull",
"api",
"worker",
"web",
],
});
});
it("passes one --file per Compose file, in overlay order", () => {
const overlaid = {
...target,
composeFiles: [
"/srv/rakazo/infra/compose/docker-compose.prod.yml",
"/srv/rakazo/ops/compose/docker-compose.sandbox.yml",
],
};
for (const invocation of [
composePullArgv(overlaid),
composeUpArgv(overlaid),
composePsArgv(overlaid),
]) {
const files = invocation.args.flatMap((arg, index) =>
arg === "--file" ? [invocation.args[index + 1]] : [],
);
expect(files).toEqual(overlaid.composeFiles);
}
});
it("refuses a target with no Compose file rather than running against the default stack", () => {
expect(() => composePullArgv({ ...target, composeFiles: [] })).toThrow(/no Compose file/);
});
it("recreates and pulls the target's own services when an overlay adds one", () => {
const withSupervisor = { ...target, services: ["api", "worker", "web", "supervisor"] };
expect(composePullArgv(withSupervisor).args.slice(-4)).toEqual([
"api",
"worker",
"web",
"supervisor",
]);
expect(composeUpArgv(withSupervisor).args.slice(-4)).toEqual([
"api",
"worker",
"web",
"supervisor",
]);
});
it("falls back to the built-in services when the target names none", () => {
expect(composeUpArgv({ ...target, services: [] }).args.slice(-3)).toEqual([
"api",
"worker",
"web",
]);
});
it("carries the overlay files and extra services into the update plan and its recovery", () => {
const overlaid = {
...target,
composeFiles: [
"/srv/rakazo/infra/compose/docker-compose.prod.yml",
"/srv/rakazo/ops/compose/docker-compose.sandbox.yml",
],
services: ["api", "worker", "web", "supervisor"],
};
for (const strategy of ["pull", "build"] as const) {
const steps = composeUpdatePlan({ strategy, target: overlaid });
const composeSteps = steps.filter((step) => step.command === "docker");
expect(composeSteps.length).toBeGreaterThan(0);
for (const step of composeSteps) {
expect(step.args.filter((arg) => arg === "--file")).toHaveLength(2);
expect(step.args).toContain("supervisor");
}
}
});
it("passes the project name as its own -p argument so a custom stack is the one updated", () => {
const named = { ...target, projectName: "operator-stack" };
for (const invocation of [composePullArgv(named), composeUpArgv(named), composePsArgv(named)]) {
const projectFlag = invocation.args.indexOf("-p");
expect(projectFlag).toBeGreaterThanOrEqual(0);
expect(invocation.args[projectFlag + 1]).toBe("operator-stack");
expect(
invocation.args.some((arg) => arg.includes("operator-stack") && arg !== "operator-stack"),
).toBe(false);
}
});
it("defaults -p to the name pinned in the compose file", () => {
expect(DEFAULT_COMPOSE_PROJECT_NAME).toBe("rakazo-prod");
expect(composePsArgv(target).args.slice(0, 3)).toEqual(["compose", "-p", "rakazo-prod"]);
});
it("refuses a project name that could be parsed as another flag or a second argument", () => {
expect(() => composeUpArgv({ ...target, projectName: "-f" })).toThrow(/project name/);
expect(() => composePullArgv({ ...target, projectName: "two words" })).toThrow(/project name/);
});
it("recreates detached, and only builds when asked to", () => {
expect(composeUpArgv(target).args).toContain("--detach");
expect(composeUpArgv(target).args).toContain("--wait");
expect(composeUpArgv(target).args).toContain("--wait-timeout");
expect(composeUpArgv(target).args).toContain("never");
expect(composeUpArgv(target).args).toContain("--no-build");
expect(composeUpArgv(target).args).not.toContain("--build");
expect(composeUpArgv(target, { build: true }).args).toContain("--build");
expect(composeUpArgv(target, { build: true }).args).not.toContain("--no-build");
});
it("never recreates the updater, which is the process running the update", () => {
expect(RECREATED_SERVICES).not.toContain("updater");
for (const invocation of [composeUpArgv(target), composePullArgv(target)]) {
expect(invocation.args).not.toContain("updater");
expect(invocation.args).not.toContain("postgres");
}
});
it("names the services explicitly so a bare up cannot sweep the whole project", () => {
const args = composeUpArgv(target).args;
expect(args.slice(-3)).toEqual(["api", "worker", "web"]);
});
});
describe("compose project name resolution", () => {
it("prefers the name Compose injects into running services", () => {
expect(resolveComposeProjectName({ COMPOSE_PROJECT_NAME: "live-stack" })).toBe("live-stack");
expect(
resolveComposeProjectName({
COMPOSE_PROJECT_NAME: "live-stack",
RAKAZO_COMPOSE_PROJECT_NAME: "manual-stack",
}),
).toBe("live-stack");
});
it("falls back to a dedicated override, then to the name pinned in the compose file", () => {
expect(resolveComposeProjectName({ RAKAZO_COMPOSE_PROJECT_NAME: "manual-stack" })).toBe(
"manual-stack",
);
expect(resolveComposeProjectName({})).toBe(DEFAULT_COMPOSE_PROJECT_NAME);
});
it("ignores blank values and refuses a name that is not a single argv", () => {
expect(resolveComposeProjectName({ COMPOSE_PROJECT_NAME: " " })).toBe(
DEFAULT_COMPOSE_PROJECT_NAME,
);
expect(isValidComposeProjectName("-p")).toBe(false);
expect(() => resolveComposeProjectName({ COMPOSE_PROJECT_NAME: "evil;rm" })).toThrow(
/project name/,
);
});
});
describe("dirty-tree argv and CRLF filtering", () => {
it("runs status with autocrlf as its own -c arguments, not interpolated into a shell string", () => {
expect(gitStatusArgv()).toEqual([
"--no-optional-locks",
"-c",
"core.autocrlf=true",
"status",
"--porcelain",
"--untracked-files=no",
]);
expect(gitWorktreeContentDiffArgv()).toEqual([
"--no-optional-locks",
"-c",
"core.autocrlf=true",
"diff",
"--name-only",
"--ignore-cr-at-eol",
]);
expect(gitIndexContentDiffArgv()).toEqual([
"--no-optional-locks",
"-c",
"core.autocrlf=true",
"diff",
"--cached",
"--name-only",
"--ignore-cr-at-eol",
]);
expect(gitUntrackedFilesArgv()).toEqual([
"--no-optional-locks",
"ls-files",
"--others",
"--exclude-standard",
]);
});
it("reads git name-only output as one path per line", () => {
expect(parseGitNameOnly("apps/api/src/app.ts\npackages/core/src/x.ts\n")).toEqual([
"apps/api/src/app.ts",
"packages/core/src/x.ts",
]);
expect(parseGitNameOnly("\n \n")).toEqual([]);
});
it("treats porcelain-only paths as CRLF noise when the ignore-cr diff is empty", () => {
expect(
resolveTrackedDirtyPaths({
porcelainChanged: ["apps/api/src/app.ts", "README.md"],
contentChanged: [],
contentDiffOk: true,
}),
).toEqual({ dirty: false, dirtyPaths: [] });
});
it("keeps a real content change even when it sits among CRLF-only porcelain noise", () => {
expect(
resolveTrackedDirtyPaths({
porcelainChanged: ["apps/api/src/app.ts", "README.md"],
contentChanged: ["apps/api/src/app.ts"],
contentDiffOk: true,
}),
).toEqual({ dirty: true, dirtyPaths: ["apps/api/src/app.ts"] });
});
it("keeps a content diff that porcelain parsing missed, so the guard is not weakened", () => {
expect(
resolveTrackedDirtyPaths({
porcelainChanged: [],
contentChanged: ["packages/core/src/x.ts"],
contentDiffOk: true,
}),
).toEqual({ dirty: true, dirtyPaths: ["packages/core/src/x.ts"] });
});
it("falls back to porcelain when the ignore-cr diffs cannot be read", () => {
expect(
resolveTrackedDirtyPaths({
porcelainChanged: ["apps/api/src/app.ts"],
contentChanged: [],
contentDiffOk: false,
}),
).toEqual({ dirty: true, dirtyPaths: ["apps/api/src/app.ts"] });
});
it("keeps untracked source files because Docker COPY can include them in a build", () => {
expect(
resolveTrackedDirtyPaths({
porcelainChanged: [],
contentChanged: [],
contentDiffOk: true,
untrackedPaths: ["packages/core/src/untracked.ts"],
}),
).toEqual({ dirty: true, dirtyPaths: ["packages/core/src/untracked.ts"] });
});
});
describe("update plans", () => {
it("pulls then recreates on the official path, with no migration step", () => {
const steps = composeUpdatePlan({ strategy: "pull", target });
expect(steps.map((step) => step.id)).toEqual(["pull", "recreate"]);
expect(steps.some((step) => step.id === "migrate")).toBe(false);
for (const step of steps) {
expect(step.args.slice(0, 3)).toEqual(["compose", "-p", DEFAULT_COMPOSE_PROJECT_NAME]);
}
});
it("fetches, fast-forwards, then builds on the fork path", () => {
const steps = composeUpdatePlan({
strategy: "build",
target,
repoUrl: "https://github.com/someone/rakazo",
branch: "trunk",
repointRemote: true,
});
expect(steps.map((step) => step.id)).toEqual([
"remote",
"fetch",
"checkout",
"merge",
"recreate",
]);
const merge = steps.find((step) => step.id === "merge");
expect(merge?.args).toEqual(["merge", "--ff-only", "origin/trunk"]);
expect(steps.find((step) => step.id === "fetch")?.args).not.toContain("--prune");
expect(steps.at(-1)?.args).toContain("--build");
});
it("leaves the remote alone when the checkout already points at the chosen repository", () => {
const steps = composeUpdatePlan({
strategy: "build",
target,
branch: "main",
repointRemote: false,
});
expect(steps.map((step) => step.id)).toEqual(["fetch", "checkout", "merge", "recreate"]);
});
it("keeps the repository URL in argv rather than in any compose input", () => {
const repoUrl = "https://github.com/someone/rakazo";
const steps = composeUpdatePlan({
strategy: "build",
target,
repoUrl,
branch: "main",
repointRemote: true,
});
const remote = steps.find((step) => step.id === "remote");
expect(remote?.args).toEqual(["remote", "set-url", "origin", repoUrl]);
const recreate = steps.find((step) => step.id === "recreate");
expect(recreate?.args.some((arg) => arg.includes(repoUrl))).toBe(false);
});
});
describe("rollback", () => {
it("targets the tag that was running before the last update", () => {
expect(rollbackTarget({ currentTag: "v1.1.0", previousTag: "v1.0.0" })).toEqual({
tag: "v1.0.0",
});
});
it("refuses when there is nothing recorded, nothing usable, or nothing to change", () => {
expect(rollbackTarget({ currentTag: "v1.1.0", previousTag: null })).toEqual({
error: expect.stringMatching(/nothing to roll back/),
});
expect(rollbackTarget({ currentTag: "v1.1.0", previousTag: "-rm" })).toEqual({
error: expect.stringMatching(/not a usable tag/),
});
expect(rollbackTarget({ currentTag: "v1.0.0", previousTag: "v1.0.0" })).toEqual({
error: expect.stringMatching(/already running/),
});
});
});
describe("managed env assignments", () => {
it("replaces a managed key in place and leaves everything else alone", () => {
const contents = [
"# deployment",
"POSTGRES_PASSWORD=secret",
"RAKAZO_IMAGE_TAG=v1.0.0",
"",
].join("\n");
expect(upsertEnvAssignments(contents, { RAKAZO_IMAGE_TAG: "v1.1.0" })).toBe(
["# deployment", "POSTGRES_PASSWORD=secret", "RAKAZO_IMAGE_TAG=v1.1.0", ""].join("\n"),
);
});
it("appends keys that are not present yet", () => {
const result = upsertEnvAssignments("POSTGRES_PASSWORD=secret\n", {
RAKAZO_IMAGE_TAG: "v1.1.0",
RAKAZO_IMAGE_TAG_PREVIOUS: "v1.0.0",
});
expect(result.split("\n")).toEqual([
"POSTGRES_PASSWORD=secret",
"",
"RAKAZO_IMAGE_TAG=v1.1.0",
"RAKAZO_IMAGE_TAG_PREVIOUS=v1.0.0",
"",
]);
});
it("keeps the file's existing line endings", () => {
const result = upsertEnvAssignments("A=1\r\nRAKAZO_IMAGE_TAG=v1.0.0\r\n", {
RAKAZO_IMAGE_TAG: "v2.0.0",
});
expect(result).toBe("A=1\r\nRAKAZO_IMAGE_TAG=v2.0.0\r\n");
});
it("rewrites every duplicate managed assignment so the last value cannot win", () => {
const result = upsertEnvAssignments(
"RAKAZO_IMAGE_TAG=stale\nA=1\nRAKAZO_IMAGE_TAG=still-stale\n",
{ RAKAZO_IMAGE_TAG: "sha-0123456789abcdef0123456789abcdef01234567" },
);
expect(result.match(/RAKAZO_IMAGE_TAG=sha-/g)).toHaveLength(2);
expect(result).not.toContain("stale");
});
it("refuses values that would inject a second assignment or a compose expression", () => {
// biome-ignore lint/suspicious/noTemplateCurlyInString: the literal is the hostile input under test
for (const value of ["v1\nEVIL=1", "v1 v2", "$(id)", "v1;rm -rf /", "${OTHER}"]) {
expect(() => upsertEnvAssignments("", { RAKAZO_IMAGE_TAG: value })).toThrow(/Refusing/);
}
});
it("refuses keys outside the managed shape", () => {
expect(() => upsertEnvAssignments("", { "a b": "1" })).toThrow(/Refusing/);
});
});
describe("sidecar boundary validation", () => {
it("normalizes a request the API already validated", () => {
expect(
validateUpdateRequest({ repoUrl: "https://github.com/elie222/rakazo.git", branch: " main " }),
).toEqual({
request: {
repoUrl: "https://github.com/elie222/rakazo.git",
branch: "main",
official: true,
},
});
});
it("marks a fork as unofficial so the sidecar picks the build path", () => {
const result = validateUpdateRequest({
repoUrl: "https://github.com/someone/rakazo",
branch: "main",
});
expect(result).toEqual({
request: {
repoUrl: "https://github.com/someone/rakazo",
branch: "main",
official: false,
},
});
});
it("re-rejects everything the API would have rejected", () => {
const cases: Array<{ repoUrl?: unknown; branch?: unknown }> = [
{ repoUrl: "http://github.com/a/b", branch: "main" },
{ repoUrl: "file:///etc/passwd", branch: "main" },
{ repoUrl: "https://user:pw@github.com/a/b", branch: "main" },
{ repoUrl: "https://github.com/a/b?x=1", branch: "main" },
{ repoUrl: "https://github.com/../etc", branch: "main" },
{ repoUrl: "--upload-pack=id", branch: "main" },
{ repoUrl: "https://github.com/a/b\nrm", branch: "main" },
{ repoUrl: "https://github.com/a/b", branch: "--exec=id" },
{ repoUrl: "https://github.com/a/b", branch: "main..evil" },
{ repoUrl: "https://github.com/a/b", branch: "main\tid" },
{ repoUrl: 42, branch: "main" },
{ repoUrl: "https://github.com/a/b", branch: null },
];
for (const input of cases) {
const result = validateUpdateRequest(input);
expect("error" in result, JSON.stringify(input)).toBe(true);
}
});
});