mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-21 15:40:31 +00:00
4e515cce99
Backport #38980 The workflow graph decided which job rows belonged to the same matrix by parsing display names: it stripped a trailing `" (...)"` off `name` and grouped rows sharing the prefix. That guesses at a string the user controls, and it fails both ways. `jobparser` only appends the ` (<combination>)` suffix when `name:` contains no `${{ }}`, so a leg named `E2E on ${{ matrix.browser }}` never grouped, while two unrelated jobs `build (fast)` and `build (slow)` folded into one bogus matrix panel. Matrix legs already have a real identity: expansion clones one row per combination, all sharing the workflow's `JobID` and differing only in `Name`. Group on that instead, so a matrix is whatever the backend says it is. Matrix expansion state is keyed on the graph node id for the same reason. Closes https://github.com/go-gitea/gitea/issues/38975, though that report's own example already groups on main, since `explicit (${{ matrix.leg }})` interpolates to a name that still ends in a suffix. The interpolated shapes above are the broken ones. ## Screenshots: Before: <img width="1268" height="618" alt="image" src="https://github.com/user-attachments/assets/2d98dd1f-5454-423c-b610-8db920f1e99c" /> after: <img width="1145" height="607" alt="image" src="https://github.com/user-attachments/assets/25da1804-8386-4f5b-a4e3-f63380010907" /> _Assisted-by: Claude Code:claude-opus-5_ Co-authored-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
211 lines
16 KiB
TypeScript
211 lines
16 KiB
TypeScript
import {computeGraphHighlightState, computeJobLevels, createWorkflowGraphModel} from './WorkflowGraph.utils.ts';
|
|
import type {ActionsJob} from '../modules/gitea-actions.ts';
|
|
|
|
const mockJobs: ActionsJob[] = [
|
|
{id: 1, link: '', jobId: 'job-100', name: 'job-100', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
|
{id: 2, link: '', jobId: 'job-101', name: 'job-101', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['job-100']},
|
|
{id: 3, link: '', jobId: 'job-102', name: 'job-102', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-101']},
|
|
{id: 4, link: '', jobId: 'job-103', name: 'job-103', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100']},
|
|
{id: 5, link: '', jobId: 'prep-jdk', name: 'prep-jdk', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
|
{id: 6, link: '', jobId: 'code-analysis', name: 'code-analysis', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
|
{id: 7, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (1, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
|
{id: 8, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (1, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
|
{id: 9, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (2, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
|
{id: 10, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (3, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
|
{id: 11, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (3, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
|
{id: 12, link: '', jobId: 'matrix-e2e', name: 'matrix-e2e (99, webkit)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
|
{id: 13, link: '', jobId: 'unit-test', name: 'unit-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
|
|
{id: 14, link: '', jobId: 'arch-test', name: 'arch-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
|
|
{id: 15, link: '', jobId: 'integration-test', name: 'integration-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['prep-jdk', 'code-analysis']},
|
|
{id: 16, link: '', jobId: 'build-image', name: 'build-image', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: [
|
|
'unit-test',
|
|
'arch-test',
|
|
'integration-test',
|
|
'matrix-e2e',
|
|
]},
|
|
];
|
|
|
|
const verifyDeployJobs: ActionsJob[] = [
|
|
{id: 101, link: '', jobId: 'seed-dev', name: 'seed-dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s'},
|
|
{id: 102, link: '', jobId: 'seed-qa', name: 'seed-qa', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
|
{id: 103, link: '', jobId: 'verify-dev', name: 'Verify Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['seed-dev']},
|
|
{id: 104, link: '', jobId: 'verify-qa', name: 'Verify QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['seed-qa']},
|
|
{id: 105, link: '', jobId: 'deploy', name: 'Deploy', status: 'blocked', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '', needs: ['verify-dev', 'verify-qa']},
|
|
];
|
|
|
|
// Multi-level pipeline with two matrices and a leaf with two parents.
|
|
const wfTest1Jobs: ActionsJob[] = [
|
|
{id: 1, link: '', jobId: 'init', name: 'Initialize Pipeline', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
|
|
{id: 2, link: '', jobId: 'lint-frontend', name: 'Lint Frontend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['init']},
|
|
{id: 3, link: '', jobId: 'lint-backend', name: 'Lint Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['init']},
|
|
{id: 4, link: '', jobId: 'build-frontend', name: 'Build Frontend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['lint-frontend']},
|
|
{id: 5, link: '', jobId: 'build-backend', name: 'Build Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '5s', needs: ['lint-backend']},
|
|
{id: 6, link: '', jobId: 'unit-tests', name: 'Unit Tests (api, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
|
{id: 7, link: '', jobId: 'unit-tests', name: 'Unit Tests (api, false)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
|
{id: 8, link: '', jobId: 'unit-tests', name: 'Unit Tests (service, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
|
{id: 9, link: '', jobId: 'test-integration', name: 'Integration Tests', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '6s', needs: ['build-backend']},
|
|
{id: 10, link: '', jobId: 'e2e-tests', name: 'E2E Tests (chrome, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
|
|
{id: 11, link: '', jobId: 'e2e-tests', name: 'E2E Tests (chrome, mobile)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
|
|
{id: 12, link: '', jobId: 'e2e-tests', name: 'E2E Tests (firefox, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'unit-tests']},
|
|
{id: 13, link: '', jobId: 'bundle-app', name: 'Bundle Application', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['unit-tests', 'test-integration', 'e2e-tests']},
|
|
{id: 14, link: '', jobId: 'deploy-dev', name: 'Deploy to Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
|
|
{id: 15, link: '', jobId: 'deploy-qa', name: 'Deploy to QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
|
|
{id: 16, link: '', jobId: 'verify-dev', name: 'Verify Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-dev']},
|
|
{id: 17, link: '', jobId: 'verify-qa', name: 'Verify QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-qa']},
|
|
{id: 18, link: '', jobId: 'deploy-prod', name: 'Deploy to Production', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '5s', needs: ['verify-dev', 'verify-qa']},
|
|
{id: 19, link: '', jobId: 'post-deploy-checks', name: 'Post-Deploy Checks', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-prod']},
|
|
];
|
|
|
|
const mockJob = (id: number, jobId: string, name: string, needs?: string[]): ActionsJob =>
|
|
({id, link: '', jobId, name, status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s', needs});
|
|
|
|
test('matrix nodes key on job id, not on the display name', () => {
|
|
const legs = createWorkflowGraphModel([mockJob(1, 'explicit', 'leg one'), mockJob(2, 'explicit', 'leg two')]);
|
|
expect(legs.nodes).toHaveLength(1);
|
|
expect(legs.nodes[0].type).toBe('matrix');
|
|
expect(legs.nodes[0].name).toBe('explicit');
|
|
expect(legs.nodes[0].jobs.map((j) => j.id)).toEqual([1, 2]);
|
|
|
|
const lookalikes = createWorkflowGraphModel([
|
|
mockJob(1, 'setup', 'setup'),
|
|
mockJob(2, 'build-fast', 'build (fast)', ['setup']),
|
|
mockJob(3, 'build-slow', 'build (slow)', ['setup']),
|
|
]);
|
|
expect(lookalikes.nodes.map((n) => n.type)).toEqual(['job', 'group']);
|
|
|
|
const noJobId = createWorkflowGraphModel([mockJob(1, '', 'first'), mockJob(2, '', 'second')]);
|
|
expect(noJobId.nodes.map((n) => n.id)).toEqual(['job:1', 'job:2']);
|
|
});
|
|
|
|
test('computeJobLevels keeps stable topological levels', () => {
|
|
const levels = computeJobLevels(mockJobs);
|
|
expect(levels.get('job-100')).toBe(0);
|
|
expect(levels.get('job-101')).toBe(1);
|
|
expect(levels.get('job-102')).toBe(2);
|
|
expect(levels.get('build-image')).toBe(2);
|
|
});
|
|
|
|
test('graph model collapses matrix and groups jobs that share parents and children', () => {
|
|
const graph = createWorkflowGraphModel(mockJobs);
|
|
|
|
expect(graph.nodes.find((n) => n.type === 'matrix')?.jobs).toHaveLength(6);
|
|
const groupJobIds = graph.nodes.filter((n) => n.type === 'group').map((g) => g.jobs.map((j) => j.jobId));
|
|
expect(groupJobIds).toEqual(expect.arrayContaining([
|
|
['prep-jdk', 'code-analysis'],
|
|
['unit-test', 'arch-test', 'integration-test'],
|
|
]));
|
|
});
|
|
|
|
test('expanded matrix height includes summary and toggle rows', () => {
|
|
const collapsed = createWorkflowGraphModel(mockJobs);
|
|
const expanded = createWorkflowGraphModel(mockJobs, new Set(['matrix:matrix-e2e']));
|
|
const collapsedMatrix = collapsed.nodes.find((n) => n.id === 'matrix:matrix-e2e');
|
|
const expandedMatrix = expanded.nodes.find((n) => n.id === 'matrix:matrix-e2e');
|
|
|
|
expect(collapsedMatrix?.displayHeight).toBeLessThan(expandedMatrix?.displayHeight ?? 0);
|
|
// 6 jobs * 26 row height + 24 header + 6 pad * 2 = 192
|
|
expect(expandedMatrix?.displayHeight).toBe(192);
|
|
});
|
|
|
|
test('every dependency is rendered as one routed edge', () => {
|
|
const graph = createWorkflowGraphModel(mockJobs);
|
|
const rootGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'prep-jdk'))!;
|
|
const testGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'unit-test'))!;
|
|
const expectedKeys = [
|
|
`${rootGroup.id}->matrix:matrix-e2e`,
|
|
`${rootGroup.id}->${testGroup.id}`,
|
|
];
|
|
const keys = new Set(graph.routedEdges.map((e) => e.key));
|
|
for (const k of expectedKeys) expect(keys.has(k)).toBe(true);
|
|
});
|
|
|
|
test('same-row edge collapses to a single horizontal line', () => {
|
|
const graph = createWorkflowGraphModel(verifyDeployJobs);
|
|
const verifyDevEdge = graph.routedEdges.find((e) => e.fromId === 'job:101' && e.toId === 'job:103');
|
|
const verifyQaEdge = graph.routedEdges.find((e) => e.fromId === 'job:102' && e.toId === 'job:104');
|
|
expect(verifyDevEdge?.path).toMatch(/^M [\d.]+ [\d.]+ H [\d.]+$/);
|
|
expect(verifyQaEdge?.path).toMatch(/^M [\d.]+ [\d.]+ H [\d.]+$/);
|
|
});
|
|
|
|
test('different-row edge uses cubic bezier curve', () => {
|
|
const graph = createWorkflowGraphModel(verifyDeployJobs);
|
|
const deployLowerEdge = graph.routedEdges.find((e) => e.fromId === 'job:104' && e.toId === 'job:105');
|
|
expect(deployLowerEdge?.path).toContain(' C ');
|
|
});
|
|
|
|
test('multi-level pipeline with two matrices and a converging leaf renders without errors', () => {
|
|
const graph = createWorkflowGraphModel(wfTest1Jobs);
|
|
const matrices = graph.nodes.filter((n) => n.type === 'matrix');
|
|
expect(matrices.map((n) => n.name).sort()).toEqual(['E2E Tests', 'Unit Tests']);
|
|
|
|
const deployProd = graph.nodes.find((n) => n.id === 'job:18');
|
|
const verifyDev = graph.nodes.find((n) => n.id === 'job:16');
|
|
const verifyQa = graph.nodes.find((n) => n.id === 'job:17');
|
|
expect(verifyDev?.level).toBe(verifyQa?.level);
|
|
expect(deployProd?.level).toBe((verifyDev?.level ?? 0) + 1);
|
|
|
|
for (const node of graph.nodes) {
|
|
expect(Number.isFinite(node.x)).toBe(true);
|
|
expect(Number.isFinite(node.y)).toBe(true);
|
|
expect(node.x).toBeGreaterThanOrEqual(0);
|
|
expect(node.y).toBeGreaterThanOrEqual(0);
|
|
}
|
|
for (const edge of graph.routedEdges) {
|
|
expect(edge.path).not.toMatch(/NaN|undefined|Infinity/);
|
|
}
|
|
});
|
|
|
|
test('reusable callers with identical dependency signature are kept as separate nodes', () => {
|
|
const jobs: ActionsJob[] = [
|
|
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
|
|
{id: 2, link: '', jobId: 'local_caller', name: 'local caller', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '5m', needs: ['prepare'], callUses: './.gitea/workflows/lib.yml'},
|
|
{id: 3, link: '', jobId: 'cross_caller', name: 'cross-repo caller', status: 'waiting', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '0s', needs: ['prepare'], callUses: 'user2/lib/.gitea/workflows/ext.yml@main'},
|
|
{id: 4, link: '', jobId: 'final', name: 'final', status: 'blocked', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '0s', needs: ['local_caller', 'cross_caller']},
|
|
];
|
|
const graph = createWorkflowGraphModel(jobs);
|
|
expect(graph.nodes.find((n) => n.type === 'group')).toBeUndefined();
|
|
expect(graph.nodes.find((n) => n.id === 'job:2')?.name).toBe('local caller');
|
|
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('cross-repo caller');
|
|
});
|
|
|
|
test('matrix legs that call a reusable workflow are folded into a single matrix node', () => {
|
|
const jobs: ActionsJob[] = [
|
|
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
|
|
{id: 2, link: '', jobId: 'build-call', name: 'build-call (linux)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '1m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
|
{id: 3, link: '', jobId: 'build-call', name: 'build-call (windows)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2m', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
|
{id: 4, link: '', jobId: 'build-call', name: 'build-call (macos)', status: 'success', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '90s', needs: ['prepare'], callUses: './.gitea/workflows/build.yml'},
|
|
];
|
|
const graph = createWorkflowGraphModel(jobs);
|
|
const matrixNodes = graph.nodes.filter((n) => n.type === 'matrix');
|
|
expect(matrixNodes).toHaveLength(1);
|
|
expect(matrixNodes[0].name).toBe('build-call');
|
|
expect(matrixNodes[0].jobs.map((j) => j.id).sort()).toEqual([2, 3, 4]);
|
|
});
|
|
|
|
test('directed highlight state covers ancestors and descendants of the hovered node', () => {
|
|
const graph = createWorkflowGraphModel(mockJobs);
|
|
const rootGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'prep-jdk'))!;
|
|
|
|
const highlight = computeGraphHighlightState(rootGroup.id, graph.adjacency);
|
|
expect(highlight.nodeIds.has('matrix:matrix-e2e')).toBe(true);
|
|
expect(highlight.nodeIds.has('job:16')).toBe(true);
|
|
expect(highlight.edgeKeys.has(`${rootGroup.id}->matrix:matrix-e2e`)).toBe(true);
|
|
});
|
|
|
|
test('directed highlight state for converging graph excludes sibling branch when hovering parent', () => {
|
|
const graph = createWorkflowGraphModel(verifyDeployJobs);
|
|
|
|
const parentHighlight = computeGraphHighlightState('job:103', graph.adjacency);
|
|
expect(parentHighlight.nodeIds.has('job:101')).toBe(true);
|
|
expect(parentHighlight.nodeIds.has('job:105')).toBe(true);
|
|
expect(parentHighlight.nodeIds.has('job:104')).toBe(false);
|
|
expect(parentHighlight.edgeKeys.has('job:103->job:105')).toBe(true);
|
|
expect(parentHighlight.edgeKeys.has('job:104->job:105')).toBe(false);
|
|
|
|
const sinkHighlight = computeGraphHighlightState('job:105', graph.adjacency);
|
|
expect(sinkHighlight.nodeIds.has('job:103')).toBe(true);
|
|
expect(sinkHighlight.nodeIds.has('job:104')).toBe(true);
|
|
expect(sinkHighlight.edgeKeys.has('job:103->job:105')).toBe(true);
|
|
expect(sinkHighlight.edgeKeys.has('job:104->job:105')).toBe(true);
|
|
});
|