Skip to content

Commit 21018aa

Browse files
committed
Finalize a topo sort algorithm to determine changes
1 parent 20bf4dd commit 21018aa

File tree

11 files changed

+574
-192
lines changed

11 files changed

+574
-192
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as exec from '@actions/exec';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import * as git from '../git.js';
4+
5+
vi.mock(import('lodash/memoize.js'), () => ({
6+
default: (x: any) => x
7+
}) as any);
8+
9+
const mockedExecOutput = vi.spyOn(exec, 'getExecOutput');
10+
11+
describe('Test checkForChanges', () => {
12+
function mockChanges(value: boolean) {
13+
mockedExecOutput.mockResolvedValueOnce({
14+
exitCode: value ? 1 : 0, stdout: '', stderr: ''
15+
});
16+
}
17+
18+
it('should return true if git diff exits with non zero code', async () => {
19+
mockChanges(true);
20+
await expect(git.checkForChanges('/')).resolves.toEqual(true);
21+
expect(mockedExecOutput).toHaveBeenCalledOnce();
22+
});
23+
24+
it('should return false if git diff exits with 0', async () => {
25+
mockChanges(false);
26+
27+
await expect(git.checkForChanges('/')).resolves.toEqual(false);
28+
expect(mockedExecOutput).toHaveBeenCalledOnce();
29+
});
30+
});
Lines changed: 193 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,204 @@
1-
import * as exec from '@actions/exec';
2-
import { describe, expect, it, vi } from 'vitest';
3-
import { checkForChanges } from '../index.js';
1+
import type { Dirent } from 'fs';
2+
import fs from 'fs/promises';
3+
import pathlib from 'path';
4+
import { describe, expect, test, vi } from 'vitest';
5+
import * as git from '../git.js';
6+
import { getAllPackages, getRawPackages } from '../index.js';
47

5-
vi.mock(import('lodash/memoize.js'), () => ({
6-
default: (x: any) => x
7-
}) as any);
8+
const mockedCheckChanges = vi.spyOn(git, 'checkForChanges');
89

9-
const mockedExecOutput = vi.spyOn(exec, 'getExecOutput');
10+
class NodeError extends Error {
11+
constructor(public readonly code: string) {
12+
super();
13+
}
14+
}
1015

11-
describe('Test checkForChanges', () => {
12-
it('should return true if program exits with non zero code', async () => {
13-
mockedExecOutput.mockResolvedValueOnce({
14-
exitCode: 1, stdout: '', stderr: ''
16+
const mockDirectory: Record<string, string | Record<string, unknown>> = {
17+
'package.json': JSON.stringify({
18+
name: '@sourceacademy/modules'
19+
}),
20+
lib: {
21+
'modules-lib': {
22+
'package.json': JSON.stringify({
23+
name: '@sourceacademy/modules-lib'
24+
}),
25+
}
26+
},
27+
src: {
28+
bundles: {
29+
bundle0: {
30+
'package.json': JSON.stringify({
31+
name: '@sourceacademy/bundle-bundle0',
32+
devDependencies: {
33+
'@sourceacademy/modules-lib': 'workspace:^'
34+
}
35+
})
36+
},
37+
},
38+
tabs: {
39+
tab0: {
40+
'package.json': JSON.stringify({
41+
name: '@sourceacademy/tab-Tab0',
42+
dependencies: {
43+
'@sourceacademy/bundle-bundle0': 'workspace:^'
44+
},
45+
devDependencies: {
46+
playwright: '^1.54.0'
47+
}
48+
})
49+
}
50+
}
51+
}
52+
};
53+
54+
function mockReaddir(path: string) {
55+
function recurser(segments: string[], obj: string | Record<string, unknown>): Promise<Dirent[]> {
56+
if (segments.length === 0) {
57+
if (typeof obj === 'string') throw new NodeError('ENOTDIR');
58+
59+
const dirents = Object.entries(obj)
60+
.map(([name, each]): Dirent => {
61+
if (typeof each === 'string') {
62+
return {
63+
isFile: () => true,
64+
isDirectory: () => false,
65+
name,
66+
} as Dirent;
67+
}
68+
69+
return {
70+
isFile: () => false,
71+
isDirectory: () => true,
72+
name
73+
} as Dirent;
74+
});
75+
76+
return Promise.resolve(dirents);
77+
}
78+
79+
if (typeof obj === 'string') throw new NodeError('ENOENT');
80+
81+
const [seg0, ...remainingSegments] = segments;
82+
return recurser(remainingSegments, obj[seg0] as string | Record<string, unknown>);
83+
}
84+
85+
const segments = path.split(pathlib.sep);
86+
return recurser(segments, { root: mockDirectory });
87+
}
88+
89+
function mockReadFile(path: string) {
90+
function recurser(segments: string[], obj: string | Record<string, unknown>): Promise<string> {
91+
if (segments.length === 0) {
92+
if (typeof obj !== 'string') throw new NodeError('EISDIR');
93+
return Promise.resolve(obj);
94+
}
95+
96+
if (typeof obj === 'string') throw new NodeError('ENOENT');
97+
98+
const [seg0, ...remainingSegments] = segments;
99+
return recurser(remainingSegments, obj[seg0] as string | Record<string, unknown>);
100+
}
101+
102+
const segments = path.split(pathlib.sep);
103+
return recurser(segments, { root: mockDirectory });
104+
}
105+
106+
vi.spyOn(fs, 'readdir').mockImplementation(mockReaddir as any);
107+
vi.spyOn(fs, 'readFile').mockImplementation(mockReadFile as any);
108+
109+
describe('Test getRawPackages', () => {
110+
test('maxDepth = 1', async () => {
111+
mockedCheckChanges.mockResolvedValueOnce(true);
112+
const results = Object.entries(await getRawPackages('root', 1));
113+
expect(fs.readdir).toHaveBeenCalledTimes(3);
114+
expect(results.length).toEqual(1);
115+
116+
const [[name, packageData]] = results;
117+
expect(name).toEqual('@sourceacademy/modules');
118+
expect(packageData.hasChanges).toEqual(true);
119+
expect(git.checkForChanges).toHaveBeenCalledOnce();
120+
});
121+
122+
test('maxDepth = 3', async () => {
123+
mockedCheckChanges.mockResolvedValue(true);
124+
const results = await getRawPackages('root', 3);
125+
expect(Object.values(results).length).toEqual(4);
126+
expect(fs.readdir).toHaveBeenCalledTimes(8);
127+
128+
expect(results).toHaveProperty('@sourceacademy/bundle-bundle0');
129+
const bundleResult = results['@sourceacademy/bundle-bundle0'];
130+
expect(bundleResult.hasChanges).toEqual(true);
131+
132+
expect(results).toHaveProperty('@sourceacademy/tab-Tab0');
133+
const tabResult = results['@sourceacademy/tab-Tab0'];
134+
expect(tabResult.hasChanges).toEqual(true);
135+
136+
expect(results).toHaveProperty('@sourceacademy/modules-lib');
137+
const libResult = results['@sourceacademy/modules-lib'];
138+
expect(libResult.hasChanges).toEqual(true);
139+
});
140+
141+
test('hasChanges fields accurately reflects value returned from checkChanges', async () => {
142+
mockedCheckChanges.mockImplementation(p => {
143+
switch (p) {
144+
case 'root/src/bundles/bundle0':
145+
return Promise.resolve(false);
146+
case 'root/src/tabs/tab0':
147+
return Promise.resolve(false);
148+
case 'root':
149+
return Promise.resolve(true);
150+
}
151+
152+
return Promise.resolve(false);
15153
});
16154

17-
await expect(checkForChanges('/')).resolves.toEqual(true);
18-
expect(mockedExecOutput).toHaveBeenCalledOnce();
155+
const results = await getRawPackages('root');
156+
expect(Object.keys(results).length).toEqual(4);
157+
158+
expect(results).toHaveProperty('@sourceacademy/modules');
159+
expect(results['@sourceacademy/modules'].hasChanges).toEqual(true);
160+
161+
expect(results).toHaveProperty('@sourceacademy/bundle-bundle0');
162+
expect(results['@sourceacademy/bundle-bundle0'].hasChanges).toEqual(false);
163+
164+
expect(results).toHaveProperty('@sourceacademy/tab-Tab0');
165+
expect(results['@sourceacademy/tab-Tab0'].hasChanges).toEqual(false);
166+
167+
expect(results).toHaveProperty('@sourceacademy/modules-lib');
168+
expect(results['@sourceacademy/modules-lib'].hasChanges).toEqual(false);
19169
});
170+
});
171+
172+
describe('Test getAllPackages', () => {
173+
test('Transitive change dependencies', async () => {
174+
mockedCheckChanges.mockImplementation(p => {
175+
switch (p) {
176+
case 'root/lib/modules-lib':
177+
return Promise.resolve(true);
178+
case 'root/src/bundles/bundle0':
179+
return Promise.resolve(false);
180+
case 'root/src/tabs/tab0':
181+
return Promise.resolve(false);
182+
case 'root':
183+
return Promise.resolve(false);
184+
}
20185

21-
it('should return false if program exits with 0', async () => {
22-
mockedExecOutput.mockResolvedValueOnce({
23-
exitCode: 0, stdout: '', stderr: ''
186+
return Promise.resolve(false);
24187
});
25188

26-
await expect(checkForChanges('/')).resolves.toEqual(false);
27-
expect(mockedExecOutput).toHaveBeenCalledOnce();
189+
const { packages: results } = await getAllPackages('root');
190+
expect(Object.keys(results).length).toEqual(4);
191+
192+
expect(results).toHaveProperty('@sourceacademy/modules');
193+
expect(results['@sourceacademy/modules'].changes).toEqual(false);
194+
195+
expect(results).toHaveProperty('@sourceacademy/bundle-bundle0');
196+
expect(results['@sourceacademy/bundle-bundle0'].changes).toEqual(true);
197+
198+
expect(results).toHaveProperty('@sourceacademy/tab-Tab0');
199+
expect(results['@sourceacademy/tab-Tab0'].changes).toEqual(true);
200+
201+
expect(results).toHaveProperty('@sourceacademy/modules-lib');
202+
expect(results['@sourceacademy/modules-lib'].changes).toEqual(true);
28203
});
29204
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { topoSortPackages } from '../sorter.js';
3+
4+
describe('Test topoSorter', () => {
5+
test('Without a cycle', () => {
6+
const result = topoSortPackages({
7+
'@sourceacademy/0': {
8+
hasChanges: false,
9+
directory: '/',
10+
package: {
11+
name: '@sourceacademy/0',
12+
devDependencies: {
13+
'@sourceacademy/1': 'workspace:^'
14+
},
15+
dependencies: {}
16+
}
17+
},
18+
'@sourceacademy/1': {
19+
hasChanges: false,
20+
directory: '/',
21+
package: {
22+
name: '@sourceacademy/1',
23+
devDependencies: {
24+
'@sourceacademy/2': 'workspace:^'
25+
},
26+
dependencies: {}
27+
}
28+
},
29+
'@sourceacademy/2': {
30+
hasChanges: false,
31+
directory: '/',
32+
package: {
33+
name: '@sourceacademy/2',
34+
devDependencies: {},
35+
dependencies: {}
36+
}
37+
}
38+
});
39+
40+
expect(result).toEqual([
41+
'@sourceacademy/2',
42+
'@sourceacademy/1',
43+
'@sourceacademy/0',
44+
]);
45+
});
46+
47+
test('With a cycle', () => {
48+
const func = () => topoSortPackages({
49+
'@sourceacademy/0': {
50+
hasChanges: false,
51+
directory: '/',
52+
package: {
53+
name: '@sourceacademy/0',
54+
devDependencies: {
55+
'@sourceacademy/1': 'workspace:^'
56+
},
57+
dependencies: {}
58+
}
59+
},
60+
'@sourceacademy/1': {
61+
hasChanges: false,
62+
directory: '/',
63+
package: {
64+
name: '@sourceacademy/1',
65+
devDependencies: {
66+
'@sourceacademy/2': 'workspace:^'
67+
},
68+
dependencies: {}
69+
}
70+
},
71+
'@sourceacademy/2': {
72+
hasChanges: false,
73+
directory: '/',
74+
package: {
75+
name: '@sourceacademy/2',
76+
devDependencies: {},
77+
dependencies: {
78+
'@sourceacademy/0': 'workspace:^'
79+
}
80+
}
81+
}
82+
});
83+
84+
expect(func).toThrowError('Dependency graph has a cycle!');
85+
});
86+
});

.github/actions/src/info/git.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { getExecOutput } from '@actions/exec';
2+
import memoize from 'lodash/memoize.js';
3+
4+
// Not using the repotools version since this uses @action/exec instead of
5+
// calling execFile from child_process
6+
7+
export async function getGitRoot() {
8+
const { stdout } = await getExecOutput('git rev-parse --show-toplevel');
9+
return stdout.trim();
10+
}
11+
12+
/**
13+
* Returns `true` if there are changes present in the given directory relative to
14+
* the master branch\
15+
* Used to determine, particularly for libraries if running tests and tsc are necessary
16+
*/
17+
export const checkForChanges = memoize(async (directory: string) => {
18+
const { exitCode } = await getExecOutput(
19+
'git',
20+
['--no-pager', 'diff', '--quiet', 'origin/master', '--', directory],
21+
{
22+
failOnStdErr: false,
23+
ignoreReturnCode: true
24+
}
25+
);
26+
return exitCode !== 0;
27+
});

.github/actions/src/info/gitRoot.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

0 commit comments

Comments
 (0)