mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
- test: the complete-identity case now checks the combined agentImageBuildArgPairs() argv on both producers, so the manual build-agent-image.mjs path cannot drop the identity unnoticed - both producers: GIT_IDENTITY_BUILD_ARGS carries the mirror/parity warning its gh/az neighbour has - the partial-identity error names CODEMAN_AGENT_IMAGE_GIT_USER_NAME and CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL; test regex follows - wiki Docker-Cases: mention the identity variables next to the gh/az switches Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
201 lines
9.2 KiB
TypeScript
201 lines
9.2 KiB
TypeScript
/**
|
|
* @fileoverview The two producers of the agent-image `docker build` command line must agree.
|
|
*
|
|
* There are two, and there have to be: `scripts/build-agent-image.mjs` is what a human runs
|
|
* and is a `.mjs`, so it cannot import the TypeScript registry and reads the generated
|
|
* `config/clis.stock.json` instead; `src/docker-hosts.ts` builds the same command for the
|
|
* in-app auto-build on the first Docker case, from `STOCK_CLIS` directly.
|
|
*
|
|
* Two independent producers of one command line is exactly the shape that drifts, and the
|
|
* failure would be quiet and confusing: an image built by hand and an image built by the app
|
|
* would hold different CLIs under the SAME `codeman/agent:base` tag, so which CLIs a container
|
|
* has would depend on who built it.
|
|
*
|
|
* Port: none (pure).
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
agentImageBuildArgPairs as mjsPairs,
|
|
agentImageNpmPackages as mjsPackages,
|
|
GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs,
|
|
gitHostCliBuildArgPairs as mjsGitHostPairs,
|
|
GIT_IDENTITY_BUILD_ARGS as mjsGitIdentityArgs,
|
|
gitIdentityBuildArgPairs as mjsGitIdentityPairs,
|
|
} from '../scripts/lib/cli-catalog.mjs';
|
|
import {
|
|
agentImageBuildArgPairs as tsPairs,
|
|
agentImageBuildArgs,
|
|
agentImageNpmPackages as tsPackages,
|
|
GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs,
|
|
gitHostCliBuildArgPairs as tsGitHostPairs,
|
|
GIT_IDENTITY_BUILD_ARGS as tsGitIdentityArgs,
|
|
gitIdentityBuildArgPairs as tsGitIdentityPairs,
|
|
} from '../src/docker-hosts.js';
|
|
|
|
const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8'));
|
|
|
|
describe('agent-image build args: the .mjs and the TS mirror agree', () => {
|
|
it('resolve the same npm package list, in the same order', () => {
|
|
// Order matters as well as membership: a different order is a different RUN string, hence
|
|
// a different layer hash, hence a cache miss between the two build paths.
|
|
expect(tsPackages()).toEqual(mjsPackages(CATALOG));
|
|
});
|
|
|
|
it('produce the same --build-arg pairs', () => {
|
|
expect(tsPairs()).toEqual(mjsPairs(CATALOG));
|
|
});
|
|
|
|
it('render the same argv', () => {
|
|
// What the .mjs assembles by hand around its pairs, spelled out here so a change to
|
|
// either side's argv SHAPE (not just its values) fails too.
|
|
const pairs = tsPairs();
|
|
const expected = [
|
|
'build',
|
|
'-f',
|
|
'/repo/docker/agent.Dockerfile',
|
|
'-t',
|
|
'codeman/agent:base',
|
|
'--no-cache',
|
|
...pairs.flatMap(([name, value]) => ['--build-arg', `${name}=${value}`]),
|
|
'/repo',
|
|
];
|
|
expect(agentImageBuildArgs('/repo/docker/agent.Dockerfile', 'codeman/agent:base', '/repo', true, pairs)).toEqual(
|
|
expected
|
|
);
|
|
});
|
|
|
|
it('keeps --build-arg out of the argv when nothing is passed', () => {
|
|
// The parameter defaults to empty, so an existing caller that has not been updated still
|
|
// produces exactly the command it produced before.
|
|
expect(agentImageBuildArgs('/d', 'i', '/c')).toEqual(['build', '-f', '/d', '-t', 'i', '/c']);
|
|
});
|
|
|
|
it('resolves a non-empty list (anti-vacuity)', () => {
|
|
// Two empty lists compare equal very happily.
|
|
expect(tsPackages().length).toBeGreaterThan(3);
|
|
expect(tsPairs()[0][1].length).toBeGreaterThan(20);
|
|
});
|
|
|
|
it('matches the Dockerfile ARG default, so a bare `docker build` is cache-identical', () => {
|
|
const dockerfile = readFileSync(fileURLToPath(new URL('../docker/agent.Dockerfile', import.meta.url)), 'utf-8');
|
|
const declared = /^ARG CLI_NPM_PACKAGES="([^"]*)"$/m.exec(dockerfile)?.[1];
|
|
expect(declared, 'the Dockerfile no longer declares CLI_NPM_PACKAGES').toBeDefined();
|
|
expect(declared).toBe(tsPackages().join(' '));
|
|
});
|
|
|
|
it('validates an unsafe package name with the SAME regex on both sides', () => {
|
|
// Equal OUTPUT on today's catalogue (asserted above) does not prove equal VALIDATION — a
|
|
// looser regex on one side would only show up the day someone ships a hostile package name.
|
|
// The regex is duplicated rather than shared (the .mjs side cannot import the .ts side, the
|
|
// whole reason this file exists), so pin the literal PATTERN text is identical between the
|
|
// two source files rather than trusting the comment that says so.
|
|
const tsSource = readFileSync(fileURLToPath(new URL('../src/docker-hosts.ts', import.meta.url)), 'utf-8');
|
|
const mjsSource = readFileSync(fileURLToPath(new URL('../scripts/lib/cli-catalog.mjs', import.meta.url)), 'utf-8');
|
|
const extract = (source: string, file: string): string => {
|
|
// Non-greedy to `/;` deliberately: the pattern itself contains a `/` (inside the
|
|
// character class), so a naive `[^/]+` stops at the wrong slash.
|
|
const m = /const SAFE_PACKAGE = (\/.+?\/);/.exec(source);
|
|
expect(m, `could not find the SAFE_PACKAGE regex literal in ${file}`).toBeDefined();
|
|
return m![1];
|
|
};
|
|
expect(extract(tsSource, 'docker-hosts.ts')).toBe(extract(mjsSource, 'cli-catalog.mjs'));
|
|
});
|
|
});
|
|
|
|
describe('optional gh / az in the agent image: both producers pass the same switches', () => {
|
|
const ENV_GH = 'CODEMAN_AGENT_IMAGE_INSTALL_GH';
|
|
const ENV_AZ = 'CODEMAN_AGENT_IMAGE_INSTALL_AZ';
|
|
|
|
it('map the same environment variables to the same Dockerfile ARGs', () => {
|
|
expect(tsGitHostArgs).toEqual(mjsGitHostArgs);
|
|
expect(tsGitHostArgs.map(([, arg]) => arg)).toEqual(['CODEMAN_INSTALL_GH', 'CODEMAN_INSTALL_AZ']);
|
|
});
|
|
|
|
it('agree for every combination, and an unset or empty variable adds nothing', () => {
|
|
for (const gh of [undefined, '', '0', '1']) {
|
|
for (const az of [undefined, '', '0', '1']) {
|
|
const env: NodeJS.ProcessEnv = {};
|
|
if (gh !== undefined) env[ENV_GH] = gh;
|
|
if (az !== undefined) env[ENV_AZ] = az;
|
|
const expected: Array<[string, string]> = [];
|
|
if (gh) expected.push(['CODEMAN_INSTALL_GH', gh]);
|
|
if (az) expected.push(['CODEMAN_INSTALL_AZ', az]);
|
|
expect(tsGitHostPairs(env)).toEqual(expected);
|
|
expect(mjsGitHostPairs(env)).toEqual(expected);
|
|
expect(tsPairs(env)).toEqual(mjsPairs(CATALOG, env));
|
|
}
|
|
}
|
|
});
|
|
|
|
it('keeps the default argv unchanged when neither variable is set', () => {
|
|
expect(tsPairs({})).toEqual([['CLI_NPM_PACKAGES', tsPackages().join(' ')]]);
|
|
});
|
|
|
|
it('refuses anything but 0 or 1 on both sides, naming the variable', () => {
|
|
for (const bad of ['yes', 'true', '2', ' 1', '0 && echo']) {
|
|
expect(() => tsGitHostPairs({ [ENV_AZ]: bad })).toThrow(new RegExp(ENV_AZ));
|
|
expect(() => mjsGitHostPairs({ [ENV_AZ]: bad })).toThrow(new RegExp(ENV_AZ));
|
|
}
|
|
});
|
|
|
|
it('both Dockerfiles declare the switches, defaulting to OFF (opt-in)', () => {
|
|
for (const file of ['../docker/agent.Dockerfile', '../docker/server.Dockerfile']) {
|
|
const dockerfile = readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf-8');
|
|
expect(dockerfile, file).toMatch(/^ARG CODEMAN_INSTALL_GH=0$/m);
|
|
expect(dockerfile, file).toMatch(/^ARG CODEMAN_INSTALL_AZ=0$/m);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Git identity in the agent image: both producers pass the same settings', () => {
|
|
it('maps the Git environment variables to matching Dockerfile ARGs', () => {
|
|
expect(tsGitIdentityArgs).toEqual(mjsGitIdentityArgs);
|
|
expect(tsGitIdentityArgs).toEqual([
|
|
['CODEMAN_AGENT_IMAGE_GIT_USER_NAME', 'GIT_USER_NAME'],
|
|
['CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL', 'GIT_USER_EMAIL'],
|
|
]);
|
|
});
|
|
|
|
it('passes a complete identity and omits an absent identity', () => {
|
|
const identity = {
|
|
CODEMAN_AGENT_IMAGE_GIT_USER_NAME: 'Ada Lovelace',
|
|
CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: 'ada@example.com',
|
|
};
|
|
const expected: Array<[string, string]> = [
|
|
['GIT_USER_NAME', 'Ada Lovelace'],
|
|
['GIT_USER_EMAIL', 'ada@example.com'],
|
|
];
|
|
expect(tsGitIdentityPairs(identity)).toEqual(expected);
|
|
expect(mjsGitIdentityPairs(identity)).toEqual(expected);
|
|
expect(tsGitIdentityPairs({})).toEqual([]);
|
|
expect(mjsGitIdentityPairs({})).toEqual([]);
|
|
// The combined argv, not just the helper: the manual build path could drop the identity otherwise.
|
|
expect(tsPairs(identity)).toEqual(mjsPairs(CATALOG, identity));
|
|
expect(tsPairs(identity)).toEqual(expect.arrayContaining(expected));
|
|
});
|
|
|
|
it('refuses a partial identity in both build paths', () => {
|
|
for (const identity of [
|
|
{ CODEMAN_AGENT_IMAGE_GIT_USER_NAME: 'Ada Lovelace' },
|
|
{ CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL: 'ada@example.com' },
|
|
]) {
|
|
const named = /CODEMAN_AGENT_IMAGE_GIT_USER_NAME and CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL must both be set/;
|
|
expect(() => tsGitIdentityPairs(identity)).toThrow(named);
|
|
expect(() => mjsGitIdentityPairs(identity)).toThrow(named);
|
|
}
|
|
});
|
|
|
|
it('both Dockerfiles configure system Git identity from the build arguments', () => {
|
|
for (const file of ['../docker/agent.Dockerfile', '../docker/server.Dockerfile']) {
|
|
const dockerfile = readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf-8');
|
|
expect(dockerfile, file).toMatch(/^ARG GIT_USER_NAME=$/m);
|
|
expect(dockerfile, file).toMatch(/^ARG GIT_USER_EMAIL=$/m);
|
|
expect(dockerfile, file).toContain('git config --system user.name "${GIT_USER_NAME}"');
|
|
expect(dockerfile, file).toContain('git config --system user.email "${GIT_USER_EMAIL}"');
|
|
}
|
|
});
|
|
});
|