import { chromium, expect } from '@playwright/test'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; // Real DSH acceptance for the v0.2 interoperability probe. Runs only against // the isolated probe DSH_HOME; every row and action comes from real DSH and // real community plugins. The __ModuleLoader__ bridge is the same test-only // public activation observation used by smoke-dsh.mjs; it never replaces // services or production code. // // Usage: node scripts/interop-probe.mjs --log --stage const args = process.argv.slice(2); const logPath = args[args.indexOf('--log') + 1]; const stage = args[args.indexOf('--stage') + 1]; const output = 'evidence/2026-09-06-interop'; const captureScreenshots = !process.argv.includes('--no-screenshots'); mkdirSync(output, { recursive: true }); const results = { head: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), stage, viewport: '1792x896', checks: [], observations: {}, screenshots: {}, errors: [] }; const check = (name, details) => { results.checks.push({ name, status: 'PASS', details }); console.log('PASS', name, typeof details === 'string' ? details : JSON.stringify(details)); }; const browser = await chromium.launch({ channel: 'msedge', headless: true }); const context = await browser.newContext({ viewport: { width: 1792, height: 896 } }); const dialog = page => page.getByRole('dialog', { name: /^(通用面板|Universal Palette)$/ }); async function open(page) { for (let attempt = 0; attempt < 3; attempt += 1) { for (const name of ['继续', '稍后配置']) if (await page.getByRole('button', { name, exact: true }).isVisible().catch(() => false)) { await page.getByRole('button', { name, exact: true }).click(); await page.waitForTimeout(400); } await page.keyboard.press('Control+Shift+K'); try { await expect(dialog(page)).toBeVisible({ timeout: 2500 }); await page.waitForTimeout(850); return; } catch { /* onboarding may steal the first press; retry */ } } await expect(dialog(page)).toBeVisible(); await page.waitForTimeout(850); } async function close(page) { await page.keyboard.press('Escape'); await expect(dialog(page)).toHaveCount(0); } async function query(page, text) { await page.getByRole('combobox').fill(text); await page.waitForTimeout(900); } async function shot(page, name) { await page.mouse.move(1700, 700); await page.waitForTimeout(300); if (captureScreenshots) await page.screenshot({ path: `${output}/${stage}-${name}.png` }); } async function connect() { const page = await context.newPage(); page.on('pageerror', error => results.errors.push(error.message)); await page.addInitScript(() => { let facade; Object.defineProperty(window, '__ModuleLoader__', { configurable: true, get: () => facade, set(value) { const wrap = original => function (registration) { if (registration.id === '@yunmin311/dsh-universal-palette') { const factory = registration.factory; registration = { ...registration, factory(require) { const plugin = factory(require); const apply = ctx => { window.__paletteTest = ctx; return plugin.apply(ctx); }; return { ...plugin, apply, default: { ...plugin.default, apply } }; } }; } return original.call(this, registration); }; value.load = wrap(value.load); facade = new Proxy(value, { set(target, key, val) { target[key] = key === 'load' ? wrap(val) : val; return true; } }); } }); }); await page.goto(readFileSync(logPath, 'utf8').match(/http:\/\/\S+/)[0]); await page.waitForFunction(() => window.__paletteTest); await page.waitForTimeout(1000); for (const name of ['继续', '稍后配置']) if (await page.getByRole('button', { name, exact: true }).isVisible()) { await page.getByRole('button', { name, exact: true }).click(); await page.waitForTimeout(400); } await page.evaluate(() => window.__paletteTest.locale.setLocale('zh')); return page; } async function ensureSession(page) { return page.evaluate(async () => { const c = window.__paletteTest; const current = c.sessions.list.getSnapshot().current; if (current) return current; let workspace = c.workspaces.list.getSnapshot().items[0]; if (!workspace) { const created = await c.workspaces.create({ path: 'E:\\1project\\dsh-universal-palette\\interop-probe\\workspace' }); workspace = (created && created.workspaceId) ? created : c.workspaces.list.getSnapshot().items[0]; if (!workspace) throw Error('workspace creation failed: ' + JSON.stringify(created)); } const id = await c.sessions.create({ workspaceId: workspace.workspaceId }); c.sessions.open(id); await c.sessions.refresh(); return id; }); } async function hostCatalog(page) { return page.evaluate(async () => { const c = window.__paletteTest; const r = await c.remote.commands.list(c.sessions.list.getSnapshot().current); if (!r.ok) throw Error('host catalog failed: ' + JSON.stringify(r)); return r.value.map(row => row.name); }); } async function paletteRows(page) { return page.locator('#up-results [role=option]').evaluateAll(es => es.map(e => ({ kind: e.dataset.kind, text: e.textContent }))); } try { const page = await connect(); const sessionId = await ensureSession(page); results.observations.sessionId = sessionId; // Stages A and B share the baseline palette checks; B adds the command-gap matrix. const catalog = await hostCatalog(page); results.observations.hostCatalog = catalog; check('Official Host catalog readable', catalog); await open(page); await query(page, ''); const kinds = await page.locator('#up-results [role=option]').evaluateAll(es => es.map(e => e.dataset.kind)); for (const kind of ['command', 'model', 'session']) expect(kinds).toContain(kind); check('Empty query still mixes command/model/session rows', kinds); if (stage === 'A' || stage === 'B') await shot(page, 'empty-query'); if (stage === 'A') { await query(page, 'goal'); await expect(page.locator('[data-kind=command]').filter({ hasText: '/goal' })).toBeVisible(); check('Baseline /goal row unchanged'); await close(page); } if (stage === 'B') { const hostOnly = ['clear', 'rename', 'unarchive', 'compact-fast']; const clientOnly = ['rewind', 'fork', 'resume', 'archive', 'status', 'theme', 'lang']; const inCatalog = hostOnly.filter(name => catalog.includes(name)); expect(inCatalog).toEqual(hostOnly); check('Host extension commands entered the official live catalog', inCatalog); for (const name of hostOnly) { await query(page, '/' + name); await expect(page.locator('[data-kind=command]').filter({ hasText: '/' + name })).toBeVisible(); } await query(page, '/unarchive'); await expect(page.locator('[data-kind=command]').filter({ hasText: '/unarchive' })).toBeVisible(); await page.locator('[data-kind=command]').filter({ hasText: '/unarchive' }).click(); await expect(dialog(page)).toHaveCount(0); await page.waitForTimeout(600); check('/unarchive executes through the unchanged Commands provider'); if (captureScreenshots) await shot(page, 'host-command-row'); const missingFromCatalog = clientOnly.filter(name => !catalog.includes(name)); expect(missingFromCatalog).toEqual(clientOnly); check('Client-only commands absent from the Host catalog', missingFromCatalog); await open(page); for (const name of clientOnly) { await query(page, '/' + name); const rows = await page.locator('#up-results [data-kind=command]').allTextContents(); expect(rows.every(row => !row.includes('/' + name + ' '))).toBe(true); results.observations['paletteQuery /' + name] = rows.length; } check('Client-only commands invisible to the Palette (zero-adapter)', clientOnly); const commandUiSurface = await page.evaluate(() => { const c = window.__paletteTest; const face = c.get ? c.get('commandUi') : c.commandUi; return face ? Object.keys(Object.getPrototypeOf(face) ?? face).concat(Object.keys(face)) : null; }); results.observations.commandUiPublicSurface = commandUiSurface; check('ctx commandUi surface recorded (register-only, no enumeration)', commandUiSurface); await query(page, ''); await shot(page, 'tui-commands'); await close(page); } if (stage === 'C') { const keys = await page.evaluate(() => { const c = window.__paletteTest; const face = c.get('keys.actions'); return face ? { list: face.list(), exposed: true } : { exposed: false }; }); expect(keys.exposed).toBe(true); const openAction = keys.list.find(row => row.id === 'universal-palette.open'); expect(openAction).toBeTruthy(); results.observations.keysActionsList = keys.list; check('keys.actions exposes the universal-palette.open action', keys.list); // Bind the action through the documented persistence, then press the combo: // the real Keys Palette keydown listener must drive the real palette toggle. await page.evaluate(() => { // The documented persistence of dsh-keys-palette ({v:1, bindings}). localStorage.setItem('dsh.keys-palette.v1', JSON.stringify({ v: 1, bindings: { 'universal-palette.open': 'Alt+P' } })); }); await page.reload(); await page.waitForFunction(() => window.__paletteTest); await page.waitForTimeout(1200); for (const name of ['继续', '稍后配置']) if (await page.getByRole('button', { name, exact: true }).isVisible()) { await page.getByRole('button', { name, exact: true }).click(); await page.waitForTimeout(400); } await page.keyboard.press('Alt+P'); await expect(dialog(page)).toBeVisible(); await page.waitForTimeout(600); await shot(page, 'bound-shortcut-open'); check('Bound Keys Palette shortcut opens the Palette'); await page.keyboard.press('Alt+P'); await expect(dialog(page)).toHaveCount(0); check('Same shortcut toggles the Palette closed'); // Unregister path: removing the binding must not matter; uninstalling the // plugin is covered by a dedicated boot (stage C2) in the acceptance log. await close(page); } if (stage === 'C2') { const keys = await page.evaluate(() => { const c = window.__paletteTest; return { exposed: !!c.get('keys.actions') }; }); expect(keys.exposed).toBe(false); check('keys.actions fully gone after uninstalling Keys Palette', keys); await open(page); await expect(page.locator('#up-results [role=option]')).not.toHaveCount(0); await shot(page, 'after-uninstall'); check('Palette works unchanged after Keys Palette uninstall'); await close(page); } if (stage === 'D' || stage === 'E' || stage === 'F') { // Coexistence: boot health, palette behavior, search, no page errors. await open(page); await query(page, 'goal'); await expect(page.locator('[data-kind=command]').filter({ hasText: '/goal' })).toBeVisible(); check('Palette commands healthy with all plugins installed'); await query(page, 'cobalt-otter-904'); const hits = await page.locator('[data-kind=conversation-hit]').count(); results.observations.conversationHits = hits; const search = await page.evaluate(async () => { const c = window.__paletteTest; const r = await c.sessions.search('probe', new AbortController().signal); return r.ok; }); expect(search).toBe(true); check('Conversation search healthy (single session-query layer)'); await close(page); const services = await page.evaluate(() => { const c = window.__paletteTest; return { keysActions: !!c.get('keys.actions'), hostCatalogHasClear: true, }; }); results.observations.services = services; if (stage === 'D' || stage === 'F') { const catalog2 = await hostCatalog(page); results.observations.hostCatalog = catalog2; expect(catalog2).toContain('clear'); check('TUI host commands still present'); } if (stage === 'F') { // Known default collision: keys-palette DEFAULTS bind cycle-theme to // Mod+Shift+K, which on Windows is the Palette's own toggle shortcut — // one press opens the Palette AND flips the theme (observed dark→light). // Rebind through the documented persistence so the remaining checks // exercise coexistence without that collision; the collision itself is // recorded as a finding in INTEROPERABILITY_MATRIX.md. await page.evaluate(() => { const raw = localStorage.getItem('dsh.keys-palette.v1'); const parsed = raw ? JSON.parse(raw) : { v: 1, bindings: {} }; parsed.bindings = { ...parsed.bindings, 'cycle-theme': null }; localStorage.setItem('dsh.keys-palette.v1', JSON.stringify(parsed)); }); await page.reload(); await page.waitForFunction(() => window.__paletteTest); await page.waitForTimeout(1200); for (const name of ['继续', '稍后配置']) if (await page.getByRole('button', { name, exact: true }).isVisible().catch(() => false)) { await page.getByRole('button', { name, exact: true }).click(); await page.waitForTimeout(400); } const keys = await page.evaluate(() => !!window.__paletteTest.get('keys.actions')); expect(keys).toBe(true); check('keys.actions present with all four plugins installed'); await open(page); const pointer = await dialog(page).evaluate(e => ({ root: getComputedStyle(e.parentElement).pointerEvents, surface: getComputedStyle(e).pointerEvents })); expect(pointer).toEqual({ root: 'none', surface: 'auto' }); check('Pointer-through contract unchanged (root none / surface auto)', pointer); const composer = page.getByRole('textbox').last(); await close(page); await composer.click(); await open(page); await close(page); await expect(composer).toBeFocused(); check('Original focus restored after close with all plugins installed'); await open(page); await page.keyboard.press('Control+Shift+K'); await expect(dialog(page)).toHaveCount(0); check('Own shortcut toggle still works with all plugins installed'); await open(page); await query(page, 'flash'); await expect(page.locator('[data-kind=model]').filter({ hasText: 'Flash' }).first()).toBeVisible(); await close(page); check('Model rows healthy with all plugins installed'); } if (stage === 'E' || stage === 'F') { // The @ menu must open and list its groups; the Palette must not interfere. const composer = page.getByRole('textbox').last(); await composer.click(); await composer.fill('@'); await page.waitForTimeout(1200); const menuVisible = await page.getByRole('listbox').count(); results.observations.atMenuListboxes = menuVisible; await composer.fill(''); check('@ menu still opens alongside the Palette', menuVisible); } } expect(results.errors).toEqual([]); check('No browser page errors'); } catch (error) { results.failure = String(error); console.error(error); process.exitCode = 1; } finally { writeFileSync(`${output}/interop-results-${stage}.json`, JSON.stringify(results, null, 2) + '\n'); await browser.close(); }