client-plugin-live.e2e.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. /** Real profile, Remote, bundle scripts and Cordis slots: page-local client lifecycle without navigation. */
  2. import { fileURLToPath } from 'node:url'
  3. import { join } from 'node:path'
  4. import { tmpdir } from 'node:os'
  5. import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  6. import { chromium, type Page } from 'playwright'
  7. import { expect, it, onTestFailed, onTestFinished } from 'vitest'
  8. import { launchWebScaffold, watchConsole, captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
  9. import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
  10. const FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-live-client', import.meta.url))
  11. const EXPECTED = fileURLToPath(new URL('./expected/client-plugin-live', import.meta.url))
  12. async function openInventory(page: Page, url: string) {
  13. await page.goto(url, { waitUntil: 'load' })
  14. await page.getByRole('button', { name: '设置', exact: true }).click()
  15. const dialog = page.getByRole('dialog', { name: '设置' })
  16. await dialog.getByRole('button', { name: '插件', exact: true }).click()
  17. await dialog.getByRole('tab', { name: '插件列表', exact: true }).click()
  18. await dialog.getByRole('searchbox', { name: '搜索插件' }).waitFor()
  19. return dialog
  20. }
  21. it('synchronizes two pages, disposes effects and restores an offline page from the latest graph without navigation', async () => {
  22. const scaffold = await launchWebScaffold({
  23. extraInstallAnchors: [join(FIXTURE, 'package.json')],
  24. })
  25. const host = scaffold.ctx.loader.ctx.fiber.uid
  26. const browser = await chromium.launch()
  27. try {
  28. const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  29. const otherContext = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  30. const page = await context.newPage()
  31. const other = await otherContext.newPage()
  32. const consoles = [watchConsole(page), watchConsole(other)]
  33. onTestFailed(() => saveFailureShot(page, 'web-e2e-client-live'))
  34. await openInventory(page, scaffold.authenticatedUrl)
  35. const otherInventory = await openInventory(other, scaffold.authenticatedUrl)
  36. let entryId: string | undefined
  37. const toggle = async () => {
  38. if (entryId === undefined) entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
  39. else { scaffold.ctx.loader.remove(entryId); entryId = undefined }
  40. }
  41. let navigations = 0
  42. for (const target of [page, other]) target.on('framenavigated', () => { navigations++ })
  43. const live = (target: Page) => target.locator('[data-live-client]')
  44. const dataset = (target: Page) => target.evaluate(() => ({
  45. liveMounts: document.documentElement.dataset.liveMounts,
  46. liveDisposals: document.documentElement.dataset.liveDisposals,
  47. liveHits: document.documentElement.dataset.liveHits,
  48. }))
  49. const ping = (target: Page) => target.evaluate(() => { window.dispatchEvent(new Event('dsh-fixture-ping')) })
  50. expect(await live(page).count()).toBe(0)
  51. expect(scaffold.ctx.clientModules.graph().entries.some(row => row.id === '@fixture/live-client')).toBe(false)
  52. await toggle()
  53. for (const target of [page, other]) {
  54. await live(target).waitFor()
  55. expect(await live(target).evaluate(el => getComputedStyle(el).color)).toBe('rgb(12, 34, 56)')
  56. await ping(target)
  57. expect((await dataset(target)).liveHits).toBe('1')
  58. }
  59. await compareOrRefreshGolden(join(EXPECTED, 'enabled.expected.md'), await captureStableAria(page, '[data-live-client]', scaffold.workspaceCwd), webSnapshotMode())
  60. // The inventory filter is page-owned state that live composition must preserve.
  61. const draft = otherInventory.getByRole('searchbox', { name: '搜索插件' })
  62. await draft.fill('unfinished-filter')
  63. await toggle()
  64. for (const target of [page, other]) {
  65. await expect.poll(() => live(target).count()).toBe(0)
  66. await expect.poll(async () => (await dataset(target)).liveDisposals).toBe('1')
  67. await ping(target)
  68. expect((await dataset(target)).liveHits).toBe('1')
  69. expect(await target.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(0)
  70. }
  71. expect(await draft.inputValue()).toBe('unfinished-filter')
  72. await toggle()
  73. for (const target of [page, other]) {
  74. await live(target).waitFor()
  75. expect(await live(target).count()).toBe(1)
  76. expect((await dataset(target)).liveMounts).toBe('2')
  77. expect(await target.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(1)
  78. }
  79. const disconnected = other.waitForEvent('requestfailed', request => request.url().includes('/plugins/events'))
  80. await otherContext.setOffline(true)
  81. // Chromium offline emulation leaves established SSE sockets open. Cycling this
  82. // fixture's transport plugin closes them without replacing the Host or profile.
  83. const transport = [...scaffold.ctx.loader.entries()].find(entry => entry.options.name === '@deepseek-ai/dsh-client-hmr')!.fiber!
  84. const reconnected = page.waitForResponse(response => response.url().includes('/plugins/events') && response.status() === 200)
  85. await transport.restart()
  86. await disconnected
  87. await reconnected
  88. await toggle()
  89. await expect.poll(() => live(page).count()).toBe(0)
  90. expect(await live(other).count()).toBe(1)
  91. await otherContext.setOffline(false)
  92. await expect.poll(() => live(other).count(), { timeout: 20_000 }).toBe(0)
  93. expect(await draft.inputValue()).toBe('unfinished-filter')
  94. expect(navigations).toBe(0)
  95. expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
  96. for (const console of consoles) expect(console.pageErrors).toEqual([])
  97. } finally {
  98. await browser.close()
  99. await scaffold.close()
  100. }
  101. }, 90_000)
  102. it('keeps a failed client download local and retries without changing Host enablement', async () => {
  103. const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] })
  104. const browser = await chromium.launch()
  105. try {
  106. const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
  107. onTestFailed(() => saveFailureShot(page, 'web-e2e-client-live-retry'))
  108. await openInventory(page, scaffold.authenticatedUrl)
  109. const bundle = (url: URL) => url.pathname.startsWith('/plugins/') && url.search.includes('@fixture/live-client/client.js')
  110. await page.route(bundle, route => route.abort())
  111. const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
  112. const failure = page.locator('[data-client-sync-failure]')
  113. await failure.waitFor()
  114. expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
  115. expect(await page.locator('[data-live-client]').count()).toBe(0)
  116. expect(scaffold.ctx.clientModules.graph().entries.some(row => row.id === '@fixture/live-client')).toBe(true)
  117. await page.unroute(bundle)
  118. await failure.getByRole('button', { name: '重试本页面同步' }).click()
  119. await page.locator('[data-live-client]').waitFor()
  120. await expect.poll(() => failure.count()).toBe(0)
  121. expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
  122. } finally {
  123. await browser.close()
  124. await scaffold.close()
  125. }
  126. }, 90_000)
  127. it('recovers an uncreated client entry with rebuilt factory code without navigation', async () => {
  128. const fixture = await mkdtemp(join(tmpdir(), 'dsh-client-rebuild-'))
  129. // Finished hooks unwind in reverse order, so the Host closes before its fixture is removed.
  130. onTestFinished(() => rm(fixture, { recursive: true, force: true }))
  131. await cp(FIXTURE, fixture, { recursive: true })
  132. const file = join(fixture, 'client.js')
  133. const source = await readFile(file, 'utf8')
  134. const broken = source.replace("const React = require('react')", "throw new Error('fixture r0 factory failed')")
  135. expect(broken).not.toBe(source)
  136. await writeFile(file, broken)
  137. const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(fixture, 'package.json')] })
  138. onTestFinished(() => scaffold.close())
  139. const host = scaffold.ctx.loader.ctx.fiber.uid
  140. const browser = await chromium.launch()
  141. try {
  142. const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
  143. onTestFailed(() => saveFailureShot(page, 'web-e2e-client-factory-rebuild'))
  144. const inventory = await openInventory(page, scaffold.authenticatedUrl)
  145. const draft = inventory.getByRole('searchbox', { name: '搜索插件' })
  146. await draft.fill('unfinished-filter')
  147. let navigations = 0
  148. page.on('framenavigated', () => { navigations++ })
  149. const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
  150. const failure = page.locator('[data-client-sync-failure]')
  151. await failure.getByText(/fixture r0 factory failed/).waitFor()
  152. const rebuilt = source.replace('动态插件已启用', '动态插件 r1 已启用').replace('Live plugin enabled', 'Live plugin r1 enabled')
  153. await writeFile(file, rebuilt)
  154. scaffold.ctx.clientModules.rebuilt('@fixture/live-client')
  155. await page.getByText('动态插件 r1 已启用', { exact: true }).waitFor()
  156. await expect.poll(() => failure.count()).toBe(0)
  157. await compareOrRefreshGolden(join(EXPECTED, 'recovered.expected.md'), await captureStableAria(page, '[data-live-client]', scaffold.workspaceCwd), webSnapshotMode())
  158. expect(await draft.inputValue()).toBe('unfinished-filter')
  159. expect(await page.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(1)
  160. expect(await page.evaluate(() => document.documentElement.dataset.liveMounts)).toBe('1')
  161. expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
  162. expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
  163. expect(navigations).toBe(0)
  164. } finally {
  165. await browser.close()
  166. }
  167. })
  168. it('reports bootstrap rebuilds without remounting the settings page or navigating', async () => {
  169. const scaffold = await launchWebScaffold()
  170. onTestFinished(() => scaffold.close())
  171. const host = scaffold.ctx.loader.ctx.fiber.uid
  172. const browser = await chromium.launch()
  173. try {
  174. const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
  175. const console = watchConsole(page)
  176. onTestFailed(() => saveFailureShot(page, 'web-e2e-client-bootstrap-rebuild'))
  177. const inventory = await openInventory(page, scaffold.authenticatedUrl)
  178. const draft = inventory.getByRole('searchbox', { name: '搜索插件' })
  179. await draft.fill('unfinished-filter')
  180. const originalInput = await draft.elementHandle()
  181. let navigations = 0
  182. page.on('framenavigated', () => { navigations++ })
  183. scaffold.ctx.clientModules.rebuilt('@deepseek-ai/dsh-client-modules')
  184. const failure = page.locator('[data-client-sync-failure]')
  185. await failure.getByText(/replacing bootstrap module .* requires a page reload/).waitFor()
  186. await failure.getByRole('button', { name: '重试本页面同步' }).click()
  187. await failure.getByText(/replacing bootstrap module .* requires a page reload/).waitFor()
  188. await compareOrRefreshGolden(join(EXPECTED, 'bootstrap-rebuild.expected.md'), await captureStableAria(page, '[data-client-sync-failure]', scaffold.workspaceCwd), webSnapshotMode())
  189. expect(await originalInput!.evaluate(input => input.isConnected)).toBe(true)
  190. expect(await draft.inputValue()).toBe('unfinished-filter')
  191. expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
  192. expect(navigations).toBe(0)
  193. expect(console.pageErrors).toEqual([])
  194. } finally {
  195. await browser.close()
  196. }
  197. })
  198. it('removes the client UI and resources while Host cleanup is still pending', async () => {
  199. const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] })
  200. let release!: () => void
  201. const cleanup = new Promise<void>((resolve) => { release = resolve })
  202. onTestFinished(async () => { release(); await scaffold.close() })
  203. const host = scaffold.ctx.loader.ctx.fiber.uid
  204. const browser = await chromium.launch()
  205. try {
  206. const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
  207. await openInventory(page, scaffold.authenticatedUrl)
  208. let navigations = 0
  209. page.on('framenavigated', () => { navigations++ })
  210. const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
  211. const live = page.locator('[data-live-client]')
  212. await live.waitFor()
  213. const fiber = scaffold.ctx.loader.resolve(entryId).fiber!
  214. let hostDisposed = false
  215. fiber.ctx.effect(() => async () => { await cleanup; hostDisposed = true })
  216. scaffold.ctx.loader.remove(entryId)
  217. await expect.poll(() => live.count()).toBe(0)
  218. await expect.poll(() => page.evaluate(() => document.documentElement.dataset.liveDisposals)).toBe('1')
  219. expect(await page.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(0)
  220. await page.evaluate(() => { window.dispatchEvent(new Event('dsh-fixture-ping')) })
  221. expect(await page.evaluate(() => document.documentElement.dataset.liveHits)).toBeUndefined()
  222. expect(hostDisposed).toBe(false)
  223. expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
  224. expect(navigations).toBe(0)
  225. release()
  226. while (fiber.inertia !== undefined) await fiber.inertia
  227. expect(hostDisposed).toBe(true)
  228. } finally {
  229. await browser.close()
  230. }
  231. })