reporting-labs 0.6.10 → 0.6.11
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 +35 -4
- package/dist/reporter.d.ts +15 -0
- package/dist/reporter.js +31 -0
- package/dist/template.html +237 -7
- package/dist/template.js +237 -7
- package/dist/types.d.ts +7 -0
- package/dist/wdio/env.d.ts +4 -0
- package/dist/wdio/env.js +62 -0
- package/dist/wdio/finalize.d.ts +38 -0
- package/dist/wdio/finalize.js +203 -0
- package/dist/wdio/index.d.ts +17 -0
- package/dist/wdio/index.js +22 -0
- package/dist/wdio/reporter.d.ts +67 -0
- package/dist/wdio/reporter.js +332 -0
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -305,8 +305,6 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
|
|
|
305
305
|
| `project` | – | `{ name, version, team, url }` shown under the title |
|
|
306
306
|
| `metadata` | `{}` | Chips in the header, e.g. `{ env: 'staging', build: '#1842' }`. `build` labels the run in the trend; in CI the run number is used when it is not set. The environment name is also read from the process environment and wins over `env` here: `ENV`, `TEST_ENV`, `APP_ENV`, `TARGET_ENV` and friends, or any variable ending in `_ENV` / `_ENVIRONMENT` (`OPENCART_ENV`), so a config that says `local` still labels CI reports `dev`, `qa`, `stage` |
|
|
307
307
|
| `envVar` | – | Name of the variable that holds the environment name, when the detection cannot guess it |
|
|
308
|
-
|
|
309
|
-
**Runtime overrides.** `REPORTING_LABS_METADATA_<KEY>` sets a header chip from the environment (`REPORTING_LABS_METADATA_ENV=qa`, `REPORTING_LABS_METADATA_RELEASE=2.3`) and `REPORTING_LABS_TITLE`, `_THEME`, `_PALETTE`, `_ACCENT`, `_LOGO` the matching option; they win over the config. The env chip resolves in this order: `REPORTING_LABS_METADATA_ENV`, the variable `envVar` names, the conventional names (`ENV`, `TEST_ENV`, `APP_ENV`, `TARGET_ENV`, `CI_ENVIRONMENT_NAME`, anything ending in `_ENV`), then `metadata.env` in the config.
|
|
310
308
|
| `env` | – | Extra rows on the Environment card |
|
|
311
309
|
| `links` | `{}` | Turn meta keys into links. `{id}` is replaced by the value. An object `{ url, display }` builds the URL from several fields of an object passed to `meta()`, see below |
|
|
312
310
|
| `maskKeys` | `[]` | Extra keys to mask as `****` |
|
|
@@ -330,11 +328,14 @@ Every option is optional. `npx reporting-labs init` writes them all, with commen
|
|
|
330
328
|
| `embedVideos` | `false` | Videos inside the HTML too (bigger file, no folder issues) |
|
|
331
329
|
| `emitJson` | `true` | Also write `report.json` alongside `index.html` (used by `merge`) |
|
|
332
330
|
| `jsonFile` | `'report.json'` | File name of the JSON blob |
|
|
331
|
+
| `pdf` | `true` | Also write `report.pdf` (light theme, print-ready) with Playwright's Chromium. `false` turns it off; `{ file: 'run.pdf' }` renames it. The Export PDF button in the report works either way |
|
|
333
332
|
| `embedFonts` | `true` | Bundle the fonts (~140 KB) so it looks the same offline |
|
|
334
333
|
| `announce` | `true` | Print the report path after the run |
|
|
335
334
|
| `open` | `'on-failure'` | Open the report in the browser after the run: `'on-failure'`, `'always'` or `'never'`. Never opens in CI |
|
|
336
335
|
| `warnMissingMeta` | `true` | After the run, list the tests that have no `meta()` in the console, so nobody on the team forgets |
|
|
337
336
|
|
|
337
|
+
**Runtime overrides.** `REPORTING_LABS_METADATA_<KEY>` sets a header chip from the environment (`REPORTING_LABS_METADATA_ENV=qa`, `REPORTING_LABS_METADATA_RELEASE=2.3`) and `REPORTING_LABS_TITLE`, `_THEME`, `_PALETTE`, `_ACCENT`, `_LOGO` the matching option; they win over the config. The env chip resolves in this order: `REPORTING_LABS_METADATA_ENV`, the variable `envVar` names, the conventional names (`ENV`, `TEST_ENV`, `APP_ENV`, `TARGET_ENV`, `CI_ENVIRONMENT_NAME`, anything ending in `_ENV`), then `metadata.env` in the config.
|
|
338
|
+
|
|
338
339
|
If your reporter list differs between CI and local, add the same line to both:
|
|
339
340
|
|
|
340
341
|
```ts
|
|
@@ -606,7 +607,7 @@ Your Slack step can then read `results.json` for pass / fail counts and top fail
|
|
|
606
607
|
- **Each run replaces the report.** The old report stays until the new run finishes. Archive the folder if you want to keep an old one.
|
|
607
608
|
- **Videos on macOS.** If the report is in Downloads, Desktop or Documents and you open it as a file, Chrome may not be allowed to read the `assets/` folder (the player shows a clear message). Allow Chrome under System Settings → Privacy & Security → Files and Folders, move the project elsewhere, or set `embedVideos: true`.
|
|
608
609
|
- **Keyboard.** `j` / `k` next and previous test, `f` failed only, `/` search, `1`–`5` switch views, `Esc` close.
|
|
609
|
-
- **
|
|
610
|
+
- **PDF.** `report.pdf` is written next to the report, and the Export PDF button in the header saves the same print-ready copy from the browser.
|
|
610
611
|
- **Themes.** Light and dark follow the OS. The toggle in the header remembers your choice.
|
|
611
612
|
|
|
612
613
|
## Java teams
|
|
@@ -617,12 +618,42 @@ The same report, from Java: `dev.reportinglabs` on Maven Central, for TestNG and
|
|
|
617
618
|
|
|
618
619
|
The same report, from Python: `pip install reporting-labs` ([PyPI](https://pypi.org/project/reporting-labs/)). A pytest plugin that turns on the moment it is installed, with zero-code support for Playwright and Selenium, and a Robot Framework listener (one row per test, keywords as steps). Source: [reporting-labs-python](https://github.com/naveenautomationlabs/reporting-labs-python). Guides: [reportinglabs.dev/get-started/python](https://reportinglabs.dev/get-started/python).
|
|
619
620
|
|
|
621
|
+
## WebdriverIO
|
|
622
|
+
|
|
623
|
+
The same report, from a WebdriverIO suite (Mocha, Jasmine or Cucumber): one row per test, WebDriver commands as steps, a screenshot on failure, failure clusters and the PDF export. WebdriverIO runs a reporter per spec, so reportingLabs writes a part per runner and stitches them together in `onComplete`:
|
|
624
|
+
|
|
625
|
+
```ts
|
|
626
|
+
// wdio.conf.ts
|
|
627
|
+
import ReportingLabsReporter, { reportingLabsComplete } from 'reporting-labs/wdio';
|
|
628
|
+
|
|
629
|
+
export const config = {
|
|
630
|
+
reporters: ['spec', [ReportingLabsReporter, { outputFolder: 'reporting-labs' }]],
|
|
631
|
+
async onComplete() {
|
|
632
|
+
await reportingLabsComplete({ outputFolder: 'reporting-labs', title: 'Web E2E' });
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
Guide: [reportinglabs.dev/get-started/webdriverio](https://reportinglabs.dev/get-started/webdriverio).
|
|
638
|
+
|
|
620
639
|
## Roadmap
|
|
621
640
|
|
|
622
|
-
-
|
|
641
|
+
- Cypress, Jest/Vitest and JUnit XML adapters
|
|
623
642
|
- AI summary of failures (bring your own API key)
|
|
624
643
|
- Hosted history dashboard across branches and projects
|
|
625
644
|
|
|
645
|
+
## Security & privacy
|
|
646
|
+
|
|
647
|
+
reportingLabs is a library that runs inside your own test run. There is no reportingLabs server, account, API key, telemetry or licence check.
|
|
648
|
+
|
|
649
|
+
- **Nothing is sent anywhere.** The reporter makes no network requests of its own; its only traffic is the traffic your tests already make. An opened report makes no external requests either: fonts, scripts and the logo are embedded, so it works offline and behind a firewall. The only exceptions are opt-in (`embedFonts: false`, a logo given as an `https://` URL) or need a click (CI, commit and issue links).
|
|
650
|
+
- **Everything stays on your machine:** the report folder (`reporting-labs/` by default) holds `index.html`, `report.json`, `report.pdf` and `assets/`, plus the run history `reporting-labs.history.json` next to your project. Nothing is written anywhere else. Whoever can read your test artifacts can read the report; deleting them deletes the data.
|
|
651
|
+
- **Secrets are masked before anything is written:** passwords, tokens, cookies, auth headers, API keys, JWTs and card numbers in logs, API bodies and headers, test data, errors and step titles. With Playwright, a value passed to `fill()` shows in the step title unless it is a known secret, so read test passwords from environment variables (masked by default) or list them in `maskValues`. The WebdriverIO reporter masks values typed into password fields.
|
|
652
|
+
- **Screenshots, videos and traces are not masked.** They are images and recordings of the application, so run tests against test data, or turn them off for suites that show real personal data.
|
|
653
|
+
- **No runtime dependencies;** `@playwright/test` is an optional peer, the one your project already has. No install or post-install scripts. MIT licensed.
|
|
654
|
+
|
|
655
|
+
Full details for security reviewers and client projects, including what is read, what is written and what to tell a client: [reportinglabs.dev/security-privacy](https://reportinglabs.dev/security-privacy). To report a vulnerability, open an issue saying you have a security report (no details) and a private channel will be arranged.
|
|
656
|
+
|
|
626
657
|
## License
|
|
627
658
|
|
|
628
659
|
MIT © Naveen Automation Labs
|
package/dist/reporter.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export default class ReportingLabsReporter implements Reporter {
|
|
|
20
20
|
private pushOutput;
|
|
21
21
|
onBegin(config: FullConfig, suite: Suite): void;
|
|
22
22
|
onEnd(result: FullResult): Promise<void>;
|
|
23
|
+
/** Render the report's print layout to a PDF with the Chromium that Playwright already ships. Best-effort:
|
|
24
|
+
* a failure here (no browser, sandbox) never fails the run — the report and its Export PDF button remain. */
|
|
25
|
+
private writePdf;
|
|
23
26
|
/** One short list of tests that carry no meta() at all, so the whole team keeps the report useful. */
|
|
24
27
|
private printMissingMeta;
|
|
25
28
|
/** Open the report in the default browser, like Playwright's HTML reporter. Never in CI. */
|
|
@@ -61,3 +64,15 @@ export default class ReportingLabsReporter implements Reporter {
|
|
|
61
64
|
* it differently; this finds it without being told. Undefined when nothing fits.
|
|
62
65
|
*/
|
|
63
66
|
export declare function detectEnvName(env: NodeJS.ProcessEnv, envVar?: string): string | undefined;
|
|
67
|
+
export declare function ciRunLabel(env: NodeJS.ProcessEnv): string | undefined;
|
|
68
|
+
export declare function ciLink(env: NodeJS.ProcessEnv): {
|
|
69
|
+
name: string;
|
|
70
|
+
url?: string;
|
|
71
|
+
} | null;
|
|
72
|
+
export declare function gitInfo(cwd: string, env: NodeJS.ProcessEnv): {
|
|
73
|
+
sha?: string;
|
|
74
|
+
author?: string;
|
|
75
|
+
subject?: string;
|
|
76
|
+
branch?: string;
|
|
77
|
+
url?: string;
|
|
78
|
+
};
|
package/dist/reporter.js
CHANGED
|
@@ -34,6 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.detectEnvName = detectEnvName;
|
|
37
|
+
exports.ciRunLabel = ciRunLabel;
|
|
38
|
+
exports.ciLink = ciLink;
|
|
39
|
+
exports.gitInfo = gitInfo;
|
|
37
40
|
const fs = __importStar(require("fs"));
|
|
38
41
|
const path = __importStar(require("path"));
|
|
39
42
|
const os = __importStar(require("os"));
|
|
@@ -240,8 +243,36 @@ class ReportingLabsReporter {
|
|
|
240
243
|
this.printMissingMeta(tests);
|
|
241
244
|
console.log('');
|
|
242
245
|
}
|
|
246
|
+
if (this.options.pdf !== false)
|
|
247
|
+
await this.writePdf(file);
|
|
243
248
|
this.maybeOpen(file, result);
|
|
244
249
|
}
|
|
250
|
+
/** Render the report's print layout to a PDF with the Chromium that Playwright already ships. Best-effort:
|
|
251
|
+
* a failure here (no browser, sandbox) never fails the run — the report and its Export PDF button remain. */
|
|
252
|
+
async writePdf(htmlFile) {
|
|
253
|
+
const name = (typeof this.options.pdf === 'object' && this.options.pdf?.file) || 'report.pdf';
|
|
254
|
+
const pdfFile = path.join(this.outDir, name);
|
|
255
|
+
try {
|
|
256
|
+
const { chromium } = await import('@playwright/test');
|
|
257
|
+
const { pathToFileURL } = await import('url');
|
|
258
|
+
const browser = await chromium.launch();
|
|
259
|
+
try {
|
|
260
|
+
const page = await browser.newPage();
|
|
261
|
+
await page.goto(pathToFileURL(htmlFile).href, { waitUntil: 'load' });
|
|
262
|
+
await page.evaluate(() => window.reportingLabsPreparePrint?.());
|
|
263
|
+
await page.pdf({ path: pdfFile, printBackground: true, preferCSSPageSize: true });
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
await browser.close();
|
|
267
|
+
}
|
|
268
|
+
if (this.options.announce !== false)
|
|
269
|
+
console.log(` reporting-labs: PDF written to ${path.relative(process.cwd(), pdfFile)}\n`);
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
if (this.options.announce !== false)
|
|
273
|
+
console.log(` reporting-labs: PDF skipped — ${String(e.message).split('\n')[0]}\n`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
245
276
|
/** One short list of tests that carry no meta() at all, so the whole team keeps the report useful. */
|
|
246
277
|
printMissingMeta(tests) {
|
|
247
278
|
if (this.options.warnMissingMeta === false)
|
package/dist/template.html
CHANGED
|
@@ -647,6 +647,104 @@ html,body{font-size:var(--fs)}
|
|
|
647
647
|
.nav .cnt,.chip,.step .cat,.folder,.item .d,.dim .k,.wk .n,.wk-sum,.attn2 .pr,.clu2 .n small,.flk .m,.own .cnts,.ediff .lbl,.ttip .tt-h span,.tags button,.hint-kbd,.trace-how,.api-col h5,.meta .k,.kv.env dt,.apitbl th,.hm thead th,.hm td button{font-family:var(--sans)}
|
|
648
648
|
.chip b,.loc,.file,.file-row,.kbd,.slow .d,.step .d,.hist .l,.wk .k,.tbl td,.kv2 dd,.kv.env dd,.logs,.txt,.err,.ediff .val,.ediff.lines,.clu2 .msg,.apitbl .u,.api-head .m,.api-head .sc,.api-head .d,.api-col pre,.badge.tag,.trend text,.tl text,.attn2 .tt .s .mono,.trace-how code{font-family:var(--mono)}
|
|
649
649
|
.chip{font-family:var(--sans)} .chip b{font-weight:500}
|
|
650
|
+
|
|
651
|
+
/* ---------- report footer: reportingLabs branding ---------- */
|
|
652
|
+
.rl-foot{max-width:1400px;margin:30px auto 0;padding:18px 28px 30px;border-top:1px solid var(--line);display:flex;align-items:center;gap:10px;color:var(--ink-3);font-size:12.5px}
|
|
653
|
+
.rl-foot-mark{width:22px;height:22px;display:inline-flex;flex:none}
|
|
654
|
+
.rl-foot-mark svg{width:22px;height:22px;display:block;border-radius:6px}
|
|
655
|
+
.rl-foot-txt b{color:var(--ink-2);font-weight:600}
|
|
656
|
+
@media print{ .rl-foot{display:none} }
|
|
657
|
+
|
|
658
|
+
/* ---------- print / PDF: a clean, client-shareable executive report ----------
|
|
659
|
+
Page 1 is a full executive summary (no wasted space); analytics, failure analysis, the
|
|
660
|
+
full test-case table and failure evidence each start on a fresh page. Per-test steps,
|
|
661
|
+
logs, API and console are left out so the PDF stays small and readable at 500+ tests. */
|
|
662
|
+
.printdoc{display:none}
|
|
663
|
+
@media print{
|
|
664
|
+
@page{ size:A4; margin:12mm 11mm 13mm; }
|
|
665
|
+
html,body{background:#fff!important}
|
|
666
|
+
#app{display:none!important}
|
|
667
|
+
.printdoc{display:block!important;max-width:none;margin:0;padding:0;color:var(--ink);font-size:12px}
|
|
668
|
+
.printdoc *{ -webkit-print-color-adjust:exact!important; print-color-adjust:exact!important; }
|
|
669
|
+
/* On paper nothing can be clicked or hovered, so never truncate text with an ellipsis or a
|
|
670
|
+
height clamp — wrap it and show it in full. Numbers, durations and status chips stay on one line. */
|
|
671
|
+
.printdoc .apitbl .u,.printdoc .apitbl .t,.printdoc .apitbl td,.printdoc .slow .t,.printdoc .proj .name,
|
|
672
|
+
.printdoc .file,.printdoc .item .tt .p,.printdoc .item .tt .n,.printdoc .item .d,.printdoc .step .t,
|
|
673
|
+
.printdoc .att figcaption,.printdoc .dim .k,.printdoc .attn .t,.printdoc .attn .t .tt,.printdoc .attn .sev,
|
|
674
|
+
.printdoc .attn2 .pr,.printdoc .attn2 .ow,.printdoc .attn2 .tt .n,.printdoc .attn2 .tt .s,
|
|
675
|
+
.printdoc .tbl td,.printdoc .tbl th,.printdoc .api-head .u,.printdoc .clu .msg,.printdoc .clu .n,
|
|
676
|
+
.printdoc .clu2 .msg,.printdoc .clu2 .who,.printdoc .clu2 .who button,.printdoc .own .ts,.printdoc .own .nm,
|
|
677
|
+
.printdoc .hist .b .l,.printdoc .loc,.printdoc .crumb span,.printdoc .pt-name,.printdoc .pt-sub,.printdoc .pt-spec{
|
|
678
|
+
white-space:normal!important;overflow:visible!important;text-overflow:clip!important;
|
|
679
|
+
max-width:none!important;max-height:none!important;overflow-wrap:break-word;-webkit-line-clamp:none!important;
|
|
680
|
+
}
|
|
681
|
+
/* durations / counts / status stay on one line so "9ms" never breaks to "9m s" */
|
|
682
|
+
.printdoc .apitbl td.t:last-child,.printdoc .num,.printdoc .pstatus,.printdoc .since-txt{white-space:nowrap!important;overflow-wrap:normal!important}
|
|
683
|
+
/* only genuinely unbreakable strings (urls, file paths, selectors) may break mid-token */
|
|
684
|
+
.printdoc .apitbl .u,.printdoc .api-head .u,.printdoc .pt-spec,.printdoc .loc,.printdoc .file,.printdoc .clu .msg,.printdoc .clu2 .msg{overflow-wrap:anywhere}
|
|
685
|
+
/* Content flows continuously — no forced page breaks between sections, which were leaving
|
|
686
|
+
whole pages nearly empty. Cards/rows just avoid being split across a page. */
|
|
687
|
+
.printdoc .psec{padding-top:0;margin-top:18px}
|
|
688
|
+
.printdoc .psec-h{font-size:16px;font-weight:600;letter-spacing:-.01em;margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--line);color:var(--ink);break-after:avoid}
|
|
689
|
+
/* Only small, atomic blocks avoid a page split. Big cards (clusters, needs-attention, the tables)
|
|
690
|
+
may flow across pages — keeping them whole is what left pages half-empty. */
|
|
691
|
+
.printdoc .gchart,.printdoc figure,.printdoc .kpi,.printdoc .hm,.printdoc .pev,.printdoc .pexec-card,.printdoc tr{break-inside:avoid}
|
|
692
|
+
.printdoc h4{break-after:avoid}
|
|
693
|
+
/* CSS grid does not fragment across print pages, so a tall two-column grid jumps to the next page
|
|
694
|
+
and leaves a gap. In print everything becomes a single full-width column that flows naturally. */
|
|
695
|
+
.printdoc .grid{display:block!important;margin:0}
|
|
696
|
+
.printdoc .grid>*{grid-column:auto!important;width:auto!important;max-width:none!important;margin:0 0 12px!important;break-inside:avoid}
|
|
697
|
+
.printdoc .grid>.card.w12,.printdoc .grid>.fail-rail,.printdoc .grid>.fx-tools{break-inside:auto}
|
|
698
|
+
.printdoc .graphs-grid>*{break-inside:avoid}
|
|
699
|
+
.printdoc .list,.printdoc .nav,.printdoc .band,.printdoc .stripe,.printdoc .fx-tools .btn,.printdoc .fx-tools .spacer{display:none!important}
|
|
700
|
+
/* drop interactive-only chrome and anything that cannot live on paper */
|
|
701
|
+
.printdoc .actions,.printdoc .gchart-head button,.printdoc .vids,.printdoc .cmp,.printdoc .hint-kbd,.printdoc .mask-note,.printdoc .clu2 .who .ex{display:none!important}
|
|
702
|
+
.printdoc a{color:var(--ink);text-decoration:none}
|
|
703
|
+
/* ---- cover / executive summary (page 1) ---- */
|
|
704
|
+
.printdoc .pcover{padding:2mm 0 0}
|
|
705
|
+
.printdoc .pcover-top{display:flex;align-items:center;gap:13px}
|
|
706
|
+
.printdoc .pcover .logo{width:44px;height:44px;flex:none}
|
|
707
|
+
.printdoc .pcover .logo svg{width:44px;height:44px;display:block;border-radius:11px}
|
|
708
|
+
.printdoc .pcover-title{font-size:25px;font-weight:600;letter-spacing:-.02em;line-height:1.1}
|
|
709
|
+
.printdoc .pcover-sub{color:var(--ink-3);font-size:12px;margin-top:3px}
|
|
710
|
+
.printdoc .pcover-meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
|
|
711
|
+
.printdoc .pverdict{font-size:17px;font-weight:600;margin:16px 0 2px}
|
|
712
|
+
.printdoc .pverdict.ok{color:var(--pass)} .printdoc .pverdict.bad{color:var(--fail)}
|
|
713
|
+
.printdoc .pcover-stats{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 4px}
|
|
714
|
+
.printdoc .pstat{border:1px solid var(--line);border-radius:10px;padding:9px 15px;min-width:84px;background:var(--surface)}
|
|
715
|
+
.printdoc .pstat .n{font-size:25px;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.1}
|
|
716
|
+
.printdoc .pstat .l{font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em;margin-top:3px}
|
|
717
|
+
.printdoc .pexec{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:16px;align-items:start}
|
|
718
|
+
.printdoc .pexec-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface)}
|
|
719
|
+
.printdoc .psub-h{font-size:12.5px;font-weight:600;color:var(--ink);margin:0 0 10px}
|
|
720
|
+
/* ---- the all-tests table ---- */
|
|
721
|
+
.printdoc .ptbl{width:100%;border-collapse:collapse;font-size:11px;table-layout:fixed}
|
|
722
|
+
.printdoc .ptbl thead{display:table-header-group}
|
|
723
|
+
.printdoc .ptbl th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--ink-3);font-weight:600;padding:7px 8px;border-bottom:2px solid var(--line)}
|
|
724
|
+
.printdoc .ptbl td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}
|
|
725
|
+
.printdoc .ptbl td.num{text-align:right;font-variant-numeric:tabular-nums;color:var(--ink-2);white-space:nowrap}
|
|
726
|
+
.printdoc .ptbl .pt-name{font-weight:500;color:var(--ink)}
|
|
727
|
+
.printdoc .ptbl .pt-sub{font-size:10px;color:var(--ink-3);margin-top:1px}
|
|
728
|
+
.printdoc .ptbl .pt-spec{font:10.5px var(--mono);color:var(--ink-2)}
|
|
729
|
+
.printdoc .pstatus{display:inline-flex;align-items:center;gap:6px;font-weight:500;white-space:nowrap}
|
|
730
|
+
.printdoc .pstatus .st{width:9px;height:9px;border-radius:50%;flex:none}
|
|
731
|
+
.printdoc .ptbl .c-n{width:30px} .printdoc .ptbl .c-pri{width:72px} .printdoc .ptbl .c-own{width:84px} .printdoc .ptbl .c-st{width:88px} .printdoc .ptbl .c-dur{width:62px}
|
|
732
|
+
/* ---- failure evidence ---- */
|
|
733
|
+
.printdoc .pev{padding:14px 0;border-top:1px solid var(--line)}
|
|
734
|
+
.printdoc .pev:first-child{border-top:0;padding-top:2px}
|
|
735
|
+
.printdoc .pev-head{display:flex;align-items:center;gap:9px}
|
|
736
|
+
.printdoc .pev-head .st{width:10px;height:10px;border-radius:50%;flex:none}
|
|
737
|
+
.printdoc .pev-title{font-size:14px;font-weight:600;letter-spacing:-.01em}
|
|
738
|
+
.printdoc .pev-loc{font:11px var(--mono);color:var(--ink-3);margin:3px 0 8px}
|
|
739
|
+
.printdoc .pev .why{margin-bottom:8px}
|
|
740
|
+
.printdoc .pev .att{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}
|
|
741
|
+
.printdoc .pev .att figure{flex:1 1 320px;min-width:0;margin:0}
|
|
742
|
+
.printdoc .pev .att img{width:100%;max-height:115mm;object-fit:contain;border:1px solid var(--line);border-radius:6px;background:#fff}
|
|
743
|
+
.printdoc .pev .att figcaption{font:11px var(--mono);color:var(--ink-3);padding:5px 7px;background:var(--surface-2);border-radius:0 0 6px 6px;margin-top:-6px}
|
|
744
|
+
.printdoc .pev .errfull,.printdoc .pev details{display:none!important}
|
|
745
|
+
.printdoc .pfoot{margin-top:5mm;padding-top:3mm;border-top:1px solid var(--line);font-size:10px;color:var(--ink-3)}
|
|
746
|
+
.printdoc .err,.printdoc pre{white-space:pre-wrap;word-break:break-word}
|
|
747
|
+
}
|
|
650
748
|
__RL_ACCENT_CSS____RL_CUSTOM_CSS__</style>
|
|
651
749
|
</head>
|
|
652
750
|
<body>
|
|
@@ -684,7 +782,8 @@ app.append(h('div',{class:'band'}, stripe(), header(), nav()),
|
|
|
684
782
|
h('div',{class:'view','data-view':'failures'}, h('div',{class:'view-pad'}, failuresView())),
|
|
685
783
|
h('div',{class:'view','data-view':'api'}, h('div',{class:'view-pad'}, apiView())),
|
|
686
784
|
h('div',{class:'view','data-view':'graphs'}, graphsView()),
|
|
687
|
-
hasTimelineView()? h('div',{class:'view','data-view':'timeline'}, h('div',{class:'view-pad'}, timelineView())) : null
|
|
785
|
+
hasTimelineView()? h('div',{class:'view','data-view':'timeline'}, h('div',{class:'view-pad'}, timelineView())) : null,
|
|
786
|
+
rlFooter());
|
|
688
787
|
select(state.selected || firstInteresting(), true);
|
|
689
788
|
showView(state.view);
|
|
690
789
|
window.addEventListener('keydown', e=>{
|
|
@@ -979,6 +1078,7 @@ function header(){
|
|
|
979
1078
|
const pal = h('select',{class:'pal','aria-label':'Palette',onchange:e=>{ html.setAttribute('data-palette',e.target.value); try{localStorage.setItem('rl-palette',e.target.value);}catch(e){} }},
|
|
980
1079
|
[['lab','Blue'],['ocean','Ocean'],['ember','Ember'],['mono','Mono']].map(([v,l])=>h('option',{value:v,selected:html.getAttribute('data-palette')===v},l)));
|
|
981
1080
|
const copy = h('button',{class:'btn',onclick:e=>copyText(summaryMarkdown(),e.currentTarget,'Copied for Slack')}, 'Copy summary');
|
|
1081
|
+
const pdf = h('button',{class:'btn',title:'Save the whole report as a PDF — every section, chart, cluster and screenshot, print-friendly',onclick:()=>exportPdf()}, 'Export PDF');
|
|
982
1082
|
return h('header',{class:'hdr'},
|
|
983
1083
|
data.options.logo ? h('img',{class:'logo brand',src:data.options.logo,alt:''}) : h('div',{class:'logo',title:'Generated by reportingLabs','aria-label':'reportingLabs'}, document.getElementById('rl-logo').content.cloneNode(true)),
|
|
984
1084
|
data.options.project ? h('div',{class:'proj-blk'}, h('h1',{}, data.title),
|
|
@@ -989,7 +1089,17 @@ function header(){
|
|
|
989
1089
|
h('div',{class:'hmeta'}, Object.entries(data.metadata).map(([k,v])=>h('span',{class:'chip'}, k+' ', h('b',{},v)))),
|
|
990
1090
|
h('div',{class:'spacer'}),
|
|
991
1091
|
h('span',{class:'when'}, new Date(data.startTime).toLocaleString()+' · '+ms(data.duration)+' · '+data.workers+' worker'+(data.workers===1?'':'s')),
|
|
992
|
-
h('div',{class:'ctl'}, copy, pal, toggle));
|
|
1092
|
+
h('div',{class:'ctl'}, copy, pdf, pal, toggle));
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/* ---------- footer: reportingLabs branding ---------- */
|
|
1096
|
+
function rlFooter(){
|
|
1097
|
+
const src=document.getElementById('rl-logo').content.cloneNode(true).querySelector('svg');
|
|
1098
|
+
const svg=src.outerHTML.replace(/(id="|url\(#|href="#)(rl-[a-z]+)/g,'$1$2-f'); // unique ids vs the header's copy
|
|
1099
|
+
return h('footer',{class:'rl-foot'},
|
|
1100
|
+
h('span',{class:'rl-foot-mark',html:svg}),
|
|
1101
|
+
h('span',{class:'rl-foot-txt'}, 'Generated by ', h('b',{},'reportingLabs'),
|
|
1102
|
+
data.generatedAt? ' · '+new Date(data.generatedAt).toLocaleString() : null));
|
|
993
1103
|
}
|
|
994
1104
|
|
|
995
1105
|
/* ---------- summary ---------- */
|
|
@@ -1514,8 +1624,9 @@ function select(id, quiet){
|
|
|
1514
1624
|
state.selected=id; state.retry=t.results.length-1; if(!quiet){ if(state.view!=='tests') showView('tests'); else setHash(); } refresh(); renderDetail(t);
|
|
1515
1625
|
const it=document.querySelector('.item[data-id="'+id+'"]'); if(it&&it.scrollIntoView) it.scrollIntoView({block:'nearest'});
|
|
1516
1626
|
}
|
|
1517
|
-
function renderDetail(t){
|
|
1518
|
-
|
|
1627
|
+
function renderDetail(t, d, retryIdx){
|
|
1628
|
+
d=d||$('#detail'); d.innerHTML='';
|
|
1629
|
+
const retry = retryIdx==null ? state.retry : retryIdx;
|
|
1519
1630
|
d.append(h('div',{class:'crumb'}, [t.file, ...t.path.map(p=>data.bdd&&!/^Feature:/i.test(p)?'Feature: '+p:p)].map(p=>h('span',{},p))),
|
|
1520
1631
|
h('div',{class:'titlebar'}, h('h3',{},t.title), h('div',{class:'actions'},
|
|
1521
1632
|
h('button',{class:'btn',onclick:e=>copyText(location.href.split('#')[0]+'#t='+t.id,e.currentTarget,'Link copied')},'Copy link'),
|
|
@@ -1528,11 +1639,11 @@ function renderDetail(t){
|
|
|
1528
1639
|
const otherAnn=t.annotations.filter(a=>!DIMS.includes(a.type.toLowerCase()) && !(a.type.toLowerCase() in t.meta));
|
|
1529
1640
|
if(otherAnn.length) d.append(h('h4',{},'Annotations'), h('dl',{class:'kv'}, otherAnn.map(a=>[h('dt',{},a.type),h('dd',{},a.description||'')])));
|
|
1530
1641
|
if(t.results.length>1){
|
|
1531
|
-
d.append(h('div',{class:'tabs'}, t.results.map((r,i)=>h('button',{class:'tab','aria-selected':
|
|
1642
|
+
d.append(h('div',{class:'tabs'}, t.results.map((r,i)=>h('button',{class:'tab','aria-selected':retry===i,onclick:()=>{state.retry=i;renderDetail(t);}}, (i===0?'Attempt 1':'Retry '+i)+' · '+(label[r.status]||r.status)))));
|
|
1532
1643
|
}
|
|
1533
|
-
const r=t.results[
|
|
1644
|
+
const r=t.results[retry]; if(!r){ d.append(h('p',{class:'empty'},'This test did not run.')); return; }
|
|
1534
1645
|
const body=h('div',{});
|
|
1535
|
-
if(t.note&&
|
|
1646
|
+
if(t.note&&retry===t.results.length-1) body.append(h('div',{class:'note '+(t.outcome==='passed'?'ok':'warn')}, t.note));
|
|
1536
1647
|
if(r.errors.length){ body.append(h('h4',{},r.errors.length>1?'Errors':'Error'), ...r.errors.map(errorView)); }
|
|
1537
1648
|
if(r.steps.length){ const stepTotal=Math.max(1, r.duration||0, r.steps.reduce((a,x)=>a+x.duration,0)); body.append(h('h4',{},data.bdd?'Scenario steps':'Steps', h('span',{class:'hint'},'bar = share of '+ms(stepTotal))), stepTree(r.steps, stepTotal)); }
|
|
1538
1649
|
if(r.logs.length){ const t0=r.startTime; body.append(h('h4',{},'Log'), h('div',{class:'logs'}, r.logs.map(l=>h('div',{class:'ln'+(/\b(error|fail|exception)\b/i.test(l.msg)?' err':/\bwarn/i.test(l.msg)?' warn':'')}, h('span',{class:'ts'},'+'+ms(Math.max(0,l.t-t0))), h('span',{class:'lm'},l.msg))))); }
|
|
@@ -1688,6 +1799,125 @@ function openBugReport(t){
|
|
|
1688
1799
|
}
|
|
1689
1800
|
function lightbox(src){ const lb=h('div',{class:'lb',onclick:()=>lb.remove()}, h('img',{src})); document.body.append(lb); }
|
|
1690
1801
|
function escape(s){ return s.replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
|
1802
|
+
|
|
1803
|
+
/* ---------- print / PDF ----------
|
|
1804
|
+
One builder serves both the "Export PDF" button (window.print) and headless page.pdf():
|
|
1805
|
+
Chromium fires 'beforeprint' for both. We assemble the whole report — cover, overview,
|
|
1806
|
+
failures, graphs, API and every test's detail expanded — into a .printdoc, force the light
|
|
1807
|
+
theme for paper, print, then tear it all down so the live app is untouched. Video is skipped. */
|
|
1808
|
+
function buildPrintDoc(){
|
|
1809
|
+
if(document.querySelector('.printdoc')) return;
|
|
1810
|
+
const html=document.documentElement;
|
|
1811
|
+
html.setAttribute('data-rl-theme-prev', html.getAttribute('data-theme')||'');
|
|
1812
|
+
html.setAttribute('data-theme','light'); // always light on paper, whatever the screen theme
|
|
1813
|
+
const pd=h('div',{class:'printdoc'});
|
|
1814
|
+
const s=data.stats, f=s.failed+s.timedOut+s.interrupted, ran=s.total-s.skipped, rate=ran?Math.round(s.passed/ran*100):0;
|
|
1815
|
+
const stat=(n,l,c)=>h('div',{class:'pstat'}, h('div',{class:'n',style:c?'color:'+c:''},String(n)), h('div',{class:'l'},l));
|
|
1816
|
+
let logo;
|
|
1817
|
+
if(data.options.logo){ logo = h('img',{src:data.options.logo,alt:'',style:'width:40px;height:40px;border-radius:8px'}); }
|
|
1818
|
+
else {
|
|
1819
|
+
// The live header already holds one copy of this SVG; in print that copy is display:none, and its
|
|
1820
|
+
// gradient/clip ids would win over an identical clone (first id in the document). Rewrite the ids so
|
|
1821
|
+
// the cover's logo paints its gradients and flask clip correctly.
|
|
1822
|
+
const src=document.getElementById('rl-logo').content.cloneNode(true).querySelector('svg');
|
|
1823
|
+
const html2=src.outerHTML.replace(/(id="|url\(#|href="#)(rl-[a-z]+)/g,'$1$2-p');
|
|
1824
|
+
const holder=h('span',{class:'logo',style:'display:inline-flex;width:40px;height:40px',html:html2});
|
|
1825
|
+
logo=holder;
|
|
1826
|
+
}
|
|
1827
|
+
const verdict = f ? (f+' of '+s.total+' test'+(s.total===1?'':'s')+' failed'+(s.flaky?', '+s.flaky+' flaky':''))
|
|
1828
|
+
: (ran===0 ? 'No tests ran' : 'All '+ran+' test'+(ran===1?'':'s')+' passed'+(s.flaky?', '+s.flaky+' flaky':'')+(s.skipped?' · '+s.skipped+' skipped':''));
|
|
1829
|
+
// ---- page 1: executive summary ----
|
|
1830
|
+
pd.append(h('div',{class:'pcover'},
|
|
1831
|
+
h('div',{class:'pcover-top'}, logo,
|
|
1832
|
+
h('div',{}, h('div',{class:'pcover-title'}, data.title),
|
|
1833
|
+
h('div',{class:'pcover-sub'}, (data.options.project?[data.options.project.name, data.options.project.version?'v'+data.options.project.version:null, data.options.project.team].filter(Boolean).join(' · ')+' · ':'')
|
|
1834
|
+
+ new Date(data.startTime).toLocaleString()+' · '+ms(data.duration)+' · '+data.workers+' worker'+(data.workers===1?'':'s')))),
|
|
1835
|
+
Object.keys(data.metadata).length? h('div',{class:'pcover-meta'}, Object.entries(data.metadata).map(([k,v])=>h('span',{class:'chip'}, k+' ', h('b',{},v)))) : null,
|
|
1836
|
+
h('div',{class:'pverdict '+(f?'bad':'ok')}, verdict),
|
|
1837
|
+
h('div',{class:'pcover-stats'},
|
|
1838
|
+
stat(rate+'%','Pass rate'), stat(s.passed,'Passed','var(--pass)'),
|
|
1839
|
+
f?stat(f,'Failed','var(--fail)'):null, s.flaky?stat(s.flaky,'Flaky','var(--flaky)'):null,
|
|
1840
|
+
s.skipped?stat(s.skipped,'Skipped','var(--ink-3)'):null, stat(s.total,'Total'))));
|
|
1841
|
+
const pexec=h('div',{class:'pexec'});
|
|
1842
|
+
pexec.append(h('div',{class:'pexec-card'}, h('div',{class:'psub-h'},'Pass rate'), donut()));
|
|
1843
|
+
const envc=printEnvCard(); if(envc) pexec.append(envc);
|
|
1844
|
+
pd.append(pexec);
|
|
1845
|
+
|
|
1846
|
+
// The first section flows right after the executive summary so page 1 is never half-empty;
|
|
1847
|
+
// the rest start on a fresh page.
|
|
1848
|
+
let first=true;
|
|
1849
|
+
const sec=(title,node)=>{ if(!node) return; pd.append(h('section',{class:'psec'+(first?'':' brk')}, h('h2',{class:'psec-h'},title), node)); first=false; };
|
|
1850
|
+
// ---- analytics, failure analysis, full inventory, evidence ----
|
|
1851
|
+
if(data.tests.length) sec('Test analytics', graphsView());
|
|
1852
|
+
if(f + s.flaky > 0){
|
|
1853
|
+
const fn=failuresView();
|
|
1854
|
+
// On paper there is no "+N more" to click, so expand every cluster to list all its tests.
|
|
1855
|
+
fn.querySelectorAll('.clu2 .who .ex').forEach(b=>{ try{ b.click(); }catch(e){} });
|
|
1856
|
+
sec('Failure analysis', fn);
|
|
1857
|
+
}
|
|
1858
|
+
sec('All test cases', allTestsTable());
|
|
1859
|
+
const bad=[...data.tests].filter(t=>isFail(t.outcome)||t.outcome==='flaky').sort((a,b)=>rank(a)-rank(b)||a.title.localeCompare(b.title));
|
|
1860
|
+
if(bad.length){ const ev=h('div',{}); for(const t of bad) ev.append(printEvidence(t)); sec('Failure evidence', ev); }
|
|
1861
|
+
pd.append(h('div',{class:'pfoot'}, 'Generated by reportingLabs · '+new Date().toLocaleString()));
|
|
1862
|
+
document.body.append(pd);
|
|
1863
|
+
}
|
|
1864
|
+
function printEnvCard(){
|
|
1865
|
+
if(!data.env||!data.env.length) return null;
|
|
1866
|
+
return h('div',{class:'pexec-card'}, h('div',{class:'psub-h'},'Environment'),
|
|
1867
|
+
h('dl',{class:'kv env'}, data.env.flatMap(r=>[h('dt',{},r.k), h('dd',{}, r.href? h('a',{href:r.href},r.v) : r.v)])));
|
|
1868
|
+
}
|
|
1869
|
+
function allTestsTable(){
|
|
1870
|
+
const hasPri=data.tests.some(t=>t.meta.priority||t.meta.severity), hasOwn=data.tests.some(t=>t.meta.owner), multiProj=data.projects.length>1;
|
|
1871
|
+
const cols=[['c-n','#'],['','Test'],['','Spec'], hasPri?['c-pri','Priority']:null, hasOwn?['c-own','Owner']:null, ['c-st','Status'], ['c-dur','Duration']].filter(Boolean);
|
|
1872
|
+
const order=[...data.tests].sort((a,b)=>((isFail(b.outcome)||b.outcome==='flaky')-(isFail(a.outcome)||a.outcome==='flaky'))||rank(a)-rank(b)||a.file.localeCompare(b.file)||a.title.localeCompare(b.title));
|
|
1873
|
+
let i=0;
|
|
1874
|
+
return h('table',{class:'ptbl'},
|
|
1875
|
+
h('colgroup',{}, cols.map(([c])=>h('col',{class:c||''}))),
|
|
1876
|
+
h('thead',{}, h('tr',{}, cols.map(([,l])=>h('th',{},l)))),
|
|
1877
|
+
h('tbody',{}, order.map(t=>{ i++; return h('tr',{}, [
|
|
1878
|
+
h('td',{class:'num'}, String(i)),
|
|
1879
|
+
h('td',{}, h('div',{class:'pt-name'}, t.title), t.path.length? h('div',{class:'pt-sub'}, t.path.join(' › ')) : null),
|
|
1880
|
+
h('td',{}, h('span',{class:'pt-spec'}, t.file.split('/').pop()+(multiProj?' · '+t.project:''))),
|
|
1881
|
+
hasPri? h('td',{}, [t.meta.priority,t.meta.severity].filter(Boolean).join(' · ')) : null,
|
|
1882
|
+
hasOwn? h('td',{}, t.meta.owner||'') : null,
|
|
1883
|
+
h('td',{}, h('span',{class:'pstatus'}, h('span',{class:'st '+t.outcome,style:'background:'+colorOf(bucket(t))}), label[t.outcome])),
|
|
1884
|
+
h('td',{class:'num'}, ms(t.duration)),
|
|
1885
|
+
].filter(x=>x!==null)); })));
|
|
1886
|
+
}
|
|
1887
|
+
function printEvidence(t){
|
|
1888
|
+
const r=t.results[t.results.length-1], e=r&&r.errors[0];
|
|
1889
|
+
const art=h('article',{class:'pev'});
|
|
1890
|
+
art.append(h('div',{class:'pev-head'}, h('span',{class:'st',style:'background:'+colorOf(bucket(t))}), h('span',{class:'pev-title'}, t.title), h('span',{class:'badge '+t.outcome}, label[t.outcome])));
|
|
1891
|
+
art.append(h('div',{class:'pev-loc'}, t.file+':'+t.line+(data.projects.length>1?' · '+t.project:'')+' · '+ms(t.duration)+(t.results.length>1?' · '+t.results.length+' attempts':'')+(t.meta.owner?' · '+t.meta.owner:'')+(t.meta.priority?' · '+t.meta.priority:'')));
|
|
1892
|
+
if(e){ if(e.explain) art.append(whyView(e.explain)); art.append(errorBody(e)); }
|
|
1893
|
+
else art.append(h('div',{style:'color:var(--ink-3)'}, t.outcome==='flaky'?'Passed on retry after an earlier failure.':'No error message was recorded.'));
|
|
1894
|
+
const imgs=(r?r.attachments:[]).filter(a=>a.src&&(a.contentType||'').startsWith('image/')&&!/-(expected|actual|diff)(\.\w+)?$/.test(a.name));
|
|
1895
|
+
if(imgs.length) art.append(h('div',{class:'att'}, imgs.slice(0,2).map(a=>h('figure',{}, h('img',{src:a.src,alt:a.name}), h('figcaption',{},a.name)))));
|
|
1896
|
+
return art;
|
|
1897
|
+
}
|
|
1898
|
+
function teardownPrintDoc(){
|
|
1899
|
+
const pd=document.querySelector('.printdoc'); if(pd) pd.remove();
|
|
1900
|
+
const html=document.documentElement, prev=html.getAttribute('data-rl-theme-prev');
|
|
1901
|
+
if(prev!=null){ if(prev) html.setAttribute('data-theme',prev); else html.removeAttribute('data-theme'); html.removeAttribute('data-rl-theme-prev'); }
|
|
1902
|
+
}
|
|
1903
|
+
// Build the print document and wait for every screenshot (a data: URI) to actually decode, so it
|
|
1904
|
+
// is painted — otherwise a print fired right after the DOM is built captures blank image boxes.
|
|
1905
|
+
async function preparePrint(){
|
|
1906
|
+
buildPrintDoc();
|
|
1907
|
+
try{ await Promise.all([...document.querySelectorAll('.printdoc img')].map(im=> im.decode? im.decode().catch(()=>{}) : Promise.resolve())); }catch(e){}
|
|
1908
|
+
return document.querySelectorAll('.printdoc img').length;
|
|
1909
|
+
}
|
|
1910
|
+
async function exportPdf(){
|
|
1911
|
+
await preparePrint();
|
|
1912
|
+
try{ window.print(); }catch(e){}
|
|
1913
|
+
}
|
|
1914
|
+
// A headless driver (the reporter's auto-generated report.pdf) awaits this, then calls page.pdf().
|
|
1915
|
+
window.reportingLabsPreparePrint = preparePrint;
|
|
1916
|
+
// A plain headless Chrome launched with --print-to-pdf on this file with ?rl-print (how the Java/CLI
|
|
1917
|
+
// generator renders report.pdf) has no way to call the hook, so build the print document on load instead.
|
|
1918
|
+
if(/[?&#]rl-print\b/.test(location.href)){ if(document.readyState!=='loading') preparePrint(); else window.addEventListener('DOMContentLoaded', preparePrint); }
|
|
1919
|
+
window.addEventListener('beforeprint', buildPrintDoc); // native Ctrl+P: build if not already built
|
|
1920
|
+
window.addEventListener('afterprint', teardownPrintDoc);
|
|
1691
1921
|
})();
|
|
1692
1922
|
</script>
|
|
1693
1923
|
</body>
|