Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 194 additions & 1 deletion dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54698,6 +54698,132 @@ class ActionsCacheStorage {
exports.ActionsCacheStorage = ActionsCacheStorage;


/***/ }),

/***/ 88232:
/***/ ((__unused_webpack_module, exports) => {

"use strict";

Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.computeDelta = computeDelta;
function buildTestKey(suite, name) {
return `${suite}::${name}`;
}
function computePassRate(entry) {
const { total, passed } = entry.summary;
return total > 0 ? (passed / total) * 100 : 0;
}
function zeroCounts() {
return {
passed: 0,
failed: 0,
skipped: 0,
errored: 0,
};
}
function countStatuses(tests) {
const counts = zeroCounts();
for (const t of tests) {
counts[t.status] += 1;
}
return counts;
}
function sumCounts(counts) {
return counts.passed + counts.failed + counts.skipped + counts.errored;
}
function pushRepeated(target, entry, count) {
for (let i = 0; i < count; i++) {
target.push(entry);
}
}
function computeDelta(previous, current) {
const prevMap = new Map();
for (const t of previous.tests) {
const key = buildTestKey(t.suite, t.name);
const existing = prevMap.get(key) ?? [];
existing.push(t);
prevMap.set(key, existing);
}
const currMap = new Map();
for (const t of current.tests) {
const key = buildTestKey(t.suite, t.name);
const existing = currMap.get(key) ?? [];
existing.push(t);
currMap.set(key, existing);
}
const testsAdded = [];
const testsRemoved = [];
const newlyFailing = [];
const newlyPassing = [];
// Only compute test-level diffs when both entries have test data
if (previous.tests.length > 0 && current.tests.length > 0) {
const allKeys = new Set([...prevMap.keys(), ...currMap.keys()]);
for (const key of allKeys) {
const prevTests = prevMap.get(key) ?? [];
const currTests = currMap.get(key) ?? [];
const [suite, name] = key.split('::');
const entry = { name, suite };
const prevCounts = countStatuses(prevTests);
const currCounts = countStatuses(currTests);
// Remove unchanged tests first, then analyze status changes among remaining.
const remainingPrev = zeroCounts();
const remainingCurr = zeroCounts();
for (const status of ['passed', 'failed', 'skipped', 'errored']) {
const unchanged = Math.min(prevCounts[status], currCounts[status]);
remainingPrev[status] = prevCounts[status] - unchanged;
remainingCurr[status] = currCounts[status] - unchanged;
}
const remainingPrevFailing = remainingPrev.failed + remainingPrev.errored;
const remainingCurrFailing = remainingCurr.failed + remainingCurr.errored;
const becameFailing = Math.min(remainingPrev.passed, remainingCurrFailing);
const becamePassing = Math.min(remainingPrevFailing, remainingCurr.passed);
pushRepeated(newlyFailing, entry, becameFailing);
pushRepeated(newlyPassing, entry, becamePassing);
const remainingPrevTotal = sumCounts(remainingPrev);
const remainingCurrTotal = sumCounts(remainingCurr);
const countAdded = Math.max(0, remainingCurrTotal - remainingPrevTotal);
const countRemoved = Math.max(0, remainingPrevTotal - remainingCurrTotal);
pushRepeated(testsAdded, entry, countAdded);
pushRepeated(testsRemoved, entry, countRemoved);
}
}
else if (previous.tests.length === 0 && current.tests.length > 0) {
// Previous was trimmed by size guard — skip test-level comparison
}
const passRatePrev = computePassRate(previous);
const passRateCurr = computePassRate(current);
const passRateDelta = passRateCurr - passRatePrev;
const durationPrev = previous.summary.duration;
const durationCurr = current.summary.duration;
const durationDelta = durationCurr - durationPrev;
const durationDeltaPercent = durationPrev > 0 ? (durationDelta / durationPrev) * 100 : 0;
const EPSILON = 1e-9;
const metricsChanged = Math.abs(passRateDelta) > EPSILON ||
Math.abs(durationDelta) > EPSILON ||
Math.abs(durationDeltaPercent) > EPSILON;
const hasChanges = testsAdded.length > 0 ||
testsRemoved.length > 0 ||
newlyFailing.length > 0 ||
newlyPassing.length > 0 ||
metricsChanged;
return {
testsAdded,
testsRemoved,
newlyFailing,
newlyPassing,
passRatePrev,
passRateCurr,
passRateDelta,
durationPrev,
durationCurr,
durationDelta,
durationDeltaPercent,
hasChanges,
};
}


/***/ }),

/***/ 75878:
Expand Down Expand Up @@ -54901,6 +55027,7 @@ const post_pr_comment_1 = __nccwpck_require__(4685);
const check_run_1 = __nccwpck_require__(8290);
const actions_cache_storage_1 = __nccwpck_require__(58701);
const manager_1 = __nccwpck_require__(75878);
const comparison_1 = __nccwpck_require__(88232);
const DEFAULT_SLOWEST_TESTS = 10;
function parseSlowestTestsCount(input) {
const trimmed = input.trim();
Expand Down Expand Up @@ -54987,6 +55114,7 @@ async function run() {
const parsed = (0, merge_results_1.mergeTestRuns)(successful);
core.info(`Parsed ${parsed.summary.total} tests from ${successful.length} file(s): ${parsed.summary.passed} passed, ${parsed.summary.failed} failed, ${parsed.summary.skipped} skipped, ${parsed.summary.errored} errored`);
let loadedHistory = null;
let delta = null;
if (historyEnabled) {
try {
const branch = (process.env.GITHUB_HEAD_REF ||
Expand All @@ -55011,6 +55139,15 @@ async function run() {
});
await manager.saveHistory();
loadedHistory = manager.getHistory();
if (loadedHistory && loadedHistory.entries.length >= 2) {
try {
const entries = loadedHistory.entries;
delta = (0, comparison_1.computeDelta)(entries[entries.length - 2], entries[entries.length - 1]);
}
catch (err) {
core.debug(`Delta comparison failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
catch (err) {
core.warning(`History tracking failed: ${err instanceof Error ? err.message : String(err)}`);
Expand Down Expand Up @@ -55049,6 +55186,7 @@ async function run() {
dashboardUrl,
highlights: result?.highlights ?? [],
slowestTests: slowestTestsCount,
delta,
});
if (createCheck) {
if (githubToken) {
Expand Down Expand Up @@ -55478,12 +55616,13 @@ exports.truncate = truncate;
exports.collectFailedTests = collectFailedTests;
exports.renderSuiteBreakdown = renderSuiteBreakdown;
exports.renderHighlights = renderHighlights;
exports.renderDeltaSection = renderDeltaSection;
const core = __importStar(__nccwpck_require__(16966));
const MAX_FAILED_TESTS_SHOWN = 25;
const MAX_ERROR_MESSAGE_LENGTH = 200;
const MAX_STACK_TRACE_LINES = 30;
async function generateSummary(options) {
const { parsed, apiSuccess, healthScore, dashboardUrl, flakyCount, highlights, slowestTests } = options;
const { parsed, apiSuccess, healthScore, dashboardUrl, flakyCount, highlights, slowestTests, delta, } = options;
const { summary } = parsed;
const passRate = summary.total > 0 ? ((summary.passed / summary.total) * 100).toFixed(1) : '0.0';
core.summary.addHeading('TestGlance Results', 2);
Expand Down Expand Up @@ -55512,6 +55651,9 @@ async function generateSummary(options) {
if (highlights && highlights.length > 0) {
core.summary.addRaw(renderHighlights(highlights, dashboardUrl));
}
if (delta) {
core.summary.addRaw(renderDeltaSection(delta));
}
try {
if (parsed.suites.length > 1) {
renderSuiteBreakdown(parsed.suites);
Expand Down Expand Up @@ -55653,6 +55795,57 @@ function renderHighlights(highlights, dashboardUrl) {
}
return lines.join('');
}
const MAX_DELTA_TESTS_SHOWN = 10;
const DELTA_STATUS_EMOJI = {
added: '🆕 Added',
removed: '🗑️ Removed',
newlyFailing: '❌ New Failure',
newlyPassing: '✅ Now Passing',
};
function renderDeltaSection(delta) {
const lines = ['### Changes Since Last Run\n\n'];
const sign = (n) => (n >= 0 ? '+' : '-');
if (!delta.hasChanges) {
lines.push('✅ No changes since last run\n\n');
lines.push(`**Pass rate:** ${delta.passRateCurr.toFixed(1)}% | **Duration:** ${formatDuration(delta.durationCurr)}\n\n`);
return lines.join('');
}
lines.push(`**Pass rate:** ${delta.passRatePrev.toFixed(1)}% → ${delta.passRateCurr.toFixed(1)}% (${sign(delta.passRateDelta)}${Math.abs(delta.passRateDelta).toFixed(1)}%)\n\n`);
const durSign = sign(delta.durationDelta);
lines.push(`**Duration:** ${formatDuration(delta.durationPrev)} → ${formatDuration(delta.durationCurr)} (${durSign}${formatDuration(Math.abs(delta.durationDelta))}, ${durSign}${Math.abs(delta.durationDeltaPercent).toFixed(1)}%)\n\n`);
const categories = [
{ key: 'added', tests: delta.testsAdded },
{ key: 'newlyFailing', tests: delta.newlyFailing },
{ key: 'newlyPassing', tests: delta.newlyPassing },
{ key: 'removed', tests: delta.testsRemoved },
];
const nonEmpty = categories.filter((c) => Array.isArray(c.tests) && c.tests.length > 0);
if (nonEmpty.length > 0) {
const allRows = [];
for (const cat of nonEmpty) {
const tests = cat.tests;
const shown = tests.slice(0, MAX_DELTA_TESTS_SHOWN);
for (const t of shown) {
allRows.push(`<tr><td>${DELTA_STATUS_EMOJI[cat.key]}</td><td>${escapeHtml(t.name)}</td><td>${escapeHtml(t.suite)}</td></tr>`);
}
if (tests.length > MAX_DELTA_TESTS_SHOWN) {
allRows.push(`<tr><td>${DELTA_STATUS_EMOJI[cat.key]}</td><td colspan="2"><em>and ${tests.length - MAX_DELTA_TESTS_SHOWN} more...</em></td></tr>`);
}
}
const totalTests = nonEmpty.reduce((sum, c) => sum + c.tests.length, 0);
const needsCollapse = totalTests > MAX_DELTA_TESTS_SHOWN;
const table = '<table>\n<tr><th>Status</th><th>Test</th><th>Suite</th></tr>\n' +
allRows.join('\n') +
'\n</table>\n\n';
if (needsCollapse) {
lines.push(`<details><summary><strong>Changed tests</strong> (${totalTests} tests)</summary>\n\n${table}</details>\n\n`);
}
else {
lines.push(table);
}
}
return lines.join('');
}
function renderHighlightMessage(h) {
const data = h.data;
switch (h.type) {
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ describe('run() integration', () => {
dashboardUrl: 'https://www.testglance.dev/runs/run-1',
highlights: [],
slowestTests: 10,
delta: null,
});
});

Expand All @@ -470,6 +471,7 @@ describe('run() integration', () => {
dashboardUrl: undefined,
highlights: [],
slowestTests: 10,
delta: null,
});
});

Expand Down Expand Up @@ -998,4 +1000,90 @@ describe('run() integration', () => {
expect(result.history).toBeNull();
});
});

describe('delta comparison (Story 7.2)', () => {
const PREVIOUS_ENTRY = {
timestamp: '2026-03-30T12:00:00.000Z',
commitSha: 'prev123',
summary: { total: 2, passed: 1, failed: 1, skipped: 0, errored: 0, duration: 1.0 },
tests: [
{ name: 'test1', suite: 'suite1', status: 'passed' as const, duration: 0.5 },
{ name: 'test2', suite: 'suite1', status: 'failed' as const, duration: 0.5 },
],
};

const EXISTING_HISTORY = JSON.stringify({
version: 1,
branch: 'main',
entries: [PREVIOUS_ENTRY],
});

async function setupHistoryWithPreviousRun() {
setupInputs({ history: 'true' });
process.env.GITHUB_REF_NAME = 'main';
process.env.GITHUB_SHA = 'abc1234';

const cache = await import('@actions/cache');
(cache.restoreCache as ReturnType<typeof vi.fn>).mockResolvedValueOnce('some-cache-key');

const fs = await import('node:fs');
(fs.existsSync as ReturnType<typeof vi.fn>).mockImplementation(
(p: string) => typeof p === 'string' && p.includes('history.json'),
);
mockReadFileSync.mockImplementation((p: string) => {
if (typeof p === 'string' && p.includes('history.json')) return EXISTING_HISTORY;
return '<xml>content</xml>';
});
}

it('delta is computed and passed to summary when history has 2+ entries', async () => {
await setupHistoryWithPreviousRun();

const result = await run();

expect(result.history).not.toBeNull();
expect(result.history!.entries).toHaveLength(2);

const summaryCall = mockGenerateSummary.mock.calls[0][0];
expect(summaryCall.delta).not.toBeNull();
expect(summaryCall.delta.passRateCurr).toBe(50);
expect(summaryCall.delta.passRatePrev).toBe(50);
});

it('delta is null when history has only 1 entry (first run)', async () => {
setupInputs({ history: 'true' });
process.env.GITHUB_REF_NAME = 'main';
process.env.GITHUB_SHA = 'abc1234';

await run();

const summaryCall = mockGenerateSummary.mock.calls[0][0];
expect(summaryCall.delta).toBeNull();
});

it('delta is null when history is disabled', async () => {
setupInputs({ history: 'false' });

await run();

const summaryCall = mockGenerateSummary.mock.calls[0][0];
expect(summaryCall.delta).toBeNull();
});

it('delta computation error does not fail the action (debug log only)', async () => {
await setupHistoryWithPreviousRun();

const comparison = await import('../history/comparison');
vi.spyOn(comparison, 'computeDelta').mockImplementationOnce(() => {
throw new Error('comparison boom');
});

await run();

expect(mockSetFailed).not.toHaveBeenCalled();
const summaryCall = mockGenerateSummary.mock.calls[0][0];
expect(summaryCall.delta).toBeNull();
expect(mockDebug).toHaveBeenCalledWith(expect.stringContaining('Delta comparison failed'));
});
});
});
Loading
Loading