@qualflare/playwright 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,8 +5,9 @@
5
5
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](./LICENSE)
6
6
 
7
7
  A native Playwright reporter for [Qualflare](https://qualflare.com) — captures test results directly
8
- from your `playwright test` run: status, real retry counts and flakiness, nested `test.step()` trees,
9
- screenshots, videos, and author-facing metadata (labels, links, tags, priority, custom attachments).
8
+ from your `playwright test` run: status, per-attempt retry history and flakiness, nested
9
+ `test.step()` trees,
10
+ screenshots, videos, traces, and author-facing metadata (labels, links, tags, priority, custom attachments).
10
11
 
11
12
  The reporter itself makes **no network calls**. It writes a report directory, and
12
13
  [`qualflare-cli`](https://github.com/Qualflare/qualflare-cli) uploads it — which is what lets any
@@ -113,17 +114,31 @@ wrong value cannot fail at test time — the reporter makes no requests — so t
113
114
 
114
115
  ## Known limitations
115
116
 
116
- - **Traces are not uploaded.** Playwright traces are `application/zip`, which Qualflare's attachment
117
- upload endpoint rejects. They are deliberately not attached rather than attached as a link to
118
- nothing.
117
+ - **Traces need `--upload-artifacts=trace` at collect time.** The zip is copied into `outputDir`,
118
+ but `qf collect` uploads no heavy artifact unless asked pass `--upload-artifacts=trace` (or
119
+ `video,trace`). Needs `@qualflare/cli` v0.1.20+; older CLIs ignore it.
119
120
  - **`pw:api` and `fixture` steps are filtered out by default** (`includeApiSteps`) — a single
120
121
  browser test emits hundreds, which buries the steps you actually wrote. A *failed* one is always
121
122
  kept.
122
123
  - **A stale `outputDir` is refused, not merged** — each report carries a `runId`, and `qf collect`
123
124
  errors rather than merging files from two different runs. Needs `@qualflare/cli` v0.1.19+; older
124
125
  CLIs merge as before.
125
- - **`merge-reports` mode is not supported** in v0.1.0 — use the `outputDir` flow above rather than
126
+ - **`merge-reports` mode is not supported** — use the `outputDir` flow above rather than
126
127
  Playwright's `blob` reporter.
128
+ - **Playwright-native `tag` needs 1.42+** while the peer floor is 1.40 — on 1.40/1.41 the
129
+ native tag array is not read; `qualflare.tag()` works throughout.
130
+ - **`parameter()` outside a step is not masked** — `masked` is a display hint for the UI; the
131
+ server never redacts the value, so never put a real secret in one. See
132
+ [`docs/LIMITATIONS.md`](./docs/LIMITATIONS.md#parameter-outside-a-step-has-no-masking).
133
+ - **Attachment caps are two budgets, not one pool** — `maxAttachmentBytes` bounds a single
134
+ attachment and `maxTotalAttachmentBytes` the whole run; anything over either is dropped
135
+ outright rather than truncated. Raising them is the easiest way to push a request past
136
+ `/collect`'s body limit. See
137
+ [`docs/LIMITATIONS.md`](./docs/LIMITATIONS.md#per-case-and-per-attachment-caps-are-independent-not-pooled).
138
+ - **Retries carry per-attempt errors, but everything else is the final attempt** — `Case.attempts`
139
+ records each attempt's status, duration and error; steps, labels, links, tags, priority,
140
+ properties and attachments come from the last attempt only, so an abandoned attempt's step trace
141
+ is discarded rather than replayed alongside the final one.
127
142
 
128
143
  Full details in [`docs/LIMITATIONS.md`](./docs/LIMITATIONS.md).
129
144
 
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ var import_test = require("@playwright/test");
31
31
  // src/shared/constants.ts
32
32
  var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
33
33
  var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
34
+ var MAX_TRACE_UPLOAD_BYTES = 50 * 1024 * 1024;
34
35
 
35
36
  // src/shared/logger.ts
36
37
  var PREFIX = "[qualflare-playwright]";
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/runtime/qualflare-api.ts","../src/shared/constants.ts","../src/shared/logger.ts"],"sourcesContent":["import type { ReporterDescription } from '@playwright/test';\n\nimport type { QualflarePlaywrightOptions } from './config/resolve-config.js';\n\nexport { qualflare } from './runtime/qualflare-api.js';\n\nexport type { QualflarePlaywrightOptions, ResolvedReporterConfig } from './config/resolve-config.js';\n\nexport type {\n Attachment,\n Case,\n CasePriority,\n CaseStatus,\n Collect,\n FrameworkCategory,\n Label,\n Link,\n LinkType,\n Metadata,\n NanosecondDuration,\n Parameter,\n Platform,\n Step,\n Suite,\n} from './shared/types.js';\n\n/**\n * Typed helper for registering the reporter.\n *\n * Playwright types a reporter's options as `any` (`ReporterDescription` ends\n * in `[string, any]`), so writing the tuple by hand gives no autocomplete and\n * silently accepts typos. This returns the same tuple with the options\n * checked:\n *\n * ```ts\n * import { defineConfig } from '@playwright/test';\n * import { qualflareReporter } from '@qualflare/playwright';\n *\n * export default defineConfig({\n * reporter: [['list'], qualflareReporter({ environment: 'staging' })],\n * });\n * ```\n */\nexport function qualflareReporter(options: QualflarePlaywrightOptions = {}): ReporterDescription {\n return ['@qualflare/playwright/reporter', options];\n}\n","import { test } from '@playwright/test';\n\nimport { RESERVED_MESSAGE_MEDIA_TYPE } from '../shared/constants.js';\nimport { logger } from '../shared/logger.js';\nimport type { CasePriority, LinkType } from '../shared/types.js';\nimport type { RuntimeMessage } from './message-types.js';\n\n/**\n * Ships one structured message from the test process to the reporter.\n *\n * Playwright runs tests in worker processes and reporters in the main\n * process, with no shared memory — the only user-reachable channel back is\n * `testInfo.attach()`. So every `qualflare.*()` call is serialized and\n * attached under a reserved content type; the reporter recognizes that exact\n * type in `onTestEnd`, replays it as a model mutation, and excludes it from\n * real attachment processing. Same trick `@qualflare/cucumberjs` plays with\n * `World.attach()`, for the same reason.\n *\n * `test.info()` throws when called outside a running test (module scope, a\n * `globalSetup`, a stray import). Warn and drop: a metadata call is never\n * worth failing somebody's suite over.\n */\nfunction send(message: RuntimeMessage): void {\n try {\n // The .catch() is not optional. `attach()` returns a promise, and an\n // unhandled rejection TERMINATES the process by default on Node >= 15 —\n // a metadata call would take the user's entire test run down with it.\n // The surrounding try/catch only covers a synchronous throw from\n // `test.info()` (called outside a running test), not this.\n void test\n .info()\n .attach(`qualflare:${message.type}`, {\n body: Buffer.from(JSON.stringify(message), 'utf8'),\n contentType: RESERVED_MESSAGE_MEDIA_TYPE,\n })\n .catch((err: unknown) => {\n logger.warn(`qualflare.${message.type}() could not be recorded: ${(err as Error).message}`);\n });\n } catch {\n logger.warn(`qualflare.${message.type}() was called outside a running test; the call was ignored.`);\n }\n}\n\n/**\n * Author-facing metadata API. Import it in a spec and annotate tests with\n * business context Playwright itself has no concept of:\n *\n * ```ts\n * import { qualflare } from '@qualflare/playwright';\n *\n * test('checks out', async ({ page }) => {\n * qualflare.label('epic', 'Billing');\n * qualflare.link('https://tracker/QF-1', { type: 'issue', name: 'QF-1' });\n * await qualflare.step('pay', async () => { ... });\n * });\n * ```\n */\nexport const qualflare = {\n /** Arbitrary name/value metadata (epic, feature, story, owner, severity). */\n label(name: string, value: string): void {\n send({ type: 'label', name, value });\n },\n\n /** A typed external reference. `type` defaults to `custom`. */\n link(url: string, opts?: { type?: LinkType; name?: string }): void {\n send({ type: 'link', url, linkType: opts?.type, name: opts?.name });\n },\n\n /** One or more free-text tags. */\n tag(...tags: string[]): void {\n send({ type: 'tag', tags });\n },\n\n /** Markdown description shown on the case. */\n description(text: string): void {\n send({ type: 'description', text });\n },\n\n /** Case priority (low | medium | high | critical). */\n priority(value: CasePriority): void {\n send({ type: 'priority', value });\n },\n\n /** A named input. Inside an open `step()` it attaches to that step;\n * outside any step it lands in the case's properties. `masked` is a\n * display hint for the UI only — the server does not redact the value. */\n parameter(name: string, value?: string, opts?: { masked?: boolean }): void {\n send({ type: 'parameter', name, value, masked: opts?.masked });\n },\n\n /** Attach in-memory content. */\n attachment(name: string, content: string, opts?: { encoding?: 'utf8' | 'base64'; mimeType?: string }): void {\n const contentBase64 = opts?.encoding === 'base64' ? content : Buffer.from(content, 'utf8').toString('base64');\n send({ type: 'attachment', name, contentBase64, mimeType: opts?.mimeType });\n },\n\n /** Attach a file from disk. */\n attachmentFromFile(name: string, path: string, opts?: { mimeType?: string }): void {\n send({ type: 'attachment_from_file', name, path, mimeType: opts?.mimeType });\n },\n\n /**\n * Records a named step around `fn`.\n *\n * Unlike the cucumber-js equivalent, this delegates to Playwright's own\n * `test.step()` as well, so the step shows up in Playwright's HTML report\n * and trace viewer in addition to Qualflare — there is no reason to make\n * users choose, and a step that exists in only one of the two is a\n * confusing thing to debug against.\n */\n async step<T>(name: string, fn: () => T | Promise<T>): Promise<T> {\n return test.step(name, async () => {\n send({ type: 'step_start', name, timestamp: Date.now() });\n try {\n const result = await fn();\n send({ type: 'step_stop', status: 'passed', timestamp: Date.now() });\n return result;\n } catch (err) {\n send({\n type: 'step_stop',\n status: 'failed',\n error: err instanceof Error ? err.message : String(err),\n timestamp: Date.now(),\n });\n throw err;\n }\n });\n },\n};\n","/**\n * Shared constants used across the reporter and the author-facing runtime\n * API.\n */\n\n/** Reserved `testInfo.attach()` content type used to smuggle structured\n * `qualflare.*()` calls (label/tag/step/etc.) from test and hook code back to\n * the reporter — the only channel Playwright gives user code back to a\n * running reporter. The reporter recognizes this exact content type and\n * replays the message as a model mutation instead of reporting it as a\n * literal attachment. */\nexport const RESERVED_MESSAGE_MEDIA_TYPE = 'application/vnd.qualflare.message+json';\n\n/** Server-side caps this client should respect defensively (see\n * `api-service/internal/core/domain/launch/launch.go`). */\nexport const MAX_SUITES_PER_LAUNCH = 2000;\nexport const MAX_CASES_PER_SUITE = 5000;\nexport const MAX_STEPS_PER_CASE = 1000;\nexport const MAX_PARAMETERS_PER_STEP = 50;\nexport const MAX_ATTACHMENTS_PER_CASE = 50;\nexport const MAX_LABELS_PER_CASE = 100;\nexport const MAX_LINKS_PER_CASE = 20;\nexport const MAX_TAGS_PER_CASE = 64;\nexport const MAX_TAG_LENGTH = 255;\n\n/** Mirrors `launch.MaxAttachmentUploadFileSize` — the server's hard cap on a\n * single `POST /api/v1/attachments/upload-url` request (video). */\nexport const MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;\n\n/** Client-side SOFT cap on steps recorded per scenario attempt — well under\n * the server's 1000-per-case hard cap (`MAX_STEPS_PER_CASE`). Once hit,\n * further steps within that attempt are dropped (with a one-time warning),\n * not queued and truncated later. */\nexport const MAX_STEPS_PER_TEST_ATTEMPT = 300;\n","/**\n * A minimal logger writing to stderr. Deliberately avoids stdout, since\n * that's typically Playwright's own reporter output stream and shouldn't be\n * polluted with reporter diagnostics.\n */\n\nconst PREFIX = '[qualflare-playwright]';\n\nexport const logger = {\n debug(...args: unknown[]): void {\n console.debug(PREFIX, ...args);\n },\n info(...args: unknown[]): void {\n console.log(PREFIX, ...args);\n },\n warn(...args: unknown[]): void {\n console.warn(PREFIX, ...args);\n },\n error(...args: unknown[]): void {\n console.error(PREFIX, ...args);\n },\n};\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/runtime/qualflare-api.ts","../src/shared/constants.ts","../src/shared/logger.ts"],"sourcesContent":["import type { ReporterDescription } from '@playwright/test';\n\nimport type { QualflarePlaywrightOptions } from './config/resolve-config.js';\n\nexport { qualflare } from './runtime/qualflare-api.js';\n\nexport type { QualflarePlaywrightOptions, ResolvedReporterConfig } from './config/resolve-config.js';\n\nexport type {\n Attachment,\n Case,\n CasePriority,\n CaseStatus,\n Collect,\n FrameworkCategory,\n Label,\n Link,\n LinkType,\n Metadata,\n NanosecondDuration,\n Parameter,\n Platform,\n Step,\n Suite,\n} from './shared/types.js';\n\n/**\n * Typed helper for registering the reporter.\n *\n * Playwright types a reporter's options as `any` (`ReporterDescription` ends\n * in `[string, any]`), so writing the tuple by hand gives no autocomplete and\n * silently accepts typos. This returns the same tuple with the options\n * checked:\n *\n * ```ts\n * import { defineConfig } from '@playwright/test';\n * import { qualflareReporter } from '@qualflare/playwright';\n *\n * export default defineConfig({\n * reporter: [['list'], qualflareReporter({ environment: 'staging' })],\n * });\n * ```\n */\nexport function qualflareReporter(options: QualflarePlaywrightOptions = {}): ReporterDescription {\n return ['@qualflare/playwright/reporter', options];\n}\n","import { test } from '@playwright/test';\n\nimport { RESERVED_MESSAGE_MEDIA_TYPE } from '../shared/constants.js';\nimport { logger } from '../shared/logger.js';\nimport type { CasePriority, LinkType } from '../shared/types.js';\nimport type { RuntimeMessage } from './message-types.js';\n\n/**\n * Ships one structured message from the test process to the reporter.\n *\n * Playwright runs tests in worker processes and reporters in the main\n * process, with no shared memory — the only user-reachable channel back is\n * `testInfo.attach()`. So every `qualflare.*()` call is serialized and\n * attached under a reserved content type; the reporter recognizes that exact\n * type in `onTestEnd`, replays it as a model mutation, and excludes it from\n * real attachment processing. Same trick `@qualflare/cucumberjs` plays with\n * `World.attach()`, for the same reason.\n *\n * `test.info()` throws when called outside a running test (module scope, a\n * `globalSetup`, a stray import). Warn and drop: a metadata call is never\n * worth failing somebody's suite over.\n */\nfunction send(message: RuntimeMessage): void {\n try {\n // The .catch() is not optional. `attach()` returns a promise, and an\n // unhandled rejection TERMINATES the process by default on Node >= 15 —\n // a metadata call would take the user's entire test run down with it.\n // The surrounding try/catch only covers a synchronous throw from\n // `test.info()` (called outside a running test), not this.\n void test\n .info()\n .attach(`qualflare:${message.type}`, {\n body: Buffer.from(JSON.stringify(message), 'utf8'),\n contentType: RESERVED_MESSAGE_MEDIA_TYPE,\n })\n .catch((err: unknown) => {\n logger.warn(`qualflare.${message.type}() could not be recorded: ${(err as Error).message}`);\n });\n } catch {\n logger.warn(`qualflare.${message.type}() was called outside a running test; the call was ignored.`);\n }\n}\n\n/**\n * Author-facing metadata API. Import it in a spec and annotate tests with\n * business context Playwright itself has no concept of:\n *\n * ```ts\n * import { qualflare } from '@qualflare/playwright';\n *\n * test('checks out', async ({ page }) => {\n * qualflare.label('epic', 'Billing');\n * qualflare.link('https://tracker/QF-1', { type: 'issue', name: 'QF-1' });\n * await qualflare.step('pay', async () => { ... });\n * });\n * ```\n */\nexport const qualflare = {\n /** Arbitrary name/value metadata (epic, feature, story, owner, severity). */\n label(name: string, value: string): void {\n send({ type: 'label', name, value });\n },\n\n /** A typed external reference. `type` defaults to `custom`. */\n link(url: string, opts?: { type?: LinkType; name?: string }): void {\n send({ type: 'link', url, linkType: opts?.type, name: opts?.name });\n },\n\n /** One or more free-text tags. */\n tag(...tags: string[]): void {\n send({ type: 'tag', tags });\n },\n\n /** Markdown description shown on the case. */\n description(text: string): void {\n send({ type: 'description', text });\n },\n\n /** Case priority (low | medium | high | critical). */\n priority(value: CasePriority): void {\n send({ type: 'priority', value });\n },\n\n /** A named input. Inside an open `step()` it attaches to that step;\n * outside any step it lands in the case's properties. `masked` is a\n * display hint for the UI only — the server does not redact the value. */\n parameter(name: string, value?: string, opts?: { masked?: boolean }): void {\n send({ type: 'parameter', name, value, masked: opts?.masked });\n },\n\n /** Attach in-memory content. */\n attachment(name: string, content: string, opts?: { encoding?: 'utf8' | 'base64'; mimeType?: string }): void {\n const contentBase64 = opts?.encoding === 'base64' ? content : Buffer.from(content, 'utf8').toString('base64');\n send({ type: 'attachment', name, contentBase64, mimeType: opts?.mimeType });\n },\n\n /** Attach a file from disk. */\n attachmentFromFile(name: string, path: string, opts?: { mimeType?: string }): void {\n send({ type: 'attachment_from_file', name, path, mimeType: opts?.mimeType });\n },\n\n /**\n * Records a named step around `fn`.\n *\n * Unlike the cucumber-js equivalent, this delegates to Playwright's own\n * `test.step()` as well, so the step shows up in Playwright's HTML report\n * and trace viewer in addition to Qualflare — there is no reason to make\n * users choose, and a step that exists in only one of the two is a\n * confusing thing to debug against.\n */\n async step<T>(name: string, fn: () => T | Promise<T>): Promise<T> {\n return test.step(name, async () => {\n send({ type: 'step_start', name, timestamp: Date.now() });\n try {\n const result = await fn();\n send({ type: 'step_stop', status: 'passed', timestamp: Date.now() });\n return result;\n } catch (err) {\n send({\n type: 'step_stop',\n status: 'failed',\n error: err instanceof Error ? err.message : String(err),\n timestamp: Date.now(),\n });\n throw err;\n }\n });\n },\n};\n","/**\n * Shared constants used across the reporter and the author-facing runtime\n * API.\n */\n\n/** Reserved `testInfo.attach()` content type used to smuggle structured\n * `qualflare.*()` calls (label/tag/step/etc.) from test and hook code back to\n * the reporter — the only channel Playwright gives user code back to a\n * running reporter. The reporter recognizes this exact content type and\n * replays the message as a model mutation instead of reporting it as a\n * literal attachment. */\nexport const RESERVED_MESSAGE_MEDIA_TYPE = 'application/vnd.qualflare.message+json';\n\n/** Server-side caps this client should respect defensively (see\n * `api-service/internal/core/domain/launch/launch.go`). */\nexport const MAX_SUITES_PER_LAUNCH = 2000;\nexport const MAX_CASES_PER_SUITE = 5000;\nexport const MAX_STEPS_PER_CASE = 1000;\nexport const MAX_PARAMETERS_PER_STEP = 50;\nexport const MAX_ATTACHMENTS_PER_CASE = 50;\nexport const MAX_LABELS_PER_CASE = 100;\nexport const MAX_LINKS_PER_CASE = 20;\nexport const MAX_TAGS_PER_CASE = 64;\nexport const MAX_TAG_LENGTH = 255;\n\n/** Mirrors `launch.MaxCaseAttempts`. Beyond this the server keeps the first\n * 49 attempts plus the final one and drops the middle, so sending more is\n * wasted payload rather than an error. */\nexport const MAX_ATTEMPTS_PER_CASE = 50;\n\n/** Mirrors the server's per-attempt text bounds (`launch.MaxAttempt*Runes`).\n *\n * Clamped CLIENT-side, not left to the server, because attempts are the only\n * repeated-per-case payload with no size budget of its own. Measured: one\n * retried test with a deep stack and a chatty log serializes to ~630KB\n * unclamped — most of it text the server discards on write — against a 10MB\n * request body limit that, once exceeded, loses the ENTIRE launch. Sending\n * bytes the server will throw away is pure risk. */\nexport const MAX_ATTEMPT_MESSAGE_RUNES = 8192;\nexport const MAX_ATTEMPT_TRACE_RUNES = 32768;\nexport const MAX_ATTEMPT_SNIPPET_RUNES = 4096;\nexport const MAX_ATTEMPT_OUTPUT_RUNES = 16384;\nexport const MAX_ATTEMPT_OUTPUT_LINES = 200;\n\n/** Mirrors `launch.MaxAttachmentUploadFileSize` — the server's hard cap on a\n * single `POST /api/v1/attachments/upload-url` request (video). */\nexport const MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;\n\n/** Cap on one Playwright trace zip. Matches MAX_VIDEO_UPLOAD_BYTES because both\n * end up at the same server-side limit: the attachment upload endpoint rejects\n * anything past 50MB regardless of kind. Copying a larger trace would only\n * defer the rejection to collect time, after the bytes were already written. */\nexport const MAX_TRACE_UPLOAD_BYTES = 50 * 1024 * 1024;\n\n/** Client-side SOFT cap on steps recorded per scenario attempt — well under\n * the server's 1000-per-case hard cap (`MAX_STEPS_PER_CASE`). Once hit,\n * further steps within that attempt are dropped (with a one-time warning),\n * not queued and truncated later. */\nexport const MAX_STEPS_PER_TEST_ATTEMPT = 300;\n","/**\n * A minimal logger writing to stderr. Deliberately avoids stdout, since\n * that's typically Playwright's own reporter output stream and shouldn't be\n * polluted with reporter diagnostics.\n */\n\nconst PREFIX = '[qualflare-playwright]';\n\nexport const logger = {\n debug(...args: unknown[]): void {\n console.debug(PREFIX, ...args);\n },\n info(...args: unknown[]): void {\n console.log(PREFIX, ...args);\n },\n warn(...args: unknown[]): void {\n console.warn(PREFIX, ...args);\n },\n error(...args: unknown[]): void {\n console.error(PREFIX, ...args);\n },\n};\n"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ReporterDescription } from '@playwright/test';
2
- import { L as LinkType, C as CasePriority, Q as QualflarePlaywrightOptions } from './resolve-config-CQe-oDpg.cjs';
3
- export { A as Attachment, a as Case, b as CaseStatus, c as Collect, F as FrameworkCategory, d as Label, e as Link, M as Metadata, N as NanosecondDuration, P as Parameter, f as Platform, R as ResolvedReporterConfig, S as Step, g as Suite } from './resolve-config-CQe-oDpg.cjs';
2
+ import { L as LinkType, C as CasePriority, Q as QualflarePlaywrightOptions } from './resolve-config-T2ChZzAm.cjs';
3
+ export { A as Attachment, a as Case, b as CaseStatus, c as Collect, F as FrameworkCategory, d as Label, e as Link, M as Metadata, N as NanosecondDuration, P as Parameter, f as Platform, R as ResolvedReporterConfig, S as Step, g as Suite } from './resolve-config-T2ChZzAm.cjs';
4
4
 
5
5
  /**
6
6
  * Author-facing metadata API. Import it in a spec and annotate tests with
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ReporterDescription } from '@playwright/test';
2
- import { L as LinkType, C as CasePriority, Q as QualflarePlaywrightOptions } from './resolve-config-CQe-oDpg.js';
3
- export { A as Attachment, a as Case, b as CaseStatus, c as Collect, F as FrameworkCategory, d as Label, e as Link, M as Metadata, N as NanosecondDuration, P as Parameter, f as Platform, R as ResolvedReporterConfig, S as Step, g as Suite } from './resolve-config-CQe-oDpg.js';
2
+ import { L as LinkType, C as CasePriority, Q as QualflarePlaywrightOptions } from './resolve-config-T2ChZzAm.js';
3
+ export { A as Attachment, a as Case, b as CaseStatus, c as Collect, F as FrameworkCategory, d as Label, e as Link, M as Metadata, N as NanosecondDuration, P as Parameter, f as Platform, R as ResolvedReporterConfig, S as Step, g as Suite } from './resolve-config-T2ChZzAm.js';
4
4
 
5
5
  /**
6
6
  * Author-facing metadata API. Import it in a spec and annotate tests with
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { test } from "@playwright/test";
4
4
  // src/shared/constants.ts
5
5
  var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
6
6
  var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
7
+ var MAX_TRACE_UPLOAD_BYTES = 50 * 1024 * 1024;
7
8
 
8
9
  // src/shared/logger.ts
9
10
  var PREFIX = "[qualflare-playwright]";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/runtime/qualflare-api.ts","../src/shared/constants.ts","../src/shared/logger.ts","../src/index.ts"],"sourcesContent":["import { test } from '@playwright/test';\n\nimport { RESERVED_MESSAGE_MEDIA_TYPE } from '../shared/constants.js';\nimport { logger } from '../shared/logger.js';\nimport type { CasePriority, LinkType } from '../shared/types.js';\nimport type { RuntimeMessage } from './message-types.js';\n\n/**\n * Ships one structured message from the test process to the reporter.\n *\n * Playwright runs tests in worker processes and reporters in the main\n * process, with no shared memory — the only user-reachable channel back is\n * `testInfo.attach()`. So every `qualflare.*()` call is serialized and\n * attached under a reserved content type; the reporter recognizes that exact\n * type in `onTestEnd`, replays it as a model mutation, and excludes it from\n * real attachment processing. Same trick `@qualflare/cucumberjs` plays with\n * `World.attach()`, for the same reason.\n *\n * `test.info()` throws when called outside a running test (module scope, a\n * `globalSetup`, a stray import). Warn and drop: a metadata call is never\n * worth failing somebody's suite over.\n */\nfunction send(message: RuntimeMessage): void {\n try {\n // The .catch() is not optional. `attach()` returns a promise, and an\n // unhandled rejection TERMINATES the process by default on Node >= 15 —\n // a metadata call would take the user's entire test run down with it.\n // The surrounding try/catch only covers a synchronous throw from\n // `test.info()` (called outside a running test), not this.\n void test\n .info()\n .attach(`qualflare:${message.type}`, {\n body: Buffer.from(JSON.stringify(message), 'utf8'),\n contentType: RESERVED_MESSAGE_MEDIA_TYPE,\n })\n .catch((err: unknown) => {\n logger.warn(`qualflare.${message.type}() could not be recorded: ${(err as Error).message}`);\n });\n } catch {\n logger.warn(`qualflare.${message.type}() was called outside a running test; the call was ignored.`);\n }\n}\n\n/**\n * Author-facing metadata API. Import it in a spec and annotate tests with\n * business context Playwright itself has no concept of:\n *\n * ```ts\n * import { qualflare } from '@qualflare/playwright';\n *\n * test('checks out', async ({ page }) => {\n * qualflare.label('epic', 'Billing');\n * qualflare.link('https://tracker/QF-1', { type: 'issue', name: 'QF-1' });\n * await qualflare.step('pay', async () => { ... });\n * });\n * ```\n */\nexport const qualflare = {\n /** Arbitrary name/value metadata (epic, feature, story, owner, severity). */\n label(name: string, value: string): void {\n send({ type: 'label', name, value });\n },\n\n /** A typed external reference. `type` defaults to `custom`. */\n link(url: string, opts?: { type?: LinkType; name?: string }): void {\n send({ type: 'link', url, linkType: opts?.type, name: opts?.name });\n },\n\n /** One or more free-text tags. */\n tag(...tags: string[]): void {\n send({ type: 'tag', tags });\n },\n\n /** Markdown description shown on the case. */\n description(text: string): void {\n send({ type: 'description', text });\n },\n\n /** Case priority (low | medium | high | critical). */\n priority(value: CasePriority): void {\n send({ type: 'priority', value });\n },\n\n /** A named input. Inside an open `step()` it attaches to that step;\n * outside any step it lands in the case's properties. `masked` is a\n * display hint for the UI only — the server does not redact the value. */\n parameter(name: string, value?: string, opts?: { masked?: boolean }): void {\n send({ type: 'parameter', name, value, masked: opts?.masked });\n },\n\n /** Attach in-memory content. */\n attachment(name: string, content: string, opts?: { encoding?: 'utf8' | 'base64'; mimeType?: string }): void {\n const contentBase64 = opts?.encoding === 'base64' ? content : Buffer.from(content, 'utf8').toString('base64');\n send({ type: 'attachment', name, contentBase64, mimeType: opts?.mimeType });\n },\n\n /** Attach a file from disk. */\n attachmentFromFile(name: string, path: string, opts?: { mimeType?: string }): void {\n send({ type: 'attachment_from_file', name, path, mimeType: opts?.mimeType });\n },\n\n /**\n * Records a named step around `fn`.\n *\n * Unlike the cucumber-js equivalent, this delegates to Playwright's own\n * `test.step()` as well, so the step shows up in Playwright's HTML report\n * and trace viewer in addition to Qualflare — there is no reason to make\n * users choose, and a step that exists in only one of the two is a\n * confusing thing to debug against.\n */\n async step<T>(name: string, fn: () => T | Promise<T>): Promise<T> {\n return test.step(name, async () => {\n send({ type: 'step_start', name, timestamp: Date.now() });\n try {\n const result = await fn();\n send({ type: 'step_stop', status: 'passed', timestamp: Date.now() });\n return result;\n } catch (err) {\n send({\n type: 'step_stop',\n status: 'failed',\n error: err instanceof Error ? err.message : String(err),\n timestamp: Date.now(),\n });\n throw err;\n }\n });\n },\n};\n","/**\n * Shared constants used across the reporter and the author-facing runtime\n * API.\n */\n\n/** Reserved `testInfo.attach()` content type used to smuggle structured\n * `qualflare.*()` calls (label/tag/step/etc.) from test and hook code back to\n * the reporter — the only channel Playwright gives user code back to a\n * running reporter. The reporter recognizes this exact content type and\n * replays the message as a model mutation instead of reporting it as a\n * literal attachment. */\nexport const RESERVED_MESSAGE_MEDIA_TYPE = 'application/vnd.qualflare.message+json';\n\n/** Server-side caps this client should respect defensively (see\n * `api-service/internal/core/domain/launch/launch.go`). */\nexport const MAX_SUITES_PER_LAUNCH = 2000;\nexport const MAX_CASES_PER_SUITE = 5000;\nexport const MAX_STEPS_PER_CASE = 1000;\nexport const MAX_PARAMETERS_PER_STEP = 50;\nexport const MAX_ATTACHMENTS_PER_CASE = 50;\nexport const MAX_LABELS_PER_CASE = 100;\nexport const MAX_LINKS_PER_CASE = 20;\nexport const MAX_TAGS_PER_CASE = 64;\nexport const MAX_TAG_LENGTH = 255;\n\n/** Mirrors `launch.MaxAttachmentUploadFileSize` — the server's hard cap on a\n * single `POST /api/v1/attachments/upload-url` request (video). */\nexport const MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;\n\n/** Client-side SOFT cap on steps recorded per scenario attempt — well under\n * the server's 1000-per-case hard cap (`MAX_STEPS_PER_CASE`). Once hit,\n * further steps within that attempt are dropped (with a one-time warning),\n * not queued and truncated later. */\nexport const MAX_STEPS_PER_TEST_ATTEMPT = 300;\n","/**\n * A minimal logger writing to stderr. Deliberately avoids stdout, since\n * that's typically Playwright's own reporter output stream and shouldn't be\n * polluted with reporter diagnostics.\n */\n\nconst PREFIX = '[qualflare-playwright]';\n\nexport const logger = {\n debug(...args: unknown[]): void {\n console.debug(PREFIX, ...args);\n },\n info(...args: unknown[]): void {\n console.log(PREFIX, ...args);\n },\n warn(...args: unknown[]): void {\n console.warn(PREFIX, ...args);\n },\n error(...args: unknown[]): void {\n console.error(PREFIX, ...args);\n },\n};\n","import type { ReporterDescription } from '@playwright/test';\n\nimport type { QualflarePlaywrightOptions } from './config/resolve-config.js';\n\nexport { qualflare } from './runtime/qualflare-api.js';\n\nexport type { QualflarePlaywrightOptions, ResolvedReporterConfig } from './config/resolve-config.js';\n\nexport type {\n Attachment,\n Case,\n CasePriority,\n CaseStatus,\n Collect,\n FrameworkCategory,\n Label,\n Link,\n LinkType,\n Metadata,\n NanosecondDuration,\n Parameter,\n Platform,\n Step,\n Suite,\n} from './shared/types.js';\n\n/**\n * Typed helper for registering the reporter.\n *\n * Playwright types a reporter's options as `any` (`ReporterDescription` ends\n * in `[string, any]`), so writing the tuple by hand gives no autocomplete and\n * silently accepts typos. This returns the same tuple with the options\n * checked:\n *\n * ```ts\n * import { defineConfig } from '@playwright/test';\n * import { qualflareReporter } from '@qualflare/playwright';\n *\n * export default defineConfig({\n * reporter: [['list'], qualflareReporter({ environment: 'staging' })],\n * });\n * ```\n */\nexport function qualflareReporter(options: QualflarePlaywrightOptions = {}): ReporterDescription {\n return ['@qualflare/playwright/reporter', options];\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/runtime/qualflare-api.ts","../src/shared/constants.ts","../src/shared/logger.ts","../src/index.ts"],"sourcesContent":["import { test } from '@playwright/test';\n\nimport { RESERVED_MESSAGE_MEDIA_TYPE } from '../shared/constants.js';\nimport { logger } from '../shared/logger.js';\nimport type { CasePriority, LinkType } from '../shared/types.js';\nimport type { RuntimeMessage } from './message-types.js';\n\n/**\n * Ships one structured message from the test process to the reporter.\n *\n * Playwright runs tests in worker processes and reporters in the main\n * process, with no shared memory — the only user-reachable channel back is\n * `testInfo.attach()`. So every `qualflare.*()` call is serialized and\n * attached under a reserved content type; the reporter recognizes that exact\n * type in `onTestEnd`, replays it as a model mutation, and excludes it from\n * real attachment processing. Same trick `@qualflare/cucumberjs` plays with\n * `World.attach()`, for the same reason.\n *\n * `test.info()` throws when called outside a running test (module scope, a\n * `globalSetup`, a stray import). Warn and drop: a metadata call is never\n * worth failing somebody's suite over.\n */\nfunction send(message: RuntimeMessage): void {\n try {\n // The .catch() is not optional. `attach()` returns a promise, and an\n // unhandled rejection TERMINATES the process by default on Node >= 15 —\n // a metadata call would take the user's entire test run down with it.\n // The surrounding try/catch only covers a synchronous throw from\n // `test.info()` (called outside a running test), not this.\n void test\n .info()\n .attach(`qualflare:${message.type}`, {\n body: Buffer.from(JSON.stringify(message), 'utf8'),\n contentType: RESERVED_MESSAGE_MEDIA_TYPE,\n })\n .catch((err: unknown) => {\n logger.warn(`qualflare.${message.type}() could not be recorded: ${(err as Error).message}`);\n });\n } catch {\n logger.warn(`qualflare.${message.type}() was called outside a running test; the call was ignored.`);\n }\n}\n\n/**\n * Author-facing metadata API. Import it in a spec and annotate tests with\n * business context Playwright itself has no concept of:\n *\n * ```ts\n * import { qualflare } from '@qualflare/playwright';\n *\n * test('checks out', async ({ page }) => {\n * qualflare.label('epic', 'Billing');\n * qualflare.link('https://tracker/QF-1', { type: 'issue', name: 'QF-1' });\n * await qualflare.step('pay', async () => { ... });\n * });\n * ```\n */\nexport const qualflare = {\n /** Arbitrary name/value metadata (epic, feature, story, owner, severity). */\n label(name: string, value: string): void {\n send({ type: 'label', name, value });\n },\n\n /** A typed external reference. `type` defaults to `custom`. */\n link(url: string, opts?: { type?: LinkType; name?: string }): void {\n send({ type: 'link', url, linkType: opts?.type, name: opts?.name });\n },\n\n /** One or more free-text tags. */\n tag(...tags: string[]): void {\n send({ type: 'tag', tags });\n },\n\n /** Markdown description shown on the case. */\n description(text: string): void {\n send({ type: 'description', text });\n },\n\n /** Case priority (low | medium | high | critical). */\n priority(value: CasePriority): void {\n send({ type: 'priority', value });\n },\n\n /** A named input. Inside an open `step()` it attaches to that step;\n * outside any step it lands in the case's properties. `masked` is a\n * display hint for the UI only — the server does not redact the value. */\n parameter(name: string, value?: string, opts?: { masked?: boolean }): void {\n send({ type: 'parameter', name, value, masked: opts?.masked });\n },\n\n /** Attach in-memory content. */\n attachment(name: string, content: string, opts?: { encoding?: 'utf8' | 'base64'; mimeType?: string }): void {\n const contentBase64 = opts?.encoding === 'base64' ? content : Buffer.from(content, 'utf8').toString('base64');\n send({ type: 'attachment', name, contentBase64, mimeType: opts?.mimeType });\n },\n\n /** Attach a file from disk. */\n attachmentFromFile(name: string, path: string, opts?: { mimeType?: string }): void {\n send({ type: 'attachment_from_file', name, path, mimeType: opts?.mimeType });\n },\n\n /**\n * Records a named step around `fn`.\n *\n * Unlike the cucumber-js equivalent, this delegates to Playwright's own\n * `test.step()` as well, so the step shows up in Playwright's HTML report\n * and trace viewer in addition to Qualflare — there is no reason to make\n * users choose, and a step that exists in only one of the two is a\n * confusing thing to debug against.\n */\n async step<T>(name: string, fn: () => T | Promise<T>): Promise<T> {\n return test.step(name, async () => {\n send({ type: 'step_start', name, timestamp: Date.now() });\n try {\n const result = await fn();\n send({ type: 'step_stop', status: 'passed', timestamp: Date.now() });\n return result;\n } catch (err) {\n send({\n type: 'step_stop',\n status: 'failed',\n error: err instanceof Error ? err.message : String(err),\n timestamp: Date.now(),\n });\n throw err;\n }\n });\n },\n};\n","/**\n * Shared constants used across the reporter and the author-facing runtime\n * API.\n */\n\n/** Reserved `testInfo.attach()` content type used to smuggle structured\n * `qualflare.*()` calls (label/tag/step/etc.) from test and hook code back to\n * the reporter — the only channel Playwright gives user code back to a\n * running reporter. The reporter recognizes this exact content type and\n * replays the message as a model mutation instead of reporting it as a\n * literal attachment. */\nexport const RESERVED_MESSAGE_MEDIA_TYPE = 'application/vnd.qualflare.message+json';\n\n/** Server-side caps this client should respect defensively (see\n * `api-service/internal/core/domain/launch/launch.go`). */\nexport const MAX_SUITES_PER_LAUNCH = 2000;\nexport const MAX_CASES_PER_SUITE = 5000;\nexport const MAX_STEPS_PER_CASE = 1000;\nexport const MAX_PARAMETERS_PER_STEP = 50;\nexport const MAX_ATTACHMENTS_PER_CASE = 50;\nexport const MAX_LABELS_PER_CASE = 100;\nexport const MAX_LINKS_PER_CASE = 20;\nexport const MAX_TAGS_PER_CASE = 64;\nexport const MAX_TAG_LENGTH = 255;\n\n/** Mirrors `launch.MaxCaseAttempts`. Beyond this the server keeps the first\n * 49 attempts plus the final one and drops the middle, so sending more is\n * wasted payload rather than an error. */\nexport const MAX_ATTEMPTS_PER_CASE = 50;\n\n/** Mirrors the server's per-attempt text bounds (`launch.MaxAttempt*Runes`).\n *\n * Clamped CLIENT-side, not left to the server, because attempts are the only\n * repeated-per-case payload with no size budget of its own. Measured: one\n * retried test with a deep stack and a chatty log serializes to ~630KB\n * unclamped — most of it text the server discards on write — against a 10MB\n * request body limit that, once exceeded, loses the ENTIRE launch. Sending\n * bytes the server will throw away is pure risk. */\nexport const MAX_ATTEMPT_MESSAGE_RUNES = 8192;\nexport const MAX_ATTEMPT_TRACE_RUNES = 32768;\nexport const MAX_ATTEMPT_SNIPPET_RUNES = 4096;\nexport const MAX_ATTEMPT_OUTPUT_RUNES = 16384;\nexport const MAX_ATTEMPT_OUTPUT_LINES = 200;\n\n/** Mirrors `launch.MaxAttachmentUploadFileSize` — the server's hard cap on a\n * single `POST /api/v1/attachments/upload-url` request (video). */\nexport const MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;\n\n/** Cap on one Playwright trace zip. Matches MAX_VIDEO_UPLOAD_BYTES because both\n * end up at the same server-side limit: the attachment upload endpoint rejects\n * anything past 50MB regardless of kind. Copying a larger trace would only\n * defer the rejection to collect time, after the bytes were already written. */\nexport const MAX_TRACE_UPLOAD_BYTES = 50 * 1024 * 1024;\n\n/** Client-side SOFT cap on steps recorded per scenario attempt — well under\n * the server's 1000-per-case hard cap (`MAX_STEPS_PER_CASE`). Once hit,\n * further steps within that attempt are dropped (with a one-time warning),\n * not queued and truncated later. */\nexport const MAX_STEPS_PER_TEST_ATTEMPT = 300;\n","/**\n * A minimal logger writing to stderr. Deliberately avoids stdout, since\n * that's typically Playwright's own reporter output stream and shouldn't be\n * polluted with reporter diagnostics.\n */\n\nconst PREFIX = '[qualflare-playwright]';\n\nexport const logger = {\n debug(...args: unknown[]): void {\n console.debug(PREFIX, ...args);\n },\n info(...args: unknown[]): void {\n console.log(PREFIX, ...args);\n },\n warn(...args: unknown[]): void {\n console.warn(PREFIX, ...args);\n },\n error(...args: unknown[]): void {\n console.error(PREFIX, ...args);\n },\n};\n","import type { ReporterDescription } from '@playwright/test';\n\nimport type { QualflarePlaywrightOptions } from './config/resolve-config.js';\n\nexport { qualflare } from './runtime/qualflare-api.js';\n\nexport type { QualflarePlaywrightOptions, ResolvedReporterConfig } from './config/resolve-config.js';\n\nexport type {\n Attachment,\n Case,\n CasePriority,\n CaseStatus,\n Collect,\n FrameworkCategory,\n Label,\n Link,\n LinkType,\n Metadata,\n NanosecondDuration,\n Parameter,\n Platform,\n Step,\n Suite,\n} from './shared/types.js';\n\n/**\n * Typed helper for registering the reporter.\n *\n * Playwright types a reporter's options as `any` (`ReporterDescription` ends\n * in `[string, any]`), so writing the tuple by hand gives no autocomplete and\n * silently accepts typos. This returns the same tuple with the options\n * checked:\n *\n * ```ts\n * import { defineConfig } from '@playwright/test';\n * import { qualflareReporter } from '@qualflare/playwright';\n *\n * export default defineConfig({\n * reporter: [['list'], qualflareReporter({ environment: 'staging' })],\n * });\n * ```\n */\nexport function qualflareReporter(options: QualflarePlaywrightOptions = {}): ReporterDescription {\n return ['@qualflare/playwright/reporter', options];\n}\n"],"mappings":"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","names":[]}
@@ -51,7 +51,14 @@ var MAX_LABELS_PER_CASE = 100;
51
51
  var MAX_LINKS_PER_CASE = 20;
52
52
  var MAX_TAGS_PER_CASE = 64;
53
53
  var MAX_TAG_LENGTH = 255;
54
+ var MAX_ATTEMPTS_PER_CASE = 50;
55
+ var MAX_ATTEMPT_MESSAGE_RUNES = 8192;
56
+ var MAX_ATTEMPT_TRACE_RUNES = 32768;
57
+ var MAX_ATTEMPT_SNIPPET_RUNES = 4096;
58
+ var MAX_ATTEMPT_OUTPUT_RUNES = 16384;
59
+ var MAX_ATTEMPT_OUTPUT_LINES = 200;
54
60
  var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
61
+ var MAX_TRACE_UPLOAD_BYTES = 50 * 1024 * 1024;
55
62
  var MAX_STEPS_PER_TEST_ATTEMPT = 300;
56
63
 
57
64
  // src/config/ci-detect.ts
@@ -279,6 +286,7 @@ function resolveConfig(options, deps = {}) {
279
286
  maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
280
287
  maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
281
288
  maxVideoBytes: options.maxVideoBytes ?? envInt("QUALFLARE_MAX_VIDEO_BYTES") ?? MAX_VIDEO_UPLOAD_BYTES,
289
+ maxTraceBytes: options.maxTraceBytes ?? envInt("QUALFLARE_MAX_TRACE_BYTES") ?? MAX_TRACE_UPLOAD_BYTES,
282
290
  debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
283
291
  enabled,
284
292
  outputDir,
@@ -315,35 +323,51 @@ var VIDEO_MIME_TYPES_BY_EXTENSION = {
315
323
  ".webm": "video/webm",
316
324
  ".mov": "video/quicktime"
317
325
  };
318
- function copyVideoAttachment(filePath, outputDir, maxVideoBytes) {
319
- const ext = path.extname(filePath).toLowerCase();
320
- const mimeType = VIDEO_MIME_TYPES_BY_EXTENSION[ext];
321
- if (!mimeType) {
322
- logger.warn(`skipping video attachment "${filePath}": unsupported video format.`);
323
- return void 0;
324
- }
326
+ var TRACE_MIME_TYPE = "application/zip";
327
+ var TRACE_EXTENSION = ".zip";
328
+ function copyArtifact(filePath, outputDir, maxBytes, ext, capName, label) {
325
329
  let fileSize;
326
330
  try {
327
331
  fileSize = fs.statSync(filePath).size;
328
332
  } catch (err) {
329
- logger.warn(`skipping video attachment "${filePath}": could not stat file: ${err.message}`);
333
+ logger.warn(`skipping ${label} attachment "${filePath}": could not stat file: ${err.message}`);
330
334
  return void 0;
331
335
  }
332
- if (fileSize > maxVideoBytes) {
336
+ if (fileSize > maxBytes) {
333
337
  logger.warn(
334
- `skipping video attachment "${filePath}": ${fileSize} bytes exceeds the configured maxVideoBytes cap of ${maxVideoBytes} bytes.`
338
+ `skipping ${label} attachment "${filePath}": ${fileSize} bytes exceeds the configured ${capName} cap of ${maxBytes} bytes.`
335
339
  );
336
340
  return void 0;
337
341
  }
338
- const localVideoPath = `${(0, import_node_crypto2.randomUUID)()}${ext}`;
342
+ const localPath = `${(0, import_node_crypto2.randomUUID)()}${ext}`;
339
343
  try {
340
344
  fs.mkdirSync(outputDir, { recursive: true });
341
- fs.copyFileSync(filePath, path.join(outputDir, localVideoPath));
345
+ fs.copyFileSync(filePath, path.join(outputDir, localPath));
342
346
  } catch (err) {
343
- logger.warn(`skipping video attachment "${filePath}": could not copy file: ${err.message}`);
347
+ logger.warn(`skipping ${label} attachment "${filePath}": could not copy file: ${err.message}`);
348
+ return void 0;
349
+ }
350
+ return { localPath, fileSize };
351
+ }
352
+ function copyVideoAttachment(filePath, outputDir, maxVideoBytes) {
353
+ const ext = path.extname(filePath).toLowerCase();
354
+ const mimeType = VIDEO_MIME_TYPES_BY_EXTENSION[ext];
355
+ if (!mimeType) {
356
+ logger.warn(`skipping video attachment "${filePath}": unsupported video format.`);
357
+ return void 0;
358
+ }
359
+ const copied = copyArtifact(filePath, outputDir, maxVideoBytes, ext, "maxVideoBytes", "video");
360
+ if (!copied) {
344
361
  return void 0;
345
362
  }
346
- return { localVideoPath, fileSize, mimeType };
363
+ return { localVideoPath: copied.localPath, fileSize: copied.fileSize, mimeType };
364
+ }
365
+ function copyTraceAttachment(filePath, outputDir, maxTraceBytes) {
366
+ const copied = copyArtifact(filePath, outputDir, maxTraceBytes, TRACE_EXTENSION, "maxTraceBytes", "trace");
367
+ if (!copied) {
368
+ return void 0;
369
+ }
370
+ return { localTracePath: copied.localPath, fileSize: copied.fileSize, mimeType: TRACE_MIME_TYPE };
347
371
  }
348
372
 
349
373
  // src/reporter/attachment-reader.ts
@@ -373,6 +397,7 @@ var AttachmentBudget = class {
373
397
  };
374
398
  var NAME_VIDEO = "video";
375
399
  var NAME_TRACE = "trace";
400
+ var TRACE_CONTENT_TYPE = "application/zip";
376
401
  function isVideo(a) {
377
402
  return a.name === NAME_VIDEO || (a.contentType?.startsWith("video/") ?? false);
378
403
  }
@@ -393,7 +418,20 @@ function resolveAttachments(result, config, budget) {
393
418
  }
394
419
  break;
395
420
  }
396
- if (a.name === NAME_TRACE || a.contentType === "application/zip") {
421
+ if (a.name === NAME_TRACE || a.contentType === TRACE_CONTENT_TYPE) {
422
+ if (!a.path) {
423
+ logger.warn(`skipping in-memory trace attachment "${a.name}": only file-backed traces are supported.`);
424
+ continue;
425
+ }
426
+ const copied = copyTraceAttachment(a.path, config.outputDir, config.maxTraceBytes);
427
+ if (copied) {
428
+ out.push({
429
+ name: a.name,
430
+ mimeType: copied.mimeType,
431
+ localTracePath: copied.localTracePath,
432
+ fileSize: copied.fileSize
433
+ });
434
+ }
397
435
  continue;
398
436
  }
399
437
  if (isVideo(a)) {
@@ -481,6 +519,34 @@ function msToNs(ms) {
481
519
  return Math.round(ms * NS_PER_MS);
482
520
  }
483
521
 
522
+ // src/shared/text.ts
523
+ function truncateRunes(value, maxRunes) {
524
+ if (value.length <= maxRunes) {
525
+ return value;
526
+ }
527
+ const runes = Array.from(value);
528
+ if (runes.length <= maxRunes) {
529
+ return value;
530
+ }
531
+ return runes.slice(0, maxRunes).join("");
532
+ }
533
+ function clampOutputLines(lines, maxLines, maxRunes) {
534
+ const out = [];
535
+ let budget = maxRunes;
536
+ for (const line of lines.slice(0, maxLines)) {
537
+ const cost = Array.from(line).length + 1;
538
+ if (cost > budget) {
539
+ if (budget > 1) {
540
+ out.push(truncateRunes(line, budget - 1));
541
+ }
542
+ break;
543
+ }
544
+ out.push(line);
545
+ budget -= cost;
546
+ }
547
+ return out.length > 0 ? out : void 0;
548
+ }
549
+
484
550
  // src/reporter/step-mapper.ts
485
551
  var CATEGORY_TEST_STEP = "test.step";
486
552
  var CATEGORY_EXPECT = "expect";
@@ -589,6 +655,63 @@ function formatError(result) {
589
655
  }
590
656
  return parts.join("\n");
591
657
  }
658
+ function outputLines(chunks) {
659
+ if (!chunks || chunks.length === 0) {
660
+ return void 0;
661
+ }
662
+ const text = chunks.map((c) => typeof c === "string" ? c : c.toString("utf8")).join("");
663
+ const lines = stripAnsi(text).split("\n");
664
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
665
+ lines.pop();
666
+ }
667
+ return clampOutputLines(lines, MAX_ATTEMPT_OUTPUT_LINES, MAX_ATTEMPT_OUTPUT_RUNES);
668
+ }
669
+ function buildAttempts(results) {
670
+ if (results.length < 2) {
671
+ return void 0;
672
+ }
673
+ let kept = results;
674
+ if (results.length > MAX_ATTEMPTS_PER_CASE) {
675
+ kept = [...results.slice(0, MAX_ATTEMPTS_PER_CASE - 1), results[results.length - 1]];
676
+ }
677
+ return kept.map((r, i) => {
678
+ const err = r.error;
679
+ const attempt = {
680
+ // 1-based and contiguous. Deliberately the index rather than
681
+ // `r.retry`: results are already ordered by attempt, and a filtered-out
682
+ // never-ran result would leave a hole in the retry numbering that the
683
+ // server reads as a truncated history.
684
+ attempt: i + 1,
685
+ status: mapStatus(r.status),
686
+ duration: msToNs(r.duration),
687
+ startedAt: r.startTime.toISOString()
688
+ };
689
+ if (err) {
690
+ const message = err.message ?? err.value;
691
+ if (message) {
692
+ attempt.message = truncateRunes(stripAnsi(message), MAX_ATTEMPT_MESSAGE_RUNES);
693
+ }
694
+ if (err.stack) {
695
+ attempt.trace = truncateRunes(stripAnsi(err.stack), MAX_ATTEMPT_TRACE_RUNES);
696
+ }
697
+ if (err.snippet) {
698
+ attempt.snippet = truncateRunes(stripAnsi(err.snippet), MAX_ATTEMPT_SNIPPET_RUNES);
699
+ }
700
+ if (typeof err.location?.line === "number") {
701
+ attempt.line = err.location.line;
702
+ }
703
+ }
704
+ const stdout = outputLines(r.stdout);
705
+ if (stdout) {
706
+ attempt.stdout = stdout;
707
+ }
708
+ const stderr = outputLines(r.stderr);
709
+ if (stderr) {
710
+ attempt.stderr = stderr;
711
+ }
712
+ return attempt;
713
+ });
714
+ }
592
715
  function replayMetadata(result, config, budget) {
593
716
  const meta = {
594
717
  labels: [],
@@ -711,6 +834,7 @@ function buildCase(test, config, attachmentsByResult, budget) {
711
834
  const nativeTags = test.tags ?? [];
712
835
  const tags = capTags([...nativeTags, ...meta.tags]);
713
836
  const error = formatError(final);
837
+ const attempts = buildAttempts(results);
714
838
  return {
715
839
  id: test.id,
716
840
  name: test.title,
@@ -719,6 +843,7 @@ function buildCase(test, config, attachmentsByResult, budget) {
719
843
  duration: msToNs(final.duration),
720
844
  retryCount: results.length - 1,
721
845
  isFlaky: outcome === "flaky",
846
+ ...attempts ? { attempts } : {},
722
847
  ...error ? { error } : {},
723
848
  ...meta.priority ? { priority: meta.priority } : {},
724
849
  ...meta.description ? { description: meta.description } : {},
@@ -736,7 +861,7 @@ function buildCase(test, config, attachmentsByResult, budget) {
736
861
  var os = __toESM(require("os"), 1);
737
862
 
738
863
  // src/config/version.ts
739
- var PACKAGE_VERSION = "0.2.0";
864
+ var PACKAGE_VERSION = "0.4.0";
740
865
 
741
866
  // src/reporter/collect-builder.ts
742
867
  function resolveOs(config) {
@@ -927,7 +1052,8 @@ var QualflareReporter = class {
927
1052
  return path3.isAbsolute(outputDir) ? outputDir : path3.resolve(this.options.configDir ?? this.rootDir, outputDir);
928
1053
  }
929
1054
  /** Drops everything an earlier, now-superseded attempt produced: deletes the
930
- * video copied into outputDir and refunds its bytes to the run budget. */
1055
+ * video or trace copied into outputDir and refunds its bytes to the run
1056
+ * budget. */
931
1057
  discardSupersededAttempt(testId, retry, outputDir) {
932
1058
  const previous = this.latestAttemptByTest.get(testId);
933
1059
  if (previous === void 0 || previous >= retry) {
@@ -935,9 +1061,10 @@ var QualflareReporter = class {
935
1061
  }
936
1062
  const key = `${testId}:${previous}`;
937
1063
  for (const attachment of this.attachmentsByResult.get(key) ?? []) {
938
- if (attachment.localVideoPath) {
1064
+ const orphan = attachment.localVideoPath ?? attachment.localTracePath;
1065
+ if (orphan) {
939
1066
  try {
940
- fs3.rmSync(path3.join(this.resolveOutputDir(outputDir), attachment.localVideoPath), { force: true });
1067
+ fs3.rmSync(path3.join(this.resolveOutputDir(outputDir), orphan), { force: true });
941
1068
  } catch {
942
1069
  }
943
1070
  }