Skip to content

Commit

Permalink
refactor: replace ndjson with jsonl specification (#9)
Browse files Browse the repository at this point in the history
* refactor: rename `ndjson` to `jsonl` in utils

* chore: fix linting issues

* refactor: rename default stats file extension to `.jsonl`

* test: add `utils/stats` tests

* test: fix issue when initializing the fixture files
  • Loading branch information
byCedric authored Mar 17, 2024
1 parent 1658f3a commit b3bb892
Show file tree
Hide file tree
Showing 10 changed files with 227 additions and 65 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
/build
/webui/dist

# test fixtures
*.temp.jsonl

# dependencies
node_modules/
npm-debug.log*
Expand Down
2 changes: 1 addition & 1 deletion src/data/StatsFileSource.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'assert';

import type { PartialStatsEntry, StatsEntry, StatsSource } from './types';
import { appendJsonLine, forEachJsonLines, parseJsonLine } from '../utils/ndjson';
import { appendJsonLine, forEachJsonLines, parseJsonLine } from '../utils/jsonl';

export class StatsFileSource implements StatsSource {
constructor(public readonly statsPath: string) {
Expand Down
File renamed without changes.
93 changes: 93 additions & 0 deletions src/utils/__tests__/jsonl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it, mock } from 'bun:test';
import fs from 'fs';
import path from 'path';

import { appendJsonLine, forEachJsonLines, parseJsonLine } from '../jsonl';

describe('forEachJsonLines', () => {
it('iterates each line of file', async () => {
const lines: string[] = [];
await forEachJsonLines(fixture('specification'), (content) => {
lines.push(content);
});

expect(lines).toEqual([
expect.stringContaining('Gilbert'),
expect.stringContaining('Alexa'),
expect.stringContaining('May'),
expect.stringContaining('Deloise'),
]);
});

it('iterates each line with line numbers starting from 1', async () => {
const onReadLine = mock();
await forEachJsonLines(fixture('specification'), onReadLine);

// Callback is invoked with (content, line, reader) => ...
expect(onReadLine).not.toHaveBeenCalledWith(expect.any(String), 0, expect.any(Object));
expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 1, expect.any(Object));
expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 2, expect.any(Object));
expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 3, expect.any(Object));
expect(onReadLine).toHaveBeenCalledWith(expect.any(String), 4, expect.any(Object));
});
});

describe('parseJsonLine', () => {
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' });
});

it('throws if single line is not found', async () => {
await expect(parseJsonLine(fixture('specification'), 99999)).rejects.toThrow(
'Line 99999 not found in file'
);
});
});

describe('appendJsonLine', () => {
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');
});

it('appends multiple lines to file', async () => {
const file = fixture('append-multiple', { temporary: true });
const data = [
{ name: 'Gilbert', list: ['some-list'] },
{ name: 'Alexa', nested: { nested: true, list: ['other', 'items'] } },
{ name: 'May', names: 1 },
{ name: 'Deloise', simple: true },
];

for (const item of data) {
await appendJsonLine(file, item);
}

await expect(fs.promises.readFile(file, 'utf-8')).resolves.toBe(
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;
}
51 changes: 0 additions & 51 deletions src/utils/__tests__/ndjson.test.ts

This file was deleted.

119 changes: 119 additions & 0 deletions src/utils/__tests__/stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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 `<project>/.expo/stats.jsonl`', () => {
expect(getStatsPath('<project>')).toBe('<project>/.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`);

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;
}
},
};
}
8 changes: 4 additions & 4 deletions src/utils/env.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
File renamed without changes.
2 changes: 1 addition & 1 deletion src/utils/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}

Expand Down
14 changes: 6 additions & 8 deletions src/utils/stats.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import fs from 'fs';
import path from 'path';

import { appendJsonLine, parseJsonLine } from './jsonl';
import { name, version } from '../../package.json';
import { env } from '../utils/env';
import { AtlasValidationError } from '../utils/errors';
import { parseJsonLine } from '../utils/ndjson';

export type StatsMetadata = { name: string; version: string };

/** The default location of the metro stats file */
export function getStatsPath(projectRoot: string) {
return path.join(projectRoot, '.expo/stats.json');
return path.join(projectRoot, '.expo/stats.jsonl');
}

/** The information to validate if a stats file is compatible with this library version */
Expand All @@ -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;
}

Expand All @@ -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());
}

0 comments on commit b3bb892

Please sign in to comment.