Skip to content
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ scripts/perf/.results/
.pnpm-store/
.fallow/
dist/
coverage/
.tmp/
.DS_Store
__pycache__/
Expand Down
108 changes: 88 additions & 20 deletions src/__tests__/package-exports.test.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,100 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { test } from 'vitest';
import assert from 'node:assert/strict';

test('package exports only supported public subpaths', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')) as {
exports: Record<string, unknown>;
};

const supportedSubpaths = [
'.',
'./io',
'./artifacts',
'./metro',
'./batch',
'./remote-config',
'./install-source',
'./android-adb',
'./android-snapshot-helper',
'./contracts',
'./selectors',
'./finders',
];
const repoRoot = process.cwd();

const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
exports: Record<string, { import: string; types: string }>;
};

// The rslib build is what actually emits the files package.json points at, so a
// subpath only reaches the npm tarball when both agree on the same source entry.
// Loaded via a runtime path so its build-tooling types stay out of the program.
const rslibConfig = (await import(pathToFileURL(path.join(repoRoot, 'rslib.config.ts')).href)) as {
default: { lib: Array<{ source?: { entry?: Record<string, string> } }> };
};
const rslibEntries = rslibConfig.default.lib[0]?.source?.entry ?? {};

const supportedSubpaths = [
'.',
'./io',
'./artifacts',
'./metro',
'./batch',
'./remote-config',
'./install-source',
'./android-adb',
'./android-snapshot-helper',
'./contracts',
'./selectors',
'./finders',
];

function exportTarget(subpath: string): { import: string; types: string } {
const target = pkg.exports[subpath];
assert.ok(target, `${subpath} should be exported`);
return target;
}

// Resolve `./dist/src/<name>.js` back to the rslib entry key (`<name>`).
function entryKeyForDist(distImportPath: string): string {
const key = distImportPath.match(/^\.\/dist\/src\/(.+)\.js$/)?.[1];
assert.ok(key, `Unexpected export target shape: ${distImportPath}`);
return key;
}

// The repo-relative source file the rslib build compiles for this subpath.
function sourcePathFor(subpath: string): string {
const entryKey = entryKeyForDist(exportTarget(subpath).import);
const entry = rslibEntries[entryKey];
assert.ok(
entry,
`exports["${subpath}"] needs an rslib build entry "${entryKey}" to reach the npm tarball`,
);
return path.join(repoRoot, entry);
}

test('package exports only supported public subpaths', () => {
for (const subpath of supportedSubpaths) {
assert.equal(pkg.exports[subpath] !== undefined, true, `${subpath} should be exported`);
}

assert.equal(pkg.exports['./android-apps'], undefined);
assert.equal(pkg.exports['./daemon'], undefined);
});

test('every public subpath is backed by a configured rslib build entry', () => {
for (const subpath of supportedSubpaths) {
const sourcePath = sourcePathFor(subpath);
assert.ok(
fs.existsSync(sourcePath),
`exports["${subpath}"] source ${sourcePath} does not exist`,
);
}
});

test('every public subpath ships matching import and types targets', () => {
for (const subpath of supportedSubpaths) {
const target = exportTarget(subpath);
assert.equal(
target.types,
target.import.replace(/\.js$/, '.d.ts'),
`exports["${subpath}"] import and types targets are out of sync`,
);
}
});

test('every public subpath resolves to a module that exposes named exports', async () => {
for (const subpath of supportedSubpaths) {
const sourcePath = sourcePathFor(subpath);
const module = (await import(pathToFileURL(sourcePath).href)) as Record<string, unknown>;
const namedExports = Object.keys(module).filter((name) => name !== 'default');
assert.ok(
namedExports.length > 0,
`exports["${subpath}"] resolves to a module with no named exports`,
);
}
});
266 changes: 266 additions & 0 deletions src/commands/cli-grammar/gesture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { describe, expect, test } from 'vitest';
import type { CliFlags } from '../../utils/cli-flags.ts';
import type { CommandInput } from './types.ts';
import { gestureCliReaders, gestureDaemonWriters } from './gesture.ts';

const NO_FLAGS = {} as CliFlags;

function readCli(positionals: string[]) {
return gestureCliReaders.gesture(positionals, NO_FLAGS);
}

function writePositionals(writerKey: keyof typeof gestureDaemonWriters, input: CommandInput) {
const request = gestureDaemonWriters[writerKey](input);
expect(request.command).toBe('gesture');
return request.positionals;
}

function expectInvalidArgs(fn: () => unknown, messageFragment?: string) {
expect(fn).toThrow(
expect.objectContaining({
code: 'INVALID_ARGS',
...(messageFragment ? { message: expect.stringContaining(messageFragment) } : {}),
}),
);
}

describe('gestureInputFromCli reader', () => {
test('parses a pan gesture with origin, delta and duration', () => {
expect(readCli(['pan', '10', '20', '5', '6', '300'])).toMatchObject({
kind: 'pan',
origin: { x: 10, y: 20 },
delta: { x: 5, y: 6 },
durationMs: 300,
});
});

test('leaves an omitted pan duration undefined', () => {
expect(readCli(['pan', '10', '20', '5', '6']).durationMs).toBeUndefined();
});

test('parses a fling gesture with direction, origin, distance and duration', () => {
expect(readCli(['fling', 'up', '10', '20', '100', '250'])).toMatchObject({
kind: 'fling',
direction: 'up',
origin: { x: 10, y: 20 },
distance: 100,
durationMs: 250,
});
});

test('parses a swipe preset gesture', () => {
expect(readCli(['swipe', 'left', '400'])).toMatchObject({
kind: 'swipe',
preset: 'left',
durationMs: 400,
});
});

test('parses a pinch gesture with an explicit origin', () => {
expect(readCli(['pinch', '2', '10', '20'])).toMatchObject({
kind: 'pinch',
scale: 2,
origin: { x: 10, y: 20 },
});
});

test('leaves the pinch origin undefined when coordinates are missing', () => {
expect(readCli(['pinch', '0.5']).origin).toBeUndefined();
});

test('parses a rotate gesture with origin and velocity', () => {
expect(readCli(['rotate', '90', '10', '20', '5'])).toMatchObject({
kind: 'rotate',
degrees: 90,
origin: { x: 10, y: 20 },
velocity: 5,
});
});

test('leaves the rotate origin undefined when coordinates are missing', () => {
expect(readCli(['rotate', '45']).origin).toBeUndefined();
});

test('parses a transform gesture with all parameters', () => {
expect(readCli(['transform', '1', '2', '3', '4', '1.5', '30', '200'])).toMatchObject({
kind: 'transform',
origin: { x: 1, y: 2 },
delta: { x: 3, y: 4 },
scale: 1.5,
degrees: 30,
durationMs: 200,
});
});

test('rejects an unknown gesture subcommand', () => {
expectInvalidArgs(() => readCli(['twist']), 'gesture requires pan, fling, swipe');
});
});

describe('gesture daemon writers', () => {
test('the default gesture writer serializes a pan kind from origin/delta', () => {
const positionals = writePositionals('gesture', {
kind: 'pan',
origin: { x: 10, y: 20 },
delta: { x: 5, y: 6 },
durationMs: 300,
} as CommandInput);
expect(positionals).toEqual(['pan', '10', '20', '5', '6', '300']);
});

test('the default gesture writer omits an absent duration', () => {
const positionals = writePositionals('gesture', {
kind: 'pan',
origin: { x: 1, y: 2 },
delta: { x: 3, y: 4 },
} as CommandInput);
expect(positionals).toEqual(['pan', '1', '2', '3', '4']);
});

test('the default gesture writer serializes swipe presets', () => {
expect(
writePositionals('gesture', { kind: 'swipe', preset: 'up', durationMs: 400 } as CommandInput),
).toEqual(['swipe', 'up', '400']);
});

test('the default gesture writer requires a swipe preset', () => {
expectInvalidArgs(
() => writePositionals('gesture', { kind: 'swipe' } as CommandInput),
'gesture swipe requires preset',
);
});

test('the default gesture writer requires a fling direction', () => {
expectInvalidArgs(
() =>
writePositionals('gesture', {
kind: 'fling',
origin: { x: 1, y: 2 },
} as CommandInput),
'gesture fling requires direction',
);
});

test('the default gesture writer rejects unknown kinds', () => {
expectInvalidArgs(
() => writePositionals('gesture', { kind: 'mystery' } as CommandInput),
'gesture requires pan, fling, swipe',
);
});

test('the default gesture writer serializes a pinch kind from scale and origin', () => {
expect(
writePositionals('gesture', {
kind: 'pinch',
scale: 2,
origin: { x: 10, y: 20 },
} as CommandInput),
).toEqual(['pinch', '2', '10', '20']);
});

test('the default gesture writer serializes a rotate kind from degrees and origin', () => {
expect(
writePositionals('gesture', {
kind: 'rotate',
degrees: 90,
origin: { x: 10, y: 20 },
velocity: 5,
} as CommandInput),
).toEqual(['rotate', '90', '10', '20', '5']);
});

test('the default gesture writer serializes a transform kind from origin/delta/scale/degrees', () => {
expect(
writePositionals('gesture', {
kind: 'transform',
origin: { x: 1, y: 2 },
delta: { x: 3, y: 4 },
scale: 1.5,
degrees: 30,
durationMs: 200,
} as CommandInput),
).toEqual(['transform', '1', '2', '3', '4', '1.5', '30', '200']);
});

test('the gesture-pan writer serializes flat x/y/dx/dy coordinates', () => {
expect(
writePositionals('gesture-pan', {
x: 10,
y: 20,
dx: 5,
dy: 6,
durationMs: 300,
} as CommandInput),
).toEqual(['pan', '10', '20', '5', '6', '300']);
});

test('the gesture-fling writer defaults distance to 180 when only a duration is given', () => {
expect(
writePositionals('gesture-fling', {
direction: 'down',
x: 10,
y: 20,
durationMs: 250,
} as CommandInput),
).toEqual(['fling', 'down', '10', '20', '180', '250']);
});

test('the gesture-fling writer keeps an explicit distance and omits an absent duration', () => {
expect(
writePositionals('gesture-fling', {
direction: 'up',
x: 10,
y: 20,
distance: 120,
} as CommandInput),
).toEqual(['fling', 'up', '10', '20', '120']);
});

test('the gesture-pinch writer serializes scale and optional origin', () => {
expect(writePositionals('gesture-pinch', { scale: 2, x: 10, y: 20 } as CommandInput)).toEqual([
'pinch',
'2',
'10',
'20',
]);
});

test('the gesture-rotate writer serializes degrees with a complete center', () => {
expect(
writePositionals('gesture-rotate', {
degrees: 90,
x: 10,
y: 20,
velocity: 5,
} as CommandInput),
).toEqual(['rotate', '90', '10', '20', '5']);
});

test('the gesture-rotate writer omits the center when no coordinates are given', () => {
expect(writePositionals('gesture-rotate', { degrees: 45 } as CommandInput)).toEqual([
'rotate',
'45',
]);
});

test('the gesture-rotate writer rejects a half-specified center', () => {
expectInvalidArgs(
() => writePositionals('gesture-rotate', { degrees: 45, x: 10 } as CommandInput),
'gesture rotate center requires both x and y',
);
});

test('the gesture-transform writer serializes the full parameter list', () => {
expect(
writePositionals('gesture-transform', {
x: 1,
y: 2,
dx: 3,
dy: 4,
scale: 1.5,
degrees: 30,
durationMs: 200,
} as CommandInput),
).toEqual(['transform', '1', '2', '3', '4', '1.5', '30', '200']);
});
});
Loading
Loading