/ src / pipeline / steps / tap.ts
tap.ts
  1  /**
  2   * Pipeline step: tap — declarative Store Action Bridge.
  3   *
  4   * Generates a self-contained IIFE that:
  5   * 1. Injects fetch + XHR dual interception proxy
  6   * 2. Finds the Pinia/Vuex store and calls the action
  7   * 3. Captures the response matching the URL pattern
  8   * 4. Auto-cleans up interception in finally block
  9   * 5. Returns the captured data (optionally sub-selected)
 10   */
 11  
 12  import type { IPage } from '../../types.js';
 13  import { render } from '../template.js';
 14  import { generateTapInterceptorJs } from '../../interceptor.js';
 15  import { CliError } from '../../errors.js';
 16  
 17  interface TapParams {
 18    store?: string;
 19    action?: string;
 20    capture?: string;
 21    timeout?: number;
 22    select?: string;
 23    framework?: string | null;
 24    args?: unknown[];
 25  }
 26  
 27  export async function stepTap(
 28    page: IPage | null,
 29    params: unknown,
 30    data: unknown,
 31    args: Record<string, unknown>,
 32  ): Promise<unknown> {
 33    const cfg: TapParams = typeof params === 'object' && params !== null ? (params as TapParams) : {};
 34    const storeName = String(render(cfg.store ?? '', { args, data }));
 35    const actionName = String(render(cfg.action ?? '', { args, data }));
 36    const capturePattern = String(render(cfg.capture ?? '', { args, data }));
 37    const timeout = cfg.timeout ?? 5;
 38    const selectPath = cfg.select ? String(render(cfg.select, { args, data })) : null;
 39    const framework = cfg.framework ?? null;
 40    const actionArgs: unknown[] = cfg.args ?? [];
 41  
 42    if (!storeName || !actionName) throw new CliError('TAP_MISSING_PARAMS', 'tap: store and action are required');
 43  
 44    // Build select chain for the captured response
 45    const selectChain = selectPath
 46      ? selectPath.split('.').map((p: string) => `?.[${JSON.stringify(p)}]`).join('')
 47      : '';
 48  
 49    // Serialize action arguments
 50    const actionArgsRendered = actionArgs.map((a) => {
 51      const rendered = render(a, { args, data });
 52      return JSON.stringify(rendered);
 53    });
 54    const actionCall = actionArgsRendered.length
 55      ? `store[${JSON.stringify(actionName)}](${actionArgsRendered.join(', ')})`
 56      : `store[${JSON.stringify(actionName)}]()`;
 57  
 58    // Use shared interceptor generator for fetch/XHR patching
 59    const tap = generateTapInterceptorJs(JSON.stringify(capturePattern));
 60  
 61    const js = `
 62      async () => {
 63        // ── 1. Setup capture proxy (fetch + XHR dual interception) ──
 64        ${tap.setupVar}
 65        ${tap.fetchPatch}
 66        ${tap.xhrPatch}
 67  
 68        try {
 69          // ── 2. Find store ──
 70          let store = null;
 71          const storeName = ${JSON.stringify(storeName)};
 72          const fw = ${JSON.stringify(framework)};
 73  
 74          const app = document.querySelector('#app');
 75          if (!fw || fw === 'pinia') {
 76            try {
 77              const pinia = app?.__vue_app__?.config?.globalProperties?.$pinia;
 78              if (pinia?._s) store = pinia._s.get(storeName);
 79            } catch {}
 80          }
 81          if (!store && (!fw || fw === 'vuex')) {
 82            try {
 83              const vuexStore = app?.__vue_app__?.config?.globalProperties?.$store
 84                ?? app?.__vue__?.$store;
 85              if (vuexStore) {
 86                store = { [${JSON.stringify(actionName)}]: (...a) => vuexStore.dispatch(storeName + '/' + ${JSON.stringify(actionName)}, ...a) };
 87              }
 88            } catch {}
 89          }
 90  
 91          if (!store) return { error: 'Store not found: ' + storeName, hint: 'Page may not be fully loaded or store name may be incorrect' };
 92          if (typeof store[${JSON.stringify(actionName)}] !== 'function') {
 93            return { error: 'Action not found: ' + ${JSON.stringify(actionName)} + ' on store ' + storeName,
 94              hint: 'Available: ' + Object.keys(store).filter(k => typeof store[k] === 'function' && !k.startsWith('$') && !k.startsWith('_')).join(', ') };
 95          }
 96  
 97          // ── 3. Call store action ──
 98          await ${actionCall};
 99  
100          // ── 4. Wait for network response ──
101          if (!${tap.capturedVar}) {
102            const timeoutPromise = new Promise(r => setTimeout(r, ${timeout} * 1000));
103            await Promise.race([${tap.promiseVar}, timeoutPromise]);
104          }
105        } finally {
106          // ── 5. Always restore originals ──
107          ${tap.restorePatch}
108        }
109  
110        if (!${tap.capturedVar}) return { error: 'No matching response captured for pattern: ' + capturePattern };
111        return ${tap.capturedVar}${selectChain} ?? ${tap.capturedVar};
112      }
113    `;
114  
115    return page!.evaluate(js);
116  }