Skip to content

Commit 9718d87

Browse files
feat(vercel): add project filter variables and parser helper
* feat(vercel): add project filter variables and parser helper * feat(vercel): honour project filter in App Availability check * fix(vercel): skip filter pass when project list is empty * feat(vercel): honour project filter in Monitoring & Alerting check * fix(vercel): use scoped project count in monitoring evidence * fix(vercel): paginate project list + guard against empty filter scope Addresses cubic PR feedback: - /v9/projects is paginated; fetchOptions now loops using pagination.next - checks now fail with actionable remediation when the configured filter resolves to zero projects (stale ids, exclude-all) --------- Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent 3fc78a8 commit 9718d87

6 files changed

Lines changed: 564 additions & 6 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { appAvailabilityCheck } from '../checks/app-availability';
3+
import type { CheckContext, CheckVariableValues } from '../../../types';
4+
import type {
5+
VercelDeployment,
6+
VercelDeploymentsResponse,
7+
VercelProject,
8+
VercelProjectsResponse,
9+
} from '../types';
10+
11+
const makeProject = (id: string, name: string): VercelProject => ({
12+
id,
13+
name,
14+
accountId: 'acc_1',
15+
createdAt: 0,
16+
updatedAt: 0,
17+
});
18+
19+
const makeReadyDeployment = (): VercelDeployment => ({
20+
uid: 'dpl_1',
21+
name: 'd',
22+
url: 'd.vercel.app',
23+
state: 'READY',
24+
type: 'LAMBDAS',
25+
created: Date.now(),
26+
createdAt: Date.now(),
27+
creator: { uid: 'u' },
28+
});
29+
30+
interface RunResult {
31+
passedResourceIds: string[];
32+
failedResourceIds: string[];
33+
checkedProjectIds: string[];
34+
}
35+
36+
async function runWithVariables(
37+
projects: VercelProject[],
38+
variables: CheckVariableValues | undefined,
39+
): Promise<RunResult> {
40+
const checkedProjectIds: string[] = [];
41+
const passed: string[] = [];
42+
const failed: string[] = [];
43+
44+
const ctx: CheckContext = {
45+
accessToken: 'tok',
46+
credentials: {},
47+
variables,
48+
connectionId: 'conn_1',
49+
organizationId: 'org_1',
50+
metadata: { oauth: { team: { id: 'team_1', name: 'Team' } } },
51+
log: () => {},
52+
pass: (result) => {
53+
passed.push(result.resourceId);
54+
},
55+
fail: (result) => {
56+
failed.push(result.resourceId);
57+
},
58+
fetch: (async <T,>(path: string): Promise<T> => {
59+
if (path === '/v9/projects?teamId=team_1' || path === '/v9/projects') {
60+
return { projects } satisfies VercelProjectsResponse as unknown as T;
61+
}
62+
if (path.startsWith('/v6/deployments')) {
63+
const url = new URL(path, 'https://api.vercel.com');
64+
const projectId = url.searchParams.get('projectId') ?? '';
65+
checkedProjectIds.push(projectId);
66+
return {
67+
deployments: [makeReadyDeployment()],
68+
} satisfies VercelDeploymentsResponse as unknown as T;
69+
}
70+
throw new Error(`Unexpected fetch: ${path}`);
71+
}) as CheckContext['fetch'],
72+
fetchAllPages: (async () => []) as CheckContext['fetchAllPages'],
73+
graphql: (async () => ({})) as CheckContext['graphql'],
74+
} as CheckContext;
75+
76+
await appAvailabilityCheck.run(ctx);
77+
return { passedResourceIds: passed, failedResourceIds: failed, checkedProjectIds };
78+
}
79+
80+
describe('appAvailabilityCheck filter behaviour', () => {
81+
const projects = [
82+
makeProject('prj_a', 'a'),
83+
makeProject('prj_b', 'b'),
84+
makeProject('prj_c', 'c'),
85+
];
86+
87+
it('checks all projects when no filter is configured', async () => {
88+
const result = await runWithVariables(projects, undefined);
89+
expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_b', 'prj_c']);
90+
});
91+
92+
it('checks all projects when mode is "all" with a selection', async () => {
93+
const result = await runWithVariables(projects, {
94+
project_filter_mode: 'all',
95+
filtered_projects: ['prj_a'],
96+
});
97+
expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_b', 'prj_c']);
98+
});
99+
100+
it('checks only selected projects in include mode', async () => {
101+
const result = await runWithVariables(projects, {
102+
project_filter_mode: 'include',
103+
filtered_projects: ['prj_a', 'prj_c'],
104+
});
105+
expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_c']);
106+
});
107+
108+
it('skips selected projects in exclude mode', async () => {
109+
const result = await runWithVariables(projects, {
110+
project_filter_mode: 'exclude',
111+
filtered_projects: ['prj_b'],
112+
});
113+
expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_c']);
114+
});
115+
116+
it('falls back to all projects when include mode has no selection', async () => {
117+
const result = await runWithVariables(projects, {
118+
project_filter_mode: 'include',
119+
filtered_projects: [],
120+
});
121+
expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_b', 'prj_c']);
122+
});
123+
124+
it('emits a filter-applied evidence pass recording the active mode', async () => {
125+
const result = await runWithVariables(projects, {
126+
project_filter_mode: 'exclude',
127+
filtered_projects: ['prj_b'],
128+
});
129+
expect(result.passedResourceIds).toContain('project-filter');
130+
});
131+
132+
it('does not emit a filter-applied pass when no projects are returned', async () => {
133+
const result = await runWithVariables([], {
134+
project_filter_mode: 'exclude',
135+
filtered_projects: ['prj_anything'],
136+
});
137+
expect(result.passedResourceIds).not.toContain('project-filter');
138+
expect(result.failedResourceIds).toContain('projects');
139+
});
140+
141+
it('fails when filter resolves to zero scoped projects', async () => {
142+
const result = await runWithVariables(projects, {
143+
project_filter_mode: 'include',
144+
filtered_projects: ['prj_does_not_exist'],
145+
});
146+
expect(result.failedResourceIds).toContain('project-filter');
147+
expect(result.checkedProjectIds).toEqual([]);
148+
expect(result.passedResourceIds).not.toContain('project-filter');
149+
});
150+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { monitoringAlertingCheck } from '../checks/monitoring-alerting';
3+
import type { CheckContext, CheckVariableValues } from '../../../types';
4+
import type {
5+
VercelDeployment,
6+
VercelDeploymentsResponse,
7+
VercelProject,
8+
VercelProjectsResponse,
9+
} from '../types';
10+
11+
const makeProject = (id: string, name: string): VercelProject => ({
12+
id,
13+
name,
14+
accountId: 'acc_1',
15+
createdAt: 0,
16+
updatedAt: 0,
17+
});
18+
19+
const makeDeployment = (state: VercelDeployment['state']): VercelDeployment => ({
20+
uid: 'dpl_' + state,
21+
name: state,
22+
url: `${state}.vercel.app`,
23+
state,
24+
type: 'LAMBDAS',
25+
created: Date.now(),
26+
createdAt: Date.now(),
27+
creator: { uid: 'u' },
28+
});
29+
30+
async function runWithVariables(
31+
projects: VercelProject[],
32+
variables: CheckVariableValues | undefined,
33+
): Promise<{
34+
checkedProjectIds: string[];
35+
passedResourceIds: string[];
36+
failedResourceIds: string[];
37+
}> {
38+
const checkedProjectIds: string[] = [];
39+
const passed: string[] = [];
40+
const failed: string[] = [];
41+
42+
const ctx: CheckContext = {
43+
accessToken: 'tok',
44+
credentials: {},
45+
variables,
46+
connectionId: 'conn_1',
47+
organizationId: 'org_1',
48+
metadata: { oauth: { team: { id: 'team_1', name: 'Team' } } },
49+
log: () => {},
50+
pass: (result) => {
51+
passed.push(result.resourceId);
52+
},
53+
fail: (result) => {
54+
failed.push(result.resourceId);
55+
},
56+
fetch: (async <T,>(path: string): Promise<T> => {
57+
if (path.startsWith('/v9/projects')) {
58+
return { projects } satisfies VercelProjectsResponse as unknown as T;
59+
}
60+
if (path.startsWith('/v6/deployments')) {
61+
const url = new URL(path, 'https://api.vercel.com');
62+
const projectId = url.searchParams.get('projectId') ?? '';
63+
checkedProjectIds.push(projectId);
64+
return {
65+
deployments: [makeDeployment('READY')],
66+
} satisfies VercelDeploymentsResponse as unknown as T;
67+
}
68+
throw new Error(`Unexpected fetch: ${path}`);
69+
}) as CheckContext['fetch'],
70+
fetchAllPages: (async () => []) as CheckContext['fetchAllPages'],
71+
graphql: (async () => ({})) as CheckContext['graphql'],
72+
} as CheckContext;
73+
74+
await monitoringAlertingCheck.run(ctx);
75+
return { checkedProjectIds, passedResourceIds: passed, failedResourceIds: failed };
76+
}
77+
78+
describe('monitoringAlertingCheck filter behaviour', () => {
79+
const projects = [
80+
makeProject('prj_a', 'a'),
81+
makeProject('prj_b', 'b'),
82+
makeProject('prj_c', 'c'),
83+
];
84+
85+
it('defaults to all projects', async () => {
86+
const result = await runWithVariables(projects, undefined);
87+
expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_b', 'prj_c']);
88+
});
89+
90+
it('honours include mode', async () => {
91+
const result = await runWithVariables(projects, {
92+
project_filter_mode: 'include',
93+
filtered_projects: ['prj_b'],
94+
});
95+
expect(result.checkedProjectIds).toEqual(['prj_b']);
96+
});
97+
98+
it('honours exclude mode', async () => {
99+
const result = await runWithVariables(projects, {
100+
project_filter_mode: 'exclude',
101+
filtered_projects: ['prj_a'],
102+
});
103+
expect(result.checkedProjectIds.sort()).toEqual(['prj_b', 'prj_c']);
104+
});
105+
106+
it('emits a filter-applied evidence pass', async () => {
107+
const result = await runWithVariables(projects, {
108+
project_filter_mode: 'include',
109+
filtered_projects: ['prj_b'],
110+
});
111+
expect(result.passedResourceIds).toContain('project-filter');
112+
});
113+
114+
it('fails when filter resolves to zero scoped projects', async () => {
115+
const result = await runWithVariables(projects, {
116+
project_filter_mode: 'exclude',
117+
filtered_projects: ['prj_a', 'prj_b', 'prj_c'],
118+
});
119+
expect(result.failedResourceIds).toContain('project-filter');
120+
expect(result.checkedProjectIds).toEqual([]);
121+
});
122+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { filteredProjectsVariable, parseVercelProjectFilter } from '../variables';
3+
import type { VariableFetchContext } from '../../../types';
4+
5+
describe('parseVercelProjectFilter', () => {
6+
it('returns mode="all" and empty set when no variables are stored', () => {
7+
const result = parseVercelProjectFilter(undefined);
8+
expect(result.mode).toBe('all');
9+
expect(result.selectedIds.size).toBe(0);
10+
});
11+
12+
it('returns mode="all" when project_filter_mode is missing', () => {
13+
const result = parseVercelProjectFilter({ filtered_projects: ['prj_1'] });
14+
expect(result.mode).toBe('all');
15+
expect(result.selectedIds.has('prj_1')).toBe(true);
16+
});
17+
18+
it('returns mode="include" with selected ids', () => {
19+
const result = parseVercelProjectFilter({
20+
project_filter_mode: 'include',
21+
filtered_projects: ['prj_1', 'prj_2'],
22+
});
23+
expect(result.mode).toBe('include');
24+
expect(result.selectedIds.has('prj_1')).toBe(true);
25+
expect(result.selectedIds.has('prj_2')).toBe(true);
26+
expect(result.selectedIds.size).toBe(2);
27+
});
28+
29+
it('returns mode="exclude" with selected ids', () => {
30+
const result = parseVercelProjectFilter({
31+
project_filter_mode: 'exclude',
32+
filtered_projects: ['prj_x'],
33+
});
34+
expect(result.mode).toBe('exclude');
35+
expect(result.selectedIds.has('prj_x')).toBe(true);
36+
});
37+
38+
it('falls back to mode="all" on unknown mode strings', () => {
39+
const result = parseVercelProjectFilter({
40+
project_filter_mode: 'whatever',
41+
filtered_projects: ['prj_1'],
42+
});
43+
expect(result.mode).toBe('all');
44+
});
45+
46+
it('treats non-array filtered_projects as empty selection', () => {
47+
const result = parseVercelProjectFilter({
48+
project_filter_mode: 'include',
49+
filtered_projects: 'prj_1' as unknown as string[],
50+
});
51+
expect(result.mode).toBe('include');
52+
expect(result.selectedIds.size).toBe(0);
53+
});
54+
});
55+
56+
describe('filteredProjectsVariable.fetchOptions', () => {
57+
it('paginates through all pages using pagination.next cursor', async () => {
58+
const requestedUrls: string[] = [];
59+
const ctx: VariableFetchContext = {
60+
accessToken: 'tok',
61+
graphql: (async () => ({})) as VariableFetchContext['graphql'],
62+
fetchAllPages: (async () => []) as VariableFetchContext['fetchAllPages'],
63+
fetch: (async <T,>(path: string): Promise<T> => {
64+
requestedUrls.push(path);
65+
if (path.includes('until=100')) {
66+
return {
67+
projects: [
68+
{ id: 'prj_2', name: 'bbb', accountId: 'a', createdAt: 0, updatedAt: 0 },
69+
],
70+
pagination: { count: 1, next: null, prev: null },
71+
} as unknown as T;
72+
}
73+
return {
74+
projects: [
75+
{ id: 'prj_1', name: 'aaa', accountId: 'a', createdAt: 0, updatedAt: 0 },
76+
],
77+
pagination: { count: 1, next: 100, prev: null },
78+
} as unknown as T;
79+
}) as VariableFetchContext['fetch'],
80+
};
81+
const options = await filteredProjectsVariable.fetchOptions!(ctx);
82+
expect(options.map((o) => o.value).sort()).toEqual(['prj_1', 'prj_2']);
83+
expect(requestedUrls.length).toBe(2);
84+
expect(requestedUrls[0]).toContain('limit=100');
85+
expect(requestedUrls[1]).toContain('until=100');
86+
});
87+
88+
it('stops when pagination is missing or next is null', async () => {
89+
const ctx: VariableFetchContext = {
90+
accessToken: 'tok',
91+
graphql: (async () => ({})) as VariableFetchContext['graphql'],
92+
fetchAllPages: (async () => []) as VariableFetchContext['fetchAllPages'],
93+
fetch: (async <T,>(_path: string): Promise<T> =>
94+
({
95+
projects: [
96+
{ id: 'prj_1', name: 'a', accountId: 'x', createdAt: 0, updatedAt: 0 },
97+
],
98+
}) as unknown as T) as VariableFetchContext['fetch'],
99+
};
100+
const options = await filteredProjectsVariable.fetchOptions!(ctx);
101+
expect(options).toEqual([{ value: 'prj_1', label: 'a' }]);
102+
});
103+
});

0 commit comments

Comments
 (0)