client-plugin-live.e2e.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  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 { chromium, type Page } from 'playwright'
  5. import { expect, it, onTestFailed } from 'vitest'
  6. import { launchWebScaffold, watchConsole, captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
  7. import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
  8. const FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-live-client', import.meta.url))
  9. const EXPECTED = fileURLToPath(new URL('./expected/client-plugin-live', import.meta.url))
  10. async function openInventory(page: Page, url: string) {
  11. await page.goto(url, { waitUntil: 'load' })
  12. await page.getByRole('button', { name: '设置', exact: true }).click()
  13. const dialog = page.getByRole('dialog', { name: '设置' })
  14. await dialog.getByRole('button', { name: '插件', exact: true }).click()
  15. await dialog.getByRole('tab', { name: '插件列表', exact: true }).click()
  16. await dialog.getByRole('searchbox', { name: '搜索插件' }).waitFor()
  17. return dialog
  18. }
  19. it('synchronizes two pages, disposes effects and restores an offline page from the latest graph without navigation', async () => {
  20. const scaffold = await launchWebScaffold({
  21. extraInstallAnchors: [join(FIXTURE, 'package.json')],
  22. })
  23. const host = scaffold.ctx.loader.ctx.fiber.uid
  24. const browser = await chromium.launch()
  25. try {
  26. const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  27. const otherContext = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  28. const page = await context.newPage()
  29. const other = await otherContext.newPage()
  30. const consoles = [watchConsole(page), watchConsole(other)]
  31. onTestFailed(() => saveFailureShot(page, 'web-e2e-client-live'))
  32. await openInventory(page, scaffold.authenticatedUrl)
  33. const otherInventory = await openInventory(other, scaffold.authenticatedUrl)
  34. let entryId: string | undefined
  35. const toggle = async () => {
  36. if (entryId === undefined) entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
  37. else { scaffold.ctx.loader.remove(entryId); entryId = undefined }
  38. }
  39. let navigations = 0
  40. for (const target of [page, other]) target.on('framenavigated', () => { navigations++ })
  41. const live = (target: Page) => target.locator('[data-live-client]')
  42. const dataset = (target: Page) => target.evaluate(() => ({
  43. liveMounts: document.documentElement.dataset.liveMounts,
  44. liveDisposals: document.documentElement.dataset.liveDisposals,
  45. liveHits: document.documentElement.dataset.liveHits,
  46. }))
  47. const ping = (target: Page) => target.evaluate(() => { window.dispatchEvent(new Event('dsh-fixture-ping')) })
  48. expect(await live(page).count()).toBe(0)
  49. expect(scaffold.ctx.clientModules.graph().entries.some(row => row.id === '@fixture/live-client')).toBe(false)
  50. await toggle()
  51. for (const target of [page, other]) {
  52. await live(target).waitFor()
  53. expect(await live(target).evaluate(el => getComputedStyle(el).color)).toBe('rgb(12, 34, 56)')
  54. await ping(target)
  55. expect((await dataset(target)).liveHits).toBe('1')
  56. }
  57. await compareOrRefreshGolden(join(EXPECTED, 'enabled.expected.md'), await captureStableAria(page, '[data-live-client]', scaffold.workspaceCwd), webSnapshotMode())
  58. // The inventory filter is page-owned state that live composition must preserve.
  59. const draft = otherInventory.getByRole('searchbox', { name: '搜索插件' })
  60. await draft.fill('unfinished-filter')
  61. await toggle()
  62. for (const target of [page, other]) {
  63. await expect.poll(() => live(target).count()).toBe(0)
  64. await expect.poll(async () => (await dataset(target)).liveDisposals).toBe('1')
  65. await ping(target)
  66. expect((await dataset(target)).liveHits).toBe('1')
  67. expect(await target.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(0)
  68. }
  69. expect(await draft.inputValue()).toBe('unfinished-filter')
  70. await toggle()
  71. for (const target of [page, other]) {
  72. await live(target).waitFor()
  73. expect(await live(target).count()).toBe(1)
  74. expect((await dataset(target)).liveMounts).toBe('2')
  75. expect(await target.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(1)
  76. }
  77. const disconnected = other.waitForEvent('requestfailed', request => request.url().includes('/plugins/events'))
  78. await otherContext.setOffline(true)
  79. // Chromium offline emulation leaves established SSE sockets open. Cycling this
  80. // fixture's transport plugin closes them without replacing the Host or profile.
  81. const transport = [...scaffold.ctx.loader.entries()].find(entry => entry.options.name === '@deepseek-ai/dsh-client-hmr')!.fiber!
  82. const reconnected = page.waitForResponse(response => response.url().includes('/plugins/events') && response.status() === 200)
  83. await transport.restart()
  84. await disconnected
  85. await reconnected
  86. await toggle()
  87. await expect.poll(() => live(page).count()).toBe(0)
  88. expect(await live(other).count()).toBe(1)
  89. await otherContext.setOffline(false)
  90. await expect.poll(() => live(other).count(), { timeout: 20_000 }).toBe(0)
  91. expect(await draft.inputValue()).toBe('unfinished-filter')
  92. expect(navigations).toBe(0)
  93. expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
  94. for (const console of consoles) expect(console.pageErrors).toEqual([])
  95. } finally {
  96. await browser.close()
  97. await scaffold.close()
  98. }
  99. }, 90_000)
  100. it('keeps a failed client download local and retries without changing Host enablement', async () => {
  101. const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] })
  102. const browser = await chromium.launch()
  103. try {
  104. const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
  105. onTestFailed(() => saveFailureShot(page, 'web-e2e-client-live-retry'))
  106. await openInventory(page, scaffold.authenticatedUrl)
  107. const bundle = (url: URL) => url.pathname.startsWith('/plugins/') && url.search.includes('@fixture/live-client/client.js')
  108. await page.route(bundle, route => route.abort())
  109. const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
  110. const failure = page.locator('[data-client-sync-failure]')
  111. await failure.waitFor()
  112. expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
  113. expect(await page.locator('[data-live-client]').count()).toBe(0)
  114. expect(scaffold.ctx.clientModules.graph().entries.some(row => row.id === '@fixture/live-client')).toBe(true)
  115. await page.unroute(bundle)
  116. await failure.getByRole('button', { name: '重试本页面同步' }).click()
  117. await page.locator('[data-live-client]').waitFor()
  118. await expect.poll(() => failure.count()).toBe(0)
  119. expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
  120. } finally {
  121. await browser.close()
  122. await scaffold.close()
  123. }
  124. }, 90_000)