/** * Unit tests for the Azure DevOps PR argv builders — the pure core of the fix * for the feature→main incident (#451 / #467). */ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { buildAzurePrCreateArgs, buildAzurePrAbandonArgs, capAzureDescriptionLines, AZURE_PR_DESCRIPTION_MAX, } from '../provider.js'; import type { PROptions } from '../types.js'; const base: PROptions = { title: 'fix(x): y', body: 'd818d365 fix(licensing): break cycle\n3df43d6c chore: cleanup\n371a39f0 feat: rework', source: 'feature/zefixe-optimisation', target: 'develop', draft: false, provider: 'azuredevops', azure: { org: 'AtlasHub', project: 'SmartStack', repo: 'SmartStack.app' }, }; const valueAfter = (args: string[], flag: string): string | undefined => { const i = args.indexOf(flag); return i === -1 ? undefined : args[i + 1]; }; test('create: --target-branch is always passed (az defaults to main when absent)', () => { assert.equal(valueAfter(buildAzurePrCreateArgs(base), '--target-branch'), 'develop'); }); test('create: --source-branch and --target-branch precede the greedy --description', () => { const args = buildAzurePrCreateArgs(base); const di = args.indexOf('--description'); assert.ok(di > args.indexOf('--source-branch'), '--source-branch must come before --description'); assert.ok(di > args.indexOf('--target-branch'), '--target-branch must come before --description'); }); test('create: --description is the LAST flag — nothing after it starts with "--"', () => { const args = buildAzurePrCreateArgs(base); const di = args.indexOf('--description'); const after = args.slice(di + 1); assert.ok(after.length > 0, '--description must carry at least one value'); assert.ok(after.every((a) => !a.startsWith('--')), 'no flag may follow the greedy --description'); }); test('create: multi-line body becomes one value per line, no embedded newline', () => { const args = buildAzurePrCreateArgs(base); const desc = args.slice(args.indexOf('--description') + 1); assert.deepEqual(desc, [ 'd818d365 fix(licensing): break cycle', '3df43d6c chore: cleanup', '371a39f0 feat: rework', ]); assert.ok(desc.every((l) => !l.includes('\n'))); }); test('create: explicit org/project/repo from the azure context', () => { const args = buildAzurePrCreateArgs(base); assert.equal(valueAfter(args, '--organization'), 'https://dev.azure.com/AtlasHub'); assert.equal(valueAfter(args, '--project'), 'SmartStack'); assert.equal(valueAfter(args, '--repository'), 'SmartStack.app'); }); test('create: no azure context → no explicit org/project/repo (fall back to az detection), target still set', () => { const args = buildAzurePrCreateArgs({ ...base, azure: undefined }); assert.equal(args.indexOf('--organization'), -1); assert.equal(args.indexOf('--repository'), -1); assert.equal(valueAfter(args, '--target-branch'), 'develop'); }); test('create: empty / whitespace-only body emits no --description flag', () => { assert.equal(buildAzurePrCreateArgs({ ...base, body: '' }).indexOf('--description'), -1); assert.equal(buildAzurePrCreateArgs({ ...base, body: '\n \n' }).indexOf('--description'), -1); }); test('create: draft appends --draft true', () => { assert.equal(valueAfter(buildAzurePrCreateArgs({ ...base, draft: true }), '--draft'), 'true'); }); // --- description cap (Azure refuses > 4000 chars — the release/5.16.0 incident) --- const joined = (lines: string[]): string => lines.join('\n'); test('cap: short description passes through untouched', () => { const lines = ['aaa', 'bbb', 'ccc']; assert.deepEqual(capAzureDescriptionLines(lines), lines); }); test('cap: total at exactly the limit is kept whole', () => { const lines = ['a'.repeat(2000), 'b'.repeat(AZURE_PR_DESCRIPTION_MAX - 2000 - 1)]; assert.deepEqual(capAzureDescriptionLines(lines), lines); assert.equal(joined(lines).length, AZURE_PR_DESCRIPTION_MAX); }); test('cap: the crossing line is cut with an ellipsis and the total lands on the limit', () => { const lines = ['a'.repeat(3000), 'b'.repeat(3000)]; const out = capAzureDescriptionLines(lines); assert.equal(out.length, 2); assert.equal(out[0], lines[0]); assert.ok(out[1].endsWith('…')); assert.equal(joined(out).length, AZURE_PR_DESCRIPTION_MAX); }); test('cap: lines after the cut are dropped', () => { const out = capAzureDescriptionLines(['a'.repeat(3900), 'b'.repeat(500), 'never included']); assert.equal(out.length, 2); assert.ok(joined(out).length <= AZURE_PR_DESCRIPTION_MAX); }); test('cap: a single line longer than the limit is cut, never sent whole', () => { const out = capAzureDescriptionLines(['x'.repeat(AZURE_PR_DESCRIPTION_MAX + 500)]); assert.equal(out.length, 1); assert.ok(out[0].endsWith('…')); assert.equal(out[0].length, AZURE_PR_DESCRIPTION_MAX); }); test('cap: no room left for even one truncated char → the line is dropped entirely', () => { const out = capAzureDescriptionLines(['a'.repeat(AZURE_PR_DESCRIPTION_MAX), 'b']); assert.deepEqual(out, ['a'.repeat(AZURE_PR_DESCRIPTION_MAX)]); }); test('create: oversized real-shape body (5 giant commit subjects) is capped in the argv', () => { const body = Array.from({ length: 5 }, (_, i) => `${i}abcdef feat(x): ${'y'.repeat(1300)}`).join('\n'); const args = buildAzurePrCreateArgs({ ...base, body }); const desc = args.slice(args.indexOf('--description') + 1); assert.ok(desc.length > 0); assert.ok(joined(desc).length <= AZURE_PR_DESCRIPTION_MAX); assert.ok(desc.every((l) => !l.startsWith('--'))); }); test('abandon: update by id to status abandoned, with org/project when azure is known', () => { const args = buildAzurePrAbandonArgs(467, base.azure); assert.equal(valueAfter(args, '--id'), '467'); assert.equal(valueAfter(args, '--status'), 'abandoned'); assert.equal(valueAfter(args, '--organization'), 'https://dev.azure.com/AtlasHub'); assert.equal(valueAfter(args, '--project'), 'SmartStack'); }); test('abandon: no azure context → bare id/status (az auto-detect)', () => { const args = buildAzurePrAbandonArgs(467, undefined); assert.equal(valueAfter(args, '--status'), 'abandoned'); assert.equal(args.indexOf('--organization'), -1); }); // --------------------------------------------------------------------------- // merge: the completion verdict reads Azure's answer, not the exit code. // PR 510 (release/5.20.0 → main, 2026-09-05): `az repos pr update --status completed` // exited 0 and printed the PR still `active` / `conflicts`; the old code said // "merged" while main had not moved. // --------------------------------------------------------------------------- import { interpretAzurePrCompletion } from '../provider.js'; const azResult = (stdout: string, exitCode = 0, stderr = '') => ({ stdout, stderr, exitCode }); test('merge verdict: non-zero exit is a failure carrying stderr, reason exit', () => { const v = interpretAzurePrCompletion(azResult('', 1, 'ERROR: TF401179: pull request not found')); assert.equal(v.success, false); assert.equal(v.reason, 'exit'); assert.match(v.error ?? '', /TF401179/); }); test('merge verdict: status completed is the only success', () => { const v = interpretAzurePrCompletion(azResult(JSON.stringify({ pullRequestId: 510, status: 'completed', mergeStatus: 'succeeded' }))); assert.deepEqual(v, { success: true }); }); test('merge verdict: exit 0 + PR still active with conflicts (the PR 510 shape) is NOT a success', () => { const v = interpretAzurePrCompletion(azResult(JSON.stringify({ pullRequestId: 510, status: 'active', mergeStatus: 'conflicts' }))); assert.equal(v.success, false); assert.equal(v.reason, 'not-completed'); assert.match(v.error ?? '', /#510/); assert.match(v.error ?? '', /NOT completed/); assert.match(v.error ?? '', /mergeStatus: conflicts/); assert.match(v.error ?? '', /merge origin\//, 'conflicts must point at the merge-the-target-in fix'); }); test('merge verdict: active without conflicts points at policies/build, not at a merge', () => { const v = interpretAzurePrCompletion(azResult(JSON.stringify({ pullRequestId: 7, status: 'active', mergeStatus: 'succeeded' }))); assert.equal(v.success, false); assert.equal(v.reason, 'not-completed'); assert.match(v.error ?? '', /policies/); assert.doesNotMatch(v.error ?? '', /merge origin/); }); test('merge verdict: abandoned is a failure, never a merge', () => { const v = interpretAzurePrCompletion(azResult(JSON.stringify({ pullRequestId: 7, status: 'abandoned' }))); assert.equal(v.success, false); assert.match(v.error ?? '', /'abandoned'/); }); test('merge verdict: unreadable stdout is unverified — reason unreadable, never a success', () => { for (const out of ['', 'WARNING: some banner', '[]', 'null']) { const v = interpretAzurePrCompletion(azResult(out)); assert.equal(v.success, false, `stdout ${JSON.stringify(out)} must not pass`); assert.equal(v.reason, 'unreadable'); assert.match(v.error ?? '', /unverified/); } }); test('merge verdict: a missing status is not completed', () => { const v = interpretAzurePrCompletion(azResult(JSON.stringify({ pullRequestId: 9 }))); assert.equal(v.success, false); assert.equal(v.reason, 'not-completed'); assert.match(v.error ?? '', /'unknown'/); });