575 lines
20 KiB
TypeScript
575 lines
20 KiB
TypeScript
/**
|
|
* Tests for the v1 → v2 config normalizer (Step 1 of the 2026-05-21
|
|
* Settings UI / Config Restructure design).
|
|
*
|
|
* Coverage matrix:
|
|
* - explicit v2 input → pass through (no warning, no migration)
|
|
* - explicit `config_version: 99` → fatal
|
|
* - v1 provider.workers[].proxy = true → connection_type aao_gateway
|
|
* - v1 provider.workers[].proxy = false/missing → connection_type direct
|
|
* - provider.model fills missing worker.model
|
|
* - both empty → empty model + warning (not fatal)
|
|
* - storage.* mirrors of worktree_dir / custom_pieces_dir / user_folder_root /
|
|
* tools.task_upload_max_size_mb / tools.trash_retention_days
|
|
* - `${VAR}` references preserved verbatim (not coerced into literals)
|
|
* - fixture-driven snapshots for the four documented production shapes
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { parse as parseYaml } from 'yaml';
|
|
import { fileURLToPath } from 'url';
|
|
import { dirname } from 'path';
|
|
import {
|
|
normalizeConfig,
|
|
UnsupportedConfigVersionError,
|
|
} from './config-normalize.js';
|
|
import { toSnakeKeys } from './config.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const FIXTURES_DIR = join(__dirname, '__fixtures__', 'config-migration');
|
|
|
|
/**
|
|
* Tiny helper: parse a YAML string and pre-camelCase keys the way loadConfig
|
|
* does. The normalizer expects camelCased keys (post-transformKeys).
|
|
*/
|
|
function loadYaml(yamlText: string): unknown {
|
|
return transformToCamel(parseYaml(yamlText));
|
|
}
|
|
function loadFixture(name: string): unknown {
|
|
return loadYaml(readFileSync(join(FIXTURES_DIR, name), 'utf-8'));
|
|
}
|
|
|
|
/** snake_case → camelCase recursive (mirror of config.ts transformKeys). */
|
|
function transformToCamel(obj: unknown): unknown {
|
|
if (Array.isArray(obj)) return obj.map(transformToCamel);
|
|
if (obj !== null && typeof obj === 'object') {
|
|
return Object.fromEntries(
|
|
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
|
|
k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()),
|
|
transformToCamel(v),
|
|
]),
|
|
);
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
describe('normalizeConfig — version handling', () => {
|
|
it('v2 input passes through with config_version=2', () => {
|
|
const out = normalizeConfig({
|
|
configVersion: 2,
|
|
llm: {
|
|
workers: [
|
|
{
|
|
id: 'w1',
|
|
connectionType: 'direct',
|
|
endpoint: 'http://localhost:11434/v1',
|
|
model: 'qwen3:32b',
|
|
roles: ['auto'],
|
|
maxConcurrency: 1,
|
|
enabled: true,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(out.configVersion).toBe(2);
|
|
expect(out.llm?.workers).toHaveLength(1);
|
|
expect(out.llm?.workers[0]).toMatchObject({
|
|
id: 'w1',
|
|
connectionType: 'direct',
|
|
model: 'qwen3:32b',
|
|
});
|
|
});
|
|
|
|
it('missing config_version treated as v1', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
baseUrl: 'http://localhost:11434/v1',
|
|
model: 'qwen3:32b',
|
|
workers: [],
|
|
},
|
|
});
|
|
expect(out.configVersion).toBe(2);
|
|
// v1 → defaulted single worker from baseUrl
|
|
expect(out.llm?.workers).toHaveLength(1);
|
|
expect(out.llm?.workers[0]?.endpoint).toBe('http://localhost:11434/v1');
|
|
});
|
|
|
|
it('config_version: 99 throws UnsupportedConfigVersionError', () => {
|
|
expect(() => normalizeConfig({ configVersion: 99 })).toThrowError(UnsupportedConfigVersionError);
|
|
});
|
|
|
|
it('config_version: "2" (string) throws (typo guard)', () => {
|
|
expect(() => normalizeConfig({ configVersion: '2' })).toThrowError(UnsupportedConfigVersionError);
|
|
});
|
|
|
|
it('config_version: 0 throws', () => {
|
|
expect(() => normalizeConfig({ configVersion: 0 })).toThrowError(UnsupportedConfigVersionError);
|
|
});
|
|
|
|
it('null / non-object input is treated as empty config', () => {
|
|
const out = normalizeConfig(null);
|
|
expect(out.configVersion).toBe(2);
|
|
expect(out.llm?.workers).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('normalizeConfig — v1 provider → v2 llm', () => {
|
|
it('proxy: true → connection_type: aao_gateway', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'qwen3:8b',
|
|
workers: [
|
|
{
|
|
id: 'team-pool',
|
|
endpoint: 'http://litellm:4000/v1',
|
|
proxy: true,
|
|
apiKey: 'tok-abc',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(out.llm?.workers[0]).toMatchObject({
|
|
id: 'team-pool',
|
|
connectionType: 'aao_gateway',
|
|
endpoint: 'http://litellm:4000/v1',
|
|
apiKey: 'tok-abc',
|
|
});
|
|
});
|
|
|
|
it('proxy: false → connection_type: direct', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'qwen3:8b',
|
|
workers: [
|
|
{ id: 'gpu1', endpoint: 'http://gpu1/v1', proxy: false },
|
|
],
|
|
},
|
|
});
|
|
expect(out.llm?.workers[0]?.connectionType).toBe('direct');
|
|
});
|
|
|
|
it('proxy omitted → connection_type: direct', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'qwen3:8b',
|
|
workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }],
|
|
},
|
|
});
|
|
expect(out.llm?.workers[0]?.connectionType).toBe('direct');
|
|
});
|
|
|
|
it('worker.model empty + provider.model set → worker.model inherits', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'qwen3:32b',
|
|
workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }],
|
|
},
|
|
});
|
|
expect(out.llm?.workers[0]?.model).toBe('qwen3:32b');
|
|
});
|
|
|
|
it('worker.model explicit overrides provider.model', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'qwen3:32b',
|
|
workers: [
|
|
{ id: 'gpu1', endpoint: 'http://gpu1/v1', model: 'qwen3:14b' },
|
|
],
|
|
},
|
|
});
|
|
expect(out.llm?.workers[0]?.model).toBe('qwen3:14b');
|
|
});
|
|
|
|
it('worker.model empty + provider.model empty → empty string, no throw', () => {
|
|
expect(() =>
|
|
normalizeConfig({
|
|
provider: { workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }] },
|
|
}),
|
|
).not.toThrow();
|
|
const out = normalizeConfig({
|
|
provider: { workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }] },
|
|
});
|
|
expect(out.llm?.workers[0]?.model).toBe('');
|
|
});
|
|
|
|
it('base_url with no workers → single default worker', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
baseUrl: 'http://localhost:11434/v1',
|
|
model: 'qwen3:32b',
|
|
workers: [],
|
|
},
|
|
});
|
|
expect(out.llm?.workers).toHaveLength(1);
|
|
expect(out.llm?.workers[0]).toMatchObject({
|
|
id: 'default',
|
|
endpoint: 'http://localhost:11434/v1',
|
|
model: 'qwen3:32b',
|
|
connectionType: 'direct',
|
|
});
|
|
});
|
|
|
|
it('provider.timeout_minutes / retry / metrics → llm.*', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'qwen3:8b',
|
|
baseUrl: 'http://localhost:11434/v1',
|
|
timeoutMinutes: 20,
|
|
retry: {
|
|
maxAttempts: 5,
|
|
backoffMs: [1000, 2000],
|
|
retryableStatus: [429, 503],
|
|
},
|
|
metrics: { enabled: true, prefix: 'aao_worker' },
|
|
},
|
|
});
|
|
expect(out.llm?.timeoutMinutes).toBe(20);
|
|
expect(out.llm?.retry).toEqual({
|
|
maxAttempts: 5,
|
|
backoffMs: [1000, 2000],
|
|
retryableStatus: [429, 503],
|
|
});
|
|
expect(out.llm?.metrics).toEqual({ enabled: true, prefix: 'aao_worker' });
|
|
});
|
|
|
|
it('profiles: [...] → roles: [...] on v2 worker', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'm',
|
|
workers: [
|
|
// Use camelCased keys (post-transformKeys)
|
|
{ id: 'g', endpoint: 'http://g/v1', profiles: ['fast'] },
|
|
],
|
|
},
|
|
});
|
|
expect(out.llm?.workers[0]?.roles).toEqual(['fast']);
|
|
});
|
|
|
|
it('roles defaults to [auto, fast, quality] when neither roles nor profiles set', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'm',
|
|
workers: [{ id: 'g', endpoint: 'http://g/v1' }],
|
|
},
|
|
});
|
|
expect(out.llm?.workers[0]?.roles).toEqual(['auto', 'fast', 'quality']);
|
|
});
|
|
});
|
|
|
|
describe('normalizeConfig — storage migration', () => {
|
|
it('top-level worktree_dir → storage.worktreeDir', () => {
|
|
const out = normalizeConfig({
|
|
worktreeDir: '/data/workspaces',
|
|
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
|
|
});
|
|
expect(out.storage?.worktreeDir).toBe('/data/workspaces');
|
|
});
|
|
|
|
it('tools.task_upload_max_size_mb → storage.taskUploadMaxSizeMb', () => {
|
|
const out = normalizeConfig({
|
|
tools: { taskUploadMaxSizeMb: 100 },
|
|
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
|
|
});
|
|
expect(out.storage?.taskUploadMaxSizeMb).toBe(100);
|
|
});
|
|
|
|
it('tools.trash_retention_days → storage.trashRetentionDays', () => {
|
|
const out = normalizeConfig({
|
|
tools: { trashRetentionDays: 7 },
|
|
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
|
|
});
|
|
expect(out.storage?.trashRetentionDays).toBe(7);
|
|
});
|
|
|
|
it('all storage keys round-trip together', () => {
|
|
const out = normalizeConfig({
|
|
worktreeDir: '/w',
|
|
customPiecesDir: './cp',
|
|
userFolderRoot: './users',
|
|
tools: { taskUploadMaxSizeMb: 50, trashRetentionDays: 30 },
|
|
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
|
|
});
|
|
expect(out.storage).toEqual({
|
|
worktreeDir: '/w',
|
|
customPiecesDir: './cp',
|
|
userFolderRoot: './users',
|
|
taskUploadMaxSizeMb: 50,
|
|
trashRetentionDays: 30,
|
|
});
|
|
});
|
|
|
|
it('existing storage.* wins over legacy flat keys', () => {
|
|
const out = normalizeConfig({
|
|
worktreeDir: '/old',
|
|
storage: { worktreeDir: '/new' },
|
|
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
|
|
});
|
|
expect(out.storage?.worktreeDir).toBe('/new');
|
|
});
|
|
});
|
|
|
|
describe('normalizeConfig — env reference preservation', () => {
|
|
it('${VAR} in worker api_key is preserved verbatim', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'm',
|
|
workers: [
|
|
{
|
|
id: 'team',
|
|
endpoint: 'http://t/v1',
|
|
proxy: true,
|
|
apiKey: '${TEAM_KEY}',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(out.llm?.workers[0]?.apiKey).toBe('${TEAM_KEY}');
|
|
});
|
|
|
|
it('${VAR} in provider.metrics.bearer_token is preserved', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'm',
|
|
baseUrl: 'http://x/v1',
|
|
metrics: { bearerToken: '${BEARER}' },
|
|
},
|
|
});
|
|
expect(out.llm?.metrics?.bearerToken).toBe('${BEARER}');
|
|
});
|
|
|
|
it('env: prefix is preserved (legacy syntax)', () => {
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'm',
|
|
baseUrl: 'http://x/v1',
|
|
metrics: { bearerToken: 'env:LEGACY_BEARER' },
|
|
},
|
|
});
|
|
expect(out.llm?.metrics?.bearerToken).toBe('env:LEGACY_BEARER');
|
|
});
|
|
});
|
|
|
|
describe('normalizeConfig — fixtures', () => {
|
|
it('v1-single-ollama.yaml normalizes to expected v2 shape', () => {
|
|
const raw = loadFixture('v1-single-ollama.yaml');
|
|
const out = normalizeConfig(raw);
|
|
|
|
expect(out.configVersion).toBe(2);
|
|
expect(out.llm?.workers).toHaveLength(1);
|
|
expect(out.llm?.workers[0]).toMatchObject({
|
|
id: 'default',
|
|
connectionType: 'direct',
|
|
endpoint: 'http://localhost:11434/v1',
|
|
model: 'qwen3:32b',
|
|
enabled: true,
|
|
maxConcurrency: 1,
|
|
});
|
|
expect(out.llm?.timeoutMinutes).toBe(10);
|
|
expect(out.llm?.retry).toEqual({
|
|
maxAttempts: 3,
|
|
backoffMs: [2000, 5000, 15000],
|
|
retryableStatus: [429, 500, 502, 503, 504],
|
|
});
|
|
expect(out.storage).toEqual({
|
|
worktreeDir: '/var/lib/agent-orchestrator/workspaces',
|
|
customPiecesDir: './custom-pieces',
|
|
userFolderRoot: './data/users',
|
|
taskUploadMaxSizeMb: 50,
|
|
trashRetentionDays: 30,
|
|
});
|
|
});
|
|
|
|
it('v1-multi-worker-with-proxy.yaml maps proxy:true to aao_gateway + keeps ${} refs', () => {
|
|
const raw = loadFixture('v1-multi-worker-with-proxy.yaml');
|
|
const out = normalizeConfig(raw);
|
|
|
|
expect(out.configVersion).toBe(2);
|
|
expect(out.llm?.timeoutMinutes).toBe(15);
|
|
expect(out.llm?.workers).toHaveLength(4);
|
|
|
|
const byId = new Map(out.llm!.workers.map(w => [w.id, w]));
|
|
expect(byId.get('gpu1')).toMatchObject({
|
|
connectionType: 'direct',
|
|
model: 'qwen3:32b', // inherits from provider.model
|
|
roles: ['auto', 'fast'],
|
|
maxConcurrency: 2,
|
|
});
|
|
expect(byId.get('gpu2')).toMatchObject({
|
|
connectionType: 'direct',
|
|
model: 'qwen3:14b', // worker override
|
|
vlm: true,
|
|
});
|
|
expect(byId.get('team-pool')).toMatchObject({
|
|
connectionType: 'aao_gateway',
|
|
apiKey: '${TEAM_A_LITELLM_KEY}', // env ref preserved literally
|
|
model: 'qwen3:8b',
|
|
maxConcurrency: 4,
|
|
roles: ['quality'],
|
|
});
|
|
expect(byId.get('gpu-reflection')).toMatchObject({
|
|
connectionType: 'direct',
|
|
roles: ['reflection'],
|
|
});
|
|
});
|
|
|
|
it('v1-gateway-server-with-keys.yaml preserves gateway.* untouched + builds llm from provider', () => {
|
|
const raw = loadFixture('v1-gateway-server-with-keys.yaml');
|
|
const out = normalizeConfig(raw);
|
|
|
|
expect(out.configVersion).toBe(2);
|
|
// provider → llm path
|
|
expect(out.llm?.workers).toHaveLength(1);
|
|
expect(out.llm?.workers[0]).toMatchObject({
|
|
id: 'default',
|
|
endpoint: 'http://localhost:11434/v1',
|
|
model: 'qwen3:8b',
|
|
connectionType: 'direct',
|
|
});
|
|
expect(out.llm?.metrics?.bearerToken).toBe('${AAO_WORKER_METRICS_BEARER_TOKEN}');
|
|
|
|
// gateway.* should pass through untouched.
|
|
const gateway = (out as unknown as { gateway?: Record<string, unknown> }).gateway;
|
|
expect(gateway).toBeDefined();
|
|
expect(gateway).toMatchObject({
|
|
enabled: true,
|
|
listenPort: 4000,
|
|
});
|
|
expect(Array.isArray(gateway?.backends)).toBe(true);
|
|
expect((gateway?.backends as unknown[])).toHaveLength(2);
|
|
const vkeys = gateway?.virtualKeys as Array<Record<string, unknown>>;
|
|
expect(vkeys[0]?.key).toBe('${TEAM_A_GATEWAY_KEY}'); // env ref kept
|
|
});
|
|
|
|
it('v1-mcp-and-ssh.yaml mirrors user_folder_root into storage + preserves mcp/ssh', () => {
|
|
const raw = loadFixture('v1-mcp-and-ssh.yaml');
|
|
const out = normalizeConfig(raw);
|
|
|
|
expect(out.configVersion).toBe(2);
|
|
expect(out.storage?.userFolderRoot).toBe('/opt/aao/data/users');
|
|
expect(out.storage?.taskUploadMaxSizeMb).toBe(100);
|
|
|
|
// mcp + ssh blocks should pass through unchanged
|
|
expect(out.mcp).toMatchObject({ enabled: true, callTimeoutSeconds: 30 });
|
|
expect(out.ssh).toMatchObject({
|
|
enabled: true,
|
|
masterKeyPath: './data/secrets/ssh-master.key',
|
|
});
|
|
|
|
// ${VAR} inside tools.knowledge_namespaces stays literal
|
|
const tools = (out as unknown as { tools?: { knowledgeNamespaces?: Record<string, { apiKey: string }> } }).tools;
|
|
expect(tools?.knowledgeNamespaces?.eng?.apiKey).toBe('${DKS_ENG_KEY}');
|
|
expect(tools?.knowledgeNamespaces?.ops?.apiKey).toBe('${DKS_OPS_KEY}');
|
|
});
|
|
});
|
|
|
|
describe('normalizeConfig — backwards compat with loadConfig', () => {
|
|
it('toSnakeKeys round-trip on normalizer output stays consumable by YAML stringifier', () => {
|
|
// Sanity guard: the v2 blocks survive snake_case conversion (used by
|
|
// /api/config write path in later steps).
|
|
const out = normalizeConfig({
|
|
provider: {
|
|
model: 'qwen3:8b',
|
|
workers: [
|
|
{ id: 'gpu1', endpoint: 'http://gpu1/v1', proxy: true, apiKey: '${K}' },
|
|
],
|
|
},
|
|
});
|
|
const snake = toSnakeKeys({
|
|
config_version: out.configVersion,
|
|
llm: out.llm,
|
|
storage: out.storage,
|
|
}) as Record<string, unknown>;
|
|
expect((snake.llm as Record<string, unknown>).workers).toBeDefined();
|
|
const workers = (snake.llm as Record<string, unknown>).workers as Array<Record<string, unknown>>;
|
|
expect(workers[0]).toMatchObject({
|
|
id: 'gpu1',
|
|
connection_type: 'aao_gateway',
|
|
api_key: '${K}',
|
|
});
|
|
});
|
|
|
|
it('mirrors storage.* back into top-level flat keys for legacy readers (2026-05-21 hotfix)', () => {
|
|
// Production aao broke when v2-only config.yaml dropped top-level
|
|
// `worktree_dir`. worker-bootstrap.ts:153/172 reads
|
|
// `config.worktreeDir` and got the default `/var/lib/...` path,
|
|
// which isn't writable on the typical deploy → EACCES on mkdir.
|
|
// The normalizer now mirrors storage.* into the legacy top-level
|
|
// keys so the compat-window readers keep working.
|
|
const out = normalizeConfig({
|
|
configVersion: 2,
|
|
llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://x/v1', model: 'm' }] },
|
|
storage: {
|
|
worktreeDir: '/home/user/data/agent-workspaces',
|
|
customPiecesDir: '/home/user/data/pieces',
|
|
userFolderRoot: '/home/user/data/users',
|
|
taskUploadMaxSizeMb: 100,
|
|
trashRetentionDays: 45,
|
|
},
|
|
});
|
|
expect(out.worktreeDir).toBe('/home/user/data/agent-workspaces');
|
|
expect(out.customPiecesDir).toBe('/home/user/data/pieces');
|
|
expect(out.userFolderRoot).toBe('/home/user/data/users');
|
|
expect(out.tools?.taskUploadMaxSizeMb).toBe(100);
|
|
expect(out.tools?.trashRetentionDays).toBe(45);
|
|
});
|
|
|
|
it('storage.* explicit value wins over top-level value (#369 precedence fix)', () => {
|
|
// After hotfix #369: when the source v2 input has an explicit
|
|
// `storage.worktreeDir`, that value is authoritative and overrides
|
|
// any pre-existing top-level value — because the top-level is
|
|
// almost always the legacy default (`/var/lib/...`) merged in by
|
|
// loadConfig before the normalizer runs. The env override (#369)
|
|
// is re-applied AFTER normalizeConfig in loadConfig so a runtime
|
|
// WORKTREE_DIR still wins; that contract isn't tested here.
|
|
const out = normalizeConfig({
|
|
configVersion: 2,
|
|
llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://x/v1', model: 'm' }] },
|
|
worktreeDir: '/var/lib/maestro/workspaces', // simulates merged-in default
|
|
storage: { worktreeDir: '/home/user/data/agent-workspaces' },
|
|
});
|
|
expect(out.worktreeDir).toBe('/home/user/data/agent-workspaces');
|
|
});
|
|
|
|
it('v1 top-level worktreeDir survives when no storage block was authored', () => {
|
|
// Legacy path: a v1 file with `worktree_dir` but no `storage` block
|
|
// synthesizes storage.worktreeDir from the top-level. The mirror is
|
|
// a no-op (top-level was already set) and the user's value is
|
|
// preserved on both sides.
|
|
const out = normalizeConfig({
|
|
// configVersion omitted → v1 path
|
|
provider: { workers: [{ id: 'w1', endpoint: 'http://x/v1', model: 'm' }] },
|
|
worktreeDir: '/home/op/explicit-v1-value',
|
|
});
|
|
expect(out.worktreeDir).toBe('/home/op/explicit-v1-value');
|
|
expect(out.storage?.worktreeDir).toBe('/home/op/explicit-v1-value');
|
|
});
|
|
});
|
|
|
|
describe('browser.display_mode migration (captcha_solve rename)', () => {
|
|
it('legacy captchaSolve=novnc → displayMode=novnc, captchaSolve removed', () => {
|
|
const out = normalizeConfig({ browser: { captchaSolve: 'novnc' } });
|
|
expect(out.browser?.displayMode).toBe('novnc');
|
|
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
|
|
});
|
|
|
|
it('legacy captchaSolve=skip → displayMode=headless, captchaSolve removed', () => {
|
|
const out = normalizeConfig({ browser: { captchaSolve: 'skip' } });
|
|
expect(out.browser?.displayMode).toBe('headless');
|
|
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
|
|
});
|
|
|
|
it('displayMode wins over captchaSolve when both present; captchaSolve removed', () => {
|
|
const out = normalizeConfig({ browser: { displayMode: 'novnc', captchaSolve: 'skip' } });
|
|
expect(out.browser?.displayMode).toBe('novnc');
|
|
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
|
|
});
|
|
|
|
it('neither key → browser block unchanged (displayMode stays undefined)', () => {
|
|
const out = normalizeConfig({ browser: { maxSessions: 3 } });
|
|
expect(out.browser?.displayMode).toBeUndefined();
|
|
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
|
|
expect(out.browser?.maxSessions).toBe(3);
|
|
});
|
|
});
|