|
|
@@ -0,0 +1,115 @@
|
|
|
+// Web e2e scenario: the real host filters skill.list to the model-and-user
|
|
|
+// intersection before the browser slash source renders candidates. A real
|
|
|
+// chromium connects a fresh workspace seeded with all four policy quadrants;
|
|
|
+// no model call is issued, so a stray stream fails loud on the open LLM seam.
|
|
|
+import { mkdir, writeFile } from 'node:fs/promises'
|
|
|
+import { fileURLToPath } from 'node:url'
|
|
|
+import { join } from 'node:path'
|
|
|
+import type { Browser, Page } from 'playwright'
|
|
|
+import { chromium } from 'playwright'
|
|
|
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
|
|
+import {
|
|
|
+ assertFixtureInventory,
|
|
|
+ captureStableAria,
|
|
|
+ compareOrRefreshGolden,
|
|
|
+ launchWebScaffold,
|
|
|
+ watchConsole,
|
|
|
+ webSnapshotMode,
|
|
|
+ type WebScaffold,
|
|
|
+} from './scaffold.ts'
|
|
|
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
|
|
+
|
|
|
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-invocation-policy', import.meta.url))
|
|
|
+const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
|
|
|
+const MODE = webSnapshotMode()
|
|
|
+
|
|
|
+interface SeedSkill {
|
|
|
+ name: string
|
|
|
+ description: string
|
|
|
+ frontmatter: string
|
|
|
+}
|
|
|
+
|
|
|
+const SKILLS: readonly SeedSkill[] = [
|
|
|
+ {
|
|
|
+ name: 'policy-shared',
|
|
|
+ description: 'Available to both model and user invocation',
|
|
|
+ frontmatter: '',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'policy-model-only',
|
|
|
+ description: 'Available only to model invocation',
|
|
|
+ frontmatter: 'user-invocable: false\n',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'policy-user-only',
|
|
|
+ description: 'Available only to user invocation',
|
|
|
+ frontmatter: 'disable-model-invocation: true\n',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'policy-trusted-only',
|
|
|
+ description: 'Available only to trusted internal callers',
|
|
|
+ frontmatter: 'disable-model-invocation: true\nuser-invocable: false\n',
|
|
|
+ },
|
|
|
+]
|
|
|
+
|
|
|
+async function seedSkills(workspaceCwd: string): Promise<void> {
|
|
|
+ for (const skill of SKILLS) {
|
|
|
+ const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', skill.name)
|
|
|
+ await mkdir(directory, { recursive: true })
|
|
|
+ const policyLines = skill.frontmatter === '' ? [] : skill.frontmatter.trimEnd().split('\n')
|
|
|
+ await writeFile(join(directory, 'SKILL.md'), [
|
|
|
+ '---',
|
|
|
+ `name: ${skill.name}`,
|
|
|
+ `description: ${skill.description}`,
|
|
|
+ ...policyLines,
|
|
|
+ '---',
|
|
|
+ '',
|
|
|
+ `# ${skill.name}`,
|
|
|
+ '',
|
|
|
+ ].join('\n'))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+describe('web e2e: skill invocation policy through the real host', () => {
|
|
|
+ let scaffold: WebScaffold
|
|
|
+ let browser: Browser
|
|
|
+ let page: Page
|
|
|
+ let tripwire: ReturnType<typeof watchConsole>
|
|
|
+
|
|
|
+ beforeAll(async () => {
|
|
|
+ scaffold = await launchWebScaffold({})
|
|
|
+ await seedSkills(scaffold.workspaceCwd)
|
|
|
+ browser = await chromium.launch()
|
|
|
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
|
|
+ tripwire = watchConsole(page)
|
|
|
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
|
|
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
|
|
+ await connectFreshWorkspace(page)
|
|
|
+ }, 120_000)
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await browser?.close()
|
|
|
+ await scaffold?.close()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders only the model-and-user intersection in slash candidates', async () => {
|
|
|
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
|
|
|
+ const input = page.locator('textarea').first()
|
|
|
+ await input.fill('/policy')
|
|
|
+ const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
|
|
+ await expect.poll(
|
|
|
+ () => menu.getByRole('option', { name: /policy-shared/ }).count(),
|
|
|
+ { timeout: 10_000 },
|
|
|
+ ).toBe(1)
|
|
|
+
|
|
|
+ expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
|
|
|
+ expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0)
|
|
|
+ expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
|
|
|
+
|
|
|
+ const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
|
|
|
+ await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
|
|
|
+ expect(tripwire.pageErrors).toEqual([])
|
|
|
+ expect(tripwire.warnings).toEqual([])
|
|
|
+ await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md'])
|
|
|
+ })
|
|
|
+})
|