import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import type { BrowserSessionRepo } from '../db/browser-session-repo.js'; vi.mock('./script-runner.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runUserScript: vi.fn() }; }); vi.mock('./session-loader.js', () => ({ loadSessionStateForUser: vi.fn() })); import { runUserScript } from './script-runner.js'; import { loadSessionStateForUser } from './session-loader.js'; import { resolveScriptForKind, resolveAndRunUserScript } from './script-orchestrator.js'; const USER = 'user-1'; let root: string; function addMacro(name: string, source = 'export default async () => 1;\n'): string { const dir = join(root, USER, 'browser-macros'); mkdirSync(dir, { recursive: true }); const p = join(dir, name); writeFileSync(p, source); return p; } const MACRO_WITH_SESSION = `--- description: needs a session session_profile_id: 7 --- export default async () => 'macro'; `; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'script-orch-test-')); vi.clearAllMocks(); vi.mocked(runUserScript).mockResolvedValue({ result: 'ran', logs: ['l1'], durationMs: 12 } as never); }); afterEach(() => { rmSync(root, { recursive: true, force: true }); }); describe('resolveScriptForKind', () => { it('resolves a browser-macro to its path with the playwright runtime', () => { const p = addMacro('macro.js'); const r = resolveScriptForKind(root, USER, 'macro.js', 'browser-macro'); expect(r).toEqual({ scriptPath: p, subdir: 'browser-macros', runtime: 'playwright' }); }); it('resolves without an explicit kind', () => { const p = addMacro('macro.js'); const r = resolveScriptForKind(root, USER, 'macro.js', undefined); expect(r).toMatchObject({ scriptPath: p, subdir: 'browser-macros' }); }); it('returns an error for a missing macro', () => { const r = resolveScriptForKind(root, USER, 'ghost.js', undefined); expect(r).toEqual({ error: 'browser-macro not found: browser-macros/ghost.js' }); }); it('treats traversal names as not found instead of escaping', () => { const r = resolveScriptForKind(root, USER, '../../etc/passwd', undefined); expect('error' in r).toBe(true); }); }); describe('resolveAndRunUserScript', () => { it('runs a macro and appends .js to the name', async () => { const p = addMacro('task.js'); const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'task', params: { a: 1 } }); expect(r).toMatchObject({ ok: true, result: 'ran', logs: ['l1'], subdir: 'browser-macros', runtime: 'playwright', }); expect(runUserScript).toHaveBeenCalledWith( expect.objectContaining({ scriptPath: p, params: { a: 1 }, runtime: 'playwright', timeoutMs: 60_000, }), ); expect(loadSessionStateForUser).not.toHaveBeenCalled(); }); it('returns an error result for an unknown macro', async () => { const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'ghost', params: {} }); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toContain('not found'); expect(runUserScript).not.toHaveBeenCalled(); }); it('runs a macro without a session profile directly (no storageState)', async () => { addMacro('macro.js'); const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'macro', params: {}, headless: false, }); expect(r).toMatchObject({ ok: true, subdir: 'browser-macros', runtime: 'playwright' }); expect(runUserScript).toHaveBeenCalledWith( expect.objectContaining({ storageState: undefined, headless: false }), ); }); it('fails when a macro declares a session but no repo is configured', async () => { addMacro('macro.js', MACRO_WITH_SESSION); const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'macro', params: {} }); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toContain('BrowserSessionRepo not configured'); expect(runUserScript).not.toHaveBeenCalled(); }); it('propagates session load failures', async () => { addMacro('macro.js', MACRO_WITH_SESSION); vi.mocked(loadSessionStateForUser).mockResolvedValue({ ok: false, error: { kind: 'profile_not_found', message: 'profile 7 not found' }, } as never); const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'macro', params: {}, sessRepo: {} as BrowserSessionRepo, masterKeyPath: '/k', }); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toBe('profile 7 not found'); }); it('hydrates storageState and passes it to the runner', async () => { addMacro('macro.js', MACRO_WITH_SESSION); const state = { cookies: [] }; vi.mocked(loadSessionStateForUser).mockResolvedValue({ ok: true, storageState: state } as never); const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'macro', params: {}, sessRepo: {} as BrowserSessionRepo, masterKeyPath: '/k', timeoutMs: 5000, }); expect(r.ok).toBe(true); expect(loadSessionStateForUser).toHaveBeenCalledWith( { sessRepo: {}, masterKeyPath: '/k' }, USER, 7, ); expect(runUserScript).toHaveBeenCalledWith( expect.objectContaining({ storageState: state, timeoutMs: 5000 }), ); }); it('reports a frontmatter parse failure', async () => { addMacro('macro.js', '---\nsession_profile_id: -2\n---\nbody'); const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'macro', params: {} }); expect(r.ok).toBe(false); if (!r.ok) expect(r.error).toContain('failed to parse script frontmatter'); }); it('never throws when the runner fails — returns ok:false with duration', async () => { addMacro('boom.js'); vi.mocked(runUserScript).mockRejectedValue(new Error('script exploded')); const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'boom', params: {} }); expect(r.ok).toBe(false); if (!r.ok) { expect(r.error).toBe('script exploded'); expect(typeof r.durationMs).toBe('number'); } }); });