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: replace ndjson with jsonl specification #9

Merged
merged 5 commits into from
Mar 17, 2024
Merged
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: 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
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());
}