@qualflare/playwright 0.3.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.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/** 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-N04M1Avn.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-N04M1Avn.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-N04M1Avn.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-N04M1Avn.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.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/** 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":";AAAA,SAAS,YAAY;;;ACWd,IAAM,8BAA8B;AAmCpC,IAAM,yBAAyB,KAAK,OAAO;;;ACxClD,IAAM,SAAS;AAER,IAAM,SAAS;AAAA,EACpB,SAAS,MAAuB;AAC9B,YAAQ,MAAM,QAAQ,GAAG,IAAI;AAAA,EAC/B;AAAA,EACA,QAAQ,MAAuB;AAC7B,YAAQ,IAAI,QAAQ,GAAG,IAAI;AAAA,EAC7B;AAAA,EACA,QAAQ,MAAuB;AAC7B,YAAQ,KAAK,QAAQ,GAAG,IAAI;AAAA,EAC9B;AAAA,EACA,SAAS,MAAuB;AAC9B,YAAQ,MAAM,QAAQ,GAAG,IAAI;AAAA,EAC/B;AACF;;;AFCA,SAAS,KAAK,SAA+B;AAC3C,MAAI;AAMF,SAAK,KACF,KAAK,EACL,OAAO,aAAa,QAAQ,IAAI,IAAI;AAAA,MACnC,MAAM,OAAO,KAAK,KAAK,UAAU,OAAO,GAAG,MAAM;AAAA,MACjD,aAAa;AAAA,IACf,CAAC,EACA,MAAM,CAAC,QAAiB;AACvB,aAAO,KAAK,aAAa,QAAQ,IAAI,6BAA8B,IAAc,OAAO,EAAE;AAAA,IAC5F,CAAC;AAAA,EACL,QAAQ;AACN,WAAO,KAAK,aAAa,QAAQ,IAAI,6DAA6D;AAAA,EACpG;AACF;AAgBO,IAAM,YAAY;AAAA;AAAA,EAEvB,MAAM,MAAc,OAAqB;AACvC,SAAK,EAAE,MAAM,SAAS,MAAM,MAAM,CAAC;AAAA,EACrC;AAAA;AAAA,EAGA,KAAK,KAAa,MAAiD;AACjE,SAAK,EAAE,MAAM,QAAQ,KAAK,UAAU,MAAM,MAAM,MAAM,MAAM,KAAK,CAAC;AAAA,EACpE;AAAA;AAAA,EAGA,OAAO,MAAsB;AAC3B,SAAK,EAAE,MAAM,OAAO,KAAK,CAAC;AAAA,EAC5B;AAAA;AAAA,EAGA,YAAY,MAAoB;AAC9B,SAAK,EAAE,MAAM,eAAe,KAAK,CAAC;AAAA,EACpC;AAAA;AAAA,EAGA,SAAS,OAA2B;AAClC,SAAK,EAAE,MAAM,YAAY,MAAM,CAAC;AAAA,EAClC;AAAA;AAAA;AAAA;AAAA,EAKA,UAAU,MAAc,OAAgB,MAAmC;AACzE,SAAK,EAAE,MAAM,aAAa,MAAM,OAAO,QAAQ,MAAM,OAAO,CAAC;AAAA,EAC/D;AAAA;AAAA,EAGA,WAAW,MAAc,SAAiB,MAAkE;AAC1G,UAAM,gBAAgB,MAAM,aAAa,WAAW,UAAU,OAAO,KAAK,SAAS,MAAM,EAAE,SAAS,QAAQ;AAC5G,SAAK,EAAE,MAAM,cAAc,MAAM,eAAe,UAAU,MAAM,SAAS,CAAC;AAAA,EAC5E;AAAA;AAAA,EAGA,mBAAmB,MAAc,MAAc,MAAoC;AACjF,SAAK,EAAE,MAAM,wBAAwB,MAAM,MAAM,UAAU,MAAM,SAAS,CAAC;AAAA,EAC7E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,MAAM,KAAQ,MAAc,IAAsC;AAChE,WAAO,KAAK,KAAK,MAAM,YAAY;AACjC,WAAK,EAAE,MAAM,cAAc,MAAM,WAAW,KAAK,IAAI,EAAE,CAAC;AACxD,UAAI;AACF,cAAM,SAAS,MAAM,GAAG;AACxB,aAAK,EAAE,MAAM,aAAa,QAAQ,UAAU,WAAW,KAAK,IAAI,EAAE,CAAC;AACnE,eAAO;AAAA,MACT,SAAS,KAAK;AACZ,aAAK;AAAA,UACH,MAAM;AAAA,UACN,QAAQ;AAAA,UACR,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,UACtD,WAAW,KAAK,IAAI;AAAA,QACtB,CAAC;AACD,cAAM;AAAA,MACR;AAAA,IACF,CAAC;AAAA,EACH;AACF;;;AGrFO,SAAS,kBAAkB,UAAsC,CAAC,GAAwB;AAC/F,SAAO,CAAC,kCAAkC,OAAO;AACnD;","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":[]}
@@ -58,6 +58,7 @@ var MAX_ATTEMPT_SNIPPET_RUNES = 4096;
58
58
  var MAX_ATTEMPT_OUTPUT_RUNES = 16384;
59
59
  var MAX_ATTEMPT_OUTPUT_LINES = 200;
60
60
  var MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
61
+ var MAX_TRACE_UPLOAD_BYTES = 50 * 1024 * 1024;
61
62
  var MAX_STEPS_PER_TEST_ATTEMPT = 300;
62
63
 
63
64
  // src/config/ci-detect.ts
@@ -285,6 +286,7 @@ function resolveConfig(options, deps = {}) {
285
286
  maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
286
287
  maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
287
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,
288
290
  debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
289
291
  enabled,
290
292
  outputDir,
@@ -321,35 +323,51 @@ var VIDEO_MIME_TYPES_BY_EXTENSION = {
321
323
  ".webm": "video/webm",
322
324
  ".mov": "video/quicktime"
323
325
  };
324
- function copyVideoAttachment(filePath, outputDir, maxVideoBytes) {
325
- const ext = path.extname(filePath).toLowerCase();
326
- const mimeType = VIDEO_MIME_TYPES_BY_EXTENSION[ext];
327
- if (!mimeType) {
328
- logger.warn(`skipping video attachment "${filePath}": unsupported video format.`);
329
- return void 0;
330
- }
326
+ var TRACE_MIME_TYPE = "application/zip";
327
+ var TRACE_EXTENSION = ".zip";
328
+ function copyArtifact(filePath, outputDir, maxBytes, ext, capName, label) {
331
329
  let fileSize;
332
330
  try {
333
331
  fileSize = fs.statSync(filePath).size;
334
332
  } catch (err) {
335
- 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}`);
336
334
  return void 0;
337
335
  }
338
- if (fileSize > maxVideoBytes) {
336
+ if (fileSize > maxBytes) {
339
337
  logger.warn(
340
- `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.`
341
339
  );
342
340
  return void 0;
343
341
  }
344
- const localVideoPath = `${(0, import_node_crypto2.randomUUID)()}${ext}`;
342
+ const localPath = `${(0, import_node_crypto2.randomUUID)()}${ext}`;
345
343
  try {
346
344
  fs.mkdirSync(outputDir, { recursive: true });
347
- fs.copyFileSync(filePath, path.join(outputDir, localVideoPath));
345
+ fs.copyFileSync(filePath, path.join(outputDir, localPath));
348
346
  } catch (err) {
349
- 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.`);
350
357
  return void 0;
351
358
  }
352
- return { localVideoPath, fileSize, mimeType };
359
+ const copied = copyArtifact(filePath, outputDir, maxVideoBytes, ext, "maxVideoBytes", "video");
360
+ if (!copied) {
361
+ return void 0;
362
+ }
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 };
353
371
  }
354
372
 
355
373
  // src/reporter/attachment-reader.ts
@@ -379,6 +397,7 @@ var AttachmentBudget = class {
379
397
  };
380
398
  var NAME_VIDEO = "video";
381
399
  var NAME_TRACE = "trace";
400
+ var TRACE_CONTENT_TYPE = "application/zip";
382
401
  function isVideo(a) {
383
402
  return a.name === NAME_VIDEO || (a.contentType?.startsWith("video/") ?? false);
384
403
  }
@@ -399,7 +418,20 @@ function resolveAttachments(result, config, budget) {
399
418
  }
400
419
  break;
401
420
  }
402
- 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
+ }
403
435
  continue;
404
436
  }
405
437
  if (isVideo(a)) {
@@ -829,7 +861,7 @@ function buildCase(test, config, attachmentsByResult, budget) {
829
861
  var os = __toESM(require("os"), 1);
830
862
 
831
863
  // src/config/version.ts
832
- var PACKAGE_VERSION = "0.3.0";
864
+ var PACKAGE_VERSION = "0.4.0";
833
865
 
834
866
  // src/reporter/collect-builder.ts
835
867
  function resolveOs(config) {
@@ -1020,7 +1052,8 @@ var QualflareReporter = class {
1020
1052
  return path3.isAbsolute(outputDir) ? outputDir : path3.resolve(this.options.configDir ?? this.rootDir, outputDir);
1021
1053
  }
1022
1054
  /** Drops everything an earlier, now-superseded attempt produced: deletes the
1023
- * 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. */
1024
1057
  discardSupersededAttempt(testId, retry, outputDir) {
1025
1058
  const previous = this.latestAttemptByTest.get(testId);
1026
1059
  if (previous === void 0 || previous >= retry) {
@@ -1028,9 +1061,10 @@ var QualflareReporter = class {
1028
1061
  }
1029
1062
  const key = `${testId}:${previous}`;
1030
1063
  for (const attachment of this.attachmentsByResult.get(key) ?? []) {
1031
- if (attachment.localVideoPath) {
1064
+ const orphan = attachment.localVideoPath ?? attachment.localTracePath;
1065
+ if (orphan) {
1032
1066
  try {
1033
- fs3.rmSync(path3.join(this.resolveOutputDir(outputDir), attachment.localVideoPath), { force: true });
1067
+ fs3.rmSync(path3.join(this.resolveOutputDir(outputDir), orphan), { force: true });
1034
1068
  } catch {
1035
1069
  }
1036
1070
  }