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
3 changes: 3 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ npx @kinora/cli upload results.json --project web-app --token <project-token>

Create a project API token in the kinora dashboard (Settings → Workspace). Auth can also come from the environment (`KINORA_TOKEN`, `KINORA_URL`). Self-hosting? Point at your server with `--url` / `KINORA_URL`.

Only traces are uploaded by default. Running without tracing? Upload the videos and screenshots
on their own with `--upload-attachments trace,video,screenshot`.

Bulk-import a backlog of historical reports with `kinora import <dir>`.

## Documentation
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/kinora.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import type { AttachmentKind } from '@kinora/core'
import { existsSync, readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import process from 'node:process'
Expand Down Expand Up @@ -33,8 +34,24 @@ Options:
--pr-label <label> Distinguish matrix legs that share one PR
--pr-policy <policy> always (default) | on-failure (skip the comment on green runs)
--concurrency <n> Parallel uploads for bulk import (default 6)
--upload-attachments <kinds>
Comma-separated: trace (default), video, screenshot.
Add video/screenshot when your suite runs without traces
(with tracing on they already ride inside the trace.zip).
-h, --help`

const ATTACHMENT_KINDS: AttachmentKind[] = ['trace', 'video', 'screenshot']

function parseAttachmentKinds(raw: string | undefined): AttachmentKind[] | undefined {
if (raw === undefined)
return undefined
const kinds = raw.split(',').map(k => k.trim()).filter(Boolean)
const unknown = kinds.filter(k => !ATTACHMENT_KINDS.includes(k as AttachmentKind))
if (unknown.length)
fail(`unknown --upload-attachments value: ${unknown.join(', ')} (allowed: ${ATTACHMENT_KINDS.join(', ')})`)
return kinds as AttachmentKind[]
}

function fail(msg: string): never {
console.error(`error: ${msg}\n`)
console.error(USAGE)
Expand All @@ -60,6 +77,7 @@ async function main(): Promise<void> {
'pr-label': { type: 'string' },
'pr-policy': { type: 'string' },
'concurrency': { type: 'string' },
'upload-attachments': { type: 'string' },
'help': { type: 'boolean', short: 'h' },
},
})
Expand Down Expand Up @@ -126,6 +144,7 @@ async function main(): Promise<void> {
git,
ci,
regression: !!values['pr-comment'],
uploadAttachments: parseAttachmentKinds(values['upload-attachments']),
})

console.log(`uploaded ${res.tests} tests to ${values.project} (run ${res.runId})`)
Expand Down
13 changes: 8 additions & 5 deletions packages/cli/src/upload.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CiMeta, Counts, GitMeta, IngestRunResult } from '@kinora/core'
import type { AttachmentKind, CiMeta, Counts, GitMeta, IngestRunResult } from '@kinora/core'
import { readFile } from 'node:fs/promises'
import { buildIngestRun, createIngestClient, isTraceAttachment } from '@kinora/core'
import { buildIngestRun, createIngestClient, DEFAULT_UPLOAD_ATTACHMENTS, isUploadableAttachment } from '@kinora/core'

export type UploadResult = IngestRunResult & { counts: Counts }

Expand All @@ -13,6 +13,8 @@ export interface UploadOptions {
fetch?: typeof globalThis.fetch
// Ask the server to return a regression summary (for --pr-comment).
regression?: boolean
// Attachment kinds to upload; defaults to traces only.
uploadAttachments?: AttachmentKind[]
}

// Parse a Playwright json report and upload it to a kinora server. Shares the
Expand All @@ -26,16 +28,17 @@ export async function uploadReport(raw: unknown, opts: UploadOptions): Promise<U
const client = createIngestClient({ baseUrl: opts.url, token: opts.token, fetch: opts.fetch, regression: opts.regression })
const res = await client.uploadRun(payload)

const kinds = opts.uploadAttachments ?? DEFAULT_UPLOAD_ATTACHMENTS
for (const t of payload.tests) {
for (const a of t.attachments) {
if (!a.path || !isTraceAttachment(a))
if (!a.path || !isUploadableAttachment(a, kinds))
continue
try {
const art = await client.uploadArtifact({ runId: res.runId, testKey: t.testKey, name: a.name, contentType: a.contentType, body: await readFile(a.path) })
console.log(` trace ${t.testKey} -> ${art.url}`)
console.log(` ${a.name} ${t.testKey} -> ${art.url}`)
}
catch (err) {
console.warn(`warning: trace upload failed for ${t.testKey}: ${err instanceof Error ? err.message : err}`)
console.warn(`warning: ${a.name} upload failed for ${t.testKey}: ${err instanceof Error ? err.message : err}`)
}
}
}
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/test/upload.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { Buffer } from 'node:buffer'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { uploadReport } from '../src/upload'

Expand Down Expand Up @@ -42,4 +46,40 @@ describe('uploadReport', () => {
expect(body.run.counts.total).toBe(2)
expect(body.run.counts.unexpected).toBe(1)
})

it('uploads a video attachment only when --upload-attachments asks for it', async () => {
const dir = await mkdtemp(join(tmpdir(), 'kinora-cli-'))
const videoPath = join(dir, 'video.webm')
await writeFile(videoPath, Buffer.from('webm'))
const spec = RAW.suites[0].specs[0]
const raw = {
...RAW,
suites: [{
...RAW.suites[0],
specs: [{
...spec,
tests: [{
status: 'expected',
projectName: 'chromium',
results: [{ status: 'passed', duration: 5, attachments: [{ name: 'video', contentType: 'video/webm', path: videoPath }] }],
}],
}],
}],
}

const calls: string[] = []
const fetchMock = (async (url: string | URL | Request) => {
calls.push(String(url))
return new Response(JSON.stringify({ projectId: 'p1', runId: 'r1', tests: 1 }), { status: 201 })
}) as typeof globalThis.fetch
const opts = { project: { slug: 'web-app' }, url: 'https://api.example.com', token: 'secret', fetch: fetchMock }

await uploadReport(raw, opts)
expect(calls.filter(u => u.endsWith('/artifacts'))).toHaveLength(0)

await uploadReport(raw, { ...opts, uploadAttachments: ['trace', 'video'] })
expect(calls.filter(u => u.endsWith('/artifacts'))).toHaveLength(1)

await rm(dir, { recursive: true, force: true })
})
})
45 changes: 45 additions & 0 deletions packages/core/src/lib/ingest-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { attachmentKind, isUploadableAttachment } from './ingest-client'

const trace = { name: 'trace', contentType: 'application/zip', path: '/r/trace.zip' }
const video = { name: 'video', contentType: 'video/webm', path: '/r/video.webm' }
const shot = { name: 'screenshot', contentType: 'image/png', path: '/r/s.png' }

describe('attachmentKind', () => {
it('classifies the kinds kinora can host', () => {
expect(attachmentKind(trace)).toBe('trace')
expect(attachmentKind(video)).toBe('video')
expect(attachmentKind(shot)).toBe('screenshot')
})

it('treats a zip path as a trace whatever its name', () => {
expect(attachmentKind({ name: 'bundle', contentType: 'application/octet-stream', path: '/r/x.zip' })).toBe('trace')
})

it('returns null without a path (body-only or CI-local metadata)', () => {
expect(attachmentKind({ name: 'video', contentType: 'video/webm' })).toBeNull()
})

it('returns null for kinds with nowhere to render them', () => {
expect(attachmentKind({ name: 'stdout', contentType: 'text/plain', path: '/r/out.txt' })).toBeNull()
})
})

describe('isUploadableAttachment', () => {
it('uploads traces only by default', () => {
expect(isUploadableAttachment(trace, ['trace'])).toBe(true)
expect(isUploadableAttachment(video, ['trace'])).toBe(false)
expect(isUploadableAttachment(shot, ['trace'])).toBe(false)
})

it('uploads media once opted in', () => {
const kinds = ['trace', 'video', 'screenshot'] as const
expect(isUploadableAttachment(video, kinds)).toBe(true)
expect(isUploadableAttachment(shot, kinds)).toBe(true)
})

it('can upload media without traces', () => {
expect(isUploadableAttachment(trace, ['video'])).toBe(false)
expect(isUploadableAttachment(video, ['video'])).toBe(true)
})
})
22 changes: 22 additions & 0 deletions packages/core/src/lib/ingest-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,29 @@ export function createIngestClient(opts: IngestClientOptions) {
}
}

export type AttachmentKind = 'trace' | 'video' | 'screenshot'

export const DEFAULT_UPLOAD_ATTACHMENTS: AttachmentKind[] = ['trace']

// Trace-like attachments worth uploading (the viewer's flagship input).
export function isTraceAttachment(a: { name: string, contentType: string, path?: string }): boolean {
return !!a.path && (a.name === 'trace' || a.contentType === 'application/zip' || a.path.endsWith('.zip'))
}

// null = nothing we know how to host (text/plain logs, markdown annotations, ...).
export function attachmentKind(a: { name: string, contentType: string, path?: string }): AttachmentKind | null {
if (!a.path)
return null
if (isTraceAttachment(a))
return 'trace'
if (a.contentType.startsWith('video/'))
return 'video'
if (a.contentType.startsWith('image/'))
return 'screenshot'
return null
}

export function isUploadableAttachment(a: { name: string, contentType: string, path?: string }, kinds: readonly AttachmentKind[]): boolean {
const kind = attachmentKind(a)
return kind !== null && kinds.includes(kind)
}
17 changes: 16 additions & 1 deletion packages/reporter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Playwright reporter that uploads your test results to a [kinora](https://github.com/Kinora-dev/kinora) server: pass rates, trends, flaky tests, and the full Playwright trace for failures, across projects and over time.

It runs on `onEnd`, posts the normalized run, then uploads the trace.zip for each test that produced one. Upload never fails your test run.
It runs on `onEnd`, posts the normalized run, then uploads the trace.zip for each test that produced one (videos and screenshots too, see below). Upload never fails your test run.

## Install

Expand Down Expand Up @@ -35,6 +35,21 @@ KINORA_TOKEN=<token> npx playwright test

Create an API token in the kinora dashboard (Settings → Workspace). Self-hosting? Point at your server with `KINORA_URL`.

## Videos and screenshots without tracing

Traces are uploaded by default, and Playwright already embeds a test's screenshots and video
inside its trace.zip. If you run without tracing, upload them on their own:

```ts
reporter: [['@kinora/reporter', {
project: { slug: 'web-app' },
uploadAttachments: ['trace', 'video', 'screenshot'],
}]]
```

Kinora uploads whatever Playwright attached, so `screenshot: 'only-on-failure'` and
`video: 'retain-on-failure'` keep deciding what exists in the first place.

## Documentation

Full reporter options, GitHub PR comments, CI examples, and self-hosting are in the docs:
Expand Down
21 changes: 14 additions & 7 deletions packages/reporter/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { CiMeta, Counts, GitMeta, IngestRun, IngestRunResult, NormTest } from '@kinora/core'
import type { AttachmentKind, CiMeta, Counts, GitMeta, IngestRun, IngestRunResult, NormTest } from '@kinora/core'
import type { FullConfig, FullResult, Reporter, Suite, TestCase } from '@playwright/test/reporter'
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import process from 'node:process'
import { createIngestClient, DEFAULT_KINORA_URL, effectiveAttachments, IngestError, isTraceAttachment, makeTestKey, postPrComment, resolvePrContext } from '@kinora/core'
import { createIngestClient, DEFAULT_KINORA_URL, DEFAULT_UPLOAD_ATTACHMENTS, effectiveAttachments, IngestError, isUploadableAttachment, makeTestKey, postPrComment, resolvePrContext } from '@kinora/core'

export interface KinoraReporterOptions {
/** kinora server base URL. Defaults to env KINORA_URL, then the hosted cloud. Set for self-host. */
Expand All @@ -19,6 +19,12 @@ export interface KinoraReporterOptions {
* `permissions: pull-requests: write`). `label` distinguishes matrix legs sharing one PR.
*/
prComment?: boolean | { label?: string, policy?: 'always' | 'on-failure' }
/**
* Which attachment kinds to upload. Defaults to `['trace']`. Add `'video'` / `'screenshot'`
* to host them on their own, which is what you want when `trace` is off (with tracing on,
* Playwright already embeds them in the trace.zip).
*/
uploadAttachments?: AttachmentKind[]
}

// Rebuild the json-report identity (file path + title path + project) from the
Expand Down Expand Up @@ -135,22 +141,23 @@ export default class KinoraReporter implements Reporter {
const client = createIngestClient({ baseUrl: url, token, regression: !!this.options.prComment })
const res = await client.uploadRun(payload)

let traces = 0
const kinds = this.options.uploadAttachments ?? DEFAULT_UPLOAD_ATTACHMENTS
let artifacts = 0
for (const t of tests) {
for (const a of t.attachments) {
if (!a.path || !isTraceAttachment(a))
if (!a.path || !isUploadableAttachment(a, kinds))
continue
try {
await client.uploadArtifact({ runId: res.runId, testKey: t.testKey, name: a.name, contentType: a.contentType, body: await readFile(a.path) })
traces++
artifacts++
}
catch (err) {
console.warn(`[kinora] trace upload failed for ${t.testKey}:`, err instanceof Error ? err.message : err)
console.warn(`[kinora] ${a.name} upload failed for ${t.testKey}:`, err instanceof Error ? err.message : err)
}
}
}
// eslint-disable-next-line no-console -- a reporter's job is to report
console.log(`[kinora] uploaded ${res.tests} tests + ${traces} traces (run ${res.runId})`)
console.log(`[kinora] uploaded ${res.tests} tests + ${artifacts} artifacts (run ${res.runId})`)

await this.maybePostPrComment(payload, res)
}
Expand Down
31 changes: 29 additions & 2 deletions packages/reporter/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import KinoraReporter from '../src/index'

// Minimal fakes of the Playwright reporter objects the reporter reads.
function fakeTest(over: { title?: string, outcome?: string, ok?: boolean, tracePath?: string } = {}): TestCase {
function fakeTest(over: { title?: string, outcome?: string, ok?: boolean, tracePath?: string, videoPath?: string } = {}): TestCase {
const projectSuite = { type: 'project', title: 'chromium', parent: undefined }
const fileSuite = { type: 'file', title: 'a.spec.ts', parent: projectSuite }
const attachments = over.tracePath ? [{ name: 'trace', contentType: 'application/zip', path: over.tracePath }] : []
const attachments = [
...over.tracePath ? [{ name: 'trace', contentType: 'application/zip', path: over.tracePath }] : [],
...over.videoPath ? [{ name: 'video', contentType: 'video/webm', path: over.videoPath }] : [],
]
return {
parent: fileSuite,
title: over.title ?? 'passes',
Expand Down Expand Up @@ -130,6 +133,30 @@ describe('reporter onEnd', () => {
await rm(dir, { recursive: true, force: true })
})

it('leaves a video on the runner unless uploadAttachments asks for it', async () => {
const dir = await mkdtemp(join(tmpdir(), 'kinora-reporter-'))
const videoPath = join(dir, 'video.webm')
await writeFile(videoPath, Buffer.from('webm'))
const artifactPosts = vi.fn()
vi.stubGlobal('fetch', vi.fn(async (url: unknown) => {
if (String(url).endsWith('/artifacts'))
artifactPosts()
return new Response(JSON.stringify({ projectId: 'p', runId: 'r', tests: 1, url: 'http://x/a.webm' }), { status: 201 })
}))

const reporter = new KinoraReporter({ url: 'https://api.example.com', token: 't', project: { slug: 'web-app' } })
reporter.onBegin({ version: '1.60.0' } as FullConfig, fakeSuite([fakeTest({ videoPath })]))
await reporter.onEnd(fakeResult())
expect(artifactPosts).not.toHaveBeenCalled()

const withVideo = new KinoraReporter({ url: 'https://api.example.com', token: 't', project: { slug: 'web-app' }, uploadAttachments: ['trace', 'video'] })
withVideo.onBegin({ version: '1.60.0' } as FullConfig, fakeSuite([fakeTest({ videoPath })]))
await withVideo.onEnd(fakeResult())
expect(artifactPosts).toHaveBeenCalledTimes(1)

await rm(dir, { recursive: true, force: true })
})

it('does not throw when the server rejects with a plan-limit 402', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'limit reached' }), { status: 402 })))

Expand Down
21 changes: 20 additions & 1 deletion packages/server/src/public-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,25 @@ interface StreamedArtifact {
size: number
}

const MIME_EXTENSIONS: Record<string, string> = {
'application/zip': 'zip',
'video/webm': 'webm',
'video/mp4': 'mp4',
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
}

// Playwright attachment names carry no extension ("trace", "video"), and /artifacts/* types its
// responses from the stored key, so the extension has to come from the content type.
function withExtension(name: string, contentType: string): string {
const dot = name.lastIndexOf('.')
const fromName = dot > 0 ? name.slice(dot + 1).replace(/\W/g, '') : ''
const ext = MIME_EXTENSIONS[contentType.split(';')[0].trim()] || fromName || 'zip'
return name.endsWith(`.${ext}`) ? name : `${name}.${ext}`
}

// Parse the multipart upload and stream the file part straight to storage so a large trace.zip is
// never fully buffered. null = no file part; { tooLarge } = file part exceeded the byte cap.
async function streamArtifact(c: Context, projectId: string, runId: string): Promise<StreamedArtifact | { tooLarge: true } | null> {
Expand Down Expand Up @@ -244,7 +263,7 @@ async function streamArtifact(c: Context, projectId: string, runId: string): Pro
fileContentType = info.mimeType || 'application/zip'
// The reporter sends the file part before the name field, so derive the key from its filename.
const safeName = (info.filename || 'trace').replace(/[^\w.-]/g, '_').slice(0, 100) || 'trace'
key = `${projectId}/${runId}/${randomUUID()}-${safeName}.zip`
key = `${projectId}/${runId}/${randomUUID()}-${withExtension(safeName, fileContentType)}`
fileStream.on('limit', () => {
tooLarge = true
})
Expand Down
Loading