dx: collect diagnostics or flaky test upload1

Massimo Melina committed May 2, 2026 at 00:36 UTC 7b064ec3d614d4aeb7b806fad33355947a047098
1 file changed +144 -50
e2e/serial.spec.ts
+144 -50
@@ -1,4 +1,4 @@
1 -import { expect, test } from '@playwright/test'
1 +import { expect, test, type ConsoleMessage, type Page, type Request, type Response, type TestInfo } from '@playwright/test'
2 import { ADMIN_URL, clearUploads, clickAdminMenu, clickIconBtn, loginAdmin, password, uploadName, FRONTEND_URL, username } from './common'
3
4 // this test is separated to run serially, as it will modify folder timestamp for a few seconds, during which other tests may fail
@@ -10,58 +10,152 @@ export const fileToUpload = {
10 buffer: Buffer.alloc(100_000),
11 }
12
13 -test('upload1', async ({ page, context, browserName }) => {
13 +test('upload1', async ({ page, context, browserName }, testInfo) => {
14 if (browserName !== 'chromium') return // only chromium has cdpSession
15 - await page.goto(FRONTEND_URL)
16 - await page.getByRole('button', { name: 'Login' }).click()
17 - await page.getByRole('textbox', { name: 'Username' }).fill(username)
18 - await page.getByRole('textbox', { name: 'Password' }).fill(password)
19 - await page.getByRole('button', { name: 'Continue' }).click()
20 - await page.locator('div').filter({ hasText: 'Logged in' }).nth(3).click()
21 -
22 - await page.getByRole('link', { name: 'for-admins, Folder' }).click()
23 - await page.getByRole('link', { name: 'upload, Folder' }).click()
24 -
25 - await page.getByRole('button', { name: 'Options' }).click()
26 - const pageAdminPromise = page.waitForEvent('popup')
27 - await page.getByRole('button', { name: 'Admin-panel' }).click()
28 - const pageAdmin = await pageAdminPromise
29 - await pageAdmin.goto(ADMIN_URL + '#/monitoring'); // cross-device way of changing page
30 - await page.locator('div').filter({ hasText: 'xOptionsAdmin-panelSort by:' }).nth(2).click()
31 - await page.getByRole('button', { name: 'Close' }).click()
32 - await page.getByRole('button', { name: 'Upload' }).click()
33 - const fileChooserPromise = page.waitForEvent('filechooser')
34 - await page.getByRole('button', { name: 'Pick files' }).click()
35 - const fileChooser = await fileChooserPromise
36 - await fileChooser.setFiles(fileToUpload)
37 - // can't do without cdp to slow down the upload. I tried using route.continue, but i can't send half-body keeping the full content-length, and i also cannot pass a stream (to throttle)
38 - const cdpSession = await context.newCDPSession(page)
39 - await cdpSession.send('Network.emulateNetworkConditions', NETWORK_PRESETS.Regular2G)
40 - await page.getByRole('button', { name: 'Edit' }).click()
41 - const renameDialog = page.locator('.dialog-prompt')
42 - const renameInput = renameDialog.getByRole('textbox')
43 - await expect(renameInput).toHaveValue(fileToUpload.name) // promptDialog initializes the field value in useEffect, so we wait for that init to avoid our fill being overwritten
44 - await renameInput.fill(uploadName)
45 - await renameDialog.getByRole('button', { name: 'Continue' }).click()
46 - await expect(page.getByText(uploadName)).toBeVisible() // rename was effective
47 - // we send the upload, slowly, so that we can interrupt it in the admin-panel to test the upload resume
48 - await page.getByRole('button', { name: 'Send 1 file' }).click()
49 - const uploadCells = pageAdmin.locator('.MuiDataGrid-cell')
50 - .filter({ hasText: uploadName })
51 - .filter({ hasText: '/for-admins/upload' })
52 - await expect(uploadCells.first()).toBeVisible()
53 - // during upload resume, monitoring can briefly show two rows for the same path
54 - await uploadCells.last().click()
55 - await clickIconBtn('Disconnect', pageAdmin)
56 - await clickIconBtn('Close', pageAdmin)
57 - await pageAdmin.close()
58 - await page.getByText('Copy links').click()
59 - await page.getByText('Operation successful').click()
60 - await page.getByRole('button', { name: 'Close' }).click()
61 - await cdpSession?.send('Network.emulateNetworkConditions', NETWORK_PRESETS.NoThrottle)
62 - clearUploads()
15 + const diagnostics = await startUpload1Diagnostics(page)
16 + try {
17 + await page.goto(FRONTEND_URL)
18 + await page.getByRole('button', { name: 'Login' }).click()
19 + await page.getByRole('textbox', { name: 'Username' }).fill(username)
20 + await page.getByRole('textbox', { name: 'Password' }).fill(password)
21 + await page.getByRole('button', { name: 'Continue' }).click()
22 + await page.locator('div').filter({ hasText: 'Logged in' }).nth(3).click()
23 +
24 + await page.getByRole('link', { name: 'for-admins, Folder' }).click()
25 + await page.getByRole('link', { name: 'upload, Folder' }).click()
26 +
27 + await page.getByRole('button', { name: 'Options' }).click()
28 + const pageAdminPromise = page.waitForEvent('popup')
29 + await page.getByRole('button', { name: 'Admin-panel' }).click()
30 + const pageAdmin = await pageAdminPromise
31 + diagnostics.trackPage(pageAdmin, 'admin')
32 + await pageAdmin.goto(ADMIN_URL + '#/monitoring'); // cross-device way of changing page
33 + await page.locator('div').filter({ hasText: 'xOptionsAdmin-panelSort by:' }).nth(2).click()
34 + await page.getByRole('button', { name: 'Close' }).click()
35 + await page.getByRole('button', { name: 'Upload' }).click()
36 + const fileChooserPromise = page.waitForEvent('filechooser')
37 + await page.getByRole('button', { name: 'Pick files' }).click()
38 + const fileChooser = await fileChooserPromise
39 + await fileChooser.setFiles(fileToUpload)
40 + // can't do without cdp to slow down the upload. I tried using route.continue, but i can't send half-body keeping the full content-length, and i also cannot pass a stream (to throttle)
41 + const cdpSession = await context.newCDPSession(page)
42 + await cdpSession.send('Network.emulateNetworkConditions', NETWORK_PRESETS.Regular2G)
43 + await page.getByRole('button', { name: 'Edit' }).click()
44 + const renameDialog = page.locator('.dialog-prompt')
45 + const renameInput = renameDialog.getByRole('textbox')
46 + await expect(renameInput).toHaveValue(fileToUpload.name) // promptDialog initializes the field value in useEffect, so we wait for that init to avoid our fill being overwritten
47 + await renameInput.fill(uploadName)
48 + await renameDialog.getByRole('button', { name: 'Continue' }).click()
49 + await expect(page.getByText(uploadName)).toBeVisible() // rename was effective
50 + // we send the upload, slowly, so that we can interrupt it in the admin-panel to test the upload resume
51 + await page.getByRole('button', { name: 'Send 1 file' }).click()
52 + const uploadCells = pageAdmin.locator('.MuiDataGrid-cell')
53 + .filter({ hasText: uploadName })
54 + .filter({ hasText: '/for-admins/upload' })
55 + await expect(uploadCells.first()).toBeVisible()
56 + // during upload resume, monitoring can briefly show two rows for the same path
57 + await uploadCells.last().click()
58 + await clickIconBtn('Disconnect', pageAdmin)
59 + await clickIconBtn('Close', pageAdmin)
60 + await pageAdmin.close()
61 + await page.getByText('Copy links').click()
62 + await page.getByText('Operation successful').click()
63 + await page.getByRole('button', { name: 'Close' }).click()
64 + await cdpSession?.send('Network.emulateNetworkConditions', NETWORK_PRESETS.NoThrottle)
65 + clearUploads()
66 + }
67 + catch (err) {
68 + await diagnostics.attach(testInfo)
69 + throw err
70 + }
71 })
72
73 +const MAX_DIAGNOSTIC_LINES = 300
74 +
75 +async function startUpload1Diagnostics(page: Page) {
76 + const lines: string[] = []
77 + const started = Date.now()
78 + addLine('diagnostics started')
79 + await page.exposeBinding('__upload1Diag', (_source, entry: Record<string, unknown>) => {
80 + addLine(`xhr ${formatEntry(entry)}`)
81 + })
82 + await page.addInitScript(() => {
83 + const originalOpen = XMLHttpRequest.prototype.open
84 + const originalSend = XMLHttpRequest.prototype.send
85 + XMLHttpRequest.prototype.open = function(method: string, url: string | URL, ...args: any[]) {
86 + ;(this as any).__upload1DiagRequest = { method: String(method), url: String(url) }
87 + return Reflect.apply(originalOpen, this, [method, url, ...args])
88 + }
89 + XMLHttpRequest.prototype.send = function(...args: any[]) {
90 + const req = (this as any).__upload1DiagRequest
91 + if (req?.method === 'PUT' && req.url.includes('/for-admins/upload/')) {
92 + const log = (event: string, progress?: ProgressEvent) => {
93 + // record XHR state before Playwright closes the browser
94 + Promise.resolve((window as any).__upload1Diag?.({
95 + event,
96 + readyState: this.readyState,
97 + status: this.status,
98 + url: req.url,
99 + loaded: progress?.loaded,
100 + total: progress?.lengthComputable ? progress.total : undefined,
101 + })).catch(() => {})
102 + }
103 + for (const event of ['loadstart', 'abort', 'error', 'timeout', 'loadend'])
104 + this.addEventListener(event, log.bind(null, event))
105 + this.upload.addEventListener('progress', e => log('upload-progress', e))
106 + this.addEventListener('readystatechange', () => log('readystatechange'))
107 + }
108 + return originalSend.apply(this, args)
109 + }
110 + })
111 + trackPage(page, 'main')
112 + return { attach, trackPage }
113 +
114 + function trackPage(trackedPage: Page, label: string) {
115 + trackedPage.on('console', msg => addLine(`console:${label} ${formatConsole(msg)}`))
116 + trackedPage.on('requestfailed', request => addLine(`requestfailed:${label} ${formatRequest(request)} ${request.failure()?.errorText ?? ''}`))
117 + trackedPage.on('response', response => {
118 + if (isUploadResponse(response))
119 + addLine(`response:${label} ${response.status()} ${response.request().method()} ${response.url()}`)
120 + })
121 + }
122 +
123 + async function attach(testInfo: TestInfo) {
124 + await testInfo.attach('upload1-diagnostics', {
125 + body: lines.join('\n') + '\n',
126 + contentType: 'text/plain',
127 + })
128 + }
129 +
130 + function addLine(text: string) {
131 + const offset = `${Date.now() - started}ms`.padStart(7)
132 + lines.push(`${offset} ${text}`)
133 + if (lines.length > MAX_DIAGNOSTIC_LINES)
134 + lines.splice(0, lines.length - MAX_DIAGNOSTIC_LINES)
135 + }
136 +
137 + function formatConsole(msg: ConsoleMessage) {
138 + return `${msg.type()} ${msg.text()}`
139 + }
140 +
141 + function formatRequest(request: Request) {
142 + return `${request.method()} ${request.url()}`
143 + }
144 +
145 + function isUploadResponse(response: Response) {
146 + const request = response.request()
147 + return request.method() === 'PUT' && response.url().includes('/for-admins/upload/')
148 + }
149 +
150 + function formatEntry(entry: Record<string, unknown>) {
151 + return Object.entries(entry)
152 + .filter(([, value]) => value !== undefined)
153 + .map(([key, value]) => `${key}=${String(value)}`)
154 + .join(' ')
155 + }
156 +
157 +}
158 +
159 const NETWORK_PRESETS = {
160 Offline: {
161 offline: true,