From 2dda4582896bcb729f7f5215b743079c20ee1dd3 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Mon, 7 Sep 2026 11:35:37 +0200 Subject: [PATCH 1/4] lib/html: pin lit Move from importing lit via URL in the .js file to using an import map in JS. This lets us specify integrity information as part of the map, effectively pinning our lit version. Improve the type declarations: I had Claude go through the actual type declarations and copy over the parts relevant to our code. Add a licence header accordingly, since this is now a derived work of the original. Rename our lit.d.ts file and update it to `declare module "lit"` now that we call it that way from the import statement. --- lib/html/dashboard/dashboard.html | 10 ++++ lib/html/dashboard/dashboard.js | 2 +- lib/html/tsconfig.json | 2 +- lib/html/types/lit-cdn.d.ts | 27 ---------- lib/html/types/lit.d.ts | 85 +++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 29 deletions(-) delete mode 100644 lib/html/types/lit-cdn.d.ts create mode 100644 lib/html/types/lit.d.ts diff --git a/lib/html/dashboard/dashboard.html b/lib/html/dashboard/dashboard.html index 910986e8a8..8ae95465de 100644 --- a/lib/html/dashboard/dashboard.html +++ b/lib/html/dashboard/dashboard.html @@ -41,6 +41,16 @@ color: var(--blue); } + diff --git a/lib/html/dashboard/dashboard.js b/lib/html/dashboard/dashboard.js index 750cd0af52..83e05c0ec8 100644 --- a/lib/html/dashboard/dashboard.js +++ b/lib/html/dashboard/dashboard.js @@ -20,7 +20,7 @@ * }} DashboardData */ -import { css, html, LitElement } from "https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js"; +import { css, html, LitElement } from "lit"; class CiDashboard extends LitElement { /** @override */ diff --git a/lib/html/tsconfig.json b/lib/html/tsconfig.json index bf0fde3cee..d190c6d59a 100644 --- a/lib/html/tsconfig.json +++ b/lib/html/tsconfig.json @@ -9,5 +9,5 @@ "moduleResolution": "bundler" }, "include": ["*/*.js"], - "files": ["types/lit-cdn.d.ts"] + "files": ["types/lit.d.ts"] } diff --git a/lib/html/types/lit-cdn.d.ts b/lib/html/types/lit-cdn.d.ts deleted file mode 100644 index e5b18b9365..0000000000 --- a/lib/html/types/lit-cdn.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -declare module "https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js" { - type PropertyDeclaration = { - type?: typeof String | typeof Number | typeof Boolean | typeof Array | typeof Object; - state?: boolean; - }; - - export type CSSResult = { cssText: string }; - - export class LitElement extends HTMLElement { - static properties: Record; - static styles: CSSResult | CSSResult[]; - connectedCallback(): void; - disconnectedCallback(): void; - createRenderRoot(): HTMLElement | DocumentFragment; - requestUpdate(): void; - render(): unknown; - } - - export function css(strings: TemplateStringsArray, ...values: unknown[]): CSSResult; - export function html(strings: TemplateStringsArray, ...values: unknown[]): unknown; - export const nothing: symbol; - export function repeat( - items: Iterable, - keyFn: (item: T) => unknown, - template: (item: T) => unknown, - ): unknown; -} diff --git a/lib/html/types/lit.d.ts b/lib/html/types/lit.d.ts new file mode 100644 index 0000000000..e7471f7468 --- /dev/null +++ b/lib/html/types/lit.d.ts @@ -0,0 +1,85 @@ +/** + * @license + * Based on code copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +declare module "lit" { + export type CSSResultOrNative = CSSResult | CSSStyleSheet; + export type CSSResultArray = Array; + export type CSSResultGroup = CSSResultOrNative | CSSResultArray; + + export class CSSResult { + "_$cssResult$": boolean; + readonly cssText: string; + private _styleSheet?; + private _strings; + private constructor(); + get styleSheet(): CSSStyleSheet | undefined; + toString(): string; + } + + export const css: (strings: TemplateStringsArray, ...values: (CSSResultGroup | number)[]) => CSSResult; + + interface ComplexAttributeConverter { + fromAttribute?(value: string | null, type?: TypeHint): Type; + toAttribute?(value: Type, type?: TypeHint): unknown; + } + + type AttributeConverter = + | ComplexAttributeConverter + | ((value: string | null, type?: TypeHint) => Type); + + export interface PropertyDeclaration { + readonly state?: boolean; + readonly attribute?: boolean | string; + readonly type?: TypeHint; + readonly converter?: AttributeConverter; + readonly reflect?: boolean; + hasChanged?(value: Type, oldValue: Type): boolean; + readonly noAccessor?: boolean; + } + + export interface PropertyDeclarations { + readonly [key: string]: PropertyDeclaration; + } + + type ResultType = 1 | 2 | 3; + + export type TemplateResult = { + "_$litType$": T; + strings: TemplateStringsArray; + values: unknown[]; + }; + + export const html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>; + export const nothing: unique symbol; + export function live(value: T): T; + + type KeyFn = (item: T, index: number) => unknown; + type ItemTemplate = (item: T, index: number) => unknown; + + export interface RepeatDirectiveFn { + (items: Iterable, keyFnOrTemplate: KeyFn | ItemTemplate, template?: ItemTemplate): unknown; + (items: Iterable, template: ItemTemplate): unknown; + (items: Iterable, keyFn: KeyFn | ItemTemplate, template: ItemTemplate): unknown; + } + + export const repeat: RepeatDirectiveFn; + + export class LitElement extends HTMLElement { + static properties: PropertyDeclarations; + static styles?: CSSResultGroup; + connectedCallback(): void; + disconnectedCallback(): void; + protected createRenderRoot(): HTMLElement | DocumentFragment; + requestUpdate( + name?: PropertyKey, + oldValue?: unknown, + options?: PropertyDeclaration, + useNewValue?: boolean, + newValue?: unknown, + ): void; + protected render(): unknown; + } +} From 09e18b24643a6a612449f5d77dce69de20a13714 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Mon, 7 Sep 2026 11:41:01 +0200 Subject: [PATCH 2/4] lib/html: enable incremental tsc This speeds things up from ~1.3s to ~0.5s on no changes. It's minor, but it's a win. --- lib/html/.gitignore | 1 + lib/html/tsconfig.json | 1 + 2 files changed, 2 insertions(+) create mode 100644 lib/html/.gitignore diff --git a/lib/html/.gitignore b/lib/html/.gitignore new file mode 100644 index 0000000000..f543eba025 --- /dev/null +++ b/lib/html/.gitignore @@ -0,0 +1 @@ +/tsconfig.tsbuildinfo diff --git a/lib/html/tsconfig.json b/lib/html/tsconfig.json index d190c6d59a..2a86c1bbbd 100644 --- a/lib/html/tsconfig.json +++ b/lib/html/tsconfig.json @@ -3,6 +3,7 @@ "checkJs": true, "strict": true, "noEmit": true, + "incremental": true, "noImplicitOverride": true, "target": "ES2022", "module": "ES2022", From d7b5b544f40d496f31a950c1ff128b94990c6983 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Mon, 7 Sep 2026 11:57:35 +0200 Subject: [PATCH 3/4] log.html: rewrite in Lit with Typescript and Biome Move lib/s3-html/log.html to lib/html/ alongside the dashboard and rewrite it substantially to use Web Components with Lit. This fixes some bugs: - due to internal component state, subsections no longer snap shut during updates - updates no longer occasionally delete chunks of the previous page content (owing to side-effects related to our use of .exec on regexps, which is stateful). - the TAP parsing in general (which has been incrementally extended over the last decade) has been rewritten in simplified form and actually possible to understand and work on now - all the logic is in a separate .js file now, with types, and under Biome, and All of this means that it should be much easier to make changes to the log page going forward. --- lib/aio/s3streamer.py | 2 +- lib/html/s3streamer/log.html | 75 ++++ lib/html/s3streamer/log.js | 682 +++++++++++++++++++++++++++++++++ lib/s3-html/log.html | 703 ----------------------------------- 4 files changed, 758 insertions(+), 704 deletions(-) create mode 100644 lib/html/s3streamer/log.html create mode 100644 lib/html/s3streamer/log.js delete mode 100644 lib/s3-html/log.html diff --git a/lib/aio/s3streamer.py b/lib/aio/s3streamer.py index ef1845551f..1cc6383c47 100644 --- a/lib/aio/s3streamer.py +++ b/lib/aio/s3streamer.py @@ -216,7 +216,7 @@ def start(self, data: str) -> None: # Send the initial data immediately, to get the chunks file written out. self.pending = data.encode(errors='replace') self.send_pending() - AttachmentsDirectory(self.index, f'{LIB_DIR}/s3-html').scan() + AttachmentsDirectory(self.index, f'{LIB_DIR}/html/s3streamer').scan() def write(self, data: str) -> None: self.pending += data.encode(errors='replace') diff --git a/lib/html/s3streamer/log.html b/lib/html/s3streamer/log.html new file mode 100644 index 0000000000..c2e501a16b --- /dev/null +++ b/lib/html/s3streamer/log.html @@ -0,0 +1,75 @@ + + + + Cockpit Integration Tests + + + + + + + + + + + + diff --git a/lib/html/s3streamer/log.js b/lib/html/s3streamer/log.js new file mode 100644 index 0000000000..02fe9c2203 --- /dev/null +++ b/lib/html/s3streamer/log.js @@ -0,0 +1,682 @@ +// Live log viewer for cockpit CI test jobs. +// +// Loads TAP-format log data incrementally from the s3streamer chunked format, +// parses it into test entries, and renders a structured view with PatternFly +// components. Also supports a raw text fallback for non-TAP content. + +import { html, LitElement, live, nothing, repeat } from "lit"; + +// --- s3streamer client --- + +/** @param {number} seconds */ +function sleep(seconds) { + return new Promise((resolve) => setTimeout(resolve, 1000 * seconds)); +} + +class NotFoundError extends Error {} +class RangeNotSatisfiableError extends Error {} +class RetriableError extends Error {} + +/** + * @param {string} url + * @param {number} offset + * @returns {Promise} + */ +async function fetch_once(url, offset = 0) { + /** @type {Response} */ + let response; + try { + response = await fetch(url, { headers: { Range: `bytes=${offset}-` } }); + } catch (exc) { + // The fetch() API throws TypeError for network errors + if (exc instanceof TypeError) { + throw new RetriableError(exc.message); + } else { + throw exc; + } + } + + // The fetch() API doesn't throw on non-success responses, so we + // need to check the status ourselves and throw accordingly. + if (response.status === 206) { + return await response.text(); + } + // Accept 200 for servers that don't support Range (e.g. python -m http.server) + if (response.status === 200) { + const buffer = await response.arrayBuffer(); + return new TextDecoder().decode(buffer.slice(offset)); + } + + // S3 returns 403 instead of 404 when s3:ListBucket is not granted + if (response.status === 403 || response.status === 404) { + throw new NotFoundError(); + } else if (response.status === 416) { + throw new RangeNotSatisfiableError(); + } else if (response.status >= 500) { + throw new RetriableError(`Server error ${response.status}`); + } else { + throw new Error(`Unexpected status ${response.status}`); + } +} + +/** + * @param {string} url + * @param {number} offset + * @returns {Promise} + */ +async function fetch_from(url, offset = 0) { + for (let attempts = 0; attempts < 10; attempts++) { + try { + return await fetch_once(url, offset); + } catch (error) { + if (error instanceof RetriableError) { + const delay = 2 ** attempts; + console.log(`Failed to fetch ${url}: ${error}. Waiting ${delay}.`); + await sleep(delay); + } else { + throw error; + } + } + } + + return await fetch_once(url, offset); +} + +/** + * @param {string} filename + * @param {(text: string) => void} on_data + */ +async function fetch_content(filename, on_data) { + /* Content is unicode text, but we need to know how many bytes we have in + * order to perform chunk calculations. Track that separately. + */ + let bytes = 0; + + try { + while (true) { + /** @type {number[]} */ + const chunks = JSON.parse(await fetch_from(`${filename}.chunks`)); + let chunk_start = 0; + + for (const chunk_size of chunks) { + const chunk_end = chunk_start + chunk_size; + + if (bytes < chunk_end) { + on_data(await fetch_from(`${filename}.${chunk_start}-${chunk_end}`, bytes - chunk_start)); + bytes = chunk_end; + } + + chunk_start = chunk_end; + } + + await sleep(30); + } + } catch (e) { + // If any of the chunk files are not found, the complete file is expected to be present. + if (!(e instanceof NotFoundError)) { + throw e; + } + } + + try { + on_data(await fetch_from(filename, bytes)); + } catch (e) { + // The last chunk may have consumed the file exactly, leaving nothing to read. + if (!(e instanceof RangeNotSatisfiableError)) { + throw e; + } + } +} + +// --- UI components --- + +/** + * @typedef {{ + * status: 'prologue' | 'pass' | 'fail' | 'skip' | 'todo' | 'epilogue' + * idx: number, + * id: string, + * description?: string, + * retry: number | null, + * text: string, + * }} TapEntry + * + * @typedef {{ + * title: string, + * entries: TapEntry[], + * total: number, + * total_test_time: number | null, + * counts: { + * passed: number, + * failed: number, + * skipped: number, + * retried_fail: number, + * retried_pass: number, + * }, + * }} TapResult + * + * @typedef {{ + * url: string, + * icon: string, + * label: string, + * color: string, + * }} LinkMatch + */ + +class TestEntry extends LitElement { + /** @override */ static properties = { + entry: { type: Object }, + expanded: { state: true }, + }; + + /** Patterns for extracting artifact links from test diagnostic output. */ + static link_patterns = [ + { + label: "changed pixels", + pattern: /Pixel test ([A-Za-z0-9_\-.]+) failed:/gm, + url: "pixeldiff.html#$1", + }, + { + label: "screenshot", + pattern: /Wrote screenshot to ([A-Za-z0-9\-.]+\.png)$/gm, + icon: "fas fa-fw fa-image", + color: "pf-m-teal", + }, + { + label: "html", + pattern: /Wrote HTML dump to ([A-Za-z0-9\-.]+\.html)$/gm, + icon: "fas fa-fw fa-file", + color: "pf-m-yellow", + }, + { + label: "new pixels", + pattern: /New pixel test reference ([A-Za-z0-9\-.]+\.png)$/gm, + }, + { + label: "journal", + pattern: /Journal extracted to ([A-Za-z0-9\-.]+\.log(?:\.[gx]z)?)$/gm, + icon: "fas fa-fw fa-pencil-alt", + color: "pf-m-yellow", + }, + { + label: "coverage", + pattern: /Code coverage report in ([A-Za-z0-9\-./]+)$/gm, + color: "pf-m-yellow", + }, + ]; + + /** + * Scan a text segment for artifact links and return them grouped by pattern. + * @param {string} segment + * @returns {LinkMatch[][]} + */ + static find_patterns(segment) { + /** + * @param {string} tmpl + * @param {RegExpExecArray} match + * @returns {string} + */ + function fmt(tmpl, match) { + return tmpl.replace(/\$([0-9]+)/g, (_m, x) => match[Number(x)]); + } + + const links = []; + for (const p of TestEntry.link_patterns) { + const matches = []; + for (const m of segment.matchAll(p.pattern)) { + const url = fmt(p.url || "$1", m); + const icon = p.icon || "fas fa-fw fa-external-link-alt"; + const label = fmt(p.label || "file", m); + const color = p.color || "pf-m-custom"; + matches.push({ url, icon, label, color }); + } + if (matches.length) { + links.push(matches); + } + } + return links; + } + + /** @override */ createRenderRoot() { + return this; + } + + constructor() { + super(); + /** @type {TapEntry | undefined} */ + this.entry = undefined; + /** @type {boolean} */ + this.expanded = false; + } + + /** @override */ connectedCallback() { + super.connectedCallback(); + this.expanded = this.entry?.status === "fail" && this.entry?.retry === null; + } + + toggle() { + this.expanded = !this.expanded; + } + + /** @param {LinkMatch[]} links */ + open_all_links(links) { + for (const link of links) { + window.open(`./${link.url}`); + } + } + + /** @param {LinkMatch[]} group */ + render_link_group(group) { + return html` +
+
+ + +
+
+
`; + } + + /** @override */ render() { + const entry = this.entry; + if (!entry) { + return nothing; + } + const classes = [ + "test-entry", + entry.status === "fail" ? "failed" : "", + entry.retry !== null ? "retried" : "", + entry.status === "skip" || entry.status === "todo" ? "skipped" : "", + ] + .filter(Boolean) + .join(" "); + + return html` +
  • +
    +
    +
    + +
    +
    +
    +
    + + ${entry.id} + ${entry.description ? html`: ${entry.description}` : nothing} +
    + ${TestEntry.find_patterns(entry.text).map((group) => this.render_link_group(group))} +
    +
    +
    +
    +
    +
    +
    +
    +
    ${entry.text}
    +
    +
    +
    +
    +
  • `; + } +} + +customElements.define("test-entry", TestEntry); + +class LogViewer extends LitElement { + /** @override */ static properties = { + href: { type: String }, + content: { state: true }, + raw: { state: true }, + show_only_failed: { state: true }, + }; + + /** + * Parse a TAP (Test Anything Protocol) log into structured entries. + * + * Each entry's `text` field spans from the end of the previous TAP result line + * to the end of the current one, capturing any interleaved diagnostic output. + * + * Retry entries follow the format: `ok/not ok N description # RETRY N (reason)` + * + * @param {string} text + * @returns {TapResult | null} + */ + static parse_tap(text) { + const range = text.match(/^(?.*?^1\.\.(?\d+)\n)(?.*)$/ms); + if (!range?.groups) { + return null; + } + + /** @type {TapEntry[]} */ + const entries = [ + { + status: "prologue", + idx: 0, + id: "prologue", + retry: null, + text: range.groups.prologue, + }, + ]; + + let passed = 0; + let failed = 0; + let skipped = 0; + let retried_fail = 0; + let retried_pass = 0; + + const tap_result = /^(?ok|not ok) (?\d+) (?.*)\n/gm; + const body = range.groups.body; + let body_offset = 0; + for (const m of body.matchAll(tap_result)) { + /* Each entry's text runs from the end of the previous TAP line to + * the end of the current one, capturing interleaved diagnostic output. */ + const end = m.index + m[0].length; + const section = body.slice(body_offset, end); + body_offset = end; + + /* Categorize */ + const ok = m.groups?.ok === "ok"; + const skip = m.groups?.description?.match(/[^#]+# SKIP /); + const todo = m.groups?.description?.match(/[^#]+# TODO /); + const retry = m.groups?.description?.match(/[^#]+# RETRY (\d+)/); + + if (retry && ok) { + retried_pass++; + } else if (retry && !ok) { + retried_fail++; + } else if (skip || todo) { + skipped++; + } else if (ok) { + passed++; + } else { + failed++; + } + + /* Create the TestEntry */ + entries.push({ + status: skip ? "skip" : todo ? "todo" : ok ? "pass" : "fail", + idx: parseInt(m.groups?.num ?? "0", 10), + id: retry ? `${m.groups?.num}-${retry[1]}` : (m.groups?.num ?? "0"), + description: m.groups?.description ?? "", + retry: retry ? parseInt(retry[1], 10) : null, + text: section, + }); + } + + const epilogue = body.slice(body_offset); + if (epilogue) { + entries.push({ + status: "epilogue", + idx: Infinity, + id: "epilogue", + retry: null, + text: epilogue, + }); + } + + const timing = text.match(/^# (\d+ TESTS FAILED|TESTS PASSED) \[(\d+)s on .*\]$/m); + return { + title: text.slice(0, text.indexOf("\n")), + entries, + total_test_time: timing ? Math.ceil(parseInt(timing[2], 10) / 60) : null, + total: parseInt(range.groups.n_tests, 10), + counts: { passed, failed, skipped, retried_fail, retried_pass }, + }; + } + + /** @override */ createRenderRoot() { + return this; + } + + constructor() { + super(); + /** @type {string} */ + this.href = "log"; + /** @type {string} */ + this.content = ""; + /** @type {boolean} */ + this.raw = false; + /** @type {boolean} */ + this.show_only_failed = true; + } + + /** @override */ connectedCallback() { + super.connectedCallback(); + fetch_content(this.href, (text) => { + this.content += text; + }); + } + + /** + * @param {number} total + * @param {number} finished + */ + render_progress(total, finished) { + const pct = Math.floor((finished / total) * 100); + + return html` +
    +
    Test progress
    + +
    +
    +
    +
    `; + } + + /** @param {TapResult} tap */ + render_stats(tap) { + const left = tap.total - tap.counts.passed - tap.counts.failed - tap.counts.skipped; + + return html` +
    + ${ + tap.total_test_time + ? html` + + + +
    ` + : nothing + } +
    +
    + +
      +
    • + + + + + + ${tap.counts.passed} passed + + +
    • +
    • + + + + + + ${tap.counts.skipped} skipped + + +
    • +
    • + + + + + + ${tap.counts.failed} failed + + +
    • + ${ + tap.counts.retried_fail + ? html` +
    • + + + + + + ${tap.counts.retried_fail} retries of failures + + +
    • ` + : nothing + } + ${ + tap.counts.retried_pass + ? html` +
    • + + + + + + ${tap.counts.retried_pass} retries of successes + + +
    • ` + : nothing + } +
    +
    +
    +
    `; + } + + render_toolbar() { + return html` +
    +
    +
    + +
    + +
    +
    +
    +
    `; + } + + /** @param {boolean} has_failed */ + render_filter_toggle(has_failed) { + return html` +
    + +
    `; + } + + render_raw() { + return html` +
    +
    +
    ${this.content}
    +
    +
    `; + } + + /** @param {TapResult} tap */ + render_parsed(tap) { + const finished = tap.counts.passed + tap.counts.failed + tap.counts.skipped; + const sorted_entries = [...tap.entries].sort((a, b) => { + return a.idx - b.idx || (a.retry ?? Infinity) - (b.retry ?? Infinity); + }); + const has_failed = tap.counts.failed > 0; + const visible_entries = + this.show_only_failed && has_failed ? sorted_entries.filter((e) => e.status === "fail") : sorted_entries; + + return html` + ${this.render_progress(tap.total, finished)} + ${this.render_stats(tap)} + ${this.render_filter_toggle(has_failed)} +
      + ${repeat( + visible_entries, + (e) => e.id, + (entry) => html``, + )} +
    `; + } + + /** @override */ render() { + const tap = !this.raw ? LogViewer.parse_tap(this.content) : null; + const title = tap ? tap.title : "Logs"; + + return html` +

    ${title}

    + ${this.render_toolbar()} + ${tap ? this.render_parsed(tap) : this.render_raw()}`; + } +} + +customElements.define("log-viewer", LogViewer); diff --git a/lib/s3-html/log.html b/lib/s3-html/log.html deleted file mode 100644 index eaf508c788..0000000000 --- a/lib/s3-html/log.html +++ /dev/null @@ -1,703 +0,0 @@ - - - - Cockpit Integration Tests - - - - - - - - -

    Logs

    - - -
    -
    -
    - -
    -
    - - From 9881b68928cb6c8d9a7ff680f940fd425a52aca9 Mon Sep 17 00:00:00 2001 From: Allison Karlitskaya Date: Mon, 7 Sep 2026 12:07:08 +0200 Subject: [PATCH 4/4] HACKING.md: update "logs page" section This section was inaccurately named and offered advice that I couldn't get to work (because of the CORS settings on the Python webserver being unhappy about accesses to different hosts). Update the information more generally and provide some new ideas for testing. --- HACKING.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/HACKING.md b/HACKING.md index a39083bacd..28c5255a43 100644 --- a/HACKING.md +++ b/HACKING.md @@ -69,20 +69,32 @@ pushing PRs that will fail on trivial errors: ln -s ../../test/run .git/hooks/pre-push -### Updating pixel tests code +### Hacking on the logs page -> [!NOTE] -> This will only update the `log.html` page and redirect all links there to the log URL set in ``. For `pixeldiff.html` there is currently no written dev guide. +The logs viewer page lives in `lib/html/s3streamer`, as `log.html` and +`log.js`. It's no-bundle, based on Web Components using Lit, and covered by +TypeScript (on JSDoc comments) and Biome. It's uploaded by the s3streamer +server code (`lib/aio/s3streamer.py`) at the start of each new test job. +You can also test it locally by downloading an existing raw log file into +`lib/html/s3streamer/log` and starting a local webserver to view the result: -* Easiest way to develop is to go to `./lib/s3-html/log.html` and within `` add a test URL for what you want to improve layout for. -```html - - ``` -* Start a server for the `lib/` directory with `python -m http.server -d ./lib/s3-html` -* Open up the URL echoed in terminal and go to `/log.html` -* Make changes in `log.html` and see changes refresh live in the browser +python -m http.server -d ./lib/html/s3streamer +``` + +If you need to test the streaming code itself, it's also possible to run CI +jobs locally using `job-runner run cockpit-project/starter-kit` or something +similar. In the default configuration, `job-runner` will write to the local +disk instead of uploading to S3, but the s3streamer algorithm is still used and +live updates should work in the same way. In that case you should run the +webserver like this: + +``` +python -m http.server -b 127.0.0.1 -d ~/.cache/cockpit-dev/job-runner-logs +``` + +See `job-runner.toml` for more information. ## Debugging tips