From bcb31da77301546b976092178c605d915d61c9ca Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 2 Apr 2026 09:05:47 +0800 Subject: [PATCH] refactor: move runner context to handler's this --- src/utils/command/command-registry.ts | 92 +++++++++---- src/utils/command/command-runner.ts | 13 ++ src/utils/command/index.ts | 4 +- tests/utils/command-runner.test.ts | 191 +++++++++++++++++++++++++- 4 files changed, 270 insertions(+), 30 deletions(-) diff --git a/src/utils/command/command-registry.ts b/src/utils/command/command-registry.ts index 3e751cf..0d0d7d5 100644 --- a/src/utils/command/command-registry.ts +++ b/src/utils/command/command-registry.ts @@ -1,7 +1,8 @@ import type { Command } from './types.js'; -import type { CommandRunner, CommandRunnerContext as RunnerContext } from './command-runner.js'; +import type { CommandRunner, CommandRunnerContext, PromptEvent } from './command-runner.js'; import { parseCommand } from './command-parse.js'; import { applyCommandSchema } from './command-apply.js'; +import { parseCommandSchema } from './schema-parse.js'; export type CommandRegistry = Map>; @@ -37,17 +38,54 @@ export function getCommand( return registry.get(name); } -async function executeWithRunnerContext( +type Listener = (e: PromptEvent) => void; + +export type CommandRunnerContextExport = CommandRunnerContext & { + registry: CommandRegistry; +}; + +export function createCommandRunnerContext( registry: CommandRegistry, - context: TContext, + context: TContext +): CommandRunnerContextExport { + const listeners = new Set(); + + const on = (_event: 'prompt', listener: Listener) => { + listeners.add(listener); + }; + + const off = (_event: 'prompt', listener: Listener) => { + listeners.delete(listener); + }; + + const prompt = (schema: Parameters['prompt']>[0]): Promise => { + const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema; + return new Promise((resolve, reject) => { + const event: PromptEvent = { schema: resolvedSchema, resolve, reject }; + for (const listener of listeners) { + listener(event); + } + }); + }; + + const runnerCtx: CommandRunnerContextExport = { + registry, + context, + run: (input: string) => runCommandWithContext(registry, runnerCtx, input), + runParsed: (command: Command) => runCommandParsedWithContext(registry, runnerCtx, command), + prompt, + on, + off, + }; + + return runnerCtx; +} + +async function executeWithRunnerContext( + runnerCtx: CommandRunnerContextExport, runner: CommandRunner, command: Command ): Promise<{ success: true; result: unknown } | { success: false; error: string }> { - const runnerCtx: RunnerContext = { - context, - run: (input: string) => runCommand(registry, context, input), - runParsed: (cmd: Command) => runCommandParsed(registry, context, cmd), - }; try { const result = await runner.run.call(runnerCtx, command); return { success: true, result }; @@ -61,15 +99,33 @@ export async function runCommand( registry: CommandRegistry, context: TContext, input: string +): Promise<{ success: true; result: unknown } | { success: false; error: string }> { + const runnerCtx = createCommandRunnerContext(registry, context); + return await runCommandWithContext(registry, runnerCtx, input); +} + +async function runCommandWithContext( + registry: CommandRegistry, + runnerCtx: CommandRunnerContextExport, + input: string ): Promise<{ success: true; result: unknown } | { success: false; error: string }> { const command = parseCommand(input); - return await runCommandParsed(registry, context, command); + return await runCommandParsedWithContext(registry, runnerCtx, command); } export async function runCommandParsed( registry: CommandRegistry, context: TContext, command: Command +): Promise<{ success: true; result: unknown } | { success: false; error: string }> { + const runnerCtx = createCommandRunnerContext(registry, context); + return await runCommandParsedWithContext(registry, runnerCtx, command); +} + +async function runCommandParsedWithContext( + registry: CommandRegistry, + runnerCtx: CommandRunnerContextExport, + command: Command ): Promise<{ success: true; result: unknown } | { success: false; error: string }> { const runner = registry.get(command.name); if (!runner) { @@ -81,21 +137,5 @@ export async function runCommandParsed( return { success: false, error: validationResult.errors.join('; ') }; } - return await executeWithRunnerContext(registry, context, runner, validationResult.command); -} - -export type CommandRunnerContext = RunnerContext & { - registry: CommandRegistry; -}; - -export function createCommandRunnerContext( - registry: CommandRegistry, - context: TContext -): CommandRunnerContext { - return { - registry, - context, - run: (input: string) => runCommand(registry, context, input), - runParsed: (command: Command) => runCommandParsed(registry, context, command), - }; + return await executeWithRunnerContext(runnerCtx, runner, validationResult.command); } diff --git a/src/utils/command/command-runner.ts b/src/utils/command/command-runner.ts index eec7241..17644f0 100644 --- a/src/utils/command/command-runner.ts +++ b/src/utils/command/command-runner.ts @@ -1,9 +1,22 @@ import type { Command, CommandSchema } from './types.js'; +export type PromptEvent = { + schema: CommandSchema; + resolve: (command: Command) => void; + reject: (error: Error) => void; +}; + +export type CommandRunnerEvents = { + prompt: PromptEvent; +}; + export type CommandRunnerContext = { context: TContext; run: (input: string) => Promise<{ success: true; result: unknown } | { success: false; error: string }>; runParsed: (command: Command) => Promise<{ success: true; result: unknown } | { success: false; error: string }>; + prompt: (schema: CommandSchema | string) => Promise; + on: (event: T, listener: (e: CommandRunnerEvents[T]) => void) => void; + off: (event: T, listener: (e: CommandRunnerEvents[T]) => void) => void; }; export type CommandRunnerHandler = ( diff --git a/src/utils/command/index.ts b/src/utils/command/index.ts index 1856966..30ff9bc 100644 --- a/src/utils/command/index.ts +++ b/src/utils/command/index.ts @@ -18,5 +18,5 @@ export type { CommandFlagSchema, CommandSchema, } from './types'; -export type { CommandRunner, CommandRunnerHandler } from './command-runner'; -export type { CommandRegistry, CommandRunnerContext } from './command-registry'; +export type { CommandRunner, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './command-runner'; +export type { CommandRegistry, CommandRunnerContextExport } from './command-registry'; diff --git a/tests/utils/command-runner.test.ts b/tests/utils/command-runner.test.ts index 51fe151..e4c76aa 100644 --- a/tests/utils/command-runner.test.ts +++ b/tests/utils/command-runner.test.ts @@ -9,9 +9,9 @@ import { runCommand, createCommandRunnerContext, type CommandRegistry, - type CommandRunnerContext, + type CommandRunnerContextExport, } from '../../src/utils/command/command-registry'; -import type { CommandRunner } from '../../src/utils/command/command-runner'; +import type { CommandRunner, PromptEvent } from '../../src/utils/command/command-runner'; type TestContext = { counter: number; @@ -238,3 +238,190 @@ describe('CommandRunnerContext', () => { } }); }); + +describe('prompt', () => { + it('should dispatch prompt event with string schema', async () => { + const registry = createCommandRegistry(); + + const chooseRunner: CommandRunner = { + schema: parseCommandSchema('choose'), + run: async function () { + const result = await this.prompt('select '); + return result.params[0] as string; + }, + }; + + registerCommand(registry, chooseRunner); + + const ctx = { counter: 0, log: [] }; + let promptEvent: PromptEvent | null = null; + + const runnerCtx = createCommandRunnerContext(registry, ctx); + runnerCtx.on('prompt', (e) => { + promptEvent = e; + }); + + const runPromise = runnerCtx.run('choose'); + + await new Promise((r) => setTimeout(r, 0)); + expect(promptEvent).not.toBeNull(); + expect(promptEvent!.schema.name).toBe('select'); + }); + + it('should resolve prompt with valid input', async () => { + const registry = createCommandRegistry(); + + const chooseRunner: CommandRunner = { + schema: parseCommandSchema('choose'), + run: async function () { + const result = await this.prompt('select '); + this.context.log.push(`selected ${result.params[0]}`); + return result.params[0] as string; + }, + }; + + registerCommand(registry, chooseRunner); + + const ctx = { counter: 0, log: [] }; + let promptEvent: PromptEvent | null = null; + + const runnerCtx = createCommandRunnerContext(registry, ctx); + runnerCtx.on('prompt', (e) => { + promptEvent = e; + }); + + const runPromise = runnerCtx.run('choose'); + + await new Promise((r) => setTimeout(r, 0)); + expect(promptEvent).not.toBeNull(); + + const parsed = { name: 'select', params: ['Ace'], options: {}, flags: {} }; + promptEvent!.resolve(parsed); + + const result = await runPromise; + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe('Ace'); + } + expect(ctx.log).toEqual(['selected Ace']); + }); + + it('should reject prompt with invalid input', async () => { + const registry = createCommandRegistry(); + + const chooseRunner: CommandRunner = { + schema: parseCommandSchema('choose'), + run: async function () { + try { + await this.prompt('select '); + return 'unexpected success'; + } catch (e) { + return (e as Error).message; + } + }, + }; + + registerCommand(registry, chooseRunner); + + const ctx = { counter: 0, log: [] }; + let promptEvent: PromptEvent | null = null; + + const runnerCtx = createCommandRunnerContext(registry, ctx); + runnerCtx.on('prompt', (e) => { + promptEvent = e; + }); + + const runPromise = runnerCtx.run('choose'); + + await new Promise((r) => setTimeout(r, 0)); + expect(promptEvent).not.toBeNull(); + + promptEvent!.reject(new Error('user cancelled')); + + const result = await runPromise; + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe('user cancelled'); + } + }); + + it('should accept CommandSchema object in prompt', async () => { + const registry = createCommandRegistry(); + const schema = parseCommandSchema('pick '); + + const pickRunner: CommandRunner = { + schema: parseCommandSchema('pick'), + run: async function () { + const result = await this.prompt(schema); + return result.params[0] as string; + }, + }; + + registerCommand(registry, pickRunner); + + const ctx = { counter: 0, log: [] }; + let promptEvent: PromptEvent | null = null; + + const runnerCtx = createCommandRunnerContext(registry, ctx); + runnerCtx.on('prompt', (e) => { + promptEvent = e; + }); + + const runPromise = runnerCtx.run('pick'); + + await new Promise((r) => setTimeout(r, 0)); + expect(promptEvent).not.toBeNull(); + expect(promptEvent!.schema.name).toBe('pick'); + + promptEvent!.resolve({ name: 'pick', params: ['sword'], options: {}, flags: {} }); + + const result = await runPromise; + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe('sword'); + } + }); + + it('should allow multiple sequential prompts', async () => { + const registry = createCommandRegistry(); + + const multiPromptRunner: CommandRunner = { + schema: parseCommandSchema('multi'), + run: async function () { + const first = await this.prompt('first '); + const second = await this.prompt('second '); + return [first.params[0] as string, second.params[0] as string]; + }, + }; + + registerCommand(registry, multiPromptRunner); + + const ctx = { counter: 0, log: [] }; + const promptEvents: PromptEvent[] = []; + + const runnerCtx = createCommandRunnerContext(registry, ctx); + runnerCtx.on('prompt', (e) => { + promptEvents.push(e); + }); + + const runPromise = runnerCtx.run('multi'); + + await new Promise((r) => setTimeout(r, 0)); + expect(promptEvents.length).toBe(1); + expect(promptEvents[0].schema.name).toBe('first'); + + promptEvents[0].resolve({ name: 'first', params: ['one'], options: {}, flags: {} }); + + await new Promise((r) => setTimeout(r, 0)); + expect(promptEvents.length).toBe(2); + expect(promptEvents[1].schema.name).toBe('second'); + + promptEvents[1].resolve({ name: 'second', params: ['two'], options: {}, flags: {} }); + + const result = await runPromise; + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toEqual(['one', 'two']); + } + }); +});