From a58553ba66ab5bc879a3e867c5e36397d624ec8a Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 12:03:19 +0200 Subject: [PATCH] refactor: convert decode history worker to TypeScript --- .../web/generated/decode-history-worker.js | 320 +++++++++--------- .../trx-frontend-http/frontend/build.mjs | 14 +- ...ory-worker.js => decode-history-worker.ts} | 80 +++-- .../tests/decode-history-worker.test.mjs | 54 +++ 4 files changed, 285 insertions(+), 183 deletions(-) rename src/trx-client/trx-frontend/trx-frontend-http/frontend/src/{decode-history-worker.js => decode-history-worker.ts} (65%) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-history-worker.test.mjs diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js index ef16b3af..7a4fd220 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js @@ -1,167 +1,179 @@ "use strict"; -const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; -const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"]; -function decodeCborUint(view, bytes, state, additional) { - const offset = state.offset; - if (additional < 24) return additional; - if (additional === 24) { - if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated"); - state.offset += 1; - return bytes[offset]; - } - if (additional === 25) { - if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); - state.offset += 2; - return view.getUint16(offset); - } - if (additional === 26) { - if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); - state.offset += 4; - return view.getUint32(offset); - } - if (additional === 27) { - if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); - const value = view.getBigUint64(offset); - state.offset += 8; - const numeric = Number(value); - if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range"); - return numeric; - } - throw new Error("Unsupported CBOR additional info"); -} -function decodeCborFloat16(bits) { - const sign = bits & 32768 ? -1 : 1; - const exponent = bits >> 10 & 31; - const fraction = bits & 1023; - if (exponent === 0) { - return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024); - } - if (exponent === 31) { - return fraction === 0 ? sign * Infinity : Number.NaN; - } - return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024); -} -function decodeCborItem(view, bytes, state) { - if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); - const initial = bytes[state.offset++]; - const major = initial >> 5; - const additional = initial & 31; - if (major === 0) return decodeCborUint(view, bytes, state, additional); - if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional); - if (major === 2) { - const length = decodeCborUint(view, bytes, state, additional); - if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); - const chunk = bytes.slice(state.offset, state.offset + length); - state.offset += length; - return Array.from(chunk); - } - if (major === 3) { - const length = decodeCborUint(view, bytes, state, additional); - if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); - const chunk = bytes.subarray(state.offset, state.offset + length); - state.offset += length; - return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk); - } - if (major === 4) { - const length = decodeCborUint(view, bytes, state, additional); - const items = new Array(length); - for (let i = 0; i < length; i += 1) { - items[i] = decodeCborItem(view, bytes, state); +(() => { + // src/decode-history-worker.ts + var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; + var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"]; + var workerScope = self; + function decodeCborUint(view, bytes, state, additional) { + const offset = state.offset; + if (additional < 24) return additional; + if (additional === 24) { + if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 1; + return bytes[offset] ?? 0; } - return items; + if (additional === 25) { + if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 2; + return view.getUint16(offset); + } + if (additional === 26) { + if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 4; + return view.getUint32(offset); + } + if (additional === 27) { + if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getBigUint64(offset); + state.offset += 8; + const numeric = Number(value); + if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range"); + return numeric; + } + throw new Error("Unsupported CBOR additional info"); } - if (major === 5) { - const length = decodeCborUint(view, bytes, state, additional); - const value = {}; - for (let i = 0; i < length; i += 1) { - const key = decodeCborItem(view, bytes, state); - value[String(key)] = decodeCborItem(view, bytes, state); + function decodeCborFloat16(bits) { + const sign = bits & 32768 ? -1 : 1; + const exponent = bits >> 10 & 31; + const fraction = bits & 1023; + if (exponent === 0) { + return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024); + } + if (exponent === 31) { + return fraction === 0 ? sign * Infinity : Number.NaN; + } + return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024); + } + function decodeCborItem(view, bytes, state) { + if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); + const initial = bytes[state.offset++]; + if (initial === void 0) throw new Error("CBOR payload truncated"); + const major = initial >> 5; + const additional = initial & 31; + if (major === 0) return decodeCborUint(view, bytes, state, additional); + if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional); + if (major === 2) { + const length = decodeCborUint(view, bytes, state, additional); + if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); + const chunk = bytes.slice(state.offset, state.offset + length); + state.offset += length; + return Array.from(chunk); + } + if (major === 3) { + const length = decodeCborUint(view, bytes, state, additional); + if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); + const chunk = bytes.subarray(state.offset, state.offset + length); + state.offset += length; + return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk); + } + if (major === 4) { + const length = decodeCborUint(view, bytes, state, additional); + const items = new Array(length); + for (let i = 0; i < length; i += 1) { + items[i] = decodeCborItem(view, bytes, state); + } + return items; + } + if (major === 5) { + const length = decodeCborUint(view, bytes, state, additional); + const value = {}; + for (let i = 0; i < length; i += 1) { + const key = decodeCborItem(view, bytes, state); + const property = typeof key === "string" || typeof key === "number" || typeof key === "boolean" ? String(key) : JSON.stringify(key); + value[property] = decodeCborItem(view, bytes, state); + } + return value; + } + if (major === 6) { + decodeCborUint(view, bytes, state, additional); + return decodeCborItem(view, bytes, state); + } + if (major === 7) { + if (additional === 20) return false; + if (additional === 21) return true; + if (additional === 22) return null; + if (additional === 23) return void 0; + if (additional === 25) { + if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); + const bits = view.getUint16(state.offset); + state.offset += 2; + return decodeCborFloat16(bits); + } + if (additional === 26) { + if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getFloat32(state.offset); + state.offset += 4; + return value; + } + if (additional === 27) { + if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getFloat64(state.offset); + state.offset += 8; + return value; + } + } + throw new Error("Unsupported CBOR major type"); + } + function decodeCborPayload(buffer) { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const state = { offset: 0 }; + const value = decodeCborItem(view, bytes, state); + if (state.offset !== bytes.length) { + throw new Error("Unexpected trailing bytes in decode history payload"); } return value; } - if (major === 6) { - decodeCborUint(view, bytes, state, additional); - return decodeCborItem(view, bytes, state); + function isHistory(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); } - if (major === 7) { - if (additional === 20) return false; - if (additional === 21) return true; - if (additional === 22) return null; - if (additional === 23) return void 0; - if (additional === 25) { - if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); - const bits = view.getUint16(state.offset); - state.offset += 2; - return decodeCborFloat16(bits); + async function fetchAndDecodeHistory(url, batchLimit) { + workerScope.postMessage({ type: "status", phase: "fetching" }); + const resp = await fetch(url, { credentials: "same-origin" }); + if (!resp.ok) throw new Error(`History fetch failed: ${String(resp.status)}`); + const payload = await resp.arrayBuffer(); + if (payload.byteLength === 0) { + workerScope.postMessage({ type: "start", total: 0 }); + workerScope.postMessage({ type: "done", total: 0 }); + return; } - if (additional === 26) { - if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); - const value = view.getFloat32(state.offset); - state.offset += 4; - return value; - } - if (additional === 27) { - if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); - const value = view.getFloat64(state.offset); - state.offset += 8; - return value; + workerScope.postMessage({ type: "status", phase: "decoding" }); + const history = decodeCborPayload(payload); + const total = HISTORY_GROUP_KEYS.reduce((sum, key) => { + const items = isHistory(history) && Array.isArray(history[key]) ? history[key] : []; + return sum + items.length; + }, 0); + workerScope.postMessage({ type: "start", total }); + let processed = 0; + const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512)); + for (const kind of HISTORY_GROUP_KEYS) { + const items = isHistory(history) && Array.isArray(history[kind]) ? history[kind] : []; + if (items.length === 0) continue; + for (let index = 0; index < items.length; index += safeLimit) { + const messages = items.slice(index, index + safeLimit); + processed += messages.length; + workerScope.postMessage({ + type: "group", + kind, + messages, + processed, + total + }); + } } + workerScope.postMessage({ type: "done", total }); } - throw new Error("Unsupported CBOR major type"); -} -function decodeCborPayload(buffer) { - const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const state = { offset: 0 }; - const value = decodeCborItem(view, bytes, state); - if (state.offset !== bytes.length) { - throw new Error("Unexpected trailing bytes in decode history payload"); + function isFetchHistoryRequest(value) { + return typeof value === "object" && value !== null && "type" in value && value.type === "fetch-history"; } - return value; -} -async function fetchAndDecodeHistory(url, batchLimit) { - self.postMessage({ type: "status", phase: "fetching" }); - const resp = await fetch(url, { credentials: "same-origin" }); - if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`); - const payload = await resp.arrayBuffer(); - if (!payload || payload.byteLength === 0) { - self.postMessage({ type: "start", total: 0 }); - self.postMessage({ type: "done", total: 0 }); - return; - } - self.postMessage({ type: "status", phase: "decoding" }); - const history = decodeCborPayload(payload); - const total = HISTORY_GROUP_KEYS.reduce((sum, key) => { - const items = history && Array.isArray(history[key]) ? history[key] : []; - return sum + items.length; - }, 0); - self.postMessage({ type: "start", total }); - let processed = 0; - const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512)); - for (const kind of HISTORY_GROUP_KEYS) { - const items = history && Array.isArray(history[kind]) ? history[kind] : []; - if (items.length === 0) continue; - for (let index = 0; index < items.length; index += safeLimit) { - const messages = items.slice(index, index + safeLimit); - processed += messages.length; - self.postMessage({ - type: "group", - kind, - messages, - processed, - total + workerScope.onmessage = (event) => { + const data = event.data; + if (!isFetchHistoryRequest(data)) return; + fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit).catch((error) => { + workerScope.postMessage({ + type: "error", + message: error instanceof Error ? error.message : typeof error === "string" ? error : "unknown worker failure" }); - } - } - self.postMessage({ type: "done", total }); -} -self.onmessage = (event) => { - const data = event?.data || {}; - if (data?.type !== "fetch-history") return; - fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit).catch((err) => { - self.postMessage({ - type: "error", - message: err && err.message ? err.message : String(err || "unknown worker failure") }); - }); -}; + }; +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index ca45c216..7f4775df 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -21,7 +21,6 @@ await build({ "map-core": path.join(sourceDir, "map-core.js"), screenshot: path.join(sourceDir, "screenshot.js"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.js"), - "decode-history-worker": path.join(sourceDir, "decode-history-worker.js"), "leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.js"), ais: path.join(sourceDir, "plugins", "ais.js"), aprs: path.join(sourceDir, "plugins", "aprs.js"), @@ -48,3 +47,16 @@ await build({ charset: "utf8", logLevel: "info", }); + +await build({ + entryPoints: [path.join(sourceDir, "decode-history-worker.ts")], + outfile: path.join(outputDir, "decode-history-worker.js"), + bundle: true, + format: "iife", + platform: "browser", + target: "es2022", + sourcemap: false, + legalComments: "inline", + charset: "utf8", + logLevel: "info", +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/decode-history-worker.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/decode-history-worker.ts similarity index 65% rename from src/trx-client/trx-frontend/trx-frontend-http/frontend/src/decode-history-worker.js rename to src/trx-client/trx-frontend/trx-frontend-http/frontend/src/decode-history-worker.ts index 08f42b6e..838515ff 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/decode-history-worker.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/decode-history-worker.ts @@ -2,16 +2,27 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; -const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"]; +export {}; -function decodeCborUint(view, bytes, state, additional) { +const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; +const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"] as const; +type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number]; +type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue }; +interface DecodeState { offset: number } +interface FetchHistoryRequest { type: "fetch-history"; url?: string; batchLimit?: number } +interface WorkerScope { + postMessage(message: unknown): void; + onmessage: ((event: MessageEvent) => void) | null; +} +const workerScope = self as unknown as WorkerScope; + +function decodeCborUint(view: DataView, bytes: Uint8Array, state: DecodeState, additional: number): number { const offset = state.offset; if (additional < 24) return additional; if (additional === 24) { if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated"); state.offset += 1; - return bytes[offset]; + return bytes[offset] ?? 0; } if (additional === 25) { if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); @@ -34,7 +45,7 @@ function decodeCborUint(view, bytes, state, additional) { throw new Error("Unsupported CBOR additional info"); } -function decodeCborFloat16(bits) { +function decodeCborFloat16(bits: number): number { const sign = (bits & 0x8000) ? -1 : 1; const exponent = (bits >> 10) & 0x1f; const fraction = bits & 0x03ff; @@ -47,9 +58,10 @@ function decodeCborFloat16(bits) { return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024)); } -function decodeCborItem(view, bytes, state) { +function decodeCborItem(view: DataView, bytes: Uint8Array, state: DecodeState): CborValue { if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); const initial = bytes[state.offset++]; + if (initial === undefined) throw new Error("CBOR payload truncated"); const major = initial >> 5; const additional = initial & 0x1f; if (major === 0) return decodeCborUint(view, bytes, state, additional); @@ -70,7 +82,7 @@ function decodeCborItem(view, bytes, state) { } if (major === 4) { const length = decodeCborUint(view, bytes, state, additional); - const items = new Array(length); + const items: CborValue[] = new Array(length); for (let i = 0; i < length; i += 1) { items[i] = decodeCborItem(view, bytes, state); } @@ -78,10 +90,13 @@ function decodeCborItem(view, bytes, state) { } if (major === 5) { const length = decodeCborUint(view, bytes, state, additional); - const value = {}; + const value: Record = {}; for (let i = 0; i < length; i += 1) { const key = decodeCborItem(view, bytes, state); - value[String(key)] = decodeCborItem(view, bytes, state); + const property = typeof key === "string" || typeof key === "number" || typeof key === "boolean" + ? String(key) + : JSON.stringify(key); + value[property] = decodeCborItem(view, bytes, state); } return value; } @@ -116,7 +131,7 @@ function decodeCborItem(view, bytes, state) { throw new Error("Unsupported CBOR major type"); } -function decodeCborPayload(buffer) { +function decodeCborPayload(buffer: ArrayBuffer | Uint8Array): CborValue { const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const state = { offset: 0 }; @@ -127,35 +142,39 @@ function decodeCborPayload(buffer) { return value; } -async function fetchAndDecodeHistory(url, batchLimit) { - self.postMessage({ type: "status", phase: "fetching" }); +function isHistory(value: CborValue): value is Partial> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function fetchAndDecodeHistory(url: string, batchLimit?: number) { + workerScope.postMessage({ type: "status", phase: "fetching" }); const resp = await fetch(url, { credentials: "same-origin" }); - if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`); + if (!resp.ok) throw new Error(`History fetch failed: ${String(resp.status)}`); const payload = await resp.arrayBuffer(); - if (!payload || payload.byteLength === 0) { - self.postMessage({ type: "start", total: 0 }); - self.postMessage({ type: "done", total: 0 }); + if (payload.byteLength === 0) { + workerScope.postMessage({ type: "start", total: 0 }); + workerScope.postMessage({ type: "done", total: 0 }); return; } - self.postMessage({ type: "status", phase: "decoding" }); + workerScope.postMessage({ type: "status", phase: "decoding" }); const history = decodeCborPayload(payload); const total = HISTORY_GROUP_KEYS.reduce((sum, key) => { - const items = history && Array.isArray(history[key]) ? history[key] : []; + const items = isHistory(history) && Array.isArray(history[key]) ? history[key] : []; return sum + items.length; }, 0); - self.postMessage({ type: "start", total }); + workerScope.postMessage({ type: "start", total }); let processed = 0; const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512)); for (const kind of HISTORY_GROUP_KEYS) { - const items = history && Array.isArray(history[kind]) ? history[kind] : []; + const items = isHistory(history) && Array.isArray(history[kind]) ? history[kind] : []; if (items.length === 0) continue; for (let index = 0; index < items.length; index += safeLimit) { const messages = items.slice(index, index + safeLimit); processed += messages.length; - self.postMessage({ + workerScope.postMessage({ type: "group", kind, messages, @@ -164,17 +183,22 @@ async function fetchAndDecodeHistory(url, batchLimit) { }); } } - self.postMessage({ type: "done", total }); + workerScope.postMessage({ type: "done", total }); } -self.onmessage = (event) => { - const data = event?.data || {}; - if (data?.type !== "fetch-history") return; +function isFetchHistoryRequest(value: unknown): value is FetchHistoryRequest { + return typeof value === "object" && value !== null && "type" in value && value.type === "fetch-history"; +} + +workerScope.onmessage = (event) => { + const data = event.data; + if (!isFetchHistoryRequest(data)) return; fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit) - .catch((err) => { - self.postMessage({ + .catch((error: unknown) => { + workerScope.postMessage({ type: "error", - message: err && err.message ? err.message : String(err || "unknown worker failure"), + message: error instanceof Error ? error.message + : typeof error === "string" ? error : "unknown worker failure", }); }); }; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-history-worker.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-history-worker.test.mjs new file mode 100644 index 00000000..914702ad --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-history-worker.test.mjs @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +test("decode-history worker decodes CBOR and batches messages", async () => { + const messages = []; + const worker = { + onmessage: null, + postMessage(message) { messages.push(message); }, + }; + const payload = Uint8Array.from([0xa1, 0x63, 0x61, 0x69, 0x73, 0x83, 0x01, 0x02, 0x03]); + const context = vm.createContext({ + self: worker, + TextDecoder, + Uint8Array, + DataView, + ArrayBuffer, + Number, + Math, + String, + Error, + fetch: async () => ({ + ok: true, + status: 200, + arrayBuffer: async () => payload.buffer, + }), + }); + const source = await readFile( + new URL("../../assets/web/generated/decode-history-worker.js", import.meta.url), + "utf8", + ); + new vm.Script(source).runInContext(context); + worker.onmessage({ data: { type: "fetch-history", url: "/decode/history", batchLimit: 2 } }); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + + assert.deepEqual( + messages.map(({ type, phase, total, processed }) => ({ type, phase, total, processed })), + [ + { type: "status", phase: "fetching", total: undefined, processed: undefined }, + { type: "status", phase: "decoding", total: undefined, processed: undefined }, + { type: "start", phase: undefined, total: 3, processed: undefined }, + { type: "group", phase: undefined, total: 3, processed: 2 }, + { type: "group", phase: undefined, total: 3, processed: 3 }, + { type: "done", phase: undefined, total: 3, processed: undefined }, + ], + ); + assert.deepEqual(Array.from(messages[3].messages), [1, 2]); + assert.deepEqual(Array.from(messages[4].messages), [3]); +});