Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/actions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"@actions/artifact": "^2.3.2",
"@actions/core": "^1.11.1",
"@actions/exec": "^1.1.1",
"lodash": "^4.17.21"
"lodash": "^4.17.21",
"snyk-nodejs-lockfile-parser": "^2.4.2"
},
"scripts": {
"build": "node ./build.js",
Expand Down
6 changes: 3 additions & 3 deletions .github/actions/src/__tests__/commons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ vi.mock(import('lodash/memoize.js'), () => ({

const mockedExecOutput = vi.spyOn(exec, 'getExecOutput');

describe(commons.checkForChanges, () => {
describe(commons.checkDirForChanges, () => {
function mockChanges(value: boolean) {
mockedExecOutput.mockResolvedValueOnce({
exitCode: value ? 1 : 0, stdout: '', stderr: ''
Expand All @@ -17,14 +17,14 @@ describe(commons.checkForChanges, () => {

it('should return true if git diff exits with non zero code', async () => {
mockChanges(true);
await expect(commons.checkForChanges('/')).resolves.toEqual(true);
await expect(commons.checkDirForChanges('/')).resolves.toEqual(true);
expect(mockedExecOutput).toHaveBeenCalledOnce();
});

it('should return false if git diff exits with 0', async () => {
mockChanges(false);

await expect(commons.checkForChanges('/')).resolves.toEqual(false);
await expect(commons.checkDirForChanges('/')).resolves.toEqual(false);
expect(mockedExecOutput).toHaveBeenCalledOnce();
});
});
Expand Down
9 changes: 1 addition & 8 deletions .github/actions/src/commons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,12 @@ export function isPackageRecord(obj: unknown): obj is PackageRecord {
return true;
}

// Not using the repotools version since this uses @action/exec instead of
// calling execFile from child_process
export async function getGitRoot() {
const { stdout } = await getExecOutput('git rev-parse --show-toplevel');
return stdout.trim();
}

/**
* Returns `true` if there are changes present in the given directory relative to
* the master branch\
* Used to determine, particularly for libraries, if running tests and tsc are necessary
*/
export const checkForChanges = memoize(async (directory: string) => {
export const checkDirForChanges = memoize(async (directory: string) => {
const { exitCode } = await getExecOutput(
'git',
['--no-pager', 'diff', '--quiet', 'origin/master', '--', directory],
Expand Down
13 changes: 13 additions & 0 deletions .github/actions/src/gitRoot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getExecOutput } from '@actions/exec';

// Not using the repotools version since this uses @action/exec instead of
// calling execFile from child_process
async function getGitRoot() {
const { stdout } = await getExecOutput('git rev-parse --show-toplevel');
return stdout.trim();
}

/**
* Path to the root of the git repository
*/
export const gitRoot = await getGitRoot();
48 changes: 43 additions & 5 deletions .github/actions/src/info/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { Dirent } from 'fs';
import fs from 'fs/promises';
import pathlib from 'path';
import * as core from '@actions/core';
import { describe, expect, test, vi } from 'vitest';
import * as git from '../../commons.js';
import { getAllPackages, getRawPackages } from '../index.js';
import * as lockfiles from '../../lockfiles/index.js';
import { getAllPackages, getRawPackages, main } from '../index.js';

const mockedCheckChanges = vi.spyOn(git, 'checkForChanges');
const mockedCheckChanges = vi.spyOn(git, 'checkDirForChanges');

vi.mock(import('path'), async importOriginal => {
const { posix } = await importOriginal();
Expand All @@ -16,6 +18,10 @@ vi.mock(import('path'), async importOriginal => {
};
});

vi.mock(import('../../gitRoot.js'), () => ({
gitRoot: 'root'
}));

class NodeError extends Error {
constructor(public readonly code: string) {
super();
Expand All @@ -39,7 +45,7 @@ const mockDirectory: Record<string, string | Record<string, unknown>> = {
'package.json': JSON.stringify({
name: '@sourceacademy/bundle-bundle0',
devDependencies: {
'@sourceacademy/modules-lib': 'workspace:^'
'@sourceacademy/modules-lib': 'workspace:^',
}
})
},
Expand All @@ -49,7 +55,8 @@ const mockDirectory: Record<string, string | Record<string, unknown>> = {
'package.json': JSON.stringify({
name: '@sourceacademy/tab-Tab0',
dependencies: {
'@sourceacademy/bundle-bundle0': 'workspace:^'
lodash: '^4.1.1',
'@sourceacademy/bundle-bundle0': 'workspace:^',
},
devDependencies: {
playwright: '^1.54.0'
Expand Down Expand Up @@ -114,6 +121,7 @@ function mockReadFile(path: string) {

vi.spyOn(fs, 'readdir').mockImplementation(mockReaddir as any);
vi.spyOn(fs, 'readFile').mockImplementation(mockReadFile as any);
vi.spyOn(lockfiles, 'hasLockFileChanged').mockResolvedValue(false);

describe(getRawPackages, () => {
test('maxDepth = 1', async () => {
Expand All @@ -125,7 +133,7 @@ describe(getRawPackages, () => {
const [[name, packageData]] = results;
expect(name).toEqual('@sourceacademy/modules');
expect(packageData.hasChanges).toEqual(true);
expect(git.checkForChanges).toHaveBeenCalledOnce();
expect(git.checkDirForChanges).toHaveBeenCalledOnce();
});

test('maxDepth = 3', async () => {
Expand Down Expand Up @@ -211,3 +219,33 @@ describe(getAllPackages, () => {
expect(results['@sourceacademy/modules-lib'].changes).toEqual(true);
});
});

describe(main, () => {
const mockedSetOutput = vi.spyOn(core, 'setOutput');

vi.spyOn(core.summary, 'addHeading').mockImplementation(() => core.summary);
vi.spyOn(core.summary, 'addTable').mockImplementation(() => core.summary);
vi.spyOn(core.summary, 'write').mockImplementation(() => Promise.resolve(core.summary));

test('Does not write packages with no changes to the output', async () => {
mockedCheckChanges.mockImplementation(path => {
return Promise.resolve(path === 'root/src/tabs/tab0');
});

await main();
const { mock: { calls } } = mockedSetOutput;

expect(mockedSetOutput).toHaveBeenCalledTimes(6);

expect(calls[0]).toEqual(['bundles', []]);
expect(calls[1]).toEqual(['tabs', [expect.objectContaining({ changes: true })]]);
expect(calls[2]).toEqual(['libs', []]);

// These next two are undefined because the mock implementations
// don't return any info about them
expect(calls[3]).toEqual(['devserver', undefined]);
expect(calls[4]).toEqual(['docserver', undefined]);

expect(calls[5]).toEqual(['workflows', false]);
});
});
6 changes: 3 additions & 3 deletions .github/actions/src/info/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ description: Gets which packages are currently present in the repository

outputs:
libs:
description: The list of library packages present
description: The list of library packages that have changes
bundles:
description: The list of bundles packages present
description: The list of bundles packages that have changes
tabs:
description: The list of tabs packages present
description: The list of tabs packages that have changes
devserver:
description: Information for the devserver
docserver:
Expand Down
38 changes: 26 additions & 12 deletions .github/actions/src/info/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import utils from 'util';
import * as core from '@actions/core';
import type { SummaryTableRow } from '@actions/core/lib/summary.js';
import packageJson from '../../../../package.json' with { type: 'json' };
import { checkForChanges, getGitRoot, type PackageRecord, type RawPackageRecord } from '../commons.js';
import { checkDirForChanges, type PackageRecord, type RawPackageRecord } from '../commons.js';
import { gitRoot } from '../gitRoot.js';
import { getPackagesWithResolutionChanges, hasLockFileChanged } from '../lockfiles/index.js';
import { topoSortPackages } from './sorter.js';

const packageNameRE = /^@sourceacademy\/(.+?)-(.+)$/u;
Expand All @@ -14,23 +16,34 @@ const packageNameRE = /^@sourceacademy\/(.+?)-(.+)$/u;
* an unprocessed format
*/
export async function getRawPackages(gitRoot: string, maxDepth?: number) {
let packagesWithResolutionChanges: Set<string> | null = null;

// If there are lock file changes we need to set hasChanges to true for
// that package even if that package's directory has no changes
if (await hasLockFileChanged()) {
packagesWithResolutionChanges = await getPackagesWithResolutionChanges();
}

const output: Record<string, RawPackageRecord> = {};

/**
* Search the given directory for package.json files
*/
async function recurser(currentDir: string, currentDepth: number) {
const items = await fs.readdir(currentDir, { withFileTypes: true });
await Promise.all(items.map(async item => {
if (item.isFile()) {
if (item.name === 'package.json') {
try {
const [hasChanges, packageJson] = await Promise.all([
checkForChanges(currentDir),
checkDirForChanges(currentDir),
fs.readFile(pathlib.join(currentDir, 'package.json'), 'utf-8')
.then(JSON.parse)
]);

output[packageJson.name] = {
directory: currentDir,
hasChanges,
hasChanges: packagesWithResolutionChanges?.has(packageJson.name) ?? hasChanges,
package: packageJson
};
} catch (error) {
Expand Down Expand Up @@ -93,16 +106,18 @@ export function processRawPackages(topoOrder: string[], packages: Record<string,
if (!packageInfo.hasChanges) {
if (packageInfo.package.dependencies) {
for (const name of Object.keys(packageInfo.package.dependencies)) {
if (packages[name].hasChanges) {
if (packages[name]?.hasChanges) {
packageInfo.hasChanges = true;
break;
}
}
}

// If hasChanges still hasn't been set yet, we can proceed to iterate
// through devDependencies as well
if (!packageInfo.hasChanges && packageInfo.package.devDependencies) {
for (const name of Object.keys(packageInfo.package.devDependencies)) {
if (packages[name].hasChanges) {
if (packages[name]?.hasChanges) {
packageInfo.hasChanges = true;
break;
}
Expand Down Expand Up @@ -203,15 +218,14 @@ function setOutputs(
devserver: PackageRecord,
docserver: PackageRecord
) {
core.setOutput('bundles', bundles);
core.setOutput('tabs', tabs);
core.setOutput('libs', libs);
core.setOutput('bundles', bundles.filter(x => x.changes));
core.setOutput('tabs', tabs.filter(x => x.changes));
core.setOutput('libs', libs.filter(x => x.changes));
core.setOutput('devserver', devserver);
core.setOutput('docserver', docserver);
}

async function main() {
const gitRoot = await getGitRoot();
export async function main() {
const { packages, bundles, tabs, libs } = await getAllPackages(gitRoot);

const { repository } = packageJson;
Expand Down Expand Up @@ -260,7 +274,7 @@ async function main() {
packages['@sourceacademy/modules-docserver']
);

const workflows = await checkForChanges(pathlib.join(gitRoot, '.github/workflows'));
const workflows = await checkDirForChanges(pathlib.join(gitRoot, '.github/workflows'));
core.setOutput('workflows', workflows);
}

Expand All @@ -269,6 +283,6 @@ if (process.env.GITHUB_ACTIONS) {
try {
await main();
} catch (error: any) {
core.setFailed(error.message);
core.setFailed(error);
}
}
25 changes: 16 additions & 9 deletions .github/actions/src/load-artifacts/__tests__/artifact.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ArtifactNotFoundError } from '@actions/artifact';
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import * as manifest from '@sourceacademy/modules-repotools/manifest';
Expand All @@ -10,10 +11,10 @@ vi.mock(import('@actions/core'), async importOriginal => {
...original,
// Mock these functions to remove stdout output
error: vi.fn(),
info: () => {},
startGroup: () => {},
info: () => { },
startGroup: () => { },
setFailed: vi.fn(),
endGroup: () => {}
endGroup: () => { }
};
});
const mockedResolveAllTabs = vi.spyOn(manifest, 'resolveAllTabs');
Expand Down Expand Up @@ -41,6 +42,7 @@ test('tab resolution errors cause setFailed to be called', async () => {

await main();

expect(mockedResolveAllTabs).toHaveBeenCalledOnce();
expect(core.error).toHaveBeenCalledExactlyOnceWith('error1');
expect(core.setFailed).toHaveBeenCalledExactlyOnceWith('Tab resolution failed with errors');
});
Expand Down Expand Up @@ -68,22 +70,26 @@ test('tabs that can\'t be found are built', async () => {
if (name === 'Tab0-tab') {
return { artifact: { id: 0 } };
}
throw new Error();
throw new ArtifactNotFoundError();
});

await main();

expect(mockedGetArtifact).toHaveBeenCalledTimes(2);
expect(mockedResolveAllTabs).toHaveBeenCalledOnce();

const [[artifactCall0], [artifactCall1]] = mockedGetArtifact.mock.calls;
expect(artifactCall0).toEqual('Tab0-tab');
expect(artifactCall1).toEqual('Tab1-tab');

expect(exec.exec).toHaveBeenCalledTimes(2);
const [[,execCall0], [,execCall1]] = vi.mocked(exec.exec).mock.calls;
console.log('args are', execCall0);
const [[execCmd0, execCall0], [execCmd1, execCall1]] = vi.mocked(exec.exec).mock.calls;

expect(execCmd0).toEqual('yarn workspaces focus');
expect(execCall0).toContain('@sourceacademy/tab-Tab1');
expect(execCall0).not.toContain('@sourceacademy/tab-Tab0');

expect(execCmd1).toEqual('yarn workspaces foreach -pA');
expect(execCall1).toContain('@sourceacademy/tab-Tab1');
expect(execCall1).not.toContain('@sourceacademy/tab-Tab0');
});
Expand All @@ -101,11 +107,12 @@ test('install failure means build doesn\'t happen', async () => {
}
});

mockedGetArtifact.mockRejectedValueOnce(new Error());

mockedGetArtifact.mockRejectedValueOnce(new ArtifactNotFoundError());
mockedExec.mockResolvedValueOnce(1);

await main();

expect(mockedGetArtifact).toHaveBeenCalledOnce();
expect(exec.exec).toHaveBeenCalledOnce();
expect(core.setFailed).toHaveBeenCalledExactlyOnceWith('yarn workspace focus failed for Tab0');
expect(core.setFailed).toHaveBeenCalledExactlyOnceWith('yarn workspace focus failed');
});
Loading