skill-invocation-policy.e2e.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. // Web e2e scenario: the real host filters skill.list to the model-and-user
  2. // intersection before the browser slash source renders candidates. A real
  3. // chromium connects a fresh workspace seeded with all four policy quadrants;
  4. // no model call is issued, so a stray stream fails loud on the open LLM seam.
  5. import { mkdir, writeFile } from 'node:fs/promises'
  6. import { fileURLToPath } from 'node:url'
  7. import { join } from 'node:path'
  8. import type { Browser, Page } from 'playwright'
  9. import { chromium } from 'playwright'
  10. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  11. import {
  12. assertFixtureInventory,
  13. captureStableAria,
  14. compareOrRefreshGolden,
  15. launchWebScaffold,
  16. watchConsole,
  17. webSnapshotMode,
  18. type WebScaffold,
  19. } from './scaffold.ts'
  20. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  21. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-invocation-policy', import.meta.url))
  22. const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
  23. const MODE = webSnapshotMode()
  24. interface SeedSkill {
  25. name: string
  26. description: string
  27. frontmatter: string
  28. }
  29. const SKILLS: readonly SeedSkill[] = [
  30. {
  31. name: 'policy-shared',
  32. description: 'Available to both model and user invocation',
  33. frontmatter: '',
  34. },
  35. {
  36. name: 'policy-model-only',
  37. description: 'Available only to model invocation',
  38. frontmatter: 'user-invocable: false\n',
  39. },
  40. {
  41. name: 'policy-user-only',
  42. description: 'Available only to user invocation',
  43. frontmatter: 'disable-model-invocation: true\n',
  44. },
  45. {
  46. name: 'policy-trusted-only',
  47. description: 'Available only to trusted internal callers',
  48. frontmatter: 'disable-model-invocation: true\nuser-invocable: false\n',
  49. },
  50. ]
  51. async function seedSkills(workspaceCwd: string): Promise<void> {
  52. for (const skill of SKILLS) {
  53. const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', skill.name)
  54. await mkdir(directory, { recursive: true })
  55. const policyLines = skill.frontmatter === '' ? [] : skill.frontmatter.trimEnd().split('\n')
  56. await writeFile(join(directory, 'SKILL.md'), [
  57. '---',
  58. `name: ${skill.name}`,
  59. `description: ${skill.description}`,
  60. ...policyLines,
  61. '---',
  62. '',
  63. `# ${skill.name}`,
  64. '',
  65. ].join('\n'))
  66. }
  67. }
  68. describe('web e2e: skill invocation policy through the real host', () => {
  69. let scaffold: WebScaffold
  70. let browser: Browser
  71. let page: Page
  72. let tripwire: ReturnType<typeof watchConsole>
  73. beforeAll(async () => {
  74. scaffold = await launchWebScaffold({})
  75. await seedSkills(scaffold.workspaceCwd)
  76. browser = await chromium.launch()
  77. page = await newEnglishPage(browser)
  78. tripwire = watchConsole(page)
  79. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  80. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  81. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  82. }, 120_000)
  83. afterAll(async () => {
  84. await browser?.close()
  85. await scaffold?.close()
  86. })
  87. it('renders only the model-and-user intersection in slash candidates', async () => {
  88. onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
  89. const input = page.locator('textarea').first()
  90. await input.fill('/policy')
  91. const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
  92. await expect.poll(
  93. () => menu.getByRole('option', { name: /policy-shared/ }).count(),
  94. { timeout: 10_000 },
  95. ).toBe(1)
  96. expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
  97. expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0)
  98. expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
  99. const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
  100. await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
  101. expect(tripwire.pageErrors).toEqual([])
  102. expect(tripwire.warnings).toEqual([])
  103. await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md'])
  104. })
  105. })