skill-invocation-policy.e2e.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  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, symlink, 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, writeComposerDraft } 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 FUZZY_MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu-fuzzy.expected.md')
  25. const MODE = webSnapshotMode()
  26. interface SeedSkill {
  27. name: string
  28. description: string
  29. frontmatter: string
  30. }
  31. const SKILLS: readonly SeedSkill[] = [
  32. {
  33. name: 'policy-shared',
  34. description: 'Available to both model and user invocation',
  35. frontmatter: '',
  36. },
  37. {
  38. name: 'policy-model-only',
  39. description: 'Available only to model invocation',
  40. frontmatter: 'user-invocable: false\n',
  41. },
  42. {
  43. name: 'policy-user-only',
  44. description: 'Available only to user invocation',
  45. frontmatter: 'disable-model-invocation: true\n',
  46. },
  47. {
  48. name: 'policy-trusted-only',
  49. description: 'Available only to trusted internal callers',
  50. frontmatter: 'disable-model-invocation: true\nuser-invocable: false\n',
  51. },
  52. ]
  53. async function seedSkills(workspaceCwd: string): Promise<void> {
  54. for (const skill of SKILLS) {
  55. const root = join(workspaceCwd, 'workspace', '.agents', 'skills')
  56. const directory = skill.name === 'policy-shared' ? join(workspaceCwd, 'linked-skills', skill.name) : join(root, skill.name)
  57. await mkdir(directory, { recursive: true })
  58. const policyLines = skill.frontmatter === '' ? [] : skill.frontmatter.trimEnd().split('\n')
  59. await writeFile(join(directory, 'SKILL.md'), [
  60. '---',
  61. `name: ${skill.name}`,
  62. `description: ${skill.description}`,
  63. ...policyLines,
  64. '---',
  65. '',
  66. `# ${skill.name}`,
  67. '',
  68. ].join('\n'))
  69. if (skill.name === 'policy-shared') {
  70. await mkdir(root, { recursive: true })
  71. await symlink(join(directory, 'SKILL.md'), join(root, `${skill.name}.md`))
  72. }
  73. }
  74. }
  75. describe('web e2e: skill invocation policy through the real host', () => {
  76. let scaffold: WebScaffold
  77. let browser: Browser
  78. let page: Page
  79. let tripwire: ReturnType<typeof watchConsole>
  80. beforeAll(async () => {
  81. scaffold = await launchWebScaffold({})
  82. await seedSkills(scaffold.workspaceCwd)
  83. await writeFile(join(scaffold.workspaceCwd, 'workspace', 'meeting-notes.md'), '# Meeting notes\n\nReference preview fixture.\n')
  84. browser = await chromium.launch()
  85. page = await newEnglishPage(browser)
  86. tripwire = watchConsole(page)
  87. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  88. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  89. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  90. }, 120_000)
  91. afterAll(async () => {
  92. await browser?.close()
  93. await scaffold?.close()
  94. })
  95. it('renders every user-invocable skill and marks the user-only entry', async () => {
  96. onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
  97. const input = page.locator('[data-composer-input]').first()
  98. await input.fill('/policy')
  99. const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
  100. await expect.poll(
  101. () => menu.getByRole('option', { name: /policy-shared/ }).count(),
  102. { timeout: 10_000 },
  103. ).toBe(1)
  104. // The user-only quadrant is invocable here — its only entry point — and
  105. // wears the user-only marker; both user-disabled quadrants stay hidden.
  106. expect(await menu.getByRole('option', { name: /policy-user-only user-only · / }).count()).toBe(1)
  107. expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
  108. expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
  109. const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
  110. await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
  111. // Discovery needs no prefix: an in-order subsequence of one skill name
  112. // ranks that skill alone, through the ranker the command group uses.
  113. await writeComposerDraft(page, input, '/plcyusr')
  114. await expect.poll(() => menu.getByRole('option').count(), { timeout: 10_000 }).toBe(1)
  115. expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(1)
  116. const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
  117. await compareOrRefreshGolden(FUZZY_MENU_EXPECTED, fuzzySnapshot, MODE)
  118. expect(tripwire.pageErrors).toEqual([])
  119. expect(tripwire.warnings).toEqual([])
  120. await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md', 'preview.expected.md'])
  121. })
  122. it('opens skill and file references beside the unchanged draft with matching hover backgrounds', async () => {
  123. onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-preview'))
  124. const input = page.locator('[data-composer-input]').first()
  125. await writeComposerDraft(page, input, '/policy-shared hello @meeting-notes')
  126. const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
  127. const option = menu.getByRole('option', { name: /meeting-notes\.md/ })
  128. await option.click()
  129. const skill = input.locator('[data-composer-text-ref]').filter({ hasText: '/policy-shared' })
  130. const file = input.locator('[data-composer-chip]')
  131. const draft = await input.textContent()
  132. const alignment = await input.evaluate((el) => {
  133. const skill = el.querySelector<HTMLElement>('[data-composer-text-ref]')!
  134. const chip = el.querySelector<HTMLElement>('[data-composer-chip] span')!
  135. const plain = el.querySelector<HTMLElement>('[data-lexical-text]:not([data-composer-text-ref])')!
  136. const textTop = (element: Element): number => {
  137. const range = el.ownerDocument.createRange()
  138. range.selectNodeContents(element)
  139. return range.getBoundingClientRect().top
  140. }
  141. return {
  142. skillTop: skill.getBoundingClientRect().top,
  143. fileTop: chip.getBoundingClientRect().top,
  144. skillHeight: skill.getBoundingClientRect().height,
  145. fileHeight: chip.getBoundingClientRect().height,
  146. skillTextTop: textTop(skill),
  147. fileTextTop: textTop(chip.lastElementChild!),
  148. plainTextTop: textTop(plain),
  149. }
  150. })
  151. expect(alignment.fileHeight).toBeCloseTo(alignment.skillHeight, 0)
  152. expect(alignment.fileTop).toBeCloseTo(alignment.skillTop, 0)
  153. expect(alignment.fileTextTop).toBeCloseTo(alignment.plainTextTop, 0)
  154. expect(alignment.skillTextTop).toBeCloseTo(alignment.plainTextTop, 0)
  155. await skill.hover()
  156. const skillBackground = await skill.evaluate(el => getComputedStyle(el).backgroundColor)
  157. expect(skillBackground).not.toBe('rgba(0, 0, 0, 0)')
  158. await skill.click()
  159. const preview = page.locator('[data-document-markdown]')
  160. await expect.poll(() => preview.textContent()).toContain('policy-shared')
  161. expect(await input.textContent()).toBe(draft)
  162. const snapshot = await captureStableAria(page, '[data-document-markdown]', scaffold.workspaceCwd)
  163. await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), snapshot, MODE)
  164. await file.hover()
  165. expect(await file.locator('span').first().evaluate(el => getComputedStyle(el).backgroundColor)).toBe(skillBackground)
  166. await file.click()
  167. await expect.poll(() => preview.textContent()).toContain('Reference preview fixture.')
  168. expect(await input.textContent()).toBe(draft)
  169. await file.hover()
  170. await skill.dblclick()
  171. await expect.poll(() => preview.textContent()).toContain('policy-shared')
  172. await expect.poll(() => page.evaluate(() => document.getSelection()?.toString())).not.toBe('')
  173. expect(await input.textContent()).toBe(draft)
  174. await skill.hover()
  175. await expect.poll(() => skill.evaluate(el => getComputedStyle(el).backgroundColor)).toBe(skillBackground)
  176. expect(tripwire.pageErrors).toEqual([])
  177. expect(tripwire.warnings).toEqual([])
  178. // Establish the deletion caret before the key event; Chromium delivers
  179. // native selectionchange asynchronously after pointer and arrow actions.
  180. await input.evaluate((el) => {
  181. el.focus()
  182. const selection = el.ownerDocument.getSelection()!
  183. selection.selectAllChildren(el)
  184. selection.collapseToEnd()
  185. el.ownerDocument.dispatchEvent(new Event('selectionchange'))
  186. })
  187. await page.keyboard.press('Backspace')
  188. await page.keyboard.press('Backspace')
  189. await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(0)
  190. expect(await input.textContent()).toContain('/policy-shared')
  191. })
  192. })