clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-demo-review-fixes
lofi / package / testing / trace.ts
trace.ts 6.4K
1/**2 * Playwright trace-archive sanitization: a minimal ZIP reader/writer plus the3 * pass that drops private resource entries and redacts the retained trace4 * metadata before an archive is kept as a failure artifact.5 *6 * @module7 */89const LOCAL_FILE = 0x04034b50;10const CENTRAL_FILE = 0x02014b50;11const END_OF_CENTRAL_DIRECTORY = 0x06054b50;12const UTF8 = new TextEncoder();13const TEXT = new TextDecoder();1415function concatenate(parts: readonly Uint8Array[]): Uint8Array {16 const length = parts.reduce((total, part) => total + part.length, 0);17 const result = new Uint8Array(length);18 let offset = 0;19 for (const part of parts) {20 result.set(part, offset);21 offset += part.length;22 }23 return result;24}2526function header(length: number): { bytes: Uint8Array; view: DataView } {27 const bytes = new Uint8Array(length);28 return { bytes, view: new DataView(bytes.buffer) };29}3031function crc32(bytes: Uint8Array): number {32 let crc = 0xffffffff;33 for (const byte of bytes) {34 crc ^= byte;35 for (let bit = 0; bit < 8; bit++) {36 crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);37 }38 }39 return (crc ^ 0xffffffff) >>> 0;40}4142function endOffset(bytes: Uint8Array): number {43 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);44 for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65_557); offset--) {45 if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY) return offset;46 }47 throw new Error("Playwright trace is not a ZIP archive");48}4950async function inflateRaw(bytes: Uint8Array): Promise<Uint8Array> {51 const input = new Blob([bytes.slice()]).stream();52 const output = input.pipeThrough(new DecompressionStream("deflate-raw"));53 return new Uint8Array(await new Response(output).arrayBuffer());54}5556/**57 * Read a Playwright trace ZIP at `path` into a map of entry name to58 * decompressed bytes, supporting stored and deflate entries. Throws on59 * encrypted, malformed, or unsupported-compression archives.60 */61export async function readTraceArchive(path: string): Promise<Record<string, Uint8Array>> {62 const bytes = await Deno.readFile(path);63 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);64 const end = endOffset(bytes);65 const count = view.getUint16(end + 10, true);66 let centralOffset = view.getUint32(end + 16, true);67 const entries: Record<string, Uint8Array> = {};6869 for (let index = 0; index < count; index++) {70 if (view.getUint32(centralOffset, true) !== CENTRAL_FILE) {71 throw new Error("Playwright trace has an invalid central directory");72 }73 const flags = view.getUint16(centralOffset + 8, true);74 if (flags & 1) throw new Error("Encrypted trace entries cannot be sanitized");75 const method = view.getUint16(centralOffset + 10, true);76 const compressedSize = view.getUint32(centralOffset + 20, true);77 const nameLength = view.getUint16(centralOffset + 28, true);78 const extraLength = view.getUint16(centralOffset + 30, true);79 const commentLength = view.getUint16(centralOffset + 32, true);80 const localOffset = view.getUint32(centralOffset + 42, true);81 const name = TEXT.decode(bytes.subarray(centralOffset + 46, centralOffset + 46 + nameLength));82 if (view.getUint32(localOffset, true) !== LOCAL_FILE) {83 throw new Error(`Playwright trace entry ${name} has an invalid local header`);84 }85 const localNameLength = view.getUint16(localOffset + 26, true);86 const localExtraLength = view.getUint16(localOffset + 28, true);87 const dataOffset = localOffset + 30 + localNameLength + localExtraLength;88 const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize);89 if (method === 0) entries[name] = compressed.slice();90 else if (method === 8) entries[name] = await inflateRaw(compressed);91 else throw new Error(`Playwright trace entry ${name} uses unsupported compression ${method}`);92 centralOffset += 46 + nameLength + extraLength + commentLength;93 }94 return entries;95}9697/**98 * Serialize the given entries into a stored (uncompressed) ZIP archive with99 * CRC-32 checksums, suitable for rewriting a sanitized trace.100 */101export function writeTraceArchive(entries: Readonly<Record<string, Uint8Array>>): Uint8Array {102 const localParts: Uint8Array[] = [];103 const centralParts: Uint8Array[] = [];104 let localOffset = 0;105106 for (const [name, contents] of Object.entries(entries)) {107 const nameBytes = UTF8.encode(name);108 const checksum = crc32(contents);109 const local = header(30);110 local.view.setUint32(0, LOCAL_FILE, true);111 local.view.setUint16(4, 20, true);112 local.view.setUint32(14, checksum, true);113 local.view.setUint32(18, contents.length, true);114 local.view.setUint32(22, contents.length, true);115 local.view.setUint16(26, nameBytes.length, true);116 localParts.push(local.bytes, nameBytes, contents);117118 const central = header(46);119 central.view.setUint32(0, CENTRAL_FILE, true);120 central.view.setUint16(4, 20, true);121 central.view.setUint16(6, 20, true);122 central.view.setUint32(16, checksum, true);123 central.view.setUint32(20, contents.length, true);124 central.view.setUint32(24, contents.length, true);125 central.view.setUint16(28, nameBytes.length, true);126 central.view.setUint32(42, localOffset, true);127 centralParts.push(central.bytes, nameBytes);128 localOffset += local.bytes.length + nameBytes.length + contents.length;129 }130131 const central = concatenate(centralParts);132 const end = header(22);133 end.view.setUint32(0, END_OF_CENTRAL_DIRECTORY, true);134 end.view.setUint16(8, Object.keys(entries).length, true);135 end.view.setUint16(10, Object.keys(entries).length, true);136 end.view.setUint32(12, central.length, true);137 end.view.setUint32(16, localOffset, true);138 return concatenate([...localParts, central, end.bytes]);139}140141/**142 * Sanitize an intentionally snapshot-free trace archive. Response/source143 * resources are omitted because they can contain arbitrary private bytes; the144 * action and network metadata remain useful for failure diagnosis.145 */146export async function sanitizeTraceArchive(147 path: string,148 redact: (value: string) => string,149): Promise<void> {150 const entries = await readTraceArchive(path);151 const sanitized: Record<string, Uint8Array> = {};152 for (const [name, bytes] of Object.entries(entries)) {153 if (name.startsWith("resources/")) continue;154 sanitized[name] = UTF8.encode(redact(TEXT.decode(bytes)));155 }156 await Deno.writeFile(path, writeTraceArchive(sanitized));157}