mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
test: property-based testing foundation (fast-check) over parse/print and geometry kernels (#1437)
* test: property-based testing foundation over parse/print and geometry kernels (#1413) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test: derive selector and replay command domains from their owning registries Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
e8b779cb32
commit
8670a88afa
@@ -48,6 +48,22 @@ to make a test easier — widening the public surface for a test is a product ch
|
||||
outlive the test that motivated them. If a seam is genuinely missing, add it as a real one rather
|
||||
than as a test affordance (the workflow separately forbids test-only `typeof` DI params).
|
||||
|
||||
## Properties over examples (pure parsers and geometry)
|
||||
|
||||
**Pure parser or geometry change → extend a property, not another example.** The parse/print and
|
||||
geometry kernels (selectors, `@eN~sM` refs, `.ad` script lines, gesture planning, snapshot diff) are
|
||||
covered by fast-check properties living in each owning module's test file. Their generators are
|
||||
shared in `src/__tests__/test-utils/property-arbitraries.ts` and exported through the test-utils
|
||||
barrel, so a new hazard (another quote shape, a new gesture kind, another `.ad` command) belongs in
|
||||
the generator, where every property inherits it — not in a new hand-pinned case.
|
||||
|
||||
- Keep examples that document a specific decision or a real past bug; add the general guarantee as a
|
||||
property alongside them.
|
||||
- Bound `numRuns` with the shared `PROPERTY_RUNS` / `PROPERTY_RUNS_SMALL` constants: properties run
|
||||
in `unit-core` under the same slow-test budget as everything else.
|
||||
- A failing property prints the shrunk counterexample plus the seed and path to replay it; paste
|
||||
that seed into `fc.assert(..., { seed, path })` to re-run exactly that case.
|
||||
|
||||
## Affected-check selector (`pnpm check:affected`)
|
||||
|
||||
`pnpm check:affected --base <ref>` derives which local checks a diff needs, so
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"@types/node": "^22.19.21",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"fallow": "^2.95.0",
|
||||
"fast-check": "^4.9.0",
|
||||
"oxc-parser": "^0.138.0",
|
||||
"oxfmt": "^0.42.0",
|
||||
"oxlint": "^1.69.0",
|
||||
|
||||
Generated
+16
@@ -33,6 +33,9 @@ importers:
|
||||
fallow:
|
||||
specifier: ^2.95.0
|
||||
version: 2.95.0
|
||||
fast-check:
|
||||
specifier: ^4.9.0
|
||||
version: 4.9.0
|
||||
oxc-parser:
|
||||
specifier: ^0.138.0
|
||||
version: 0.138.0
|
||||
@@ -1483,6 +1486,10 @@ packages:
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
fast-check@4.9.0:
|
||||
resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==}
|
||||
engines: {node: '>=12.17.0'}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1978,6 +1985,9 @@ packages:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
pure-rand@8.4.2:
|
||||
resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==}
|
||||
|
||||
quansync@1.0.0:
|
||||
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
|
||||
|
||||
@@ -3528,6 +3538,10 @@ snapshots:
|
||||
'@fallow-cli/win32-arm64-msvc': 2.95.0
|
||||
'@fallow-cli/win32-x64-msvc': 2.95.0
|
||||
|
||||
fast-check@4.9.0:
|
||||
dependencies:
|
||||
pure-rand: 8.4.2
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -4375,6 +4389,8 @@ snapshots:
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
pure-rand@8.4.2: {}
|
||||
|
||||
quansync@1.0.0: {}
|
||||
|
||||
react-dom@19.2.7(react@19.2.7):
|
||||
|
||||
@@ -28,6 +28,19 @@ export {
|
||||
|
||||
export { makeSessionStore } from './store-factory.ts';
|
||||
|
||||
export {
|
||||
COMPACT_VIEWPORTS,
|
||||
formatRef,
|
||||
formatSelectorChainExpression,
|
||||
gestureInViewportArb,
|
||||
PROPERTY_RUNS,
|
||||
PROPERTY_RUNS_SMALL,
|
||||
rawSnapshotNodesArb,
|
||||
refArb,
|
||||
replayScriptArb,
|
||||
selectorChainArb,
|
||||
} from './property-arbitraries.ts';
|
||||
|
||||
export { withNoColor } from './color.ts';
|
||||
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
import fc from 'fast-check';
|
||||
import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts';
|
||||
import { GESTURE_KINDS, type GesturePayload } from '../../contracts/gesture-input.ts';
|
||||
import { SCROLL_DIRECTIONS, SWIPE_PRESETS } from '../../contracts/scroll-gesture.ts';
|
||||
import type { Point, RawSnapshotNode, Rect } from '../../kernel/snapshot.ts';
|
||||
import { SELECTOR_KEY_NAMES, type SelectorKey, type SelectorTerm } from '../../selectors/parse.ts';
|
||||
|
||||
/**
|
||||
* Shared fast-check generators for the pure parse/print and geometry kernels
|
||||
* (#1413). Each property lives in its owning module's test file; the inputs
|
||||
* live here so the hazard vocabulary (quote/apostrophe/escape shapes, compact
|
||||
* viewports, pinned refs) is written once and every property inherits the next
|
||||
* hazard someone adds.
|
||||
*
|
||||
* fast-check reports the SHRUNK counterexample plus the seed/path to replay it,
|
||||
* so a failure names a minimal input rather than the raw random one.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Run budget for every property in the unit suite. Properties share the unit
|
||||
* slow-test budget (2.5s per file, see docs/agents/testing.md), so the count is
|
||||
* bounded here rather than per call site — raise it in one place, and only with
|
||||
* a measured file duration.
|
||||
*/
|
||||
export const PROPERTY_RUNS = 100;
|
||||
|
||||
/** Cheaper budget for properties whose single run does real work (diff, planning). */
|
||||
export const PROPERTY_RUNS_SMALL = 40;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Value shape per selector key. The parser owns the key set; this only says how
|
||||
* each key's right-hand side is spelled, and `Record<SelectorKey, …>` makes a
|
||||
* new key a typecheck failure here instead of a silent hole in the property.
|
||||
*/
|
||||
const SELECTOR_KEY_VALUE_KINDS = {
|
||||
id: 'text',
|
||||
role: 'text',
|
||||
text: 'text',
|
||||
label: 'text',
|
||||
value: 'text',
|
||||
appname: 'text',
|
||||
windowtitle: 'text',
|
||||
visible: 'boolean',
|
||||
hidden: 'boolean',
|
||||
editable: 'boolean',
|
||||
selected: 'boolean',
|
||||
focused: 'boolean',
|
||||
enabled: 'boolean',
|
||||
hittable: 'boolean',
|
||||
} satisfies Record<SelectorKey, 'text' | 'boolean'>;
|
||||
|
||||
/** Keys of one value shape, derived from the parser's own `SELECTOR_KEY_NAMES`. */
|
||||
function selectorKeysOfKind(kind: 'text' | 'boolean'): SelectorKey[] {
|
||||
const keys = SELECTOR_KEY_NAMES.filter((key) => {
|
||||
const valueKind: string | undefined = SELECTOR_KEY_VALUE_KINDS[key];
|
||||
if (valueKind === undefined) {
|
||||
throw new Error(
|
||||
`selector key "${key}" from SELECTOR_KEY_NAMES (src/selectors/parse.ts) has no value shape: ` +
|
||||
'add it to SELECTOR_KEY_VALUE_KINDS in src/__tests__/test-utils/property-arbitraries.ts',
|
||||
);
|
||||
}
|
||||
return valueKind === kind;
|
||||
});
|
||||
if (keys.length === 0) throw new Error(`no selector keys of value shape "${kind}"`);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shapes that broke hand-written selector examples: both quote characters,
|
||||
* backslash runs before a quote, the `||` fallback separator and `=` inside a
|
||||
* value, whitespace-only values, and non-BMP text.
|
||||
*/
|
||||
const SELECTOR_VALUE_HAZARDS = [
|
||||
'',
|
||||
' ',
|
||||
'"',
|
||||
"'",
|
||||
"it's",
|
||||
'say "hi"',
|
||||
'\\',
|
||||
'\\"',
|
||||
'a\\\\b',
|
||||
'a || b',
|
||||
'key=value',
|
||||
'line\nbreak',
|
||||
'\tTab',
|
||||
'Ünïcøde',
|
||||
'😀 emoji',
|
||||
] as const;
|
||||
|
||||
const selectorTextValueArb: fc.Arbitrary<string> = fc.oneof(
|
||||
fc.constantFrom(...SELECTOR_VALUE_HAZARDS),
|
||||
fc.string({ minLength: 0, maxLength: 12 }),
|
||||
fc
|
||||
.array(fc.constantFrom(...SELECTOR_VALUE_HAZARDS), { minLength: 2, maxLength: 3 })
|
||||
.map((parts) => parts.join('')),
|
||||
);
|
||||
|
||||
const selectorTermArb: fc.Arbitrary<SelectorTerm> = fc.oneof(
|
||||
fc
|
||||
.record({
|
||||
key: fc.constantFrom(...selectorKeysOfKind('text')),
|
||||
value: selectorTextValueArb,
|
||||
})
|
||||
// fast-check records are null-prototype; parsed terms are plain objects.
|
||||
.map(({ key, value }) => ({ key, value })),
|
||||
fc
|
||||
.record({
|
||||
key: fc.constantFrom(...selectorKeysOfKind('boolean')),
|
||||
value: fc.boolean(),
|
||||
})
|
||||
.map(({ key, value }) => ({ key, value })),
|
||||
);
|
||||
|
||||
export type GeneratedSelectorChain = {
|
||||
/** Terms per fallback segment, in source order. */
|
||||
selectors: SelectorTerm[][];
|
||||
/** A source expression for those terms; boolean terms may use the bare form. */
|
||||
expression: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A selector chain plus one source expression that denotes it. `bareBooleans`
|
||||
* emits `visible` instead of `visible=true`, the other accepted spelling of a
|
||||
* true boolean term.
|
||||
*/
|
||||
export const selectorChainArb: fc.Arbitrary<GeneratedSelectorChain> = fc
|
||||
.record({
|
||||
selectors: fc.array(fc.array(selectorTermArb, { minLength: 1, maxLength: 4 }), {
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
}),
|
||||
bareBooleans: fc.boolean(),
|
||||
})
|
||||
.filter(({ selectors }) => selectors.length > 0)
|
||||
.map(({ selectors, bareBooleans }) => ({
|
||||
selectors,
|
||||
expression: formatSelectorChainExpression(selectors, { bareBooleans }),
|
||||
}));
|
||||
|
||||
/** Canonical printer: every text value quoted, every boolean term explicit. */
|
||||
function formatSelectorTerm(term: SelectorTerm, options: { bareBooleans?: boolean } = {}): string {
|
||||
if (typeof term.value === 'boolean') {
|
||||
return options.bareBooleans && term.value ? term.key : `${term.key}=${term.value}`;
|
||||
}
|
||||
return `${term.key}=${JSON.stringify(term.value)}`;
|
||||
}
|
||||
|
||||
export function formatSelectorChainExpression(
|
||||
selectors: readonly (readonly SelectorTerm[])[],
|
||||
options: { bareBooleans?: boolean } = {},
|
||||
): string {
|
||||
return selectors
|
||||
.map((terms) => terms.map((term) => formatSelectorTerm(term, options)).join(' '))
|
||||
.join(' || ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refs (`@eN~sM`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GeneratedRef = { base: string; generation?: number };
|
||||
|
||||
export const refArb: fc.Arbitrary<GeneratedRef> = fc
|
||||
.record({
|
||||
at: fc.boolean(),
|
||||
index: fc.nat({ max: 999 }),
|
||||
generation: fc.option(fc.nat({ max: 999 }), { nil: undefined }),
|
||||
})
|
||||
.map(({ at, index, generation }) => ({
|
||||
base: `${at ? '@' : ''}e${index}`,
|
||||
...(generation === undefined ? {} : { generation }),
|
||||
}));
|
||||
|
||||
/** The `@eN~sM` spelling of a split ref; the inverse of `splitRefGenerationSuffix`. */
|
||||
export function formatRef(ref: GeneratedRef): string {
|
||||
return ref.generation === undefined ? ref.base : `${ref.base}~s${ref.generation}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rects / viewports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Smallest supported iPhone viewports — the geometry examples worth keeping. */
|
||||
export const COMPACT_VIEWPORTS: readonly Rect[] = [
|
||||
{ x: 0, y: 0, width: 320, height: 568 },
|
||||
{ x: 0, y: 0, width: 375, height: 667 },
|
||||
];
|
||||
|
||||
const viewportRectArb: fc.Arbitrary<Rect> = fc.oneof(
|
||||
fc.constantFrom(...COMPACT_VIEWPORTS),
|
||||
fc.record({
|
||||
x: fc.integer({ min: 0, max: 200 }),
|
||||
y: fc.integer({ min: 0, max: 200 }),
|
||||
width: fc.integer({ min: 1, max: 2400 }),
|
||||
height: fc.integer({ min: 1, max: 2400 }),
|
||||
}),
|
||||
);
|
||||
|
||||
/** A point sampled from the viewport's own box, so most gestures are plannable. */
|
||||
function pointInViewportArb(viewport: Rect): fc.Arbitrary<Point> {
|
||||
return fc.record({
|
||||
x: fc.integer({ min: Math.floor(viewport.x), max: Math.floor(viewport.x + viewport.width) }),
|
||||
y: fc.integer({ min: Math.floor(viewport.y), max: Math.floor(viewport.y + viewport.height) }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gestures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One payload arbitrary per canonical gesture kind. Keyed by `GESTURE_KINDS`,
|
||||
* so a new kind fails to typecheck here instead of silently escaping the
|
||||
* geometry property.
|
||||
*/
|
||||
function gesturePayloadArbByKind(
|
||||
viewport: Rect,
|
||||
): Record<(typeof GESTURE_KINDS)[number], fc.Arbitrary<GesturePayload>> {
|
||||
const origin = pointInViewportArb(viewport);
|
||||
const delta = fc.record({
|
||||
x: fc.integer({ min: -viewport.width, max: viewport.width }),
|
||||
y: fc.integer({ min: -viewport.height, max: viewport.height }),
|
||||
});
|
||||
const durationMs = fc.option(fc.integer({ min: 16, max: 2000 }), { nil: undefined });
|
||||
const scale = fc.double({ min: 0.1, max: 4, noNaN: true, noDefaultInfinity: true });
|
||||
return {
|
||||
pan: fc.record({
|
||||
kind: fc.constant('pan' as const),
|
||||
origin,
|
||||
delta,
|
||||
pointerCount: fc.constantFrom(undefined, 1 as const, 2 as const),
|
||||
durationMs,
|
||||
}),
|
||||
fling: fc.record({
|
||||
kind: fc.constant('fling' as const),
|
||||
direction: fc.constantFrom(...SCROLL_DIRECTIONS),
|
||||
origin,
|
||||
distance: fc.option(fc.integer({ min: 1, max: 1200 }), { nil: undefined }),
|
||||
}),
|
||||
swipe: fc.record({
|
||||
kind: fc.constant('swipe' as const),
|
||||
preset: fc.constantFrom(...SWIPE_PRESETS),
|
||||
}),
|
||||
pinch: fc.record({
|
||||
kind: fc.constant('pinch' as const),
|
||||
scale,
|
||||
origin: fc.option(origin, { nil: undefined }),
|
||||
}),
|
||||
rotate: fc.record({
|
||||
kind: fc.constant('rotate' as const),
|
||||
degrees: fc.integer({ min: -720, max: 720 }),
|
||||
origin: fc.option(origin, { nil: undefined }),
|
||||
}),
|
||||
transform: fc.record({
|
||||
kind: fc.constant('transform' as const),
|
||||
origin,
|
||||
delta,
|
||||
scale,
|
||||
degrees: fc.integer({ min: -720, max: 720 }),
|
||||
durationMs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A viewport with a gesture payload of every supported kind planned against it. */
|
||||
export const gestureInViewportArb: fc.Arbitrary<{ viewport: Rect; gesture: GesturePayload }> =
|
||||
viewportRectArb.chain((viewport) => {
|
||||
const byKind = gesturePayloadArbByKind(viewport);
|
||||
return fc.record({
|
||||
viewport: fc.constant(viewport),
|
||||
gesture: fc.oneof(...GESTURE_KINDS.map((kind) => byKind[kind])),
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot trees
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SNAPSHOT_NODE_TYPES = [
|
||||
'XCUIElementTypeWindow',
|
||||
'XCUIElementTypeButton',
|
||||
'XCUIElementTypeStaticText',
|
||||
'XCUIElementTypeTextField',
|
||||
'XCUIElementTypeCell',
|
||||
'android.widget.TextView',
|
||||
'android.widget.Button',
|
||||
] as const;
|
||||
|
||||
const SNAPSHOT_LABELS = ['Increment', 'Submit', 'Email', '67', '', 'Ünïcøde', 'a b c'] as const;
|
||||
|
||||
/** A snapshot tree shaped like a rendered one: sequential indexes, bounded depth. */
|
||||
export const rawSnapshotNodesArb: fc.Arbitrary<RawSnapshotNode[]> = fc
|
||||
.array(
|
||||
fc.record({
|
||||
type: fc.constantFrom(...SNAPSHOT_NODE_TYPES),
|
||||
label: fc.option(fc.constantFrom(...SNAPSHOT_LABELS), { nil: undefined }),
|
||||
value: fc.option(fc.constantFrom(...SNAPSHOT_LABELS), { nil: undefined }),
|
||||
depth: fc.integer({ min: 0, max: 3 }),
|
||||
enabled: fc.boolean(),
|
||||
selected: fc.boolean(),
|
||||
hittable: fc.boolean(),
|
||||
}),
|
||||
{ maxLength: 12 },
|
||||
)
|
||||
.map((nodes) => nodes.map((node, index) => ({ ...node, index })));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `.ad` script lines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const scriptTextArb: fc.Arbitrary<string> = fc.oneof(
|
||||
fc.constantFrom('Submit', 'Log in', 'a "quoted" label', "it's fine", 'tab\tseparated', 'Ünïcøde'),
|
||||
fc.string({ minLength: 1, maxLength: 10 }),
|
||||
);
|
||||
|
||||
const scriptTargetArb: fc.Arbitrary<string> = fc.oneof(
|
||||
refArb.map(formatRef),
|
||||
scriptTextArb.map((text) => JSON.stringify(`label=${text}`)),
|
||||
);
|
||||
|
||||
const coordinateArb = fc.integer({ min: 0, max: 1200 }).map(String);
|
||||
|
||||
/**
|
||||
* A command is either generated from a line template or explicitly waived with
|
||||
* the reason it needs none. `Record<PublicCommandName, …>` makes the
|
||||
* classification exhaustive: a new public command does not compile until it is
|
||||
* given a template or a waiver.
|
||||
*/
|
||||
/**
|
||||
* Every command name a `.ad` line can carry. Internal commands are included
|
||||
* because scripts record them too (`runtime` has its own parse/print branch).
|
||||
*/
|
||||
type ReplayCommandName =
|
||||
| (typeof PUBLIC_COMMANDS)[keyof typeof PUBLIC_COMMANDS]
|
||||
| (typeof INTERNAL_COMMANDS)[keyof typeof INTERNAL_COMMANDS];
|
||||
|
||||
type ReplayLinePlan = fc.Arbitrary<string> | { waived: string };
|
||||
|
||||
/**
|
||||
* Commands whose `.ad` line is a bare `<command> <token>…` handled by the
|
||||
* generic parse/print branch (`appendGenericActionScriptArgs`), whose shape the
|
||||
* `wait`/`longpress` templates already exercise. A command that grows its own
|
||||
* branch in src/replay/script.ts or src/replay/script-formatting.ts must move
|
||||
* to a template.
|
||||
*/
|
||||
const GENERIC_REPLAY_LINE = {
|
||||
waived: 'generic line shape, covered by the wait/longpress templates',
|
||||
} as const;
|
||||
|
||||
const REPLAY_SCRIPT_LINE_PLANS = {
|
||||
click: fc.oneof(
|
||||
scriptTargetArb.map((target) => `click ${target}`),
|
||||
fc.tuple(coordinateArb, coordinateArb).map(([x, y]) => `click ${x} ${y}`),
|
||||
fc
|
||||
.tuple(scriptTargetArb, fc.integer({ min: 1, max: 5 }))
|
||||
.map(([target, count]) => `click ${target} --count ${count}`),
|
||||
),
|
||||
press: fc.oneof(
|
||||
scriptTargetArb.map((target) => `press ${target}`),
|
||||
fc
|
||||
.tuple(scriptTargetArb, fc.constantFrom('primary', 'secondary', 'middle'))
|
||||
.map(([target, button]) => `press ${target} --button ${button}`),
|
||||
),
|
||||
longpress: scriptTargetArb.map((target) => `longpress ${target}`),
|
||||
wait: fc
|
||||
.tuple(scriptTargetArb, fc.integer({ min: 100, max: 5000 }))
|
||||
.map(([target, timeout]) => `wait ${target} ${timeout}`),
|
||||
fill: fc
|
||||
.tuple(scriptTargetArb, scriptTextArb)
|
||||
.map(([target, text]) => `fill ${target} ${JSON.stringify(text)}`),
|
||||
type: scriptTextArb.map((text) => `type ${JSON.stringify(text)}`),
|
||||
get: fc
|
||||
.tuple(fc.constantFrom('text', 'value', 'label'), scriptTargetArb)
|
||||
.map(([property, target]) => `get ${property} ${target}`),
|
||||
swipe: fc
|
||||
.tuple(coordinateArb, coordinateArb, coordinateArb, coordinateArb)
|
||||
.map((coordinates) => `swipe ${coordinates.join(' ')}`),
|
||||
gesture: fc.oneof(
|
||||
fc
|
||||
.tuple(coordinateArb, coordinateArb, coordinateArb, coordinateArb)
|
||||
.map((coordinates) => `gesture pan ${coordinates.join(' ')}`),
|
||||
fc
|
||||
.tuple(fc.constantFrom(...SCROLL_DIRECTIONS), coordinateArb, coordinateArb)
|
||||
.map(([direction, x, y]) => `gesture fling ${direction} ${x} ${y}`),
|
||||
fc.constantFrom(...SWIPE_PRESETS).map((preset) => `gesture swipe ${preset}`),
|
||||
fc.integer({ min: -180, max: 180 }).map((degrees) => `gesture rotate ${degrees}`),
|
||||
),
|
||||
snapshot: fc.oneof(
|
||||
fc.constant('snapshot'),
|
||||
fc.constant('snapshot -i'),
|
||||
fc.integer({ min: 0, max: 6 }).map((depth) => `snapshot -d ${depth}`),
|
||||
scriptTextArb.map((scope) => `snapshot -s ${JSON.stringify(scope)}`),
|
||||
),
|
||||
screenshot: fc.constantFrom('screenshot', 'screenshot out.png'),
|
||||
open: fc.oneof(
|
||||
fc.constantFrom('open com.example.app', 'open com.example.app --relaunch'),
|
||||
fc
|
||||
.tuple(fc.constantFrom('ios', 'android'), fc.integer({ min: 1, max: 65_535 }))
|
||||
.map(
|
||||
([platform, port]) => `open com.example.app --platform ${platform} --metro-port ${port}`,
|
||||
),
|
||||
),
|
||||
runtime: fc.oneof(
|
||||
fc.constant('runtime metro'),
|
||||
fc.constantFrom('ios', 'android').map((platform) => `runtime metro --platform ${platform}`),
|
||||
),
|
||||
record: fc.oneof(
|
||||
fc.constantFrom('record start', 'record stop', 'record start --hide-touches'),
|
||||
fc.integer({ min: 1, max: 120 }).map((fps) => `record start --fps ${fps}`),
|
||||
),
|
||||
alert: GENERIC_REPLAY_LINE,
|
||||
'app-switcher': GENERIC_REPLAY_LINE,
|
||||
apps: GENERIC_REPLAY_LINE,
|
||||
appstate: GENERIC_REPLAY_LINE,
|
||||
artifacts: GENERIC_REPLAY_LINE,
|
||||
audio: GENERIC_REPLAY_LINE,
|
||||
back: GENERIC_REPLAY_LINE,
|
||||
batch: GENERIC_REPLAY_LINE,
|
||||
boot: GENERIC_REPLAY_LINE,
|
||||
capabilities: GENERIC_REPLAY_LINE,
|
||||
clipboard: GENERIC_REPLAY_LINE,
|
||||
close: GENERIC_REPLAY_LINE,
|
||||
devices: GENERIC_REPLAY_LINE,
|
||||
diff: GENERIC_REPLAY_LINE,
|
||||
doctor: GENERIC_REPLAY_LINE,
|
||||
events: GENERIC_REPLAY_LINE,
|
||||
find: GENERIC_REPLAY_LINE,
|
||||
focus: GENERIC_REPLAY_LINE,
|
||||
home: GENERIC_REPLAY_LINE,
|
||||
install: GENERIC_REPLAY_LINE,
|
||||
'install-from-source': GENERIC_REPLAY_LINE,
|
||||
is: GENERIC_REPLAY_LINE,
|
||||
keyboard: GENERIC_REPLAY_LINE,
|
||||
logs: GENERIC_REPLAY_LINE,
|
||||
network: GENERIC_REPLAY_LINE,
|
||||
orientation: GENERIC_REPLAY_LINE,
|
||||
perf: GENERIC_REPLAY_LINE,
|
||||
prepare: GENERIC_REPLAY_LINE,
|
||||
push: GENERIC_REPLAY_LINE,
|
||||
'react-native': GENERIC_REPLAY_LINE,
|
||||
reinstall: GENERIC_REPLAY_LINE,
|
||||
replay: GENERIC_REPLAY_LINE,
|
||||
scroll: GENERIC_REPLAY_LINE,
|
||||
settings: GENERIC_REPLAY_LINE,
|
||||
shutdown: GENERIC_REPLAY_LINE,
|
||||
test: GENERIC_REPLAY_LINE,
|
||||
trace: GENERIC_REPLAY_LINE,
|
||||
'trigger-app-event': GENERIC_REPLAY_LINE,
|
||||
'tv-remote': GENERIC_REPLAY_LINE,
|
||||
viewport: GENERIC_REPLAY_LINE,
|
||||
install_source: GENERIC_REPLAY_LINE,
|
||||
lease_allocate: GENERIC_REPLAY_LINE,
|
||||
lease_heartbeat: GENERIC_REPLAY_LINE,
|
||||
lease_release: GENERIC_REPLAY_LINE,
|
||||
release_materialized_paths: GENERIC_REPLAY_LINE,
|
||||
session_list: GENERIC_REPLAY_LINE,
|
||||
session_save_script: GENERIC_REPLAY_LINE,
|
||||
} satisfies Record<ReplayCommandName, ReplayLinePlan>;
|
||||
|
||||
/**
|
||||
* Line templates for every command, resolved through the runtime command
|
||||
* catalog so a command that exists at runtime but not in the plan fails loudly.
|
||||
*/
|
||||
function replayScriptLineArbs(): fc.Arbitrary<string>[] {
|
||||
const arbitraries: fc.Arbitrary<string>[] = [];
|
||||
const commands: ReplayCommandName[] = [
|
||||
...Object.values(PUBLIC_COMMANDS),
|
||||
...Object.values(INTERNAL_COMMANDS),
|
||||
];
|
||||
for (const command of commands) {
|
||||
const plan: ReplayLinePlan | undefined = REPLAY_SCRIPT_LINE_PLANS[command];
|
||||
if (plan === undefined) {
|
||||
throw new Error(
|
||||
`replay command "${command}" from src/command-catalog.ts is unclassified: ` +
|
||||
'add a line template or a waiver to REPLAY_SCRIPT_LINE_PLANS in ' +
|
||||
'src/__tests__/test-utils/property-arbitraries.ts',
|
||||
);
|
||||
}
|
||||
if (!('waived' in plan)) arbitraries.push(plan);
|
||||
}
|
||||
return arbitraries;
|
||||
}
|
||||
|
||||
const replayScriptLineArb: fc.Arbitrary<string> = fc.oneof(...replayScriptLineArbs());
|
||||
|
||||
/** A valid `.ad` script: action lines interleaved with comments and blank lines. */
|
||||
export const replayScriptArb: fc.Arbitrary<string> = fc
|
||||
.array(
|
||||
fc.oneof(
|
||||
{ weight: 8, arbitrary: replayScriptLineArb },
|
||||
{ weight: 1, arbitrary: fc.constantFrom('# a comment', '') },
|
||||
),
|
||||
{ minLength: 1, maxLength: 8 },
|
||||
)
|
||||
.map((lines) => `${lines.join('\n')}\n`);
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Point, Rect } from '../../kernel/snapshot.ts';
|
||||
import { buildGesturePlan, type MultiTouchGesturePlan } from '../../contracts/gesture-plan.ts';
|
||||
import {
|
||||
buildGesturePlan,
|
||||
type GesturePlan,
|
||||
type MultiTouchGesturePlan,
|
||||
} from '../../contracts/gesture-plan.ts';
|
||||
|
||||
export const PORTRAIT: Rect = { x: 0, y: 0, width: 390, height: 844 };
|
||||
export const LANDSCAPE: Rect = { x: 0, y: 0, width: 844, height: 390 };
|
||||
@@ -48,7 +52,7 @@ export function rotationDelta(plan: MultiTouchGesturePlan): number {
|
||||
return normalizeDegrees(end - start);
|
||||
}
|
||||
|
||||
export function assertAllSamplesInViewport(plan: MultiTouchGesturePlan): void {
|
||||
export function assertAllSamplesInViewport(plan: GesturePlan): void {
|
||||
for (const pointer of plan.pointers) {
|
||||
for (const { point } of pointer.samples) {
|
||||
assert.ok(point.x >= plan.viewport.x && point.x <= plan.viewport.x + plan.viewport.width);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fc from 'fast-check';
|
||||
import { describe, test } from 'vitest';
|
||||
import { buildGesturePlan, GESTURE_SAMPLE_INTERVAL_MS } from '../../contracts/gesture-plan.ts';
|
||||
import {
|
||||
gesturePayloadFromPositionals,
|
||||
normalizePublicGesture,
|
||||
} from '../../contracts/gesture-normalization.ts';
|
||||
import { PUBLIC_PLATFORMS } from '../../kernel/device.ts';
|
||||
import { AppError } from '../../kernel/errors.ts';
|
||||
import {
|
||||
COMPACT_VIEWPORTS,
|
||||
gestureInViewportArb,
|
||||
PROPERTY_RUNS_SMALL,
|
||||
} from '../../__tests__/test-utils/index.ts';
|
||||
import {
|
||||
assertAllSamplesInViewport,
|
||||
isErrorWithReason,
|
||||
@@ -142,6 +154,43 @@ describe('viewport-aware multi-touch geometry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Property, not another example: the planner's whole job is that a synthesized
|
||||
// point never leaves the active viewport. It either plans in bounds or refuses
|
||||
// with a structured INVALID_ARGS — never a silently out-of-bounds path. The
|
||||
// generator enumerates every gesture kind from GESTURE_KINDS, so a new kind
|
||||
// inherits the guarantee instead of needing its own pinned viewport case.
|
||||
describe('gesture planning never synthesizes an out-of-bounds point', () => {
|
||||
test('holds for every gesture kind, viewport, and platform', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
gestureInViewportArb,
|
||||
fc.constantFrom(...PUBLIC_PLATFORMS),
|
||||
({ viewport, gesture }, platform) => {
|
||||
let plan;
|
||||
try {
|
||||
plan = buildGesturePlan(normalizePublicGesture(gesture).gesture, viewport, platform);
|
||||
} catch (error) {
|
||||
assert.ok(error instanceof AppError, `unexpected error type: ${String(error)}`);
|
||||
assert.equal(error.code, 'INVALID_ARGS');
|
||||
return;
|
||||
}
|
||||
assertAllSamplesInViewport(plan);
|
||||
},
|
||||
),
|
||||
{ numRuns: PROPERTY_RUNS_SMALL },
|
||||
);
|
||||
});
|
||||
|
||||
// The smallest supported iPhone viewports, kept as examples of the general
|
||||
// property above: a recorded swipe must still fit a compact screen.
|
||||
test.each(COMPACT_VIEWPORTS)('fits a recorded swipe into %j', (viewport) => {
|
||||
const gesture = normalizePublicGesture(
|
||||
gesturePayloadFromPositionals(['pan', '160', '400', '0', '-120']),
|
||||
).gesture;
|
||||
assertAllSamplesInViewport(buildGesturePlan(gesture, viewport, 'ios'));
|
||||
});
|
||||
});
|
||||
|
||||
function buildTransformPlan(durationMs: number) {
|
||||
return requireTwoPointerPlan(
|
||||
buildGesturePlan(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import fc from 'fast-check';
|
||||
import { test } from 'vitest';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildSnapshotDiff } from '../../snapshot/snapshot-diff.ts';
|
||||
import { buildSnapshotDiff, countSnapshotComparableLines } from '../../snapshot/snapshot-diff.ts';
|
||||
import { buildNodes as nodes } from '../../__tests__/test-utils/snapshot-builders.ts';
|
||||
import { PROPERTY_RUNS_SMALL, rawSnapshotNodesArb } from '../../__tests__/test-utils/index.ts';
|
||||
|
||||
test('buildSnapshotDiff reports unchanged lines when snapshots are equal', () => {
|
||||
const previous = nodes([
|
||||
@@ -106,3 +108,55 @@ test('buildSnapshotDiff flatten option uses flat snapshot line shape', () => {
|
||||
assert.equal(line.text.startsWith(' '), false);
|
||||
}
|
||||
});
|
||||
|
||||
// Properties, not more examples: the two invariants a diff consumer relies on
|
||||
// hold for every tree pair, not for the hand-built ones above — an unchanged
|
||||
// UI must produce no churn, and the summary must be a count of the rendered
|
||||
// lines rather than an independently maintained tally.
|
||||
test('diffing a tree against itself reports no change', () => {
|
||||
fc.assert(
|
||||
fc.property(rawSnapshotNodesArb, fc.boolean(), (raw, flatten) => {
|
||||
const tree = nodes(raw);
|
||||
const diff = buildSnapshotDiff(tree, tree, { flatten });
|
||||
assert.equal(diff.summary.additions, 0);
|
||||
assert.equal(diff.summary.removals, 0);
|
||||
assert.equal(diff.summary.unchanged, countSnapshotComparableLines(tree, { flatten }));
|
||||
assert.deepEqual(
|
||||
diff.lines.filter((line) => line.kind !== 'unchanged'),
|
||||
[],
|
||||
);
|
||||
}),
|
||||
{ numRuns: PROPERTY_RUNS_SMALL },
|
||||
);
|
||||
});
|
||||
|
||||
test('diff summary counts always equal the rendered line counts', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
rawSnapshotNodesArb,
|
||||
rawSnapshotNodesArb,
|
||||
fc.boolean(),
|
||||
(rawPrevious, rawCurrent, flatten) => {
|
||||
const previous = nodes(rawPrevious);
|
||||
const current = nodes(rawCurrent);
|
||||
const diff = buildSnapshotDiff(previous, current, { flatten });
|
||||
const counted = { additions: 0, removals: 0, unchanged: 0 };
|
||||
for (const line of diff.lines) {
|
||||
if (line.kind === 'added') counted.additions += 1;
|
||||
if (line.kind === 'removed') counted.removals += 1;
|
||||
if (line.kind === 'unchanged') counted.unchanged += 1;
|
||||
}
|
||||
assert.deepEqual(diff.summary, counted);
|
||||
assert.equal(
|
||||
diff.summary.additions + diff.summary.unchanged,
|
||||
countSnapshotComparableLines(current, { flatten }),
|
||||
);
|
||||
assert.equal(
|
||||
diff.summary.removals + diff.summary.unchanged,
|
||||
countSnapshotComparableLines(previous, { flatten }),
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: PROPERTY_RUNS_SMALL },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import fc from 'fast-check';
|
||||
import { expect, test } from 'vitest';
|
||||
import { formatRef, PROPERTY_RUNS, refArb } from '../../__tests__/test-utils/index.ts';
|
||||
import { normalizeRef, splitRefGenerationSuffix } from '../snapshot.ts';
|
||||
|
||||
// #1076 versioned refs: `~s<generation>` is accepted INPUT on every ref parse
|
||||
@@ -29,6 +31,22 @@ test('splitRefGenerationSuffix rejects malformed suffixes', () => {
|
||||
expect(splitRefGenerationSuffix('~s3')).toBeNull();
|
||||
});
|
||||
|
||||
// Property, not another example: `@eN~sM` is a two-part grammar, so split and
|
||||
// join must be inverses for every ref and generation, not just the pinned
|
||||
// examples above.
|
||||
test('splitting then rejoining any pinned ref returns the same token', () => {
|
||||
fc.assert(
|
||||
fc.property(refArb, (ref) => {
|
||||
const token = formatRef(ref);
|
||||
const split = splitRefGenerationSuffix(token);
|
||||
expect(split).toEqual(ref);
|
||||
expect(split && formatRef(split)).toBe(token);
|
||||
expect(normalizeRef(token)).toBe(ref.base.replace(/^@/, ''));
|
||||
}),
|
||||
{ numRuns: PROPERTY_RUNS },
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeRef keeps legacy behavior for plain refs', () => {
|
||||
expect(normalizeRef('@e12')).toBe('e12');
|
||||
expect(normalizeRef('e12')).toBe('e12');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import fc from 'fast-check';
|
||||
import { test } from 'vitest';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PROPERTY_RUNS, replayScriptArb } from '../../__tests__/test-utils/index.ts';
|
||||
import { AppError } from '../../kernel/errors.ts';
|
||||
import {
|
||||
parseReplayScriptDetailed,
|
||||
@@ -546,3 +548,25 @@ test('formatDivergenceActionLabel categorically drops fill/type text but keeps t
|
||||
'click "label=\\"Save\\""',
|
||||
);
|
||||
});
|
||||
|
||||
// Property, not another example: `.ad` scripts are written by hand, recorded,
|
||||
// and rewritten, so the parser and the line formatter must agree on ONE
|
||||
// canonical form — re-serializing a parsed script has to be a fixed point.
|
||||
// Generated lines come from the shared `.ad` generator, so a new command shape
|
||||
// extends the generator rather than adding another pinned script here.
|
||||
test('serializing a parsed script is a fixed point for generated scripts', () => {
|
||||
fc.assert(
|
||||
fc.property(replayScriptArb, (script) => {
|
||||
const parsed = parseReplayScriptDetailed(script).actions;
|
||||
const canonical = formatReplayScriptForTest(parsed);
|
||||
const reparsed = parseReplayScriptDetailed(canonical).actions;
|
||||
assert.equal(formatReplayScriptForTest(reparsed), canonical);
|
||||
// The action identity survives the rewrite: same commands, same targets.
|
||||
assert.deepEqual(
|
||||
reparsed.map((action) => [action.command, action.positionals]),
|
||||
parsed.map((action) => [action.command, action.positionals]),
|
||||
);
|
||||
}),
|
||||
{ numRuns: PROPERTY_RUNS },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import fc from 'fast-check';
|
||||
import { test } from 'vitest';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatSelectorChainExpression,
|
||||
PROPERTY_RUNS,
|
||||
selectorChainArb,
|
||||
} from '../__tests__/test-utils/index.ts';
|
||||
import { parseSelectorChain } from './parse.ts';
|
||||
import type { SelectorTerm } from './parse.ts';
|
||||
|
||||
function chainTerms(expression: string): SelectorTerm[][] {
|
||||
return parseSelectorChain(expression).selectors.map((selector) => selector.terms);
|
||||
}
|
||||
|
||||
// Property, not another example: a selector value carries user text, so the
|
||||
// grammar has to survive both quote characters, backslash runs, `||` and `=`
|
||||
// inside a value, and whitespace-only values. Extend the hazard vocabulary in
|
||||
// src/__tests__/test-utils/property-arbitraries.ts rather than pinning one more
|
||||
// case here.
|
||||
test('parsing a printed selector chain yields the terms it was printed from', () => {
|
||||
fc.assert(
|
||||
fc.property(selectorChainArb, ({ selectors, expression }) => {
|
||||
assert.deepEqual(chainTerms(expression), selectors);
|
||||
}),
|
||||
{ numRuns: PROPERTY_RUNS },
|
||||
);
|
||||
});
|
||||
|
||||
test('parse -> serialize -> parse is idempotent for every selector chain', () => {
|
||||
fc.assert(
|
||||
fc.property(selectorChainArb, ({ expression }) => {
|
||||
const terms = chainTerms(expression);
|
||||
const canonical = formatSelectorChainExpression(terms);
|
||||
assert.deepEqual(chainTerms(canonical), terms);
|
||||
assert.equal(formatSelectorChainExpression(chainTerms(canonical)), canonical);
|
||||
}),
|
||||
{ numRuns: PROPERTY_RUNS },
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user