Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: add proper jest testing to Atlas #59

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion .github/actions/setup-project/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: Prepare the project for any CI action
inputs:
bun-version:
description: Version of Bun to install
default: 1.x
default: latest

node-version:
description: Version of Node to install
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
run: bun run typecheck

- name: 🧪 Test core
run: bun test
run: bun run test

- name: 👷 Build core
run: bun run build
Expand Down
Binary file modified bun.lockb
Binary file not shown.
18 changes: 18 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const jestPreset = require('expo-module-scripts/jest-preset-cli');

// Modify the `babel-jest` entry to include babel plugins
for (const [, value] of Object.entries(jestPreset.transform)) {
if (Array.isArray(value) && value[0] === 'babel-jest') {
value[1].plugins = value[1].plugins || [];
value[1].plugins.push('@babel/plugin-proposal-explicit-resource-management');
}
}

/** @type {import('jest').Config} */
module.exports = {
...jestPreset,
clearMocks: true,
rootDir: __dirname,
roots: ['src'],
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
};
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"build": "expo-module build",
"clean": "expo-module clean",
"lint": "eslint . --ext js,ts,tsx",
"typecheck": "expo-module typecheck"
"typecheck": "expo-module typecheck",
"test": "jest"
},
"license": "MIT",
"dependencies": {
Expand All @@ -52,6 +53,7 @@
"stream-json": "^1.8.0"
},
"devDependencies": {
"@babel/plugin-proposal-explicit-resource-management": "^7.24.7",
"@types/bun": "^1.0.8",
"@types/chai": "^4",
"@types/express": "^4.17.21",
Expand All @@ -62,6 +64,8 @@
"eslint-config-universe": "^12.0.0",
"expo": "~50.0.1",
"expo-module-scripts": "^3.1.0",
"jest": "^29.7.0",
"memfs": "^4.9.3",
"metro": "^0.80.6",
"prettier": "^3.2.5",
"typescript": "^5.1.3"
Expand Down Expand Up @@ -119,6 +123,7 @@
"files": [
"metro.config.js",
"babel.config.js",
"jest.config.js",
"webui/src/app/**/*+api.ts"
],
"extends": [
Expand Down
2 changes: 2 additions & 0 deletions src/__mocks__/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { fs } from 'memfs';
module.exports = fs;
2 changes: 2 additions & 0 deletions src/__mocks__/fs/promises.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { fs } from 'memfs';
module.exports = fs.promises;
25 changes: 25 additions & 0 deletions src/__tests__/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Change the environment variable for the duration of a test.
* This uses explicit resource management to revert the environment variable after the test.
*/
export function envVar(key: string, value?: string): { key: string; value?: string } & Disposable {
const original = process.env[key];

if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}

return {
key,
value,
[Symbol.dispose]() {
if (original === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
},
};
}
19 changes: 19 additions & 0 deletions src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Polyfill `Symbol.dispose` for explicit resource management
if (typeof Symbol.dispose === 'undefined') {
Object.defineProperty(Symbol, 'dispose', {
configurable: false,
enumerable: false,
writable: false,
value: Symbol.for('polyfill:dispose'),
});
}

// Polyfill `Symbol.dispose` for explicit resource management
if (typeof Symbol.asyncDispose === 'undefined') {
Object.defineProperty(Symbol, 'asyncDispose', {
configurable: false,
enumerable: false,
writable: false,
value: Symbol.for('polyfill:asyncDispose'),
});
}
15 changes: 11 additions & 4 deletions src/data/AtlasFileSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,17 @@ export async function validateAtlasFile(filePath: string, metadata = getAtlasMet
return;
}

const data = await parseJsonLine(filePath, 1);
try {
const data = await parseJsonLine(filePath, 1);
if (data.name !== metadata.name || data.version !== metadata.version) {
throw new AtlasValidationError('ATLAS_FILE_INCOMPATIBLE', filePath, data.version);
}
} catch (error: any) {
if (error.name === 'SyntaxError') {
throw new AtlasValidationError('ATLAS_FILE_INVALID', filePath);
}

if (data.name !== metadata.name || data.version !== metadata.version) {
throw new AtlasValidationError('ATLAS_FILE_INCOMPATIBLE', filePath, data.version);
throw error;
}
}

Expand All @@ -157,7 +164,7 @@ export async function ensureAtlasFileExist(filePath: string) {
try {
await validateAtlasFile(filePath);
} catch (error: any) {
if (error.code === 'ATLAS_FILE_NOT_FOUND' || error.code === 'ATLAS_FILE_INCOMPATIBLE') {
if (error instanceof AtlasValidationError) {
await createAtlasFile(filePath);
return false;
}
Expand Down
132 changes: 132 additions & 0 deletions src/data/__tests__/AtlasFileSource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import memfs from 'memfs';

import { name, version } from '../../../package.json';
import { envVar } from '../../__tests__/env';
import { AtlasValidationError } from '../../utils/errors';
import {
createAtlasFile,
ensureAtlasFileExist,
getAtlasMetdata,
getAtlasPath,
validateAtlasFile,
} from '../AtlasFileSource';

jest.mock('fs');
jest.mock('fs/promises');

describe(getAtlasPath, () => {
it('returns default path `<project>/.expo/atlas.jsonl`', () => {
expect(getAtlasPath('<project>')).toBe('<project>/.expo/atlas.jsonl');
});
});

describe(getAtlasMetdata, () => {
it('returns package name and version', () => {
expect(getAtlasMetdata()).toMatchObject({ name, version });
});
});

describe(createAtlasFile, () => {
afterEach(() => {
memfs.vol.reset();
});

it('creates a file with the correct metadata', async () => {
await createAtlasFile('/test/create/metadata.jsonl');
expect(memfs.vol.toJSON()).toMatchObject({
'/test/create/metadata.jsonl': JSON.stringify({ name, version }) + '\n',
});
});

it('overwrites existing file', async () => {
memfs.vol.fromJSON({ '/test/create/invalid.jsonl': 'invalid\n' });
await createAtlasFile('/test/create/invalid.jsonl');
expect(memfs.vol.toJSON()).toMatchObject({
'/test/create/invalid.jsonl': JSON.stringify({ name, version }) + '\n',
});
});
});

describe(validateAtlasFile, () => {
// TODO(cedric): figure out why memfs throws "EBADF: bad file descriptor"
// afterEach(() => {
// memfs.vol.reset();
// });

it('passes for valid file', async () => {
await createAtlasFile('/test/validate/atlas.jsonl');
await expect(validateAtlasFile('/test/validate/atlas.jsonl')).resolves.toBeUndefined();
});

it('fails for non-existing file', async () => {
await expect(validateAtlasFile('/this/file/does-not-exists')).rejects.toThrow(
AtlasValidationError
);
});

it('fails for invalid file', async () => {
memfs.vol.fromJSON({ '/test/validate/invalid-file.jsonl': 'invalid\n' });
await expect(validateAtlasFile('/test/validate/invalid-file.jsonl')).rejects.toThrow(
AtlasValidationError
);
});

it('fails for invalid version', async () => {
memfs.vol.fromJSON({
'/test/validate/invalid-version.jsonl': JSON.stringify({ name, version: '0.0.0' }) + '\n',
});
await expect(validateAtlasFile('/test/validate/invalid-version.jsonl')).rejects.toThrow(
AtlasValidationError
);
});

it('skips validation when EXPO_ATLAS_NO_VALIDATION is true', async () => {
memfs.vol.fromJSON({
'/test/validate/disabled.jsonl': JSON.stringify({ name, version: '0.0.0' }) + '\n',
});

using _ = envVar('EXPO_ATLAS_NO_VALIDATION', 'true');
await expect(validateAtlasFile('/test/validate/disabled.jsonl')).resolves.toBeUndefined();
});
});

describe(ensureAtlasFileExist, () => {
// TODO(cedric): figure out why memfs throws "EBADF: bad file descriptor"
// afterEach(() => {
// memfs.vol.reset();
// });

it('returns true for valid file', async () => {
memfs.vol.fromJSON({ '/test/ensure/valid.jsonl': JSON.stringify({ name, version }) + '\n' });
await expect(ensureAtlasFileExist('/test/ensure/valid.jsonl')).resolves.toBe(true);
});

it('returns true when EXPO_ATLAS_NO_VALIDATION is true', async () => {
memfs.vol.fromJSON({
'/test/ensure/skip-validation.jsonl': JSON.stringify({ name, version: '0.0.0' }) + '\n',
});

using _ = envVar('EXPO_ATLAS_NO_VALIDATION', 'true');
await expect(ensureAtlasFileExist('/test/ensure/skip-validation.jsonl')).resolves.toBe(true);
});

it('returns false for non-existing file', async () => {
memfs.vol.fromJSON({});
await expect(ensureAtlasFileExist('/test/ensure/non-existing.jsonl')).resolves.toBe(false);
await expect(ensureAtlasFileExist('/test/ensure/non-existing.jsonl')).resolves.toBe(true);
});

it('returns false for invalid file', async () => {
memfs.vol.fromJSON({ '/test/ensure/invalid-file.jsonl': 'invalid\n' });
await expect(ensureAtlasFileExist('/test/ensure/invalid-file.jsonl')).resolves.toBe(false);
await expect(ensureAtlasFileExist('/test/ensure/invalid-file.jsonl')).resolves.toBe(true);
});

it('returns false for invalid version', async () => {
memfs.vol.fromJSON({
'/test/ensure/invalid-version.jsonl': JSON.stringify({ name, version: '0.0.0' }) + '\n',
});
await expect(ensureAtlasFileExist('/test/ensure/invalid-version.jsonl')).resolves.toBe(false);
await expect(ensureAtlasFileExist('/test/ensure/invalid-version.jsonl')).resolves.toBe(true);
});
});
Loading