diff --git a/.github/actions/setup-project/action.yml b/.github/actions/setup-project/action.yml index ef3ec83..dcd7280 100644 --- a/.github/actions/setup-project/action.yml +++ b/.github/actions/setup-project/action.yml @@ -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 diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 46296a9..45c5ba6 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -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 diff --git a/bun.lockb b/bun.lockb index db752ec..03914a7 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..84fea6c --- /dev/null +++ b/jest.config.js @@ -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: ['/src/__tests__/setup.ts'], +}; diff --git a/package.json b/package.json index 6466a5d..273dcb4 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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", @@ -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" @@ -119,6 +123,7 @@ "files": [ "metro.config.js", "babel.config.js", + "jest.config.js", "webui/src/app/**/*+api.ts" ], "extends": [ diff --git a/src/__mocks__/fs.ts b/src/__mocks__/fs.ts new file mode 100644 index 0000000..c81a2b9 --- /dev/null +++ b/src/__mocks__/fs.ts @@ -0,0 +1,2 @@ +import { fs } from 'memfs'; +module.exports = fs; diff --git a/src/__mocks__/fs/promises.ts b/src/__mocks__/fs/promises.ts new file mode 100644 index 0000000..2ca5dd1 --- /dev/null +++ b/src/__mocks__/fs/promises.ts @@ -0,0 +1,2 @@ +import { fs } from 'memfs'; +module.exports = fs.promises; diff --git a/src/__tests__/env.ts b/src/__tests__/env.ts new file mode 100644 index 0000000..60a4f55 --- /dev/null +++ b/src/__tests__/env.ts @@ -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; + } + }, + }; +} diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..c52df14 --- /dev/null +++ b/src/__tests__/setup.ts @@ -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'), + }); +} diff --git a/src/data/AtlasFileSource.ts b/src/data/AtlasFileSource.ts index 30ad97c..035436d 100644 --- a/src/data/AtlasFileSource.ts +++ b/src/data/AtlasFileSource.ts @@ -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; } } @@ -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; } diff --git a/src/data/__tests__/AtlasFileSource.test.ts b/src/data/__tests__/AtlasFileSource.test.ts new file mode 100644 index 0000000..070e645 --- /dev/null +++ b/src/data/__tests__/AtlasFileSource.test.ts @@ -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 `/.expo/atlas.jsonl`', () => { + expect(getAtlasPath('')).toBe('/.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); + }); +}); diff --git a/src/data/__tests__/atlas.test.ts b/src/data/__tests__/atlas.test.ts deleted file mode 100644 index 81756dc..0000000 --- a/src/data/__tests__/atlas.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'bun:test'; -import fs from 'fs'; -import path from 'path'; - -import { name, version } from '../../../package.json'; -import { AtlasValidationError } from '../../utils/errors'; -import { - getAtlasPath, - getAtlasMetdata, - createAtlasFile, - validateAtlasFile, -} from '../AtlasFileSource'; - -describe('getAtlasPath', () => { - it('returns default path `/.expo/atlas.jsonl`', () => { - expect(getAtlasPath('')).toBe('/.expo/atlas.jsonl'); - }); -}); - -describe('getAtlasMetdata', () => { - it('returns package name and version', () => { - expect(getAtlasMetdata()).toMatchObject({ name, version }); - }); -}); - -describe('createAtlasFile', () => { - it('creates a file with the correct metadata', async () => { - const file = fixture('create-metadata', { temporary: true }); - await createAtlasFile(file); - await expect(fs.promises.readFile(file, 'utf8')).resolves.toBe( - JSON.stringify({ name, version }) + '\n' - ); - }); - - it('overwrites invalid file', async () => { - const file = fixture('create-invalid', { temporary: true }); - await fs.promises.writeFile(file, JSON.stringify({ name, version: '0.0.0' }) + '\n'); - await createAtlasFile(file); - await expect(fs.promises.readFile(file, 'utf8')).resolves.toBe( - JSON.stringify({ name, version }) + '\n' - ); - }); - - it('reuses valid file', async () => { - const file = fixture('create-valid', { temporary: true }); - await fs.promises.writeFile(file, JSON.stringify({ name, version }) + '\n'); - await createAtlasFile(file); - await expect(fs.promises.readFile(file, 'utf-8')).resolves.toBe( - JSON.stringify({ name, version }) + '\n' - ); - }); -}); - -describe('validateAtlasFile', () => { - it('passes for valid file', async () => { - const file = fixture('validate-valid', { temporary: true }); - await createAtlasFile(file); - await expect(validateAtlasFile(file)).resolves.pass(); - }); - - it('fails for non-existing file', async () => { - await expect(validateAtlasFile('./this-file-does-not-exists')).rejects.toThrow( - AtlasValidationError - ); - }); - - it('fails for invalid file', async () => { - const file = fixture('validate-invalid', { temporary: true }); - await fs.promises.writeFile(file, JSON.stringify({ name, version: '0.0.0' }) + '\n'); - await expect(validateAtlasFile(file)).rejects.toThrow(AtlasValidationError); - }); - - it('skips validation when EXPO_ATLAS_NO_VALIDATION is true-ish', async () => { - using _env = env('EXPO_ATLAS_NO_VALIDATION', 'true'); - const file = fixture('validate-skip-invalid', { temporary: true }); - await fs.promises.writeFile(file, JSON.stringify({ name, version: '0.0.0' }) + '\n'); - await expect(validateAtlasFile(file)).resolves.pass(); - }); -}); - -/** - * Get the file path to a fixture, by name. - * This automatically adds the required `.jsonl` or `.temp.jsonl` extension. - * Use `temporary: true` to keep it out of the repository, and reset the content automatically. - */ -function fixture(name: string, { temporary = false }: { temporary?: boolean } = {}) { - const file = temporary - ? path.join(__dirname, 'fixtures/atlas', `${name}.temp.jsonl`) - : path.join(__dirname, 'fixtures/atlas', `${name}.jsonl`); - - fs.mkdirSync(path.dirname(file), { recursive: true }); - - if (temporary) { - fs.writeFileSync(file, ''); - } - - return file; -} - -/** - * Change the environment variable for the duration of a test. - * This uses explicit resource management to revert the environment variable after the test. - */ -function env(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; - } - }, - }; -} diff --git a/src/utils/__tests__/fixtures/jsonl/specification.jsonl b/src/utils/__tests__/fixtures/jsonl/specification.jsonl deleted file mode 100644 index 9720dd6..0000000 --- a/src/utils/__tests__/fixtures/jsonl/specification.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"name": "Gilbert", "wins": [["straight", "7♣"], ["one pair", "10♥"]]} -{"name": "Alexa", "wins": [["two pair", "4♠"], ["two pair", "9♠"]]} -{"name": "May", "wins": []} -{"name": "Deloise", "wins": [["three of a kind", "5♣"]]} diff --git a/src/utils/__tests__/jsonl.test.ts b/src/utils/__tests__/jsonl.test.ts index bfd49d9..ee23198 100644 --- a/src/utils/__tests__/jsonl.test.ts +++ b/src/utils/__tests__/jsonl.test.ts @@ -1,13 +1,21 @@ -import { describe, expect, it, mock } from 'bun:test'; -import fs from 'fs'; -import path from 'path'; +import memfs from 'memfs'; import { appendJsonLine, forEachJsonLines, parseJsonLine } from '../jsonl'; +jest.mock('fs'); +jest.mock('fs/promises'); + describe('forEachJsonLines', () => { + // TODO(cedric): figure out why memfs throws "EBADF: bad file descriptor" + // afterEach(() => { + // memfs.vol.reset(); + // }); + it('iterates each line of file', async () => { + memfs.vol.fromJSON({ '/test/iterate/lines.jsonl': createJsonlExample() }); + const lines: string[] = []; - await forEachJsonLines(fixture('specification'), (content) => { + await forEachJsonLines('/test/iterate/lines.jsonl', (content) => { lines.push(content); }); @@ -20,8 +28,10 @@ describe('forEachJsonLines', () => { }); it('iterates each line with line numbers starting from 1', async () => { - const onReadLine = mock(); - await forEachJsonLines(fixture('specification'), onReadLine); + memfs.vol.fromJSON({ '/test/iterate/linenumbers.jsonl': createJsonlExample() }); + + const onReadLine = jest.fn(); + await forEachJsonLines('/test/iterate/linenumbers.jsonl', onReadLine); // Callback is invoked with (content, line, reader) => ... expect(onReadLine).not.toHaveBeenCalledWith(expect.any(String), 0, expect.any(Object)); @@ -33,29 +43,44 @@ describe('forEachJsonLines', () => { }); describe('parseJsonLine', () => { + // TODO(cedric): figure out why memfs throws "EBADF: bad file descriptor" + // afterEach(() => { + // memfs.vol.reset(); + // }); + it('parses a single line from file', async () => { - expect(await parseJsonLine(fixture('specification'), 1)).toMatchObject({ name: 'Gilbert' }); - expect(await parseJsonLine(fixture('specification'), 2)).toMatchObject({ name: 'Alexa' }); - expect(await parseJsonLine(fixture('specification'), 3)).toMatchObject({ name: 'May' }); - expect(await parseJsonLine(fixture('specification'), 4)).toMatchObject({ name: 'Deloise' }); + memfs.vol.fromJSON({ '/test/parse/line.jsonl': createJsonlExample() }); + + expect(await parseJsonLine('/test/parse/line.jsonl', 1)).toMatchObject({ name: 'Gilbert' }); + expect(await parseJsonLine('/test/parse/line.jsonl', 2)).toMatchObject({ name: 'Alexa' }); + expect(await parseJsonLine('/test/parse/line.jsonl', 3)).toMatchObject({ name: 'May' }); + expect(await parseJsonLine('/test/parse/line.jsonl', 4)).toMatchObject({ name: 'Deloise' }); }); it('throws if single line is not found', async () => { - await expect(parseJsonLine(fixture('specification'), 99999)).rejects.toThrow( + memfs.vol.fromJSON({ '/test/parse/outofbounds.jsonl': createJsonlExample() }); + await expect(parseJsonLine('/test/parse/outofbounds.jsonl', 99999)).rejects.toThrow( 'Line 99999 not found in file' ); }); }); describe('appendJsonLine', () => { + // TODO(cedric): figure out why memfs throws "EBADF: bad file descriptor" + // afterEach(() => { + // memfs.vol.reset(); + // }); + it('appends a single line to file', async () => { - const file = fixture('append-single', { temporary: true }); - await appendJsonLine(file, { name: 'Gilbert' }); - await expect(fs.promises.readFile(file, 'utf-8')).resolves.toBe('{"name":"Gilbert"}\n'); + memfs.vol.fromJSON({ '/test/append/line.jsonl': '' }); + await appendJsonLine('/test/append/line.jsonl', { name: 'Gilbert' }); + expect(memfs.vol.toJSON()).toMatchObject({ + '/test/append/line.jsonl': '{"name":"Gilbert"}\n', + }); }); it('appends multiple lines to file', async () => { - const file = fixture('append-multiple', { temporary: true }); + memfs.vol.fromJSON({ '/test/append/lines.jsonl': '' }); const data = [ { name: 'Gilbert', list: ['some-list'] }, { name: 'Alexa', nested: { nested: true, list: ['other', 'items'] } }, @@ -64,30 +89,20 @@ describe('appendJsonLine', () => { ]; for (const item of data) { - await appendJsonLine(file, item); + await appendJsonLine('/test/append/lines.jsonl', item); } - await expect(fs.promises.readFile(file, 'utf-8')).resolves.toBe( - data.map((item) => JSON.stringify(item) + '\n').join('') - ); + expect(memfs.vol.toJSON()).toMatchObject({ + '/test/append/lines.jsonl': data.map((item) => JSON.stringify(item) + '\n').join(''), + }); }); }); -/** - * Get the file path to a fixture, by name. - * This automatically adds the required `.jsonl` or `.temp.jsonl` extension. - * Use `temporary: true` to keep it out of the repository, and reset the content automatically. - */ -function fixture(name: string, { temporary = false }: { temporary?: boolean } = {}) { - const file = temporary - ? path.join(__dirname, 'fixtures/jsonl', `${name}.temp.jsonl`) - : path.join(__dirname, 'fixtures/jsonl', `${name}.jsonl`); - - fs.mkdirSync(path.dirname(file), { recursive: true }); - - if (temporary) { - fs.writeFileSync(file, ''); - } - - return file; +/** See: https://jsonlines.org/examples/ */ +function createJsonlExample() { + return `{"name": "Gilbert", "wins": [["straight", "7♣"], ["one pair", "10♥"]]} +{"name": "Alexa", "wins": [["two pair", "4♠"], ["two pair", "9♠"]]} +{"name": "May", "wins": []} +{"name": "Deloise", "wins": [["three of a kind", "5♣"]]} +`; } diff --git a/src/utils/__tests__/package.test.ts b/src/utils/__tests__/package.test.ts index 54dba2b..bece899 100644 --- a/src/utils/__tests__/package.test.ts +++ b/src/utils/__tests__/package.test.ts @@ -1,5 +1,3 @@ -import { describe, expect, it } from 'bun:test'; - import { getPackageNameFromPath } from '../package'; describe('getPackageNameFromPath', () => { diff --git a/src/utils/__tests__/paths.test.ts b/src/utils/__tests__/paths.test.ts index 5fdf174..0508a07 100644 --- a/src/utils/__tests__/paths.test.ts +++ b/src/utils/__tests__/paths.test.ts @@ -1,5 +1,3 @@ -import { describe, expect, it } from 'bun:test'; - import { convertPathToPosix } from '../paths'; describe('convertPathToPosix', () => { diff --git a/src/utils/errors.ts b/src/utils/errors.ts index f1cd219..745ba2c 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -15,7 +15,7 @@ export class AtlasError extends Error { export class AtlasValidationError extends AtlasError { constructor( - code: 'ATLAS_FILE_NOT_FOUND' | 'ATLAS_FILE_INCOMPATIBLE', + code: 'ATLAS_FILE_NOT_FOUND' | 'ATLAS_FILE_INCOMPATIBLE' | 'ATLAS_FILE_INVALID', public readonly filePath: string, public readonly incompatibleVersion?: string ) {