Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/trace-viewer/e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down Expand Up @@ -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<void> {
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 `<a download>` 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()
})
Binary file not shown.
40 changes: 33 additions & 7 deletions packages/trace-viewer/src/ui/components/AttachmentsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface AttachmentView {
contentType: string
url?: string
isImage: boolean
isVideo: boolean
isText: boolean
}

Expand Down Expand Up @@ -44,10 +45,33 @@ const attachments = computed<AttachmentView[]>(() =>
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<Record<string, boolean>>({})

// Chromium never routes `<a download>` 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<void> {
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<Record<string, string>>({})

Expand Down Expand Up @@ -85,22 +109,24 @@ watchEffect(() => {
<ImageDiff :name="d.name" :expected="d.expected" :actual="d.actual" :diff="d.diff" />
</div>

<div v-for="a in attachments" :key="a.key" class="overflow-hidden rounded-md border border-border">
<div v-for="a in attachments" :key="a.key" data-testid="attachment" class="overflow-hidden rounded-md border border-border">
<div class="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-1.5">
<Paperclip class="size-3.5 text-muted-foreground" />
<span class="text-xs font-medium">{{ a.name }}</span>
<span class="font-mono text-[10px] text-muted-foreground">{{ a.contentType }}</span>
<a
<button
v-if="a.url"
:href="a.url"
:download="a.name"
class="ml-auto flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
type="button"
data-testid="attachment-download"
class="ml-auto flex cursor-pointer items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
@click="download(a)"
>
<Download class="size-3" /> download
</a>
<Download class="size-3" /> {{ downloadFailed[a.key] ? 'unavailable' : 'download' }}
</button>
</div>
<div class="p-3">
<img v-if="a.isImage && a.url" :src="a.url" :alt="a.name" class="max-h-80 rounded border border-border">
<video v-else-if="a.isVideo && a.url" :src="a.url" controls class="max-h-80 rounded border border-border" />
<pre v-else-if="a.isText" class="overflow-auto font-mono text-xs whitespace-pre-wrap text-foreground/90">{{ texts[a.key] }}</pre>
<span v-else class="text-xs text-muted-foreground">No preview</span>
</div>
Expand Down
11 changes: 9 additions & 2 deletions packages/trace-viewer/src/ui/components/DetailTabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tab>('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<Tab>(initialTab())

const errorCount = computed(() => store.model.value?.errorDescriptors.length ?? 0)
const consoleCount = computed(() => {
Expand Down
11 changes: 8 additions & 3 deletions packages/web/src/lib/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
33 changes: 25 additions & 8 deletions packages/web/src/pages/RunPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>()
Expand Down Expand Up @@ -86,6 +86,16 @@ const filtered = computed(() =>
: searchMatched.value.filter(t => t.status === filter.value),
)

type Attachment = NonNullable<typeof report.value>['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',
Expand Down Expand Up @@ -266,13 +276,20 @@ const dateFmt = new Intl.DateTimeFormat(undefined, {
>
<Film class="size-3" />View trace
</a>
<span
v-for="a in t.attachments"
:key="a.name"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 font-mono text-[10px] text-muted-foreground"
>
<Paperclip class="size-3" />{{ a.name }}
</span>
<template v-for="a in attachmentBadges(t.attachments)" :key="a.name">
<a
v-if="traceViewerHref(t.attachments, 'attachments')"
:href="traceViewerHref(t.attachments, 'attachments')"
target="_blank"
rel="noopener"
class="transition-colors hover:border-signal/40 hover:text-signal" :class="BADGE_CLASS"
>
<Paperclip class="size-3" />{{ a.name }}
</a>
<span v-else :class="BADGE_CLASS">
<Paperclip class="size-3" />{{ a.name }}
</span>
</template>
</div>
</div>

Expand Down