From cabe8a898a36104b370b0dda1528dc068df67a1c Mon Sep 17 00:00:00 2001 From: Brandon Liu Date: Wed, 24 Dec 2025 17:33:11 +0800 Subject: [PATCH] Abort requests [#608] (#625) * Properly abort requests using AbortController * add basic adapter tests --------- Co-authored-by: Andrew Dassonville --- js/src/adapters.ts | 8 +- js/src/index.ts | 2 +- js/test/adapter.test.ts | 119 +++++++ js/test/index.test.ts | 1 + js/test/utils.ts | 44 +++ js/test/v3.test.ts | 767 +++++++++++++++++++--------------------- 6 files changed, 536 insertions(+), 405 deletions(-) create mode 100644 js/test/adapter.test.ts create mode 100644 js/test/utils.ts diff --git a/js/src/adapters.ts b/js/src/adapters.ts index 2f19acb..488d593 100644 --- a/js/src/adapters.ts +++ b/js/src/adapters.ts @@ -215,12 +215,13 @@ export class Protocol { } if (this.metadata) { - return { - data: await instance.getTileJson(params.url), - }; + const data = await instance.getTileJson(params.url); + abortController.signal.throwIfAborted(); + return { data }; } const h = await instance.getHeader(); + abortController.signal.throwIfAborted(); if (h.minLon >= h.maxLon || h.minLat >= h.maxLat) { console.error( @@ -255,6 +256,7 @@ export class Protocol { const header = await instance.getHeader(); const resp = await instance?.getZxy(+z, +x, +y, abortController.signal); + abortController.signal.throwIfAborted(); if (resp) { return { data: new Uint8Array(resp.data), diff --git a/js/src/index.ts b/js/src/index.ts index 437ed2f..31a7db9 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -355,7 +355,7 @@ export class FetchSource implements Source { let userAgent = ""; if ("navigator" in globalThis) { //biome-ignore lint: cf workers - userAgent = (globalThis as any).navigator.userAgent || ""; + userAgent = (globalThis as any).navigator?.userAgent ?? ""; } const isWindows = userAgent.indexOf("Windows") > -1; const isChromiumBased = /Chrome|Chromium|Edg|OPR|Brave/.test(userAgent); diff --git a/js/test/adapter.test.ts b/js/test/adapter.test.ts new file mode 100644 index 0000000..a6a75d9 --- /dev/null +++ b/js/test/adapter.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert"; +import { describe, mock, test } from "node:test"; +import { PMTiles, Protocol } from "../src"; +import { mockServer } from "./utils"; + +describe("Protocol", () => { + test("get TileJSON", async () => { + mockServer.reset(); + const pmtiles = new PMTiles("http://localhost:1337/example.pmtiles"); + const protocol = new Protocol(); + protocol.add(pmtiles); + + const result = await protocol.tilev4( + { + url: "pmtiles://http://localhost:1337/example.pmtiles", + type: "json", + }, + new AbortController() + ); + + assert.deepStrictEqual(result.data, { + tiles: ["pmtiles://http://localhost:1337/example.pmtiles/{z}/{x}/{y}"], + minzoom: 0, + maxzoom: 0, + bounds: [0, 0, 0.9999999, 1], + }); + assert.equal(mockServer.numRequests, 1); + }); + + test("get tile data", async () => { + const pmtiles = new PMTiles("http://localhost:1337/example.pmtiles"); + const protocol = new Protocol(); + protocol.add(pmtiles); + + const result = await protocol.tilev4( + { + url: "pmtiles://http://localhost:1337/example.pmtiles/0/0/0", + type: "arrayBuffer", + }, + new AbortController() + ); + + assert.ok(result.data instanceof Uint8Array); + assert.strictEqual(result.data.length, 49); + }); + + test("returns empty data for missing tile if errorOnMissingTile is false", async () => { + const pmtiles = new PMTiles("http://localhost:1337/example.pmtiles"); + const protocol = new Protocol({ errorOnMissingTile: false }); + protocol.add(pmtiles); + + const result = await protocol.tilev4( + { + url: "pmtiles://http://localhost:1337/example.pmtiles/25/0/0", + type: "arrayBuffer", + }, + new AbortController() + ); + + assert.ok(result.data instanceof Uint8Array); + assert.strictEqual(result.data.length, 0); + }); + + test("throws error for missing tile if errorOnMissingTile is true", async () => { + const pmtiles = new PMTiles("http://localhost:1337/example.pmtiles"); + const protocol = new Protocol({ errorOnMissingTile: true }); + protocol.add(pmtiles); + + const promise = protocol.tilev4( + { + url: "pmtiles://http://localhost:1337/example.pmtiles/25/0/0", + type: "arrayBuffer", + }, + new AbortController() + ); + + assert.rejects(promise, { message: "Tile not found." }); + }); + + test("throws AbortError when AbortController is signaled while accessing TileJSON", async () => { + const pmtiles = new PMTiles("http://localhost:1337/example.pmtiles"); + const protocol = new Protocol(); + protocol.add(pmtiles); + + const abortController = new AbortController(); + + const resultPromise = protocol.tilev4( + { + url: "pmtiles://http://localhost:1337/example.pmtiles", + type: "json", + }, + abortController + ); + + abortController.abort(); + + await assert.rejects(resultPromise, { name: "AbortError" }); + }); + + test("throws AbortError when AbortController is signaled while accessing tile data", async () => { + const pmtiles = new PMTiles("http://localhost:1337/example.pmtiles"); + const protocol = new Protocol(); + protocol.add(pmtiles); + + const abortController = new AbortController(); + + const resultPromise = protocol.tilev4( + { + url: "pmtiles://http://localhost:1337/example.pmtiles/0/0/0", + type: "arrayBuffer", + }, + abortController + ); + + abortController.abort(); + + await assert.rejects(resultPromise, { name: "AbortError" }); + }); +}); diff --git a/js/test/index.test.ts b/js/test/index.test.ts index 25b3994..0289b92 100644 --- a/js/test/index.test.ts +++ b/js/test/index.test.ts @@ -1 +1,2 @@ +import "./adapter.test"; import "./v3.test"; diff --git a/js/test/utils.ts b/js/test/utils.ts new file mode 100644 index 0000000..5ab0e4d --- /dev/null +++ b/js/test/utils.ts @@ -0,0 +1,44 @@ +import fs from "fs"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; + +class MockServer { + etag?: string; + numRequests: number; + lastCache?: string; + + reset() { + this.numRequests = 0; + this.etag = undefined; + } + + constructor() { + this.numRequests = 0; + this.etag = undefined; + const serverBuffer = fs.readFileSync("test/data/test_fixture_1.pmtiles"); + const server = setupServer( + http.get( + "http://localhost:1337/example.pmtiles", + ({ request, params }) => { + this.lastCache = request.cache; + this.numRequests++; + const range = request.headers.get("range")?.substr(6).split("-"); + if (!range) { + throw new Error("invalid range"); + } + const offset = +range[0]; + const length = +range[1]; + const body = serverBuffer.slice(offset, offset + length - 1); + return new HttpResponse(body, { + status: 206, + statusText: "OK", + headers: { etag: this.etag } as HeadersInit, + }); + } + ) + ); + server.listen({ onUnhandledRequest: "error" }); + } +} + +export const mockServer = new MockServer(); diff --git a/js/test/v3.test.ts b/js/test/v3.test.ts index 3890706..02e1d1e 100644 --- a/js/test/v3.test.ts +++ b/js/test/v3.test.ts @@ -1,8 +1,7 @@ import fs from "fs"; import assert from "node:assert"; import { afterEach, beforeEach, describe, it, test } from "node:test"; -import { http, HttpResponse } from "msw"; -import { setupServer } from "msw/node"; +import { mockServer } from "./utils"; import { BufferPosition, @@ -20,424 +19,390 @@ import { zxyToTileId, } from "../src/index"; -class MockServer { - etag?: string; - numRequests: number; - lastCache?: string; +describe("pmtiles v3", () => { + test("varint", () => { + let b: BufferPosition = { + buf: new Uint8Array([0, 1, 127, 0xe5, 0x8e, 0x26]), + pos: 0, + }; + assert.strictEqual(readVarint(b), 0); + assert.strictEqual(readVarint(b), 1); + assert.strictEqual(readVarint(b), 127); + assert.strictEqual(readVarint(b), 624485); + b = { + buf: new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f]), + pos: 0, + }; + assert.strictEqual(readVarint(b), 9007199254740991); + }); - reset() { - this.numRequests = 0; - this.etag = undefined; - } + test("zxy to tile id", () => { + assert.strictEqual(zxyToTileId(0, 0, 0), 0); + assert.strictEqual(zxyToTileId(1, 0, 0), 1); + assert.strictEqual(zxyToTileId(1, 0, 1), 2); + assert.strictEqual(zxyToTileId(1, 1, 1), 3); + assert.strictEqual(zxyToTileId(1, 1, 0), 4); + assert.strictEqual(zxyToTileId(2, 0, 0), 5); + }); - constructor() { - this.numRequests = 0; - this.etag = undefined; - const serverBuffer = fs.readFileSync("test/data/test_fixture_1.pmtiles"); - const server = setupServer( - http.get( - "http://localhost:1337/example.pmtiles", - ({ request, params }) => { - this.lastCache = request.cache; - this.numRequests++; - const range = request.headers.get("range")?.substr(6).split("-"); - if (!range) { - throw new Error("invalid range"); + test("tile id to zxy", () => { + assert.deepEqual(tileIdToZxy(0), [0, 0, 0]); + assert.deepEqual(tileIdToZxy(1), [1, 0, 0]); + assert.deepEqual(tileIdToZxy(2), [1, 0, 1]); + assert.deepEqual(tileIdToZxy(3), [1, 1, 1]); + assert.deepEqual(tileIdToZxy(4), [1, 1, 0]); + assert.deepEqual(tileIdToZxy(5), [2, 0, 0]); + }); + + test("a lot of tiles", () => { + for (let z = 0; z < 9; z++) { + for (let x = 0; x < 1 << z; x++) { + for (let y = 0; y < 1 << z; y++) { + const result = tileIdToZxy(zxyToTileId(z, x, y)); + if (result[0] !== z || result[1] !== x || result[2] !== y) { + assert.fail("roundtrip failed"); } - const offset = +range[0]; - const length = +range[1]; - const body = serverBuffer.slice(offset, offset + length - 1); - return new HttpResponse(body, { - status: 206, - statusText: "OK", - headers: { etag: this.etag } as HeadersInit, - }); - } - ) - ); - server.listen({ onUnhandledRequest: "error" }); - } -} - -const mockserver = new MockServer(); - -test("varint", () => { - let b: BufferPosition = { - buf: new Uint8Array([0, 1, 127, 0xe5, 0x8e, 0x26]), - pos: 0, - }; - assert.strictEqual(readVarint(b), 0); - assert.strictEqual(readVarint(b), 1); - assert.strictEqual(readVarint(b), 127); - assert.strictEqual(readVarint(b), 624485); - b = { - buf: new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f]), - pos: 0, - }; - assert.strictEqual(readVarint(b), 9007199254740991); -}); - -test("zxy to tile id", () => { - assert.strictEqual(zxyToTileId(0, 0, 0), 0); - assert.strictEqual(zxyToTileId(1, 0, 0), 1); - assert.strictEqual(zxyToTileId(1, 0, 1), 2); - assert.strictEqual(zxyToTileId(1, 1, 1), 3); - assert.strictEqual(zxyToTileId(1, 1, 0), 4); - assert.strictEqual(zxyToTileId(2, 0, 0), 5); -}); - -test("tile id to zxy", () => { - assert.deepEqual(tileIdToZxy(0), [0, 0, 0]); - assert.deepEqual(tileIdToZxy(1), [1, 0, 0]); - assert.deepEqual(tileIdToZxy(2), [1, 0, 1]); - assert.deepEqual(tileIdToZxy(3), [1, 1, 1]); - assert.deepEqual(tileIdToZxy(4), [1, 1, 0]); - assert.deepEqual(tileIdToZxy(5), [2, 0, 0]); -}); - -test("a lot of tiles", () => { - for (let z = 0; z < 9; z++) { - for (let x = 0; x < 1 << z; x++) { - for (let y = 0; y < 1 << z; y++) { - const result = tileIdToZxy(zxyToTileId(z, x, y)); - if (result[0] !== z || result[1] !== x || result[2] !== y) { - assert.fail("roundtrip failed"); } } } - } -}); - -test("tile extremes", () => { - for (let z = 0; z < 27; z++) { - const dim = 2 ** z - 1; - const tl = tileIdToZxy(zxyToTileId(z, 0, 0)); - assert.deepEqual([z, 0, 0], tl); - const tr = tileIdToZxy(zxyToTileId(z, dim, 0)); - assert.deepEqual([z, dim, 0], tr); - const bl = tileIdToZxy(zxyToTileId(z, 0, dim)); - assert.deepEqual([z, 0, dim], bl); - const br = tileIdToZxy(zxyToTileId(z, dim, dim)); - assert.deepEqual([z, dim, dim], br); - } -}); - -test("invalid tiles", () => { - assert.throws(() => { - tileIdToZxy(Number.MAX_SAFE_INTEGER); }); - assert.throws(() => { - zxyToTileId(27, 0, 0); + test("tile extremes", () => { + for (let z = 0; z < 27; z++) { + const dim = 2 ** z - 1; + const tl = tileIdToZxy(zxyToTileId(z, 0, 0)); + assert.deepEqual([z, 0, 0], tl); + const tr = tileIdToZxy(zxyToTileId(z, dim, 0)); + assert.deepEqual([z, dim, 0], tr); + const bl = tileIdToZxy(zxyToTileId(z, 0, dim)); + assert.deepEqual([z, 0, dim], bl); + const br = tileIdToZxy(zxyToTileId(z, dim, dim)); + assert.deepEqual([z, dim, dim], br); + } }); - assert.throws(() => { - zxyToTileId(0, 1, 1); - }); -}); + test("invalid tiles", () => { + assert.throws(() => { + tileIdToZxy(Number.MAX_SAFE_INTEGER); + }); -test("tile search for missing entry", () => { - const entries: Entry[] = []; - assert.strictEqual(findTile(entries, 101), null); -}); + assert.throws(() => { + zxyToTileId(27, 0, 0); + }); -test("tile search for first entry == id", () => { - const entries: Entry[] = [ - { tileId: 100, offset: 1, length: 1, runLength: 1 }, - ]; - const entry = findTile(entries, 100); - assert.strictEqual(entry?.offset, 1); - assert.strictEqual(entry?.length, 1); - assert.strictEqual(findTile(entries, 101), null); -}); - -test("tile search with runlength", () => { - const entries: Entry[] = [ - { tileId: 3, offset: 3, length: 1, runLength: 2 }, - { tileId: 5, offset: 5, length: 1, runLength: 2 }, - ]; - const entry = findTile(entries, 4); - assert.strictEqual(entry?.offset, 3); -}); - -test("tile search with multiple tile entries", () => { - let entries: Entry[] = [{ tileId: 100, offset: 1, length: 1, runLength: 2 }]; - let entry = findTile(entries, 101); - assert.strictEqual(entry?.offset, 1); - assert.strictEqual(entry?.length, 1); - - entries = [ - { tileId: 100, offset: 1, length: 1, runLength: 1 }, - { tileId: 150, offset: 2, length: 2, runLength: 2 }, - ]; - entry = findTile(entries, 151); - assert.strictEqual(entry?.offset, 2); - assert.strictEqual(entry?.length, 2); - - entries = [ - { tileId: 50, offset: 1, length: 1, runLength: 2 }, - { tileId: 100, offset: 2, length: 2, runLength: 1 }, - { tileId: 150, offset: 3, length: 3, runLength: 1 }, - ]; - entry = findTile(entries, 51); - assert.strictEqual(entry?.offset, 1); - assert.strictEqual(entry?.length, 1); -}); - -test("leaf search", () => { - const entries: Entry[] = [ - { tileId: 100, offset: 1, length: 1, runLength: 0 }, - ]; - const entry = findTile(entries, 150); - assert.strictEqual(entry?.offset, 1); - assert.strictEqual(entry?.length, 1); -}); - -// inefficient method only for testing -class TestNodeFileSource implements Source { - buffer: ArrayBuffer; - path: string; - key: string; - etag?: string; - - constructor(path: string, key: string) { - this.path = path; - this.buffer = fs.readFileSync(path); - this.key = key; - } - - getKey() { - return this.key; - } - - replaceData(path: string) { - this.path = path; - this.buffer = fs.readFileSync(path); - } - - async getBytes(offset: number, length: number): Promise { - const slice = new Uint8Array(this.buffer.slice(offset, offset + length)) - .buffer; - return { data: slice, etag: this.etag }; - } -} - -// echo '{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}' | ./tippecanoe -zg -o test_fixture_1.pmtiles -test("cache getHeader", async () => { - const source = new TestNodeFileSource( - "test/data/test_fixture_1.pmtiles", - "1" - ); - const cache = new SharedPromiseCache(); - const header = await cache.getHeader(source); - assert.strictEqual(header.rootDirectoryOffset, 127); - assert.strictEqual(header.rootDirectoryLength, 25); - assert.strictEqual(header.jsonMetadataOffset, 152); - assert.strictEqual(header.jsonMetadataLength, 247); - assert.strictEqual(header.leafDirectoryOffset, 0); - assert.strictEqual(header.leafDirectoryLength, 0); - assert.strictEqual(header.tileDataOffset, 399); - assert.strictEqual(header.tileDataLength, 69); - assert.strictEqual(header.numAddressedTiles, 1); - assert.strictEqual(header.numTileEntries, 1); - assert.strictEqual(header.numTileContents, 1); - assert.strictEqual(header.clustered, false); - assert.strictEqual(header.internalCompression, 2); - assert.strictEqual(header.tileCompression, 2); - assert.strictEqual(header.tileType, 1); - assert.strictEqual(header.minZoom, 0); - assert.strictEqual(header.maxZoom, 0); - assert.strictEqual(header.minLon, 0); - assert.strictEqual(header.minLat, 0); - // assert.strictEqual(header.maxLon,1); // TODO fix me - assert.strictEqual(header.maxLat, 1); -}); - -test("getUint64", async () => { - const view = new DataView(new ArrayBuffer(8)); - view.setBigUint64(0, 0n, true); - assert.strictEqual(getUint64(view, 0), 0); - view.setBigUint64(0, 1n, true); - assert.strictEqual(getUint64(view, 0), 1); - view.setBigUint64(0, 9007199254740991n, true); - assert.strictEqual(getUint64(view, 0), 9007199254740991); -}); - -test("cache check against empty", async () => { - const source = new TestNodeFileSource("test/data/empty.pmtiles", "1"); - const cache = new SharedPromiseCache(); - assert.rejects(async () => { - await cache.getHeader(source); - }); -}); - -test("cache check magic number", async () => { - const source = new TestNodeFileSource("test/data/invalid.pmtiles", "1"); - const cache = new SharedPromiseCache(); - assert.rejects(async () => { - await cache.getHeader(source); - }); -}); - -test("cache check future spec version", async () => { - const source = new TestNodeFileSource("test/data/invalid_v4.pmtiles", "1"); - const cache = new SharedPromiseCache(); - assert.rejects(async () => { - await cache.getHeader(source); - }); -}); - -test("cache getDirectory", async () => { - const source = new TestNodeFileSource( - "test/data/test_fixture_1.pmtiles", - "1" - ); - - const cache = new SharedPromiseCache(6400); - const header = await cache.getHeader(source); - - // prepopulates the root directory - assert.strictEqual(cache.cache.size, 2); - - const directory = await cache.getDirectory( - source, - header.rootDirectoryOffset, - header.rootDirectoryLength, - header - ); - assert.strictEqual(directory.length, 1); - assert.strictEqual(directory[0].tileId, 0); - assert.strictEqual(directory[0].offset, 0); - assert.strictEqual(directory[0].length, 69); - assert.strictEqual(directory[0].runLength, 1); - - for (const v of cache.cache.values()) { - assert.ok(v.lastUsed > 0); - } -}); - -test("multiple sources in a single cache", async () => { - const cache = new SharedPromiseCache(); - const source1 = new TestNodeFileSource( - "test/data/test_fixture_1.pmtiles", - "1" - ); - const source2 = new TestNodeFileSource( - "test/data/test_fixture_1.pmtiles", - "2" - ); - await cache.getHeader(source1); - assert.strictEqual(cache.cache.size, 2); - await cache.getHeader(source2); - assert.strictEqual(cache.cache.size, 4); -}); - -test("etag change", async () => { - const p = new PMTiles("http://localhost:1337/example.pmtiles"); - const tile = await p.getZxy(0, 0, 0); - // header + tile - assert.strictEqual(2, mockserver.numRequests); - mockserver.etag = "etag_2"; - await p.getZxy(0, 0, 0); - // tile + header again + tile - assert.strictEqual(5, mockserver.numRequests); -}); - -test("weak etags", async () => { - mockserver.reset(); - const p = new PMTiles("http://localhost:1337/example.pmtiles"); - const tile = await p.getZxy(0, 0, 0); - // header + tile - assert.strictEqual(2, mockserver.numRequests); - mockserver.etag = "W/weak_etag"; - await p.getZxy(0, 0, 0); - assert.strictEqual(3, mockserver.numRequests); -}); - -// handle < 16384 bytes archive case -// handle DigitalOcean case returning 200 instead of 206 - -test("cache pruning by byte size", async () => { - const cache = new SharedPromiseCache(2); - cache.cache.set("0", { lastUsed: 0, data: Promise.resolve([]) }); - cache.cache.set("1", { lastUsed: 1, data: Promise.resolve([]) }); - cache.cache.set("2", { lastUsed: 2, data: Promise.resolve([]) }); - cache.prune(); - assert.strictEqual(cache.cache.size, 2); - assert.ok(cache.cache.get("2")); - assert.ok(cache.cache.get("1")); - assert.ok(!cache.cache.get("0")); -}); - -test("pmtiles get metadata", async () => { - const source = new TestNodeFileSource( - "test/data/test_fixture_1.pmtiles", - "1" - ); - const p = new PMTiles(source); - const metadata = await p.getMetadata(); - assert.ok((metadata as { name: string }).name); -}); - -// echo '{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,0],[0,0]]]}' | ./tippecanoe -zg -o test_fixture_2.pmtiles - -test("get file extension", async () => { - assert.equal("", tileTypeExt(TileType.Unknown)); - assert.equal(".mvt", tileTypeExt(TileType.Mvt)); - assert.equal(".png", tileTypeExt(TileType.Png)); - assert.equal(".jpg", tileTypeExt(TileType.Jpeg)); - assert.equal(".webp", tileTypeExt(TileType.Webp)); - assert.equal(".avif", tileTypeExt(TileType.Avif)); -}); - -interface TileJsonLike { - tilejson: string; - scheme: string; - tiles: string[]; - description?: string; - name?: string; - attribution?: string; - version?: string; -} - -test("pmtiles get TileJSON", async () => { - const source = new TestNodeFileSource( - "test/data/test_fixture_1.pmtiles", - "1" - ); - const p = new PMTiles(source); - const tilejson = (await p.getTileJson( - "https://example.com/foo" - )) as TileJsonLike; - assert.equal("3.0.0", tilejson.tilejson); - assert.equal("xyz", tilejson.scheme); - assert.equal("https://example.com/foo/{z}/{x}/{y}.mvt", tilejson.tiles[0]); - assert.equal(undefined, tilejson.attribution); - assert.equal("test_fixture_1.pmtiles", tilejson.description); - assert.equal("test_fixture_1.pmtiles", tilejson.name); - assert.equal("2", tilejson.version); -}); - -const originalUserAgent = navigator.userAgent; - -describe("user agent", async () => { - beforeEach(() => { - Object.defineProperty(globalThis.navigator, "userAgent", { - value: "Windows Chrome", - configurable: true, + assert.throws(() => { + zxyToTileId(0, 1, 1); }); }); - afterEach(() => { - Object.defineProperty(globalThis.navigator, "userAgent", { - value: originalUserAgent, - configurable: true, + test("tile search for missing entry", () => { + const entries: Entry[] = []; + assert.strictEqual(findTile(entries, 101), null); + }); + + test("tile search for first entry == id", () => { + const entries: Entry[] = [ + { tileId: 100, offset: 1, length: 1, runLength: 1 }, + ]; + const entry = findTile(entries, 100); + assert.strictEqual(entry?.offset, 1); + assert.strictEqual(entry?.length, 1); + assert.strictEqual(findTile(entries, 101), null); + }); + + test("tile search with runlength", () => { + const entries: Entry[] = [ + { tileId: 3, offset: 3, length: 1, runLength: 2 }, + { tileId: 5, offset: 5, length: 1, runLength: 2 }, + ]; + const entry = findTile(entries, 4); + assert.strictEqual(entry?.offset, 3); + }); + + test("tile search with multiple tile entries", () => { + let entries: Entry[] = [ + { tileId: 100, offset: 1, length: 1, runLength: 2 }, + ]; + let entry = findTile(entries, 101); + assert.strictEqual(entry?.offset, 1); + assert.strictEqual(entry?.length, 1); + + entries = [ + { tileId: 100, offset: 1, length: 1, runLength: 1 }, + { tileId: 150, offset: 2, length: 2, runLength: 2 }, + ]; + entry = findTile(entries, 151); + assert.strictEqual(entry?.offset, 2); + assert.strictEqual(entry?.length, 2); + + entries = [ + { tileId: 50, offset: 1, length: 1, runLength: 2 }, + { tileId: 100, offset: 2, length: 2, runLength: 1 }, + { tileId: 150, offset: 3, length: 3, runLength: 1 }, + ]; + entry = findTile(entries, 51); + assert.strictEqual(entry?.offset, 1); + assert.strictEqual(entry?.length, 1); + }); + + test("leaf search", () => { + const entries: Entry[] = [ + { tileId: 100, offset: 1, length: 1, runLength: 0 }, + ]; + const entry = findTile(entries, 150); + assert.strictEqual(entry?.offset, 1); + assert.strictEqual(entry?.length, 1); + }); + + // inefficient method only for testing + class TestNodeFileSource implements Source { + buffer: ArrayBuffer; + path: string; + key: string; + etag?: string; + + constructor(path: string, key: string) { + this.path = path; + this.buffer = fs.readFileSync(path); + this.key = key; + } + + getKey() { + return this.key; + } + + replaceData(path: string) { + this.path = path; + this.buffer = fs.readFileSync(path); + } + + async getBytes(offset: number, length: number): Promise { + const slice = new Uint8Array(this.buffer.slice(offset, offset + length)) + .buffer; + return { data: slice, etag: this.etag }; + } + } + + // echo '{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}' | ./tippecanoe -zg -o test_fixture_1.pmtiles + test("cache getHeader", async () => { + const source = new TestNodeFileSource( + "test/data/test_fixture_1.pmtiles", + "1" + ); + const cache = new SharedPromiseCache(); + const header = await cache.getHeader(source); + assert.strictEqual(header.rootDirectoryOffset, 127); + assert.strictEqual(header.rootDirectoryLength, 25); + assert.strictEqual(header.jsonMetadataOffset, 152); + assert.strictEqual(header.jsonMetadataLength, 247); + assert.strictEqual(header.leafDirectoryOffset, 0); + assert.strictEqual(header.leafDirectoryLength, 0); + assert.strictEqual(header.tileDataOffset, 399); + assert.strictEqual(header.tileDataLength, 69); + assert.strictEqual(header.numAddressedTiles, 1); + assert.strictEqual(header.numTileEntries, 1); + assert.strictEqual(header.numTileContents, 1); + assert.strictEqual(header.clustered, false); + assert.strictEqual(header.internalCompression, 2); + assert.strictEqual(header.tileCompression, 2); + assert.strictEqual(header.tileType, 1); + assert.strictEqual(header.minZoom, 0); + assert.strictEqual(header.maxZoom, 0); + assert.strictEqual(header.minLon, 0); + assert.strictEqual(header.minLat, 0); + // assert.strictEqual(header.maxLon,1); // TODO fix me + assert.strictEqual(header.maxLat, 1); + }); + + test("getUint64", async () => { + const view = new DataView(new ArrayBuffer(8)); + view.setBigUint64(0, 0n, true); + assert.strictEqual(getUint64(view, 0), 0); + view.setBigUint64(0, 1n, true); + assert.strictEqual(getUint64(view, 0), 1); + view.setBigUint64(0, 9007199254740991n, true); + assert.strictEqual(getUint64(view, 0), 9007199254740991); + }); + + test("cache check against empty", async () => { + const source = new TestNodeFileSource("test/data/empty.pmtiles", "1"); + const cache = new SharedPromiseCache(); + assert.rejects(async () => { + await cache.getHeader(source); }); }); - it("works around caching bug on chrome on windows", async () => { + test("cache check magic number", async () => { + const source = new TestNodeFileSource("test/data/invalid.pmtiles", "1"); + const cache = new SharedPromiseCache(); + assert.rejects(async () => { + await cache.getHeader(source); + }); + }); + + test("cache check future spec version", async () => { + const source = new TestNodeFileSource("test/data/invalid_v4.pmtiles", "1"); + const cache = new SharedPromiseCache(); + assert.rejects(async () => { + await cache.getHeader(source); + }); + }); + + test("cache getDirectory", async () => { + const source = new TestNodeFileSource( + "test/data/test_fixture_1.pmtiles", + "1" + ); + + const cache = new SharedPromiseCache(6400); + const header = await cache.getHeader(source); + + // prepopulates the root directory + assert.strictEqual(cache.cache.size, 2); + + const directory = await cache.getDirectory( + source, + header.rootDirectoryOffset, + header.rootDirectoryLength, + header + ); + assert.strictEqual(directory.length, 1); + assert.strictEqual(directory[0].tileId, 0); + assert.strictEqual(directory[0].offset, 0); + assert.strictEqual(directory[0].length, 69); + assert.strictEqual(directory[0].runLength, 1); + + for (const v of cache.cache.values()) { + assert.ok(v.lastUsed > 0); + } + }); + + test("multiple sources in a single cache", async () => { + const cache = new SharedPromiseCache(); + const source1 = new TestNodeFileSource( + "test/data/test_fixture_1.pmtiles", + "1" + ); + const source2 = new TestNodeFileSource( + "test/data/test_fixture_1.pmtiles", + "2" + ); + await cache.getHeader(source1); + assert.strictEqual(cache.cache.size, 2); + await cache.getHeader(source2); + assert.strictEqual(cache.cache.size, 4); + }); + + test("etag change", async () => { + mockServer.reset(); const p = new PMTiles("http://localhost:1337/example.pmtiles"); await p.getZxy(0, 0, 0); - assert.equal("no-store", mockserver.lastCache); + // header + tile + assert.strictEqual(2, mockServer.numRequests); + mockServer.etag = "etag_2"; + await p.getZxy(0, 0, 0); + // tile + header again + tile + assert.strictEqual(5, mockServer.numRequests); + }); + + test("weak etags", async () => { + mockServer.reset(); + const p = new PMTiles("http://localhost:1337/example.pmtiles"); + await p.getZxy(0, 0, 0); + // header + tile + assert.strictEqual(2, mockServer.numRequests); + mockServer.etag = "W/weak_etag"; + await p.getZxy(0, 0, 0); + assert.strictEqual(3, mockServer.numRequests); + }); + + // handle < 16384 bytes archive case + // handle DigitalOcean case returning 200 instead of 206 + + test("cache pruning by byte size", async () => { + const cache = new SharedPromiseCache(2); + cache.cache.set("0", { lastUsed: 0, data: Promise.resolve([]) }); + cache.cache.set("1", { lastUsed: 1, data: Promise.resolve([]) }); + cache.cache.set("2", { lastUsed: 2, data: Promise.resolve([]) }); + cache.prune(); + assert.strictEqual(cache.cache.size, 2); + assert.ok(cache.cache.get("2")); + assert.ok(cache.cache.get("1")); + assert.ok(!cache.cache.get("0")); + }); + + test("pmtiles get metadata", async () => { + const source = new TestNodeFileSource( + "test/data/test_fixture_1.pmtiles", + "1" + ); + const p = new PMTiles(source); + const metadata = await p.getMetadata(); + assert.ok((metadata as { name: string }).name); + }); + + // echo '{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,0],[0,0]]]}' | ./tippecanoe -zg -o test_fixture_2.pmtiles + + test("get file extension", async () => { + assert.equal("", tileTypeExt(TileType.Unknown)); + assert.equal(".mvt", tileTypeExt(TileType.Mvt)); + assert.equal(".png", tileTypeExt(TileType.Png)); + assert.equal(".jpg", tileTypeExt(TileType.Jpeg)); + assert.equal(".webp", tileTypeExt(TileType.Webp)); + assert.equal(".avif", tileTypeExt(TileType.Avif)); + }); + + interface TileJsonLike { + tilejson: string; + scheme: string; + tiles: string[]; + description?: string; + name?: string; + attribution?: string; + version?: string; + } + + test("pmtiles get TileJSON", async () => { + const source = new TestNodeFileSource( + "test/data/test_fixture_1.pmtiles", + "1" + ); + const p = new PMTiles(source); + const tilejson = (await p.getTileJson( + "https://example.com/foo" + )) as TileJsonLike; + assert.equal("3.0.0", tilejson.tilejson); + assert.equal("xyz", tilejson.scheme); + assert.equal("https://example.com/foo/{z}/{x}/{y}.mvt", tilejson.tiles[0]); + assert.equal(undefined, tilejson.attribution); + assert.equal("test_fixture_1.pmtiles", tilejson.description); + assert.equal("test_fixture_1.pmtiles", tilejson.name); + assert.equal("2", tilejson.version); + }); + + const originalUserAgent = navigator.userAgent; + + describe("user agent", async () => { + beforeEach(() => { + Object.defineProperty(global, "navigator", { + value: { userAgent: "Windows Chrome" }, + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(global, "navigator", { + value: undefined, + configurable: true, + writable: true, + }); + }); + + it("works around caching bug on chrome on windows", async () => { + const p = new PMTiles("http://localhost:1337/example.pmtiles"); + await p.getZxy(0, 0, 0); + assert.equal("no-store", mockServer.lastCache); + }); }); });