/** * Command Recorder for capturing and replaying tool invocations * * Records all tool calls automatically and allows creating named sequences * by selecting specific command indices from the history. */ export interface RecordedCommand { tool: string; params: Record; delay?: number; comment?: string; } export interface CommandSequence { id: string; name: string; description?: string; expectedOutcome?: string; startUrl?: string; commands: RecordedCommand[]; /** * Steps that run after `commands` reach a terminal state - success, a failed * step, an abort, or the total timeout - but NOT when the run pauses (stepTo, * a breakpoint, click validation), since a paused run is not over and its * state is what the user stopped to inspect. * * They run on their own timeout budget and without the run's abort signal, so * a cancelled or timed-out run still cleans up after itself, and they share * the run's variable store so they can undo what setup captured. Their * outcome never changes the run's verdict. */ teardown?: RecordedCommand[]; createdAt: number; /** * The connection every step was recorded against, when `create` hoisted a * uniform per-step `connectionReason` off the steps (bug-018). Hoisting is * what keeps a sequence portable, but it is lossy: without this, a later * `insert` cannot tell whether the incoming steps came from the SAME browser * as the bare ones (hoist again) or a different one (genuinely * multi-connection). Absent on sequences recorded before this existed and on * ones that never shared a single connection. */ recordedConnection?: string; /** * Browsers this sequence needs before it can run, beyond the run's own * connection. A multi-browser sequence names its connections on the steps, * but naming them does not create them: without this the sequence can only * run when someone has already launched those browsers by hand, so a suite * run skips exactly the coverage that is hardest to get any other way. * * Each entry is launched before the first step if that reference is not * already live. A caller's `connections` rebinding wins: a declaration is a * default, not an override. */ requiredConnections?: Array<{ /** Connection reference the steps use, e.g. 'duo-member-two'. */ reference: string; /** Opened on launch. Defaults to the sequence's startUrl. */ url?: string; /** * Named persistent Chrome profile to bring this reference up on, e.g. * 'device-a' (see launchChrome({ profile })). The profile is the durable * identity - its cookies, localStorage and IndexedDB survive between runs, * so a device enrolled once stays enrolled - while the reference is only a * name for this session. Declaring the pair is what lets a saved sequence * be re-run tomorrow without rewiring which reference means which device. * * Implies reuse: a live Chrome already on this profile is the browser this * declaration wants, so `forceNewInstance` defaults to FALSE here. Only one * live Chrome may hold a profile, so forcing a second process would fail * against the very browser it was asking for. */ profile?: string; /** A distinct browser process, not a tab (default true, but false when * `profile` is set) - two identities sharing one browser share its * storage, which defeats the point. */ forceNewInstance?: boolean; /** Why this browser exists, for the run summary. */ role?: string; }>; /** * WebSockets this sequence's assertions depend on. Declared here rather than * passed per run because the caller cannot be expected to know which socket * carries an app's data - the sequence does, and a declaration cannot be * forgotten by whoever invokes the run. * * Each entry is a substring of the socket URL. A run enforces, for every * entry: at least one matching socket is open when the run ends, and no * matching socket closed or hit frame errors while it executed. That covers * both a transport that died mid-run and one that never came up - the second * being invisible to any "is it up now" assertion written as a final step. * * Match on the app's own path (`/api/sync/socket`), not the origin, so the * declaration survives `baseUrl` retargeting. Dev-server sockets (Vite HMR * and friends) simply go undeclared and are ignored. */ requiredSockets?: string[]; /** * What kind of sequence this is, for selecting and reporting on a suite: * `['ui']`, `['contract', 'slow']`. * * Deliberately free-form rather than a closed `kind`, because the split that * matters is not knowable in advance - a suite wants to slice by area and * speed as readily as by ui-vs-contract, and folders are already spoken for * by scenario shape (spine/story/duo). * * What this answers: a suite reporting "36 passed" reads as interface * coverage, and in one 43-sequence suite 14 of those never issued a single * `input` step - navigate, request, assert, with the browser present only to * hold the auth cookie. Good contract tests, but no UI regression can fail * any of them, and nothing said so. */ tags?: string[]; } interface HistoryCommand extends RecordedCommand { index: number; timestamp: number; } export interface ActiveSequenceState { sequenceId: string; sequenceName: string; connectionReason: string; currentStep: number; totalSteps: number; pausedAt: number; historyIndexAtPause: number; /** Variable store for {{var:name.path}} interpolation, shared by reference * with the ExecutionContext across run/step/finish calls for this pause. */ capturedVariables?: Record; /** {{timestamp}} value for this run, fixed at first resolution so it stays * stable across every step of the same run (including step/finish calls). */ runTimestamp?: number; /** The background run this paused session belongs to, when the pause came * from a registered `run` (stepTo / click validation). Lets `cancel` by * runId clear the right paused session, and `cancel` of the session mark * the owning run record cancelled. */ runId?: string; /** Recorded-reference -> this-session-reference mapping for the run that * paused (replay({ action: 'run', connections: {...} })). Carried on the * paused state so `step`/`finish` resolve per-step connections exactly the * way the original `run` did instead of reverting to raw recorded names. */ connectionMap?: Record; } export declare class CommandRecorder { private history; private sequences; /** Bumped per created sequence: two created in the same millisecond used to * share an id, and the second silently evicted the first from the map. */ private sequenceSeq; private commandCounter; private maxHistorySize; private activeSequence; private historyViewedWhilePaused; /** * Where each in-memory sequence came from, for the ones that came from disk. * A sequence built from history has no entry and is never touched by the * watcher - it exists nowhere else, so there is nothing to reload it from. */ private sequenceSources; private sequenceWatcher; private watchedDirs; /** * Get the sequences directory for a specific scope */ getSequencesDir(global?: boolean): string; /** * Watch the sequences directories and reload edited files, the way a managed * dev server is restarted when its sources change. * * Memory used to shadow disk for the lifetime of the session: a sequence * loaded once kept running its original version however many times you edited * the file, with nothing in the run output to say so. `runAll` reloads the * whole tree first, so the same sequence behaved differently depending on how * it was invoked - which is how the stale copy stayed invisible. * * Idempotent, and safe to call before the directories exist: it attaches to * whichever are present and callers re-invoke it after a save or load. */ startSequenceWatch(): void; /** Stop watching (tests, shutdown). */ stopSequenceWatch(): void; /** * Re-read every disk-backed sequence whose file is newer than the copy in * memory. Returns the names actually reloaded. * * A file that has gone missing or will not parse leaves the in-memory copy * alone: a watcher fires mid-write as readily as after one, and dropping a * good sequence because it was caught half-written would be worse than the * staleness this exists to fix. */ reloadChangedSequences(ids?: string[]): Promise; /** * The current copy of a sequence, re-read first if its file has changed * since it was loaded. * * The watcher debounces (400ms), and an edit followed immediately by a run * is the normal rhythm - so a run does not wait for the watcher to catch up, * it asks. One stat on the way past. */ getFreshSequence(id: string): Promise; /** Remember which file a sequence came from, so the watcher can refresh it. */ private trackSequenceSource; /** * Set active sequence state (for step-through) */ setActiveSequence(state: ActiveSequenceState | null): void; /** * Mark that history was viewed while paused (enables insert) */ markHistoryViewed(): void; /** * Reset history viewed flag (called when other actions are taken) */ resetHistoryViewed(): void; /** * Check if history was viewed while paused */ wasHistoryViewed(): boolean; /** * Get active sequence state */ getActiveSequence(): ActiveSequenceState | null; /** * Update current step in active sequence */ updateActiveSequenceStep(step: number): void; /** * Get commands recorded since sequence was paused */ getCommandsSincePause(): HistoryCommand[]; /** * Get current history index (for tracking pause point) */ getCurrentHistoryIndex(): number; /** * Record a command (always-on, automatic) */ recordCommand(tool: string, params: Record, options?: { delay?: number; comment?: string; }): Promise; /** * Get command history (most recent first) */ getHistory(limit?: number): HistoryCommand[]; /** * Get a specific command by index */ getCommand(index: number): HistoryCommand | undefined; /** * Create a sequence from command indices */ createSequence(name: string, commandIndices: number[], options?: { description?: string; expectedOutcome?: string; startUrl?: string; /** * Called with the fully built candidate BEFORE it replaces any same-named * sequence in memory. Return false to reject: nothing is removed and nothing * is stored, so a rejected create leaves the existing sequence intact. * (Validating after the fact deleted the good copy and then rejected the bad * one, leaving the user with neither.) */ validate?: (candidate: CommandSequence) => boolean; }): Promise; /** * Create a sequence directly from commands (not from history) */ createSequenceFromCommands(name: string, commands: RecordedCommand[], options?: { description?: string; expectedOutcome?: string; startUrl?: string; }): Promise; /** * Check if a sequence with the given name exists */ sequenceNameExists(name: string): boolean; /** * List all saved sequences */ listSequences(): CommandSequence[]; /** * Get a specific sequence by ID */ getSequence(sequenceId: string): CommandSequence | undefined; /** * Delete a sequence */ deleteSequence(sequenceId: string): boolean; /** * Remove ALL in-memory sequences with the given name. Used on create/reload so a * name maps to exactly one sequence — otherwise `loadSequence({name})` resolves the * oldest insertion-order match and re-created sequences are silently ignored (#75). */ private removeSequenceByName; /** * Clear all sequences */ clearAllSequences(): Promise; /** * Clear command history */ clearHistory(): Promise; /** * Get statistics */ getStats(): { historyCount: number; sequenceCount: number; oldestCommandIndex: number | null; newestCommandIndex: number | null; }; /** * Save a sequence to disk * @param sequenceId - ID of the sequence to save * @param global - If true, save to global ~/.cdp-tools/sequences/, otherwise working directory */ saveSequenceToDisk(sequenceId: string, global?: boolean, overwrite?: boolean): Promise<{ success: true; filepath: string; } | { success: false; error: string; conflict?: boolean; filepath?: string; } | null>; /** * Find best matching filename from saved sequences (searches both working dir and global) * Supports: exact match, with/without .json, name prefix (returns latest by timestamp) * Returns the full path to the matched file */ findMatchingFilename(searchTerm: string): Promise<{ filename: string; fullPath: string; matchType: string; location: string; } | null>; /** * Parse a sequence file from disk WITHOUT touching in-memory state. * Split out from registration so a caller can validate the parsed candidate * before anything same-named is evicted (see registerLoadedSequence). */ private parseSequenceFile; /** * Register a parsed sequence in memory, replacing any same-named copy. * Validation (if supplied) runs BEFORE the removal, so a rejected load leaves * the pre-existing sequence completely intact instead of deleting the good copy * and then rejecting the bad one, leaving the user with neither. */ private registerLoadedSequence; /** * Load a sequence from disk (supports fuzzy filename matching) * * @param options.validate - Called with the parsed candidate BEFORE it replaces any * same-named in-memory sequence. Return false to reject: nothing is removed and * nothing is stored, and this method returns null. */ loadSequenceFromDisk(filename: string, options?: { validate?: (candidate: CommandSequence) => boolean; }): Promise; /** * List saved sequences on disk from a specific directory */ private listSequencesFromDir; /** * List saved sequences on disk (checks both working directory and global) */ listSavedSequencesOnDisk(): Promise>; /** * List issue sequences on disk with associated issue metadata */ listIssueSequencesOnDisk(): Promise>; /** * Delete a sequence from disk (supports fuzzy filename matching) */ deleteSequenceFromDisk(filename: string): Promise; } export {}; //# sourceMappingURL=command-recorder.d.ts.map