From 9aa0c53457430237c0660d505392bf4748150d28 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 10:47:09 +0000 Subject: [PATCH 1/3] test: cover least-tested CLI grammar and config parsing modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add focused unit tests for the modules the coverage report flagged as least covered, exercising real behavior rather than padding metrics: - utils/source-value: env/config value parsing (booleans, enums, enum flags with setValue, int bounds, multiple) — 42% -> 100% lines - commands/cli-grammar/gesture: CLI<->daemon gesture argument translation for every gesture kind incl. error paths — 41% -> 100% - commands/cli-grammar/system: back/rotate/keyboard/clipboard/ react-native readers and writers incl. validation — 57% -> 100% - core/device-rotation: orientation parsing with aliases/errors — 100% - core/dispatch-payload: push payload loading from inline JSON and files, with temp-file I/O and JSON/shape error handling https://claude.ai/code/session_018i1mhcSe6sqM4mKLUKgYXe --- src/commands/cli-grammar/gesture.test.ts | 266 +++++++++++++++++++++++ src/commands/cli-grammar/system.test.ts | 176 +++++++++++++++ src/core/device-rotation.test.ts | 39 ++++ src/core/dispatch-payload.test.ts | 78 +++++++ src/utils/source-value.test.ts | 174 +++++++++++++++ 5 files changed, 733 insertions(+) create mode 100644 src/commands/cli-grammar/gesture.test.ts create mode 100644 src/commands/cli-grammar/system.test.ts create mode 100644 src/core/device-rotation.test.ts create mode 100644 src/core/dispatch-payload.test.ts create mode 100644 src/utils/source-value.test.ts diff --git a/src/commands/cli-grammar/gesture.test.ts b/src/commands/cli-grammar/gesture.test.ts new file mode 100644 index 000000000..0eb8ce6ae --- /dev/null +++ b/src/commands/cli-grammar/gesture.test.ts @@ -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']); + }); +}); diff --git a/src/commands/cli-grammar/system.test.ts b/src/commands/cli-grammar/system.test.ts new file mode 100644 index 000000000..4d7508974 --- /dev/null +++ b/src/commands/cli-grammar/system.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import type { CommandInput } from './types.ts'; +import { systemCliReaders, systemDaemonWriters } from './system.ts'; + +function flags(overrides: Partial = {}): CliFlags { + return overrides as CliFlags; +} + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('system CLI readers', () => { + test('the parameterless readers project the common selection flags through', () => { + for (const command of ['appstate', 'home', 'app-switcher'] as const) { + expect(systemCliReaders[command]([], flags({ platform: 'ios' }))).toEqual({ + platform: 'ios', + }); + } + }); + + test('back reader forwards the configured back mode', () => { + expect(systemCliReaders.back([], flags({ backMode: 'system' }))).toMatchObject({ + mode: 'system', + }); + }); + + test('rotate reader normalizes the orientation argument', () => { + expect(systemCliReaders.rotate(['left'], flags())).toMatchObject({ + orientation: 'landscape-left', + }); + }); + + test('rotate reader rejects a missing orientation', () => { + expectInvalidArgs(() => systemCliReaders.rotate([], flags()), 'rotate requires an orientation'); + }); + + describe('keyboard reader', () => { + test('maps the "get" alias to the status action', () => { + expect(systemCliReaders.keyboard(['get'], flags())).toMatchObject({ action: 'status' }); + }); + + test('omits the action entirely when no argument is given', () => { + expect(systemCliReaders.keyboard([], flags())).not.toHaveProperty('action'); + }); + + test('rejects more than one keyboard argument', () => { + expectInvalidArgs( + () => systemCliReaders.keyboard(['dismiss', 'extra'], flags()), + 'at most one action argument', + ); + }); + + test('rejects an unknown keyboard action', () => { + expectInvalidArgs( + () => systemCliReaders.keyboard(['wiggle'], flags()), + 'keyboard action must be', + ); + }); + }); + + describe('clipboard reader', () => { + test('parses a read subcommand', () => { + expect(systemCliReaders.clipboard(['read'], flags())).toMatchObject({ action: 'read' }); + }); + + test('joins multi-word text for a write subcommand', () => { + expect(systemCliReaders.clipboard(['write', 'hello', 'world'], flags())).toMatchObject({ + action: 'write', + text: 'hello world', + }); + }); + + test('rejects a missing subcommand', () => { + expectInvalidArgs(() => systemCliReaders.clipboard([], flags()), 'read or write'); + }); + + test('rejects extra arguments after read', () => { + expectInvalidArgs( + () => systemCliReaders.clipboard(['read', 'oops'], flags()), + 'does not accept additional arguments', + ); + }); + + test('rejects a write without any text', () => { + expectInvalidArgs( + () => systemCliReaders.clipboard(['write'], flags()), + 'clipboard write requires text', + ); + }); + }); + + describe('react-native reader', () => { + test('accepts the dismiss-overlay action', () => { + expect(systemCliReaders['react-native'](['dismiss-overlay'], flags())).toMatchObject({ + action: 'dismiss-overlay', + }); + }); + + test('rejects any other react-native action', () => { + expectInvalidArgs( + () => systemCliReaders['react-native'](['reload'], flags()), + 'react-native supports only', + ); + }); + }); +}); + +describe('system daemon writers', () => { + test('the direct writers emit their command with no positionals', () => { + for (const command of ['appstate', 'home', 'app-switcher'] as const) { + const request = systemDaemonWriters[command]({} as CommandInput); + expect(request.command).toBe(command); + expect(request.positionals).toEqual([]); + } + }); + + test('back writer keeps recognized back modes', () => { + expect(systemDaemonWriters.back({ mode: 'in-app' } as CommandInput).options).toMatchObject({ + backMode: 'in-app', + }); + }); + + test('back writer drops an unrecognized back mode', () => { + const options = systemDaemonWriters.back({ mode: 'teleport' } as unknown as CommandInput) + .options as Record; + expect(options.backMode).toBeUndefined(); + }); + + test('rotate writer serializes the orientation positional', () => { + expect( + systemDaemonWriters.rotate({ orientation: 'portrait' } as CommandInput).positionals, + ).toEqual(['portrait']); + }); + + test('rotate writer requires an orientation', () => { + expectInvalidArgs( + () => systemDaemonWriters.rotate({} as CommandInput), + 'rotate requires orientation', + ); + }); + + test('keyboard writer forwards the action when present and is empty otherwise', () => { + expect(systemDaemonWriters.keyboard({ action: 'dismiss' } as CommandInput).positionals).toEqual( + ['dismiss'], + ); + expect(systemDaemonWriters.keyboard({} as CommandInput).positionals).toEqual([]); + }); + + test('clipboard writer serializes read and write subcommands', () => { + expect(systemDaemonWriters.clipboard({ action: 'read' } as CommandInput).positionals).toEqual([ + 'read', + ]); + expect( + systemDaemonWriters.clipboard({ action: 'write', text: 'copied' } as CommandInput) + .positionals, + ).toEqual(['write', 'copied']); + }); + + test('react-native writer requires an action', () => { + expect( + systemDaemonWriters['react-native']({ action: 'dismiss-overlay' } as CommandInput) + .positionals, + ).toEqual(['dismiss-overlay']); + expectInvalidArgs( + () => systemDaemonWriters['react-native']({} as CommandInput), + 'react-native requires action', + ); + }); +}); diff --git a/src/core/device-rotation.test.ts b/src/core/device-rotation.test.ts new file mode 100644 index 000000000..32a5416db --- /dev/null +++ b/src/core/device-rotation.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest'; +import { parseDeviceRotation } from './device-rotation.ts'; + +describe('parseDeviceRotation', () => { + test('accepts the canonical orientation names', () => { + expect(parseDeviceRotation('portrait')).toBe('portrait'); + expect(parseDeviceRotation('portrait-upside-down')).toBe('portrait-upside-down'); + expect(parseDeviceRotation('landscape-left')).toBe('landscape-left'); + expect(parseDeviceRotation('landscape-right')).toBe('landscape-right'); + }); + + test('accepts the documented short aliases', () => { + expect(parseDeviceRotation('upside-down')).toBe('portrait-upside-down'); + expect(parseDeviceRotation('left')).toBe('landscape-left'); + expect(parseDeviceRotation('right')).toBe('landscape-right'); + }); + + test('is case-insensitive and trims surrounding whitespace', () => { + expect(parseDeviceRotation(' Landscape-LEFT ')).toBe('landscape-left'); + }); + + test('throws a helpful error when the orientation is missing', () => { + expect(() => parseDeviceRotation(undefined)).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining('rotate requires an orientation argument'), + }), + ); + }); + + test('throws on an unrecognized orientation and echoes the bad input', () => { + expect(() => parseDeviceRotation('sideways')).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining('Invalid rotation: sideways'), + }), + ); + }); +}); diff --git a/src/core/dispatch-payload.test.ts b/src/core/dispatch-payload.test.ts new file mode 100644 index 000000000..8408ca899 --- /dev/null +++ b/src/core/dispatch-payload.test.ts @@ -0,0 +1,78 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { readNotificationPayload } from './dispatch-payload.ts'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dispatch-payload-')); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +async function writePayloadFile(name: string, contents: string): Promise { + const filePath = path.join(tmpDir, name); + await fs.writeFile(filePath, contents, 'utf8'); + return filePath; +} + +function expectInvalidArgs(promise: Promise, messageFragment: string) { + return expect(promise).rejects.toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('readNotificationPayload inline input', () => { + test('parses an inline JSON object', async () => { + await expect(readNotificationPayload('{"aps":{"alert":"hi"}}')).resolves.toEqual({ + aps: { alert: 'hi' }, + }); + }); + + test('rejects an inline JSON array because the payload must be an object', async () => { + await expectInvalidArgs( + readNotificationPayload('[1, 2, 3]'), + 'push payload must be a JSON object', + ); + }); + + test('rejects malformed inline JSON', async () => { + await expectInvalidArgs(readNotificationPayload('{not json}'), 'Invalid push payload JSON'); + }); +}); + +describe('readNotificationPayload file input', () => { + test('reads and parses a JSON object from a file', async () => { + const filePath = await writePayloadFile('payload.json', '{"foo":"bar","n":1}'); + await expect(readNotificationPayload(filePath)).resolves.toEqual({ foo: 'bar', n: 1 }); + }); + + test('rejects a file whose contents are not valid JSON', async () => { + const filePath = await writePayloadFile('broken.json', 'this is not json'); + await expectInvalidArgs(readNotificationPayload(filePath), 'Invalid push payload JSON'); + }); + + test('rejects a file that contains a non-object JSON value', async () => { + const filePath = await writePayloadFile('array.json', '[1,2,3]'); + await expectInvalidArgs( + readNotificationPayload(filePath), + 'push payload must be a JSON object', + ); + }); + + test('reports a clear error when the payload path does not exist', async () => { + const missing = path.join(tmpDir, 'does-not-exist.json'); + await expectInvalidArgs(readNotificationPayload(missing), 'file not found'); + }); + + test('reports a clear error when the payload path is a directory', async () => { + await expectInvalidArgs(readNotificationPayload(tmpDir), 'not a file'); + }); +}); diff --git a/src/utils/source-value.test.ts b/src/utils/source-value.test.ts new file mode 100644 index 000000000..24ef08ed5 --- /dev/null +++ b/src/utils/source-value.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from 'vitest'; +import { buildPrimaryEnvVarName, parseSourceValue } from './source-value.ts'; +import type { SourceValueDefinition } from './source-value.ts'; + +const LABEL = 'config file'; + +function parse(definition: SourceValueDefinition, value: unknown) { + return parseSourceValue(definition, value, LABEL, 'someKey'); +} + +function expectInvalidArgs(fn: () => unknown, messageFragment?: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + ...(messageFragment ? { message: expect.stringContaining(messageFragment) } : {}), + }), + ); +} + +describe('buildPrimaryEnvVarName', () => { + test('converts camelCase keys into a prefixed SCREAMING_SNAKE env var', () => { + expect(buildPrimaryEnvVarName('iosSimulatorDeviceSet')).toBe( + 'AGENT_DEVICE_IOS_SIMULATOR_DEVICE_SET', + ); + }); + + test('replaces characters that are illegal in env var names with underscores', () => { + expect(buildPrimaryEnvVarName('foo.bar-baz')).toBe('AGENT_DEVICE_FOO_BAR_BAZ'); + }); +}); + +describe('parseSourceValue booleans', () => { + test('passes through real booleans untouched', () => { + expect(parse({ type: 'boolean' }, true)).toBe(true); + expect(parse({ type: 'boolean' }, false)).toBe(false); + }); + + test('accepts the documented truthy and falsy string literals', () => { + for (const truthy of ['1', 'true', 'YES', ' on ']) { + expect(parse({ type: 'boolean' }, truthy)).toBe(true); + } + for (const falsy of ['0', 'false', 'No', 'OFF']) { + expect(parse({ type: 'boolean' }, falsy)).toBe(false); + } + }); + + test('rejects strings that are not boolean literals', () => { + expectInvalidArgs(() => parse({ type: 'boolean' }, 'maybe'), 'Expected boolean'); + }); + + test('rejects non-string, non-boolean values', () => { + expectInvalidArgs(() => parse({ type: 'boolean' }, 5), 'Expected boolean'); + }); +}); + +describe('parseSourceValue booleanOrString', () => { + const definition: SourceValueDefinition = { type: 'booleanOrString' }; + + test('keeps booleans as booleans', () => { + expect(parse(definition, true)).toBe(true); + }); + + test('coerces boolean-like strings into booleans', () => { + expect(parse(definition, 'off')).toBe(false); + expect(parse(definition, 'on')).toBe(true); + }); + + test('keeps arbitrary non-empty strings as strings', () => { + expect(parse(definition, 'staging')).toBe('staging'); + }); + + test('rejects empty strings', () => { + expectInvalidArgs(() => parse(definition, ' '), 'boolean or non-empty string'); + }); +}); + +describe('parseSourceValue strings', () => { + test('accepts non-empty strings', () => { + expect(parse({ type: 'string' }, 'value')).toBe('value'); + }); + + test('rejects blank strings and non-strings', () => { + expectInvalidArgs(() => parse({ type: 'string' }, ' '), 'non-empty string'); + expectInvalidArgs(() => parse({ type: 'string' }, 42), 'non-empty string'); + }); +}); + +describe('parseSourceValue enums', () => { + const definition: SourceValueDefinition = { + type: 'enum', + enumValues: ['ios', 'android', 'linux'], + }; + + test('accepts members of the enum', () => { + expect(parse(definition, 'android')).toBe('android'); + }); + + test('rejects values outside the enum and lists the allowed values', () => { + expectInvalidArgs(() => parse(definition, 'windows'), 'ios, android, linux'); + }); + + test('rejects non-string enum inputs', () => { + expectInvalidArgs(() => parse(definition, 3)); + }); +}); + +describe('parseSourceValue enum flags with setValue', () => { + const definition: SourceValueDefinition = { + type: 'enum', + enumValues: ['fast'], + setValue: 'fast', + }; + + test('returns the configured value when the input already equals it', () => { + expect(parse(definition, 'fast')).toBe('fast'); + }); + + test('treats truthy boolean-like inputs as opting in', () => { + expect(parse(definition, true)).toBe('fast'); + expect(parse(definition, '')).toBe('fast'); + expect(parse(definition, '1')).toBe('fast'); + expect(parse(definition, 'true')).toBe('fast'); + }); + + test('treats falsy boolean-like inputs as opting out', () => { + expect(parse(definition, false)).toBeUndefined(); + expect(parse(definition, '0')).toBeUndefined(); + expect(parse(definition, 'false')).toBeUndefined(); + }); + + test('rejects inputs that are not boolean-like', () => { + expectInvalidArgs(() => parse(definition, 7), 'boolean-like value for enum flag'); + }); +}); + +describe('parseSourceValue integers', () => { + test('accepts numbers and numeric strings', () => { + expect(parse({ type: 'int' }, 12)).toBe(12); + expect(parse({ type: 'int' }, '34')).toBe(34); + }); + + test('rejects non-integers and non-numeric input', () => { + expectInvalidArgs(() => parse({ type: 'int' }, 1.5), 'Expected integer'); + expectInvalidArgs(() => parse({ type: 'int' }, 'abc'), 'Expected integer'); + expectInvalidArgs(() => parse({ type: 'int' }, {}), 'Expected integer'); + }); + + test('enforces the min bound', () => { + expect(parse({ type: 'int', min: 0 }, 0)).toBe(0); + expectInvalidArgs(() => parse({ type: 'int', min: 1 }, 0), 'Must be >= 1'); + }); + + test('enforces the max bound', () => { + expect(parse({ type: 'int', max: 10 }, 10)).toBe(10); + expectInvalidArgs(() => parse({ type: 'int', max: 5 }, 6), 'Must be <= 5'); + }); +}); + +describe('parseSourceValue multiple', () => { + test('maps over an array, parsing each entry with the singular definition', () => { + expect(parse({ type: 'int', multiple: true }, ['1', '2', 3])).toEqual([1, 2, 3]); + }); + + test('wraps a single scalar value into a one-element array', () => { + expect(parse({ type: 'string', multiple: true }, 'only')).toEqual(['only']); + }); + + test('propagates validation errors from individual entries', () => { + expectInvalidArgs( + () => parse({ type: 'int', multiple: true }, ['1', 'nope']), + 'Expected integer', + ); + }); +}); From 9bcac76c60fabc16b1d0051cf7e8502074ea312d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 10:47:58 +0000 Subject: [PATCH 2/3] chore: gitignore generated coverage report directory https://claude.ai/code/session_018i1mhcSe6sqM4mKLUKgYXe --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0f683df1c..722c2297f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ scripts/perf/.results/ .pnpm-store/ .fallow/ dist/ +coverage/ .tmp/ .DS_Store __pycache__/ From 5bb0ce2b60ca2992f6255b095b3822d5e11aec93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:57:17 +0000 Subject: [PATCH 3/3] test: verify public exports reach the npm build; drop dead daemon barrels Strengthen the package-exports test so it verifies the real publish-time invariant: every package.json "exports" subpath maps to a configured rslib build entry that points at an existing source module which actually exposes named exports. This catches a subpath being added to package.json without a matching build entry (which would ship a broken import), and keeps import/types targets in lockstep. Remove four internal re-export barrels under src/daemon that were pure 1:1 forwarders to their src/utils source, and repoint all importers at the real module so it is obvious where the code lives: - daemon/is-predicates.ts -> utils/selector-is-predicates.ts - daemon/selectors-build.ts -> utils/selector-build.ts - daemon/snapshot-diff.ts -> utils/snapshot-diff.ts - daemon/snapshot-processing.ts -> utils/snapshot-processing.ts The curated daemon/selectors.ts facade is kept; only blind forwarders were removed. No behavior change. https://claude.ai/code/session_018i1mhcSe6sqM4mKLUKgYXe --- src/__tests__/package-exports.test.ts | 108 ++++++++++++++---- src/daemon/__tests__/is-predicates.test.ts | 2 +- src/daemon/__tests__/snapshot-diff.test.ts | 2 +- .../__tests__/snapshot-processing.test.ts | 2 +- src/daemon/android-system-dialog.ts | 2 +- src/daemon/handlers/find.ts | 2 +- src/daemon/handlers/interaction-read.ts | 2 +- src/daemon/handlers/snapshot-capture.ts | 6 +- src/daemon/is-predicates.ts | 1 - src/daemon/screenshot-overlay-android.ts | 2 +- src/daemon/screenshot-overlay.ts | 2 +- src/daemon/selector-runtime.ts | 8 +- src/daemon/selectors-build.ts | 1 - src/daemon/selectors.ts | 2 +- src/daemon/snapshot-diff.ts | 1 - .../snapshot-presentation/ios/actions.ts | 2 +- src/daemon/snapshot-presentation/ios/noise.ts | 2 +- src/daemon/snapshot-presentation/ios/rows.ts | 2 +- src/daemon/snapshot-presentation/tree.ts | 2 +- src/daemon/snapshot-processing.ts | 1 - src/daemon/wait-current-surface.ts | 2 +- 21 files changed, 113 insertions(+), 41 deletions(-) delete mode 100644 src/daemon/is-predicates.ts delete mode 100644 src/daemon/selectors-build.ts delete mode 100644 src/daemon/snapshot-diff.ts delete mode 100644 src/daemon/snapshot-processing.ts diff --git a/src/__tests__/package-exports.test.ts b/src/__tests__/package-exports.test.ts index 8a62d73a3..01ec76a37 100644 --- a/src/__tests__/package-exports.test.ts +++ b/src/__tests__/package-exports.test.ts @@ -1,28 +1,63 @@ +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; - }; - - 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; +}; + +// 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 } }> }; +}; +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/.js` back to the rslib entry key (``). +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`); } @@ -30,3 +65,36 @@ test('package exports only supported public subpaths', () => { 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; + const namedExports = Object.keys(module).filter((name) => name !== 'default'); + assert.ok( + namedExports.length > 0, + `exports["${subpath}"] resolves to a module with no named exports`, + ); + } +}); diff --git a/src/daemon/__tests__/is-predicates.test.ts b/src/daemon/__tests__/is-predicates.test.ts index 536c38829..6e1f872e0 100644 --- a/src/daemon/__tests__/is-predicates.test.ts +++ b/src/daemon/__tests__/is-predicates.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { evaluateIsPredicate } from '../is-predicates.ts'; +import { evaluateIsPredicate } from '../../utils/selector-is-predicates.ts'; const viewportNode = { ref: 'e1', diff --git a/src/daemon/__tests__/snapshot-diff.test.ts b/src/daemon/__tests__/snapshot-diff.test.ts index 75cee75ba..0e6019c47 100644 --- a/src/daemon/__tests__/snapshot-diff.test.ts +++ b/src/daemon/__tests__/snapshot-diff.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { buildSnapshotDiff } from '../snapshot-diff.ts'; +import { buildSnapshotDiff } from '../../utils/snapshot-diff.ts'; import { buildNodes as nodes } from '../../__tests__/test-utils/snapshot-builders.ts'; test('buildSnapshotDiff reports unchanged lines when snapshots are equal', () => { diff --git a/src/daemon/__tests__/snapshot-processing.test.ts b/src/daemon/__tests__/snapshot-processing.test.ts index 82eb92189..630e93475 100644 --- a/src/daemon/__tests__/snapshot-processing.test.ts +++ b/src/daemon/__tests__/snapshot-processing.test.ts @@ -5,7 +5,7 @@ import { extractNodeReadText, findNearestHittableAncestor, pruneGroupNodes, -} from '../snapshot-processing.ts'; +} from '../../utils/snapshot-processing.ts'; test('pruneGroupNodes drops unlabeled group wrappers and rebalances depth', () => { const raw = [ diff --git a/src/daemon/android-system-dialog.ts b/src/daemon/android-system-dialog.ts index 0ca829358..443d0e81b 100644 --- a/src/daemon/android-system-dialog.ts +++ b/src/daemon/android-system-dialog.ts @@ -10,7 +10,7 @@ import { emitDiagnostic } from '../utils/diagnostics.ts'; import { AppError } from '../utils/errors.ts'; import { centerOfRect, attachRefs, type SnapshotNode } from '../utils/snapshot.ts'; import { sleep } from '../utils/timeouts.ts'; -import { pruneGroupNodes } from './snapshot-processing.ts'; +import { pruneGroupNodes } from '../utils/snapshot-processing.ts'; import type { SessionState } from './types.ts'; const ANDROID_BLOCKING_MODAL_PATTERN = /\bis(?:n(?:'|'|')?t| not)\s+responding\b/i; diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index 837b02d0d..457ffc27b 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -6,7 +6,7 @@ import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts' import { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; import { ensureDeviceReady } from '../device-ready.ts'; -import { extractNodeText } from '../snapshot-processing.ts'; +import { extractNodeText } from '../../utils/snapshot-processing.ts'; import { resolveActionableTouchNode, resolveActionableTouchResolution, diff --git a/src/daemon/handlers/interaction-read.ts b/src/daemon/handlers/interaction-read.ts index 1263ab1ff..7ce054bd8 100644 --- a/src/daemon/handlers/interaction-read.ts +++ b/src/daemon/handlers/interaction-read.ts @@ -1,6 +1,6 @@ import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { extractNodeReadText } from '../snapshot-processing.ts'; +import { extractNodeReadText } from '../../utils/snapshot-processing.ts'; import type { SessionState } from '../types.ts'; import type { SnapshotNode } from '../../utils/snapshot.ts'; import { prefersValueForReadableText } from '../../utils/text-surface.ts'; diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 13ab8865b..f16a77084 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -39,7 +39,11 @@ import { retryPendingInteractionOutcome, } from '../interaction-outcome-policy.ts'; import { capturePostGestureStabilizedResult } from '../post-gesture-stabilization.ts'; -import { findNodeByLabel, pruneGroupNodes, resolveRefLabel } from '../snapshot-processing.ts'; +import { + findNodeByLabel, + pruneGroupNodes, + resolveRefLabel, +} from '../../utils/snapshot-processing.ts'; import { errorResponse, type DaemonFailureResponse } from './response.ts'; import { presentIosInteractiveSnapshot } from '../snapshot-presentation/ios/index.ts'; diff --git a/src/daemon/is-predicates.ts b/src/daemon/is-predicates.ts deleted file mode 100644 index 36e9633d9..000000000 --- a/src/daemon/is-predicates.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../utils/selector-is-predicates.ts'; diff --git a/src/daemon/screenshot-overlay-android.ts b/src/daemon/screenshot-overlay-android.ts index ada24110a..6d0e1bef4 100644 --- a/src/daemon/screenshot-overlay-android.ts +++ b/src/daemon/screenshot-overlay-android.ts @@ -1,5 +1,5 @@ import type { Rect, SnapshotNode } from '../utils/snapshot.ts'; -import { normalizeType } from './snapshot-processing.ts'; +import { normalizeType } from '../utils/snapshot-processing.ts'; import { hasPositiveRect, rectContains, unionRects } from './screenshot-overlay-rects.ts'; export function resolveAndroidOverlaySourceRect( diff --git a/src/daemon/screenshot-overlay.ts b/src/daemon/screenshot-overlay.ts index 5cea039a5..70c1f9d8c 100644 --- a/src/daemon/screenshot-overlay.ts +++ b/src/daemon/screenshot-overlay.ts @@ -9,7 +9,7 @@ import { import type { PNG } from '../utils/png.ts'; import { decodePngAsync, encodePngAsync } from '../utils/png-worker-client.ts'; import { analyzeReactNativeOverlay } from '../core/react-native-overlay.ts'; -import { findNearestAncestor, normalizeType } from './snapshot-processing.ts'; +import { findNearestAncestor, normalizeType } from '../utils/snapshot-processing.ts'; import { resolveAndroidOverlaySourceRect } from './screenshot-overlay-android.ts'; import { hasPositiveRect, rectArea, rectContains } from './screenshot-overlay-rects.ts'; diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 6601fce37..91dfabff5 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -23,12 +23,16 @@ import { ensureDeviceReady } from './device-ready.ts'; import { captureSnapshot } from './handlers/snapshot-capture.ts'; import { readTextForNode } from './handlers/interaction-read.ts'; import { errorResponse } from './handlers/response.ts'; -import { findNodeByLabel } from './snapshot-processing.ts'; +import { findNodeByLabel } from '../utils/snapshot-processing.ts'; import { resolveSessionDevice, withSessionlessRunnerCleanup } from './handlers/snapshot-session.ts'; import { parseFindArgs, type FindAction } from '../utils/finders.ts'; import { splitIsSelectorArgs } from './selectors.ts'; import { refSnapshotFlagGuardResponse } from './handlers/interaction-flags.ts'; -import { evaluateIsPredicate, isSupportedPredicate, type IsPredicate } from './is-predicates.ts'; +import { + evaluateIsPredicate, + isSupportedPredicate, + type IsPredicate, +} from '../utils/selector-is-predicates.ts'; import type { ContextFromFlags } from './handlers/interaction-common.ts'; import { setSessionSnapshot } from './session-snapshot.ts'; import { getActiveAndroidSnapshotFreshness } from './android-snapshot-freshness.ts'; diff --git a/src/daemon/selectors-build.ts b/src/daemon/selectors-build.ts deleted file mode 100644 index 0c645db1b..000000000 --- a/src/daemon/selectors-build.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../utils/selector-build.ts'; diff --git a/src/daemon/selectors.ts b/src/daemon/selectors.ts index 05c9d268d..ea4dfca5c 100644 --- a/src/daemon/selectors.ts +++ b/src/daemon/selectors.ts @@ -17,4 +17,4 @@ export { formatSelectorFailure, } from './selectors-resolve.ts'; -export { buildSelectorChainForNode } from './selectors-build.ts'; +export { buildSelectorChainForNode } from '../utils/selector-build.ts'; diff --git a/src/daemon/snapshot-diff.ts b/src/daemon/snapshot-diff.ts deleted file mode 100644 index 1acb67de5..000000000 --- a/src/daemon/snapshot-diff.ts +++ /dev/null @@ -1 +0,0 @@ -export { buildSnapshotDiff } from '../utils/snapshot-diff.ts'; diff --git a/src/daemon/snapshot-presentation/ios/actions.ts b/src/daemon/snapshot-presentation/ios/actions.ts index 47b8b7f0b..9e69c0303 100644 --- a/src/daemon/snapshot-presentation/ios/actions.ts +++ b/src/daemon/snapshot-presentation/ios/actions.ts @@ -1,5 +1,5 @@ import type { RawSnapshotNode } from '../../../utils/snapshot.ts'; -import { normalizeType } from '../../snapshot-processing.ts'; +import { normalizeType } from '../../../utils/snapshot-processing.ts'; import { findLargestViewportRect, findNearestAncestor, diff --git a/src/daemon/snapshot-presentation/ios/noise.ts b/src/daemon/snapshot-presentation/ios/noise.ts index aebfcb1b7..ae2ea4c3d 100644 --- a/src/daemon/snapshot-presentation/ios/noise.ts +++ b/src/daemon/snapshot-presentation/ios/noise.ts @@ -3,7 +3,7 @@ import { isReactNativeCollapsedWarningWrapperCandidate, isReactNativeCollapsedWarningWrapperWithVisibleBanner, } from '../../../core/react-native-overlay.ts'; -import { normalizeType } from '../../snapshot-processing.ts'; +import { normalizeType } from '../../../utils/snapshot-processing.ts'; import { collectIosScrollIndicatorPresentation } from './scroll.ts'; import { areRectsApproximatelyEqual, diff --git a/src/daemon/snapshot-presentation/ios/rows.ts b/src/daemon/snapshot-presentation/ios/rows.ts index 554f02127..bd4819384 100644 --- a/src/daemon/snapshot-presentation/ios/rows.ts +++ b/src/daemon/snapshot-presentation/ios/rows.ts @@ -1,5 +1,5 @@ import type { RawSnapshotNode } from '../../../utils/snapshot.ts'; -import { normalizeType } from '../../snapshot-processing.ts'; +import { normalizeType } from '../../../utils/snapshot-processing.ts'; import { areRectsApproximatelyEqual, collectDescendants, diff --git a/src/daemon/snapshot-presentation/tree.ts b/src/daemon/snapshot-presentation/tree.ts index a20b06566..edc59e7f9 100644 --- a/src/daemon/snapshot-presentation/tree.ts +++ b/src/daemon/snapshot-presentation/tree.ts @@ -1,5 +1,5 @@ import type { RawSnapshotNode } from '../../utils/snapshot.ts'; -import { normalizeType } from '../snapshot-processing.ts'; +import { normalizeType } from '../../utils/snapshot-processing.ts'; export { areRectsApproximatelyEqual } from '../../utils/rect-center.ts'; export type SnapshotTreeRuleContext = { diff --git a/src/daemon/snapshot-processing.ts b/src/daemon/snapshot-processing.ts deleted file mode 100644 index abb5b7cb8..000000000 --- a/src/daemon/snapshot-processing.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../utils/snapshot-processing.ts'; diff --git a/src/daemon/wait-current-surface.ts b/src/daemon/wait-current-surface.ts index d5136ef89..a4559702d 100644 --- a/src/daemon/wait-current-surface.ts +++ b/src/daemon/wait-current-surface.ts @@ -2,7 +2,7 @@ import type { SnapshotNode } from '../utils/snapshot.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { captureSnapshot } from './handlers/snapshot-capture.ts'; import { errorResponse } from './handlers/response.ts'; -import { normalizeType } from './snapshot-processing.ts'; +import { normalizeType } from '../utils/snapshot-processing.ts'; type WaitCurrentSurfaceParams = { req: DaemonRequest;