diff --git a/src/utils/__tests__/fixtures/specification.jsonl b/src/utils/__tests__/fixtures/jsonl/specification.jsonl similarity index 100% rename from src/utils/__tests__/fixtures/specification.jsonl rename to src/utils/__tests__/fixtures/jsonl/specification.jsonl diff --git a/src/utils/__tests__/jsonl.test.ts b/src/utils/__tests__/jsonl.test.ts index 448909d..5a8ad07 100644 --- a/src/utils/__tests__/jsonl.test.ts +++ b/src/utils/__tests__/jsonl.test.ts @@ -80,8 +80,8 @@ describe('appendJsonLine', () => { */ function fixture(name: string, { temporary = false }: { temporary?: boolean } = {}) { const file = temporary - ? path.join(__dirname, 'fixtures', `${name}.temp.jsonl`) - : path.join(__dirname, 'fixtures', `${name}.jsonl`); + ? path.join(__dirname, 'fixtures/jsonl', `${name}.temp.jsonl`) + : path.join(__dirname, 'fixtures/jsonl', `${name}.jsonl`); if (temporary) { fs.writeFileSync(file, ''); diff --git a/src/utils/__tests__/stats.test.ts b/src/utils/__tests__/stats.test.ts new file mode 100644 index 0000000..4a0587c --- /dev/null +++ b/src/utils/__tests__/stats.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; + +import { name, version } from '../../../package.json'; +import { AtlasValidationError } from '../errors'; +import { getStatsPath, getStatsMetdata, createStatsFile, validateStatsFile } from '../stats'; + +describe('getStatsPath', () => { + it('returns default path `/.expo/stats.jsonl`', () => { + expect(getStatsPath('')).toBe('/.expo/stats.jsonl'); + }); +}); + +describe('getStatsMetadata', () => { + it('returns package name and version', () => { + expect(getStatsMetdata()).toMatchObject({ name, version }); + }); +}); + +describe('createStatsFile', () => { + it('creates a stats file with the correct metadata', async () => { + const file = fixture('create-metadata', { temporary: true }); + await createStatsFile(file); + await expect(fs.promises.readFile(file, 'utf8')).resolves.toBe( + JSON.stringify({ name, version }) + '\n' + ); + }); + + it('overwrites invalid stats file', async () => { + const file = fixture('create-invalid', { temporary: true }); + await fs.promises.writeFile(file, JSON.stringify({ name, version: '0.0.0' }) + '\n'); + await createStatsFile(file); + await expect(fs.promises.readFile(file, 'utf8')).resolves.toBe( + JSON.stringify({ name, version }) + '\n' + ); + }); + + it('reuses valid stats file', async () => { + const file = fixture('create-valid', { temporary: true }); + await fs.promises.writeFile(file, JSON.stringify({ name, version }) + '\n'); + await createStatsFile(file); + await expect(fs.promises.readFile(file, 'utf-8')).resolves.toBe( + JSON.stringify({ name, version }) + '\n' + ); + }); +}); + +describe('validateStatsFile', () => { + it('passes for valid stats file', async () => { + const file = fixture('validate-valid', { temporary: true }); + await createStatsFile(file); + await expect(validateStatsFile(file)).resolves.pass(); + }); + + it('fails for non-existing stats file', async () => { + await expect(validateStatsFile('./this-file-does-not-exists')).rejects.toThrow( + AtlasValidationError + ); + }); + + it('fails for invalid stats file', async () => { + const file = fixture('validate-invalid', { temporary: true }); + await fs.promises.writeFile(file, JSON.stringify({ name, version: '0.0.0' }) + '\n'); + await expect(validateStatsFile(file)).rejects.toThrow(AtlasValidationError); + }); + + it('skips validation when EXPO_ATLAS_NO_STATS_VALIDATION is true-ish', async () => { + using _env = env('EXPO_ATLAS_NO_STATS_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(validateStatsFile(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/stats', `${name}.temp.jsonl`) + : path.join(__dirname, 'fixtures/stats', `${name}.jsonl`); + + 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/env.ts b/src/utils/env.ts index 7f5ee27..f22a8aa 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -1,10 +1,10 @@ import { boolish } from 'getenv'; export const env = { - get EXPO_DEBUG() { - return boolish('EXPO_DEBUG', false); + get EXPO_ATLAS_DEBUG() { + return boolish('EXPO_ATLAS_DEBUG', false); }, - get EXPO_NO_STATS_VALIDATION() { - return boolish('EXPO_NO_STATS_VALIDATION', false); + get EXPO_ATLAS_NO_STATS_VALIDATION() { + return boolish('EXPO_ATLAS_NO_STATS_VALIDATION', false); }, }; diff --git a/src/utils/middleware.ts b/src/utils/middleware.ts index 3c7e95f..5ff0246 100644 --- a/src/utils/middleware.ts +++ b/src/utils/middleware.ts @@ -33,7 +33,7 @@ export function createAtlasMiddleware(source: StatsSource) { const middleware = connect(); - if (env.EXPO_DEBUG) { + if (env.EXPO_ATLAS_DEBUG) { middleware.use(morgan('tiny')); } diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 81db280..7b46773 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; -import { parseJsonLine } from './jsonl'; +import { appendJsonLine, parseJsonLine } from './jsonl'; import { name, version } from '../../package.json'; import { env } from '../utils/env'; import { AtlasValidationError } from '../utils/errors'; @@ -24,7 +24,7 @@ export async function validateStatsFile(statsFile: string, metadata = getStatsMe throw new AtlasValidationError('STATS_FILE_NOT_FOUND', statsFile); } - if (env.EXPO_NO_STATS_VALIDATION) { + if (env.EXPO_ATLAS_NO_STATS_VALIDATION) { return; } @@ -42,14 +42,12 @@ export async function validateStatsFile(statsFile: string, metadata = getStatsMe export async function createStatsFile(filePath: string) { if (fs.existsSync(filePath)) { try { - await validateStatsFile(filePath); + return await validateStatsFile(filePath); } catch { - await fs.promises.writeFile(filePath, JSON.stringify(getStatsMetdata()) + '\n'); + await fs.promises.writeFile(filePath, ''); } - - return; } await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); - await fs.promises.writeFile(filePath, JSON.stringify(getStatsMetdata()) + '\n'); + await appendJsonLine(filePath, getStatsMetdata()); }