skill-invocation-policy.e2e.ts 4.3 KB

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