diff --git a/pkg/spec/src/actions.ts b/pkg/spec/src/actions.ts index 156e480..04d72dd 100644 --- a/pkg/spec/src/actions.ts +++ b/pkg/spec/src/actions.ts @@ -71,7 +71,7 @@ export function Wait(parameters: { durationMillis: number }): WaitAction { } function builtinGenerator( - name: "taps" | "typing" | "swipes" | "waitOnce" | "pressKeys", + name: "taps" | "doubleTaps" | "typing" | "swipes" | "waitOnce" | "pressKeys", ): ActionGenerator { return new Proxy({} as ActionGenerator, { get(_target, property) { @@ -85,6 +85,7 @@ function builtinGenerator( } export const taps: ActionGenerator = builtinGenerator("taps"); +export const doubleTaps: ActionGenerator = builtinGenerator("doubleTaps"); export const typing: ActionGenerator = builtinGenerator("typing"); export const swipes: ActionGenerator = builtinGenerator("swipes"); export const waitOnce: ActionGenerator = builtinGenerator("waitOnce"); diff --git a/pkg/spec/src/defaults/actions.ts b/pkg/spec/src/defaults/actions.ts index bdc1964..429180b 100644 --- a/pkg/spec/src/defaults/actions.ts +++ b/pkg/spec/src/defaults/actions.ts @@ -1,15 +1,21 @@ -import { swipes, taps, typing, weighted } from "../actions.ts"; +import { doubleTaps, swipes, taps, typing, weighted } from "../actions.ts"; import type { ActionGenerator } from "../types.ts"; -export { pressKey, swipes, taps, typing, waitOnce } from "../actions.ts"; +export { doubleTaps, pressKey, swipes, taps, typing, waitOnce } from "../actions.ts"; // A broad exploration generator: tap things, type edge-case values into fields, // and swipe. Layer it under targeted depth flows so the fuzzer wanders the whole // app while still driving the paths an author wrote. It deliberately omits the // hardware back key: backing out past the app's root screen exits the app under // test, and exploration must stay within the app. +// +// doubleTaps fires the same target twice ~50ms apart inside one step. Real users +// double-tap (image zoom, like-to-favorite, play/pause); without this the fuzzer +// can never produce sub-100ms event spacing, so race-window bugs (debounce gaps, +// in-flight guards, init races) stay unreachable. export const defaultActions: ActionGenerator = weighted( - [55, taps], - [30, typing], + [45, taps], + [15, doubleTaps], + [25, typing], [15, swipes], ); diff --git a/pkg/spec/src/index.ts b/pkg/spec/src/index.ts index 73503ce..969cf2d 100644 --- a/pkg/spec/src/index.ts +++ b/pkg/spec/src/index.ts @@ -38,6 +38,7 @@ export { Tap, Wait, actions, + doubleTaps, from, pressKey, swipes, diff --git a/pkg/spec/src/types.ts b/pkg/spec/src/types.ts index 5752747..48336c3 100644 --- a/pkg/spec/src/types.ts +++ b/pkg/spec/src/types.ts @@ -209,6 +209,7 @@ export interface SanderlingRuntime { pressKey: (parameters: { key: Key }) => PressKeyAction; wait: (parameters: { durationMillis: number }) => WaitAction; taps: ActionGenerator; + doubleTaps: ActionGenerator; typing: ActionGenerator; swipes: ActionGenerator; waitOnce: ActionGenerator; diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index a04e133..5120abc 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -350,6 +350,7 @@ const runtime = { return { kind: "Wait", durationMillis: p.durationMillis }; }, taps: { __sanderlingActionGenerator: true, __sanderlingKind: "taps" } as ActionGeneratorHandle, + doubleTaps: { __sanderlingActionGenerator: true, __sanderlingKind: "doubleTaps" } as ActionGeneratorHandle, typing: { __sanderlingActionGenerator: true, __sanderlingKind: "typing" } as ActionGeneratorHandle, swipes: { __sanderlingActionGenerator: true, __sanderlingKind: "swipes" } as ActionGeneratorHandle, waitOnce: { __sanderlingActionGenerator: true, __sanderlingKind: "waitOnce" } as ActionGeneratorHandle, @@ -444,7 +445,9 @@ function resolveGenerator(handle: ActionGeneratorHandle): unknown { return resolveGenerator(inner); } case "taps": - return randomTap(); + return randomTap("Tap"); + case "doubleTaps": + return randomTap("DoubleTap"); case "typing": return randomInput(); case "swipes": @@ -462,7 +465,7 @@ function resolveGenerator(handle: ActionGeneratorHandle): unknown { // doesn't re-walk the DOM and re-flush layout on every iteration. let randomTapCandidates: HTMLElement[] | null = null; -function randomTap(): unknown { +function randomTap(kind: "Tap" | "DoubleTap"): unknown { if (!randomTapCandidates) { randomTapCandidates = Array.from( document.querySelectorAll( @@ -480,7 +483,7 @@ function randomTap(): unknown { if (!picked) return null; const rect = picked.getBoundingClientRect(); return { - kind: "Tap", + kind, on: { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2), diff --git a/pkg/spec/test/api.test.ts b/pkg/spec/test/api.test.ts index d55ea2d..666e867 100644 --- a/pkg/spec/test/api.test.ts +++ b/pkg/spec/test/api.test.ts @@ -150,6 +150,7 @@ function installFakeRuntime(): RecordedRuntime { pressKey: ({ key }) => ({ kind: "PressKey", key }), wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }), taps: { __sanderlingActionGenerator: true, generate: () => [] }, + doubleTaps: { __sanderlingActionGenerator: true, generate: () => [] }, typing: { __sanderlingActionGenerator: true, generate: () => [] }, swipes: { __sanderlingActionGenerator: true, generate: () => [] }, waitOnce: { __sanderlingActionGenerator: true, generate: () => [] }, diff --git a/pkg/spec/test/defaults.test.ts b/pkg/spec/test/defaults.test.ts index eb31dd2..a2ee6f9 100644 --- a/pkg/spec/test/defaults.test.ts +++ b/pkg/spec/test/defaults.test.ts @@ -64,6 +64,7 @@ function installRuntime(initialState: State): RecordedRuntime { pressKey: ({ key }) => ({ kind: "PressKey", key }), wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }), taps: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, + doubleTaps: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, typing: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, swipes: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator, waitOnce: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,