mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-31 20:04:41 +00:00
1047 lines
32 KiB
JavaScript
1047 lines
32 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import {
|
|
appendFileSync,
|
|
chmodSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
appendDegradedStepSummary,
|
|
buildPullRequestQuery,
|
|
classifyChange,
|
|
createOpenAiCompleter,
|
|
enrichEntries,
|
|
escapeWorkflowCommand,
|
|
generateAiContent,
|
|
generateReleaseNotes,
|
|
parseGeneratedEntries,
|
|
renderReleaseNotes,
|
|
tryAppendDegradedStepSummary,
|
|
} from '../generate-release-notes.js';
|
|
|
|
const PR = (number) => `https://github.com/QwenLM/qwen-code/pull/${number}`;
|
|
|
|
const entry = (number, title, labels = []) => ({
|
|
number,
|
|
title,
|
|
url: PR(number),
|
|
author: 'alice',
|
|
labels,
|
|
body: '',
|
|
});
|
|
|
|
describe('parseGeneratedEntries', () => {
|
|
it('extracts the authoritative PR list from GitHub generated notes', () => {
|
|
const body = [
|
|
"## What's Changed",
|
|
`* feat(cli): add session search by @alice in ${PR(12)}`,
|
|
`* fix(core): preserve tool results by @bob in ${PR(8)}`,
|
|
`* fix(ci): retry publishing by @carol with @Copilot in ${PR(6574)}`,
|
|
'',
|
|
'**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v1...v2',
|
|
].join('\n');
|
|
|
|
expect(parseGeneratedEntries(body)).toEqual([
|
|
{
|
|
number: 12,
|
|
title: 'feat(cli): add session search',
|
|
url: PR(12),
|
|
author: 'alice',
|
|
},
|
|
{
|
|
number: 8,
|
|
title: 'fix(core): preserve tool results',
|
|
url: PR(8),
|
|
author: 'bob',
|
|
},
|
|
{
|
|
number: 6574,
|
|
title: 'fix(ci): retry publishing',
|
|
url: PR(6574),
|
|
author: 'carol',
|
|
coAuthors: ['Copilot'],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('rejects a partially parsed GitHub PR list', () => {
|
|
const body = [
|
|
`* feat(cli): parsed by @alice in ${PR(1)}`,
|
|
`* fix(core): changed format by @bob and @carol in ${PR(2)}`,
|
|
].join('\n');
|
|
|
|
expect(() => parseGeneratedEntries(body)).toThrow(
|
|
/Could not parse every pull request entry/,
|
|
);
|
|
});
|
|
|
|
it('does not drop a PR bullet when GitHub omits the author phrase', () => {
|
|
const body = [
|
|
"## What's Changed",
|
|
`* feat(cli): parsed by @alice in ${PR(1)}`,
|
|
`* fix(core): changed format in ${PR(2)}`,
|
|
'',
|
|
'## New Contributors',
|
|
`* @newbie made their first contribution in ${PR(2)}`,
|
|
].join('\n');
|
|
|
|
expect(parseGeneratedEntries(body)).toEqual([
|
|
{
|
|
number: 1,
|
|
title: 'feat(cli): parsed',
|
|
url: PR(1),
|
|
author: 'alice',
|
|
},
|
|
{
|
|
number: 2,
|
|
title: 'fix(core): changed format',
|
|
url: PR(2),
|
|
author: null,
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('classifyChange', () => {
|
|
it.each([
|
|
['feat(cli): add x', [], 'Features'],
|
|
['fix(core): repair x', [], 'Bug Fixes'],
|
|
['perf: speed up x', [], 'Performance'],
|
|
['docs: explain x', [], 'Documentation'],
|
|
['test(core): cover x', [], 'Internal Changes'],
|
|
])('classifies %s deterministically', (title, labels, expected) => {
|
|
expect(classifyChange(entry(1, title, labels))).toBe(expected);
|
|
});
|
|
|
|
it('lets an explicit breaking-change label override the title category', () => {
|
|
expect(
|
|
classifyChange(
|
|
entry(1, 'refactor(core): replace x', ['breaking-change']),
|
|
),
|
|
).toBe('Breaking Changes');
|
|
});
|
|
|
|
it.each([
|
|
['type/feature-request', 'Features'],
|
|
['type/bug', 'Bug Fixes'],
|
|
['category/performance', 'Performance'],
|
|
['type/documentation', 'Documentation'],
|
|
['scope/documentation', 'Documentation'],
|
|
])('uses an explicit %s label for prefixless titles', (label, expected) => {
|
|
expect(classifyChange(entry(1, 'A clearer change title', [label]))).toBe(
|
|
expected,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('renderReleaseNotes', () => {
|
|
it('renders highlights and every PR exactly once in the complete list', () => {
|
|
const entries = [
|
|
{
|
|
...entry(1, 'feat(cli): add session search'),
|
|
coAuthors: ['Copilot'],
|
|
},
|
|
entry(2, 'fix(core): preserve tool results'),
|
|
entry(3, 'docs: explain session search'),
|
|
entry(4, 'refactor(core): remove legacy path', ['breaking-change']),
|
|
];
|
|
const summaries = new Map([
|
|
[1, 'Adds session search to the CLI.'],
|
|
[2, 'Preserves tool results when history is repaired.'],
|
|
[3, 'Documents session search.'],
|
|
[4, 'Removes a legacy compatibility path.'],
|
|
]);
|
|
|
|
const markdown = renderReleaseNotes({
|
|
entries,
|
|
summaries,
|
|
highlights: [
|
|
{
|
|
text: 'Session workflows are easier to find and recover.',
|
|
prs: [1, 2],
|
|
},
|
|
],
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(markdown).toContain('<!-- qwen-release-notes:v1 -->');
|
|
expect(markdown).toContain('## Highlights');
|
|
expect(markdown).toContain(
|
|
'Session workflows are easier to find and recover. ([#1]',
|
|
);
|
|
expect(markdown).toContain('## Complete Change List');
|
|
expect(markdown).toContain('### Features');
|
|
expect(markdown).toContain('### Bug Fixes');
|
|
expect(markdown).toContain('### Documentation');
|
|
expect(markdown).toContain(
|
|
`Adds session search to the CLI. ([#1](${PR(1)})) by @alice with @Copilot`,
|
|
);
|
|
for (const number of [1, 2, 3, 4]) {
|
|
expect(markdown.match(new RegExp(`\\[#${number}\\]`, 'g'))).toHaveLength(
|
|
number < 3 ? 2 : 1,
|
|
);
|
|
}
|
|
expect(markdown).toContain(
|
|
'**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v1.0.0...v1.1.0',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('generateAiContent', () => {
|
|
it('summarizes bounded batches and then generates highlights', async () => {
|
|
const entries = [
|
|
entry(1, 'feat(cli): add session search'),
|
|
entry(2, 'fix(core): preserve tool results'),
|
|
entry(3, 'docs: explain session search'),
|
|
];
|
|
const calls = [];
|
|
const complete = async (request) => {
|
|
calls.push(request);
|
|
if (request.kind === 'summaries') {
|
|
return `\`\`\`json\n${JSON.stringify({
|
|
summaries: request.entries.map((item) => ({
|
|
pr: item.number,
|
|
summary: `User-facing summary for ${item.number}.`,
|
|
})),
|
|
})}\n\`\`\``;
|
|
}
|
|
return `\`\`\`json\n${JSON.stringify({
|
|
highlights: [{ text: 'Session workflows are clearer.', prs: [1, 2] }],
|
|
})}\n\`\`\``;
|
|
};
|
|
|
|
const result = await generateAiContent(entries, complete, { batchSize: 2 });
|
|
|
|
expect(calls.map((call) => call.kind)).toEqual([
|
|
'summaries',
|
|
'summaries',
|
|
'highlights',
|
|
]);
|
|
expect(result.summaries.get(3)).toBe('User-facing summary for 3.');
|
|
expect(result.highlights).toEqual([
|
|
{ text: 'Session workflows are clearer.', prs: [1, 2] },
|
|
]);
|
|
});
|
|
|
|
it('sends only title, a bounded body excerpt, and category to the model', async () => {
|
|
const long = { ...entry(1, 'feat: long body'), body: 'x'.repeat(5000) };
|
|
const calls = [];
|
|
const complete = async (request) => {
|
|
calls.push(request);
|
|
if (request.kind === 'summaries') {
|
|
return JSON.stringify({
|
|
summaries: request.entries.map((item) => ({
|
|
pr: item.number,
|
|
summary: 'Summary.',
|
|
})),
|
|
});
|
|
}
|
|
return JSON.stringify({ highlights: [] });
|
|
};
|
|
|
|
await generateAiContent([long], complete);
|
|
|
|
const [payload] = calls[0].entries;
|
|
expect(Object.keys(payload).sort()).toEqual([
|
|
'body',
|
|
'category',
|
|
'number',
|
|
'title',
|
|
]);
|
|
expect(payload.body).toHaveLength(700);
|
|
});
|
|
|
|
it('falls back to original titles for an invalid summary batch', async () => {
|
|
const entries = [entry(1, 'feat: original'), entry(2, 'fix: original')];
|
|
const complete = async (request) => {
|
|
if (request.kind === 'summaries') {
|
|
return '{not-json';
|
|
}
|
|
return JSON.stringify({ highlights: [] });
|
|
};
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries).toEqual(
|
|
new Map([
|
|
[1, 'feat: original'],
|
|
[2, 'fix: original'],
|
|
]),
|
|
);
|
|
expect(result.warnings).toHaveLength(1);
|
|
});
|
|
|
|
it('falls back to original titles when the model omits a PR summary', async () => {
|
|
const entries = [entry(1, 'feat: original'), entry(2, 'fix: original')];
|
|
const complete = async (request) =>
|
|
request.kind === 'summaries'
|
|
? JSON.stringify({ summaries: [{ pr: 1, summary: 'Only one.' }] })
|
|
: JSON.stringify({ highlights: [] });
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries).toEqual(
|
|
new Map([
|
|
[1, 'feat: original'],
|
|
[2, 'fix: original'],
|
|
]),
|
|
);
|
|
expect(result.warnings[0]).toMatch(/missing pull request summaries/);
|
|
});
|
|
|
|
it('falls back only the summary whose text is unsafe', async () => {
|
|
const entries = [entry(1, 'feat: original'), entry(2, 'fix: original')];
|
|
const complete = async (request) =>
|
|
request.kind === 'summaries'
|
|
? JSON.stringify({
|
|
summaries: [
|
|
{ pr: 1, summary: 'A safe summary.' },
|
|
{ pr: 2, summary: '@QwenLM/security should review this.' },
|
|
],
|
|
})
|
|
: JSON.stringify({ highlights: [] });
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries).toEqual(
|
|
new Map([
|
|
[1, 'A safe summary.'],
|
|
[2, 'fix: original'],
|
|
]),
|
|
);
|
|
expect(result.warnings).toEqual([
|
|
'Summary fallback for #2: Summary for pull request 2 must be plain text without links or HTML.',
|
|
]);
|
|
});
|
|
|
|
it('rejects GFM autolinks and encoded mentions from model text', async () => {
|
|
const entries = [
|
|
entry(1, 'feat: original one'),
|
|
entry(2, 'fix: original two'),
|
|
entry(3, 'docs: original three'),
|
|
];
|
|
const complete = async (request) =>
|
|
request.kind === 'summaries'
|
|
? JSON.stringify({
|
|
summaries: [
|
|
{ pr: 1, summary: 'Visit www.example.com for details.' },
|
|
{ pr: 2, summary: 'Contact security@example.com.' },
|
|
{ pr: 3, summary: 'Ping @octocat for details.' },
|
|
],
|
|
})
|
|
: JSON.stringify({ highlights: [] });
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries).toEqual(
|
|
new Map([
|
|
[1, 'feat: original one'],
|
|
[2, 'fix: original two'],
|
|
[3, 'docs: original three'],
|
|
]),
|
|
);
|
|
expect(result.warnings).toHaveLength(3);
|
|
});
|
|
|
|
it('drops invalid highlights without losing the complete list', async () => {
|
|
const entries = [entry(1, 'feat: original')];
|
|
const complete = async (request) =>
|
|
request.kind === 'summaries'
|
|
? JSON.stringify({ summaries: [{ pr: 1, summary: 'Readable.' }] })
|
|
: JSON.stringify({
|
|
highlights: [{ text: 'Invented.', prs: [99] }],
|
|
});
|
|
|
|
const result = await generateAiContent(entries, complete);
|
|
|
|
expect(result.summaries.get(1)).toBe('Readable.');
|
|
expect(result.highlights).toEqual([]);
|
|
expect(result.warnings).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('enrichEntries', () => {
|
|
it('keeps authoritative order and fills metadata returned by GitHub', () => {
|
|
const base = parseGeneratedEntries(
|
|
`* feat: a by @alice in ${PR(2)}\n* fix: b by @bob in ${PR(1)}`,
|
|
);
|
|
const enriched = enrichEntries(base, [
|
|
{
|
|
number: 1,
|
|
body: 'Why it matters.',
|
|
labels: [{ name: 'type/bug' }],
|
|
},
|
|
]);
|
|
|
|
expect(enriched.map((item) => item.number)).toEqual([2, 1]);
|
|
expect(enriched[0].body).toBe('');
|
|
expect(enriched[1].body).toBe('Why it matters.');
|
|
expect(enriched[1].labels).toEqual([{ name: 'type/bug' }]);
|
|
});
|
|
});
|
|
|
|
describe('buildPullRequestQuery', () => {
|
|
it('builds one aliased metadata lookup per authoritative PR number', () => {
|
|
const query = buildPullRequestQuery([12, 8]);
|
|
|
|
expect(query).toContain('pr0: pullRequest(number: 12)');
|
|
expect(query).toContain('pr1: pullRequest(number: 8)');
|
|
expect(query).toContain('labels(first: 20)');
|
|
expect(query).not.toContain('files(first: 40)');
|
|
expect(query).not.toContain('pullRequest(number: undefined)');
|
|
});
|
|
});
|
|
|
|
describe('createOpenAiCompleter', () => {
|
|
it('uses a tool-free JSON completion request', async () => {
|
|
const requests = [];
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
fetchImpl: async (url, init) => {
|
|
requests.push({ url, init });
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: '{"summaries":[]}' } }],
|
|
}),
|
|
};
|
|
},
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
|
|
expect(requests[0].url).toBe('https://model.example/v1/chat/completions');
|
|
expect(requests[0].init.headers.Authorization).toBe('Bearer secret');
|
|
const body = JSON.parse(requests[0].init.body);
|
|
expect(body.model).toBe('qwen-test');
|
|
expect(body.response_format).toEqual({ type: 'json_object' });
|
|
expect(body.max_tokens).toBe(4096);
|
|
expect(body.tools).toBeUndefined();
|
|
expect(requests[0].init.signal).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('generateReleaseNotes', () => {
|
|
it('returns GitHub notes unchanged when there are no PR entries', async () => {
|
|
const generatedBody =
|
|
'**Full Changelog**: https://example.com/compare/a...b';
|
|
const result = await generateReleaseNotes({
|
|
generatedBody,
|
|
metadata: [],
|
|
complete: async () => {
|
|
throw new Error('must not be called');
|
|
},
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.0.1',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(result.markdown).toBe(generatedBody);
|
|
expect(result.usedAi).toBe(false);
|
|
});
|
|
|
|
it('renders the complete fallback list and new contributor credits', async () => {
|
|
const generatedBody = [
|
|
"## What's Changed",
|
|
`* feat(cli): add search by @alice in ${PR(1)}`,
|
|
'',
|
|
'## New Contributors',
|
|
`* @newbie made their first contribution in ${PR(1)}`,
|
|
].join('\n');
|
|
|
|
const result = await generateReleaseNotes({
|
|
generatedBody,
|
|
metadata: [],
|
|
complete: null,
|
|
previousTag: 'v1.0.0',
|
|
tag: 'v1.1.0',
|
|
repo: 'QwenLM/qwen-code',
|
|
});
|
|
|
|
expect(result.markdown).toContain('### Features');
|
|
expect(result.markdown).toContain(
|
|
`feat(cli): add search ([#1](${PR(1)})) by @alice`,
|
|
);
|
|
expect(result.markdown).toContain('## New Contributors');
|
|
expect(result.markdown).toContain(
|
|
`- @newbie made their first contribution in [#1](${PR(1)})`,
|
|
);
|
|
expect(result.usedAi).toBe(false);
|
|
expect(result.warnings).toEqual(['Model configuration is unavailable.']);
|
|
});
|
|
|
|
it('runs the CLI path with fake gh data and writes fallback notes', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'release-notes-cli-'));
|
|
try {
|
|
const gh = join(dir, 'gh');
|
|
const output = join(dir, 'notes.md');
|
|
const summaryPath = join(dir, 'summary.md');
|
|
writeFileSync(
|
|
gh,
|
|
[
|
|
'#!/usr/bin/env node',
|
|
'const args = process.argv.slice(2);',
|
|
"if (args[0] === 'api' && args.includes('repos/QwenLM/qwen-code/releases/generate-notes')) {",
|
|
" process.stdout.write([\"## What's Changed\", '* feat: add cli path by @alice in https://github.com/QwenLM/qwen-code/pull/1'].join('\\n'));",
|
|
' process.exit(0);',
|
|
'}',
|
|
"if (args[0] === 'api' && args[1] === 'graphql') {",
|
|
" process.stdout.write(JSON.stringify({ data: { repository: { pr0: { number: 1, body: 'Body.', labels: { nodes: [] } } } } }));",
|
|
' process.exit(0);',
|
|
'}',
|
|
'process.exit(1);',
|
|
].join('\n'),
|
|
);
|
|
chmodSync(gh, 0o755);
|
|
|
|
const cli = spawnSync(
|
|
process.execPath,
|
|
[
|
|
'scripts/generate-release-notes.js',
|
|
'--tag=v1.0.1',
|
|
'--previous-tag=v1.0.0',
|
|
`--output=${output}`,
|
|
],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${dir}:${process.env.PATH}`,
|
|
GITHUB_STEP_SUMMARY: summaryPath,
|
|
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
|
|
OPENAI_API_KEY: '',
|
|
OPENAI_BASE_URL: '',
|
|
OPENAI_MODEL: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(cli.status).toBe(0);
|
|
expect(cli.stderr).toContain(
|
|
'::warning::Model configuration is unavailable.',
|
|
);
|
|
expect(readFileSync(summaryPath, 'utf8')).toContain(
|
|
'Release notes: AI generation degraded',
|
|
);
|
|
const markdown = readFileSync(output, 'utf8');
|
|
expect(markdown).toContain('### Features');
|
|
expect(markdown).toContain(
|
|
`feat: add cli path ([#1](${PR(1)})) by @alice`,
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not duplicate ERROR prefixes for argument failures', () => {
|
|
try {
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
'scripts/generate-release-notes.js',
|
|
'--repo=bad repo',
|
|
'--tag=v1.0.1',
|
|
'--previous-tag=v1.0.0',
|
|
'--dry-run',
|
|
],
|
|
{ encoding: 'utf8', stdio: 'pipe' },
|
|
);
|
|
throw new Error('expected command to fail');
|
|
} catch (error) {
|
|
expect(error.stderr).toContain(
|
|
'ERROR: Invalid repository "bad repo"; expected "owner/name".',
|
|
);
|
|
expect(error.stderr).not.toContain('ERROR: ERROR:');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('createOpenAiCompleter retries', () => {
|
|
const okResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: '{"summaries":[]}' } }],
|
|
}),
|
|
};
|
|
|
|
it('retries a 500 once and then succeeds', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return calls === 1 ? { ok: false, status: 500 } : okResponse;
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).resolves.toBe(
|
|
'{"summaries":[]}',
|
|
);
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it('retries a timeout before giving up after maxRetries', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
maxRetries: 2,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
const error = new Error('timed out');
|
|
error.name = 'TimeoutError';
|
|
throw error;
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
'timed out',
|
|
);
|
|
expect(calls).toBe(3);
|
|
});
|
|
|
|
it('does not retry a 400', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return { ok: false, status: 400 };
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
'HTTP 400',
|
|
);
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('retries HTTP 429 (rate limiting)', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return calls === 1 ? { ok: false, status: 429 } : okResponse;
|
|
},
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it('retries network errors without HTTP status', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
throw new Error('fetch failed: ECONNRESET');
|
|
}
|
|
return okResponse;
|
|
},
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it('does not retry content-validation errors', async () => {
|
|
let calls = 0;
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
// Returns 200 OK but empty content — triggers content-validation error
|
|
return new Response(
|
|
JSON.stringify({ choices: [{ message: { content: '' } }] }),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
'Model response did not contain message content.',
|
|
);
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('preserves original error in deadline-expired message', async () => {
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 100,
|
|
totalTimeoutMs: 50,
|
|
fetchImpl: async () => {
|
|
return { ok: false, status: 503 };
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
/budget exhausted.*HTTP 503/,
|
|
);
|
|
});
|
|
|
|
it('preserves the original error as the deadline error cause', async () => {
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 100,
|
|
totalTimeoutMs: 50,
|
|
fetchImpl: async () => {
|
|
return { ok: false, status: 503 };
|
|
},
|
|
});
|
|
|
|
const error = await complete({ kind: 'summaries', entries: [] }).catch(
|
|
(err) => err,
|
|
);
|
|
expect(error.message).toMatch(/budget exhausted.*HTTP 503/);
|
|
expect(error.cause?.message).toBe('Model request failed with HTTP 503.');
|
|
});
|
|
|
|
it('preserves original error when the deadline expires after backoff', async () => {
|
|
let now = 0;
|
|
let calls = 0;
|
|
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
const random = vi.spyOn(Math, 'random').mockReturnValue(0);
|
|
const timeout = vi
|
|
.spyOn(globalThis, 'setTimeout')
|
|
.mockImplementation((callback) => {
|
|
now = 1001;
|
|
callback();
|
|
return 0;
|
|
});
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 100,
|
|
totalTimeoutMs: 1000,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return { ok: false, status: 500 };
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
/budget exhausted.*HTTP 500/,
|
|
);
|
|
expect(calls).toBe(1);
|
|
clock.mockRestore();
|
|
random.mockRestore();
|
|
timeout.mockRestore();
|
|
errSpy.mockRestore();
|
|
});
|
|
|
|
it('logs a retry line when backing off', async () => {
|
|
let calls = 0;
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 1,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return calls === 1
|
|
? new Response('\n::error::forged', { status: 200 })
|
|
: okResponse;
|
|
},
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
const retryLine = errSpy.mock.calls
|
|
.map((args) => args[0])
|
|
.find((line) => String(line).startsWith('Model request retry '));
|
|
expect(retryLine).toBeDefined();
|
|
expect(retryLine).not.toContain('\n');
|
|
expect(retryLine).toContain('%0A::error::forged');
|
|
errSpy.mockRestore();
|
|
});
|
|
|
|
it('stops retrying before the shared time budget expires', async () => {
|
|
let calls = 0;
|
|
const timeout = vi.spyOn(globalThis, 'setTimeout');
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
baseDelayMs: 100,
|
|
totalTimeoutMs: 50,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return { ok: false, status: 500 };
|
|
},
|
|
});
|
|
|
|
await expect(complete({ kind: 'summaries', entries: [] })).rejects.toThrow(
|
|
/budget exhausted.*HTTP 500/,
|
|
);
|
|
expect(calls).toBe(1);
|
|
expect(timeout).not.toHaveBeenCalled();
|
|
timeout.mockRestore();
|
|
});
|
|
|
|
it('caps each request at the remaining shared time budget', async () => {
|
|
const timeout = vi.spyOn(AbortSignal, 'timeout');
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
timeoutMs: 10_000,
|
|
totalTimeoutMs: 1_000,
|
|
fetchImpl: async () => okResponse,
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
expect(timeout.mock.calls[0][0]).toBeLessThanOrEqual(1_000);
|
|
timeout.mockRestore();
|
|
});
|
|
|
|
it('shares the time budget across calls', async () => {
|
|
let now = 0;
|
|
let calls = 0;
|
|
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
const complete = createOpenAiCompleter({
|
|
apiKey: 'secret',
|
|
baseUrl: 'https://model.example/v1/',
|
|
model: 'qwen-test',
|
|
totalTimeoutMs: 50,
|
|
fetchImpl: async () => {
|
|
calls += 1;
|
|
return okResponse;
|
|
},
|
|
});
|
|
|
|
await complete({ kind: 'summaries', entries: [] });
|
|
now = 51;
|
|
await expect(complete({ kind: 'highlights', entries: [] })).rejects.toThrow(
|
|
'Model generation time budget exhausted: unknown error',
|
|
);
|
|
expect(calls).toBe(1);
|
|
clock.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('generateAiContent circuit breaker', () => {
|
|
it('stops calling the model after consecutive batch failures', async () => {
|
|
const calls = [];
|
|
const failing = async (request) => {
|
|
calls.push(request.kind);
|
|
throw new Error('model down');
|
|
};
|
|
const entries = [
|
|
entry(1, 'one'),
|
|
entry(2, 'two'),
|
|
entry(3, 'three'),
|
|
entry(4, 'four'),
|
|
entry(5, 'five'),
|
|
];
|
|
|
|
const result = await generateAiContent(entries, failing, { batchSize: 1 });
|
|
|
|
// 3 batch attempts, then the breaker opens: no more batch calls and no
|
|
// highlights call at all.
|
|
expect(calls).toEqual(['summaries', 'summaries', 'summaries']);
|
|
expect([...result.summaries.values()]).toEqual([
|
|
'one',
|
|
'two',
|
|
'three',
|
|
'four',
|
|
'five',
|
|
]);
|
|
expect(
|
|
result.warnings.some((warning) =>
|
|
warning.includes('stopped after 3 consecutive failures'),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
result.warnings.some((warning) =>
|
|
warning.includes('Highlights fallback: skipped'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('recovers without the breaker when a later batch succeeds', async () => {
|
|
const calls = [];
|
|
let summaryCalls = 0;
|
|
const flaky = async (request) => {
|
|
calls.push(request.kind);
|
|
if (request.kind === 'summaries') {
|
|
summaryCalls += 1;
|
|
if (summaryCalls !== 3) throw new Error('transient');
|
|
return JSON.stringify({
|
|
summaries: request.entries.map((entry) => ({
|
|
pr: entry.number,
|
|
summary: `${entry.title} summary`,
|
|
})),
|
|
});
|
|
}
|
|
return JSON.stringify({ highlights: [] });
|
|
};
|
|
const entries = [1, 2, 3, 4, 5].map((number) =>
|
|
entry(number, String(number)),
|
|
);
|
|
|
|
const result = await generateAiContent(entries, flaky, { batchSize: 1 });
|
|
|
|
expect(calls).toEqual([
|
|
'summaries',
|
|
'summaries',
|
|
'summaries',
|
|
'summaries',
|
|
'summaries',
|
|
'highlights',
|
|
]);
|
|
expect(
|
|
result.warnings.some((warning) => warning.includes('stopped after')),
|
|
).toBe(false);
|
|
expect(result.summaries.get(3)).toBe('3 summary');
|
|
});
|
|
});
|
|
|
|
describe('appendDegradedStepSummary', () => {
|
|
it('appends a degraded note when warnings exist', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'qwen-rn-summary-'));
|
|
const summaryPath = join(dir, 'summary.md');
|
|
// test-setup mocks appendFileSync, so assert on the call instead of the file.
|
|
vi.mocked(appendFileSync).mockClear();
|
|
appendDegradedStepSummary(
|
|
{ usedAi: false, warnings: ['Summary batch fallback: HTTP 500'] },
|
|
summaryPath,
|
|
);
|
|
|
|
expect(vi.mocked(appendFileSync)).toHaveBeenCalledTimes(1);
|
|
const [writtenPath, written] = vi.mocked(appendFileSync).mock.calls[0];
|
|
expect(writtenPath).toBe(summaryPath);
|
|
expect(written).toContain('AI generation degraded');
|
|
expect(written).toContain('Summary batch fallback: HTTP 500');
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('does nothing without warnings', async () => {
|
|
const fs = await import('node:fs');
|
|
const dir = mkdtempSync(join(tmpdir(), 'qwen-rn-summary-'));
|
|
const summaryPath = join(dir, 'summary.md');
|
|
vi.mocked(appendFileSync).mockClear();
|
|
appendDegradedStepSummary({ usedAi: true, warnings: [] }, summaryPath);
|
|
expect(vi.mocked(appendFileSync)).not.toHaveBeenCalled();
|
|
expect(fs.existsSync(summaryPath)).toBe(false);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('does not propagate summary write failures (tryAppend)', () => {
|
|
vi.mocked(appendFileSync).mockImplementationOnce(() => {
|
|
throw new Error('ENOSPC');
|
|
});
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
expect(() =>
|
|
tryAppendDegradedStepSummary(
|
|
{ usedAi: false, warnings: ['Summary batch fallback: HTTP 500'] },
|
|
join(tmpdir(), 'summary.md'),
|
|
),
|
|
).not.toThrow();
|
|
expect(errSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('failed to write the degraded step summary'),
|
|
);
|
|
errSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('escapeWorkflowCommand', () => {
|
|
it('percent-encodes newlines so model text cannot forge a runner command', () => {
|
|
const malicious = 'batch failed\n::error::forged annotation';
|
|
const escaped = escapeWorkflowCommand(malicious);
|
|
expect(escaped).not.toContain('\n');
|
|
expect(escaped).not.toContain('\r');
|
|
expect(escaped).toBe('batch failed%0A::error::forged annotation');
|
|
});
|
|
|
|
it('encodes percent signs so encoded sequences are not double-decoded', () => {
|
|
expect(escapeWorkflowCommand('100% done')).toBe('100%25 done');
|
|
});
|
|
|
|
it('encodes carriage returns', () => {
|
|
expect(escapeWorkflowCommand('a\rb')).toBe('a%0Db');
|
|
});
|
|
});
|
|
|
|
describe('appendDegradedStepSummary markdown hardening', () => {
|
|
it('renders warnings as escaped single-line code', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'qwen-rn-summary-'));
|
|
const summaryPath = join(dir, 'summary.md');
|
|
vi.mocked(appendFileSync).mockClear();
|
|
appendDegradedStepSummary(
|
|
{
|
|
usedAi: true,
|
|
warnings: ['line1\n ```tick``` <b>&'],
|
|
},
|
|
summaryPath,
|
|
);
|
|
expect(vi.mocked(appendFileSync)).toHaveBeenCalledTimes(1);
|
|
const [, written] = vi.mocked(appendFileSync).mock.calls[0];
|
|
expect(written).toContain(
|
|
'AI generation was partially degraded; see the warnings on this run.',
|
|
);
|
|
expect(written).toContain(
|
|
'- ```` line1  ```tick``` <b>& ````',
|
|
);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('escapes the failed-summary warning through tryAppend', () => {
|
|
vi.mocked(appendFileSync).mockImplementationOnce(() => {
|
|
throw new Error('ENOSPC');
|
|
});
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
tryAppendDegradedStepSummary(
|
|
{ usedAi: false, warnings: ['degraded'] },
|
|
join(tmpdir(), 'summary.md'),
|
|
);
|
|
const emitted = errSpy.mock.calls[0][0];
|
|
expect(emitted).toMatch(/^::warning::/);
|
|
expect(emitted).not.toContain('\n');
|
|
errSpy.mockRestore();
|
|
});
|
|
});
|