mirror of
https://github.com/hanzoai/playwright.git
synced 2026-09-24 17:01:53 +00:00
157 lines
5.5 KiB
TypeScript
157 lines
5.5 KiB
TypeScript
/**
|
|
* Copyright (c) Microsoft Corporation.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
|
|
import { DuckDBInstance } from '@duckdb/node-api';
|
|
|
|
import type { DuckDBConnection } from '@duckdb/node-api';
|
|
|
|
const TABLE_NAME = 'test_results';
|
|
|
|
// The parquet already carries the full per-result schema (see
|
|
// tests/config/parquetReporter.ts). We deliberately do not re-declare those
|
|
// columns here: the table is created lazily from the first parquet we ingest,
|
|
// so it always matches whatever the reporter currently emits, plus these two
|
|
// trailing columns we add ourselves:
|
|
// - artifact_id: the GitHub artifact this row came from (our dedupe key)
|
|
// - ingested_at: when we imported it (debug only)
|
|
const INGEST_SELECT =
|
|
`SELECT *, $id AS artifact_id, now() AS ingested_at FROM read_parquet($file)`;
|
|
|
|
// A row lives in the `test_results` table and originates from a GitHub artifact
|
|
// identified by an opaque string id.
|
|
export class TestResultsDb {
|
|
private _instance: DuckDBInstance;
|
|
private _conn: DuckDBConnection;
|
|
readonly path: string;
|
|
|
|
private constructor(instance: DuckDBInstance, conn: DuckDBConnection, path: string) {
|
|
this._instance = instance;
|
|
this._conn = conn;
|
|
this.path = path;
|
|
}
|
|
|
|
static async open(path: string): Promise<TestResultsDb> {
|
|
const instance = await DuckDBInstance.create(path);
|
|
const conn = await instance.connect();
|
|
return new TestResultsDb(instance, conn, path);
|
|
}
|
|
|
|
private async _tableExists(): Promise<boolean> {
|
|
const reader = await this._conn.runAndReadAll(
|
|
`SELECT 1 FROM information_schema.tables WHERE table_name = $name`,
|
|
{ name: TABLE_NAME });
|
|
return reader.getRows().length > 0;
|
|
}
|
|
|
|
// The set of artifact ids already imported. Empty on a fresh (table-less) db.
|
|
async ingestedArtifactIds(): Promise<Set<string>> {
|
|
const ids = new Set<string>();
|
|
if (!await this._tableExists())
|
|
return ids;
|
|
const reader = await this._conn.runAndReadAll(
|
|
`SELECT DISTINCT artifact_id FROM ${TABLE_NAME}`);
|
|
for (const row of reader.getRows())
|
|
ids.add(String(row[0]));
|
|
return ids;
|
|
}
|
|
|
|
// Ingest one parquet file, tagging every row with `artifactId`. The table is
|
|
// created (schema inferred) on the first ingest; later ingests are matched by
|
|
// column name, so a reordered/extended parquet schema still lands correctly.
|
|
async ingestParquet(parquetFile: string, artifactId: string): Promise<void> {
|
|
const params = { id: artifactId, file: parquetFile };
|
|
if (!await this._tableExists())
|
|
await this._conn.run(`CREATE TABLE ${TABLE_NAME} AS ${INGEST_SELECT} LIMIT 0`, params);
|
|
await this._conn.run(`INSERT INTO ${TABLE_NAME} BY NAME ${INGEST_SELECT}`, params);
|
|
}
|
|
|
|
async runCount(): Promise<number> {
|
|
if (!await this._tableExists())
|
|
return 0;
|
|
const reader = await this._conn.runAndReadAll(
|
|
`SELECT count(DISTINCT (run_id, run_attempt)) FROM ${TABLE_NAME}`);
|
|
return Number(reader.getRows()[0][0]);
|
|
}
|
|
|
|
async rowCount(): Promise<number> {
|
|
if (!await this._tableExists())
|
|
return 0;
|
|
const reader = await this._conn.runAndReadAll(`SELECT count(*) FROM ${TABLE_NAME}`);
|
|
return Number(reader.getRows()[0][0]);
|
|
}
|
|
|
|
// Keep the newest `maxRuns` runs (a run is a (run_id, run_attempt) pair,
|
|
// ordered by run_started_at), delete the rest, then compact to reclaim disk.
|
|
async truncateToRuns(maxRuns: number): Promise<void> {
|
|
if (!await this._tableExists())
|
|
return;
|
|
await this._conn.run(
|
|
`DELETE FROM ${TABLE_NAME}
|
|
WHERE (run_id, run_attempt) NOT IN (
|
|
SELECT run_id, run_attempt FROM ${TABLE_NAME}
|
|
GROUP BY run_id, run_attempt
|
|
ORDER BY max(run_started_at) DESC NULLS LAST
|
|
LIMIT $n)`,
|
|
{ n: maxRuns });
|
|
await this._compact();
|
|
}
|
|
|
|
// Copy the live rows into a fresh database file and swap it in — DuckDB's
|
|
// DELETE leaves the space allocated, so this is what actually shrinks the file.
|
|
private async _compact(): Promise<void> {
|
|
const tmpPath = `${this.path}.compact.tmp`;
|
|
for (const p of [tmpPath, `${tmpPath}.wal`]) {
|
|
if (fs.existsSync(p))
|
|
fs.rmSync(p);
|
|
}
|
|
await this._conn.run(`ATTACH '${tmpPath.replace(/'/g, `''`)}' AS compacted`);
|
|
await this._conn.run(`CREATE TABLE compacted.${TABLE_NAME} AS SELECT * FROM ${TABLE_NAME}`);
|
|
await this._conn.run(`DETACH compacted`);
|
|
this.close();
|
|
|
|
fs.rmSync(this.path);
|
|
if (fs.existsSync(`${this.path}.wal`))
|
|
fs.rmSync(`${this.path}.wal`);
|
|
fs.renameSync(tmpPath, this.path);
|
|
|
|
this._instance = await DuckDBInstance.create(this.path);
|
|
this._conn = await this._instance.connect();
|
|
}
|
|
|
|
close(): void {
|
|
this._conn.closeSync();
|
|
this._instance.closeSync();
|
|
}
|
|
}
|
|
|
|
export function fileSize(path: string): number {
|
|
try {
|
|
return fs.statSync(path).size;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
export function formatBytes(bytes: number): string {
|
|
if (bytes < 1024)
|
|
return `${bytes} B`;
|
|
if (bytes < 1024 * 1024)
|
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|