diff --git a/packages/trace-viewer/e2e/viewer.spec.ts b/packages/trace-viewer/e2e/viewer.spec.ts index 1ab2f3e..621ef2a 100644 --- a/packages/trace-viewer/e2e/viewer.spec.ts +++ b/packages/trace-viewer/e2e/viewer.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' +import { readFile } from 'node:fs/promises' import { expect, test } from '@playwright/test' test.beforeEach(async ({ page }) => { @@ -90,3 +92,42 @@ test('loads a trace passed via ?trace=', async ({ page, baseURL }) => { await expect(page.getByTestId('action').first()).toBeVisible() expect(await page.getByTestId('action').count()).toBeGreaterThan(5) }) + +async function openVideoTrace(page: Page, baseURL: string | undefined): Promise { + await page.goto(`/?trace=${encodeURIComponent(`${baseURL}/fixtures/video-trace.zip`)}`) + await expect(page.getByTestId('action').first()).toBeVisible() + await page.getByRole('button', { name: /^Attachments/ }).click() +} + +test('plays a video attachment inline', async ({ page, baseURL }) => { + await openVideoTrace(page, baseURL) + const video = page.locator('video') + await expect(video).toBeVisible() + await expect.poll(() => video.evaluate((v: HTMLVideoElement) => v.duration)).toBeGreaterThan(0) +}) + +// Chromium never routes `` through the service worker, so downloading from the raw +// sha1 url saves the app's SPA fallback page instead of the attachment. +test('downloads attachment bodies rather than the app shell', async ({ page, baseURL }) => { + await openVideoTrace(page, baseURL) + + const cases = [ + { contentType: 'video/webm', filename: 'video.webm', magic: '1a45dfa3' }, + { contentType: 'image/png', filename: 'screenshot.png', magic: '89504e47' }, + ] + for (const { contentType, filename, magic } of cases) { + const row = page.getByTestId('attachment').filter({ hasText: contentType }) + const [download] = await Promise.all([ + page.waitForEvent('download'), + row.getByTestId('attachment-download').click(), + ]) + expect(download.suggestedFilename()).toBe(filename) + const body = await readFile((await download.path())!) + expect(body.subarray(0, 4).toString('hex')).toBe(magic) + } +}) + +test('opens the tab named by ?tab=', async ({ page, baseURL }) => { + await page.goto(`/?trace=${encodeURIComponent(`${baseURL}/fixtures/video-trace.zip`)}&tab=attachments`) + await expect(page.getByTestId('attachment').first()).toBeVisible() +}) diff --git a/packages/trace-viewer/public/fixtures/video-trace.zip b/packages/trace-viewer/public/fixtures/video-trace.zip new file mode 100644 index 0000000..6f72584 Binary files /dev/null and b/packages/trace-viewer/public/fixtures/video-trace.zip differ diff --git a/packages/trace-viewer/src/ui/components/AttachmentsView.vue b/packages/trace-viewer/src/ui/components/AttachmentsView.vue index 01f695b..45986a6 100644 --- a/packages/trace-viewer/src/ui/components/AttachmentsView.vue +++ b/packages/trace-viewer/src/ui/components/AttachmentsView.vue @@ -14,6 +14,7 @@ interface AttachmentView { contentType: string url?: string isImage: boolean + isVideo: boolean isText: boolean } @@ -44,10 +45,33 @@ const attachments = computed(() => contentType: att.contentType, url: attachmentUrl(att), isImage: att.contentType.startsWith('image/'), + isVideo: att.contentType.startsWith('video/'), isText: att.contentType.startsWith('text/') || att.contentType.includes('json') || att.contentType.includes('xml'), })), ) +const downloadFailed = reactive>({}) + +// Chromium never routes `` through the service worker, so the raw sha1 url +// would hit the static server and save its SPA fallback page instead of the attachment. +async function download(att: AttachmentView): Promise { + if (!att.url) + return + try { + const body = await (await fetch(att.url)).arrayBuffer() + const href = URL.createObjectURL(new Blob([body], { type: att.contentType })) + const a = document.createElement('a') + a.href = href + a.download = att.name + a.click() + setTimeout(() => URL.revokeObjectURL(href), 0) + downloadFailed[att.key] = false + } + catch { + downloadFailed[att.key] = true + } +} + // Lazily fetch textual attachment contents. const texts = reactive>({}) @@ -85,22 +109,24 @@ watchEffect(() => { -
+
{{ a.name }} {{ a.contentType }} - - download - + {{ downloadFailed[a.key] ? 'unavailable' : 'download' }} +
+
diff --git a/packages/trace-viewer/src/ui/components/DetailTabs.vue b/packages/trace-viewer/src/ui/components/DetailTabs.vue index b1ff414..884108a 100644 --- a/packages/trace-viewer/src/ui/components/DetailTabs.vue +++ b/packages/trace-viewer/src/ui/components/DetailTabs.vue @@ -13,8 +13,15 @@ import NetworkView from './NetworkView.vue' import SourceView from './SourceView.vue' const store = useTraceStore() -type Tab = 'source' | 'call' | 'log' | 'network' | 'attachments' | 'errors' | 'console' -const active = ref('source') +const TAB_IDS = ['source', 'call', 'log', 'network', 'attachments', 'errors', 'console'] as const +type Tab = typeof TAB_IDS[number] + +function initialTab(): Tab { + const tab = new URLSearchParams(window.location.search).get('tab') + return TAB_IDS.find(id => id === tab) ?? 'source' +} + +const active = ref(initialTab()) const errorCount = computed(() => store.model.value?.errorDescriptors.length ?? 0) const consoleCount = computed(() => { diff --git a/packages/web/src/lib/trace.ts b/packages/web/src/lib/trace.ts index 396cc3f..bd05bcf 100644 --- a/packages/web/src/lib/trace.ts +++ b/packages/web/src/lib/trace.ts @@ -6,12 +6,17 @@ interface AttachmentLike { url?: string } +export function isTraceAttachment(a: AttachmentLike): boolean { + return a.name === 'trace' || a.contentType === 'application/zip' +} + // Link to open a test's trace in the bundled trace viewer, or undefined if the // test has no hosted trace. The server returns an absolute artifact URL. -export function traceViewerHref(attachments: AttachmentLike[]): string | undefined { - const trace = attachments.find(a => a.url && (a.name === 'trace' || a.contentType === 'application/zip')) +export function traceViewerHref(attachments: AttachmentLike[], tab?: 'attachments'): string | undefined { + const trace = attachments.find(a => a.url && isTraceAttachment(a)) if (!trace?.url) return undefined const viewer = env.viewerBaseUrl.endsWith('/') ? env.viewerBaseUrl : `${env.viewerBaseUrl}/` - return `${viewer}?trace=${encodeURIComponent(trace.url)}` + const href = `${viewer}?trace=${encodeURIComponent(trace.url)}` + return tab ? `${href}&tab=${tab}` : href } diff --git a/packages/web/src/pages/RunPage.vue b/packages/web/src/pages/RunPage.vue index f4b4fcd..3333e06 100644 --- a/packages/web/src/pages/RunPage.vue +++ b/packages/web/src/pages/RunPage.vue @@ -15,7 +15,7 @@ import SearchInput from '@/components/app/SearchInput.vue' import TestStatusBadge from '@/components/viz/TestStatusBadge.vue' import { useManifest, useRun } from '@/composables/queries' import { testLabel } from '@/lib/test-display' -import { traceViewerHref } from '@/lib/trace' +import { isTraceAttachment, traceViewerHref } from '@/lib/trace' import { httpsUrl } from '@/lib/url' const props = defineProps<{ projectId: string, runId: string }>() @@ -86,6 +86,16 @@ const filtered = computed(() => : searchMatched.value.filter(t => t.status === filter.value), ) +type Attachment = NonNullable['tests'][number]['attachments'][number] + +// Playwright embeds screenshots and videos inside trace.zip, so a badge opens them in the +// viewer's attachments tab; their own `path` is a CI-runner path the server never received. +function attachmentBadges(attachments: Attachment[]): Attachment[] { + return traceViewerHref(attachments) ? attachments.filter(a => !isTraceAttachment(a)) : attachments +} + +const BADGE_CLASS = 'inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 font-mono text-[10px] text-muted-foreground' + const dateFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', @@ -266,13 +276,20 @@ const dateFmt = new Intl.DateTimeFormat(undefined, { > View trace - - {{ a.name }} - +