From 313cec9d0eb2e89e89fd419b14581048047b8f10 Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Wed, 11 Nov 2026 15:29:48 +0200 Subject: [PATCH 1/2] feat: tileset explorer that previews a tileset's tiles in ChatGPT Adds tileset_explorer_tool and its MCP App. The tool reads a tileset's TileJSON and returns its source layers, fields and zoom range, without the tile URLs, which carry the caller's token. The app draws every source layer in its own color, toggles layers, shows tile boundaries and a base map, and inspects a clicked feature. In ChatGPT it opens as a tab, is a thread entrypoint, shares the tileset, view and selected feature as model context, and registers load_tileset and read_tileset_view through WebMCP. --- CHANGELOG.md | 4 + README.md | 8 + src/resources/resourceRegistry.ts | 3 + .../ui-apps/TilesetExplorerUIResource.ts | 73 ++ src/resources/ui-apps/tilesetExplorerHtml.ts | 712 ++++++++++++++++++ .../TilesetExplorerTool.input.schema.ts | 25 + .../TilesetExplorerTool.output.schema.ts | 31 + .../TilesetExplorerTool.ts | 195 +++++ src/tools/toolRegistry.ts | 2 + .../ui-apps/TilesetExplorerUIResource.test.ts | 73 ++ .../tilesetExplorerHtml.script.test.ts | 543 +++++++++++++ .../TilesetExplorerTool.test.ts | 172 +++++ 12 files changed, 1841 insertions(+) create mode 100644 src/resources/ui-apps/TilesetExplorerUIResource.ts create mode 100644 src/resources/ui-apps/tilesetExplorerHtml.ts create mode 100644 src/tools/tileset-explorer-tool/TilesetExplorerTool.input.schema.ts create mode 100644 src/tools/tileset-explorer-tool/TilesetExplorerTool.output.schema.ts create mode 100644 src/tools/tileset-explorer-tool/TilesetExplorerTool.ts create mode 100644 test/resources/ui-apps/TilesetExplorerUIResource.test.ts create mode 100644 test/resources/ui-apps/tilesetExplorerHtml.script.test.ts create mode 100644 test/tools/tileset-explorer-tool/TilesetExplorerTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ffcc577..39e5e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - `render_map_tool` is now titled **Mapbox Map** (was "Render Map"), since the title is what the tab and the entrypoint show. Hosts without the extension ignore the `openai/ui` metadata and `document.modelContext`. - **Discovery:** the result of a `render_map_tool` call that opens a new map asks the model to call `read_map` once on the open map, if available, because the model only started using the map's tools after calling one. A `redraw_map` call relays only the first text of the result, so it is not asked again. - **Base map points of interest:** a click on a point of interest of the Standard base map (a shop, a station, a statue) selects it like a pin, so "what is this?" works. The base map has no `mapbox_id` for it, so the model gets its name, category and coordinates. +- **Tileset explorer: `tileset_explorer_tool`.** Previews a tileset's tiles on an interactive map (`ui://mapbox/tileset-explorer/index.html`). Takes a `tileset_id` (`username.id`; several vector tilesets joined with commas), reads its TileJSON and returns the source layers, their fields and the zoom range. + - **The map:** every source layer is drawn in its own color (polygons, lines and points) over a blank background, framed to the tileset's bounds and never below its minzoom. The panel switches layers, lists their fields, shows tile boundaries and adds a base map. A click shows a feature's layer, geometry, ID and properties. A raster tileset is one raster layer. + - **In ChatGPT:** opens as a tab next to the chat, and is a thread entrypoint (**Mapbox Tileset Explorer**) that starts on `mapbox.mapbox-streets-v8`. An open explorer shares the tileset, its hidden layers and the view as background context, and the selected feature as a titled item the user can remove. It registers two WebMCP tools: `load_tileset` (calls `tileset_explorer_tool` and shows the result in the open explorer) and `read_tileset_view` (features in view per source layer, with up to three samples each). + - **Tokens:** the TileJSON `tiles` URLs carry the caller's token, so only metadata is returned. The page loads tiles through `mapbox://` with the account's public token; without one it still lists layers and fields. - Both tools use a square Mapbox mark icon, since the sidebar would otherwise fall back to the wide server logo. `BaseTool` now passes `icons` and typed `_meta` (`ui.visibility`, `openai/ui` entrypoints and quick actions) through to `registerTool`. Hosts without the extension ignore the `openai/ui` metadata. ## 0.17.1-dev.1 - 2026-11-09 diff --git a/README.md b/README.md index 2b64d58..6ec379e 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,14 @@ export CLIENT_NEEDS_RESOURCE_FALLBACK=true ## Tools +### Tileset explorer (`tileset_explorer_tool`) + +Previews a Mapbox tileset's tiles on an interactive map. Pass a `tileset_id` (`username.id`, for example `mapbox.mapbox-streets-v8`, `mapbox.satellite` or one of your own; join several vector tilesets with commas). The tool returns the tileset's source layers, their fields and its zoom range, and the map draws every source layer in its own color over a blank background. The panel switches layers on and off, lists each layer's fields, shows tile boundaries, and puts a light or dark base map underneath. Click a feature to see its properties. Raster tilesets are drawn as they are. + +In ChatGPT the explorer opens as a tab next to the chat, and also from "+" → "More tools" → **Mapbox Tileset Explorer**, where it starts on Mapbox Streets. While open it shares the tileset, the view and the selected feature with the conversation, and in agent mode it registers two WebMCP tools, `load_tileset` and `read_tileset_view`, so the model switches tilesets and reads the features in view in the open explorer instead of opening another. + +The server reads the TileJSON with the caller's token and passes on only its metadata, never the tile URLs, which carry that token. The map loads tiles with the account's public token, so a private tileset shows only for its own account. + ### Utility Tools #### Resource Reader Tool diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 0a2e1e5..2afcb36 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -10,6 +10,7 @@ import { ToolResponseResource } from './tool-response/ToolResponseResource.js'; import { MapAppUIResource } from './ui-apps/MapAppUIResource.js'; import { StaticMapViewerUIResource } from './ui-apps/StaticMapViewerUIResource.js'; import { HomeUIResource } from './ui-apps/HomeUIResource.js'; +import { TilesetExplorerUIResource } from './ui-apps/TilesetExplorerUIResource.js'; import { VersionResource } from './version/VersionResource.js'; import { PlacePhotoResource } from './place-photo/PlacePhotoResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -28,6 +29,8 @@ export const ALL_RESOURCES = [ new StaticMapViewerUIResource(), // ChatGPT sidebar home page, targeted by mapbox_home_tool. new HomeUIResource({ httpRequest }), + // Tileset preview, targeted by tileset_explorer_tool. + new TilesetExplorerUIResource({ httpRequest }), // Photos for the map app's results carousel, which its CSP can't load directly. new PlacePhotoResource({ httpRequest }), new VersionResource() diff --git a/src/resources/ui-apps/TilesetExplorerUIResource.ts b/src/resources/ui-apps/TilesetExplorerUIResource.ts new file mode 100644 index 0000000..ae4048f --- /dev/null +++ b/src/resources/ui-apps/TilesetExplorerUIResource.ts @@ -0,0 +1,73 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { + ReadResourceResult, + ServerContext +} from '@modelcontextprotocol/server'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; +import { BaseResource } from '../BaseResource.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; +import { renderTilesetExplorerHtml } from './tilesetExplorerHtml.js'; +import { mapAppCsp } from './mapAppCsp.js'; + +export const TILESET_EXPLORER_URI = 'ui://mapbox/tileset-explorer/index.html'; + +/** UI for `tileset_explorer_tool`. */ +export class TilesetExplorerUIResource extends BaseResource { + readonly name = 'Mapbox Tileset Explorer UI'; + readonly uri = TILESET_EXPLORER_URI; + readonly description = + "Interactive preview of a Mapbox tileset's tiles, source layers and features (MCP Apps)"; + readonly mimeType = RESOURCE_MIME_TYPE; + + private readonly httpRequest: HttpRequest; + private readonly apiEndpoint: () => string; + + constructor(params: { + httpRequest: HttpRequest; + apiEndpoint?: () => string; + }) { + super(); + this.httpRequest = params.httpRequest; + this.apiEndpoint = + params.apiEndpoint ?? + (() => process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'); + } + + async read(_uri: string, extra?: ServerContext): Promise { + const accessToken = + (extra?.http?.authInfo?.token as string | undefined) || + process.env.MAPBOX_ACCESS_TOKEN || + ''; + + const publicToken = accessToken + ? await resolveMapboxPublicToken({ + accessToken, + apiEndpoint: this.apiEndpoint(), + httpRequest: this.httpRequest + }) + : undefined; + + return { + contents: [ + { + uri: this.uri, + mimeType: RESOURCE_MIME_TYPE, + text: renderTilesetExplorerHtml({ publicToken: publicToken ?? '' }), + _meta: { + ui: { + csp: mapAppCsp(this.apiEndpoint()), + preferredSize: { width: 1000, height: 600 } + }, + 'openai/ui': { + preferredDisplayMode: 'fullscreen', + availableDisplayModes: ['inline', 'fullscreen'] + } + } + } + ] + }; + } +} diff --git a/src/resources/ui-apps/tilesetExplorerHtml.ts b/src/resources/ui-apps/tilesetExplorerHtml.ts new file mode 100644 index 0000000..18b27c7 --- /dev/null +++ b/src/resources/ui-apps/tilesetExplorerHtml.ts @@ -0,0 +1,712 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { MAPBOX_GL_VERSION } from './mapAppHtml.js'; + +/** + * HTML for the tileset explorer (`tileset_explorer_tool`): a GL JS map that + * draws every source layer of a tileset in its own color over a blank + * background, with a panel to switch layers, read their fields and inspect a + * clicked feature. Tiles load through `mapbox://` with the public token. + * + * While open next to a chat it shares the tileset, the view and the selected + * feature as model context, and registers WebMCP tools (`load_tileset`, + * `read_tileset_view`) so the model works on this explorer instead of opening + * another. Tileset data only goes in through textContent. + */ +export function renderTilesetExplorerHtml(params: { + publicToken: string; + glVersion?: string; +}): string { + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + // Safe inside " can survive the escaping. + const json = (value: unknown) => + JSON.stringify(value).replace(/ + + + + +Mapbox Tileset Explorer + + + +
+
+
+ + +
+ +

+
Loading the tileset…
+
+ + +
+ +

Feature

+
Click a feature to inspect it.
+
+ + + + +`; +} diff --git a/src/tools/tileset-explorer-tool/TilesetExplorerTool.input.schema.ts b/src/tools/tileset-explorer-tool/TilesetExplorerTool.input.schema.ts new file mode 100644 index 0000000..a55a5bd --- /dev/null +++ b/src/tools/tileset-explorer-tool/TilesetExplorerTool.input.schema.ts @@ -0,0 +1,25 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +/** `username.id`, or several joined with commas (a composite source). */ +export const TILESET_ID_PATTERN = + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(,[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)*$/; + +export const DEFAULT_TILESET_ID = 'mapbox.mapbox-streets-v8'; + +export const TilesetExplorerInputSchema = z.object({ + tileset_id: z + .string() + .trim() + .max(500) + .regex( + TILESET_ID_PATTERN, + 'Expected username.id, for example mapbox.mapbox-streets-v8' + ) + .optional() + .describe( + `Tileset ID as username.id, for example mapbox.mapbox-streets-v8 or mapbox.satellite. Join several vector tilesets with commas to see them together. Defaults to ${DEFAULT_TILESET_ID}.` + ) +}); diff --git a/src/tools/tileset-explorer-tool/TilesetExplorerTool.output.schema.ts b/src/tools/tileset-explorer-tool/TilesetExplorerTool.output.schema.ts new file mode 100644 index 0000000..1a036ba --- /dev/null +++ b/src/tools/tileset-explorer-tool/TilesetExplorerTool.output.schema.ts @@ -0,0 +1,31 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const TilesetLayerSchema = z.object({ + id: z.string(), + description: z.string().optional(), + minzoom: z.number().optional(), + maxzoom: z.number().optional(), + /** Field name to its type or description, as TileJSON reports it. */ + fields: z.record(z.string(), z.string()) +}); + +export const TilesetExplorerOutputSchema = z.object({ + tilesetExplorer: z.literal(true), + tileset_id: z.string(), + name: z.string().optional(), + type: z.enum(['vector', 'raster']), + /** [west, south, east, north] */ + bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), + /** [longitude, latitude, zoom] */ + center: z.tuple([z.number(), z.number(), z.number()]).optional(), + minzoom: z.number().optional(), + maxzoom: z.number().optional(), + /** Source layers; empty for a raster tileset. */ + layers: z.array(TilesetLayerSchema) +}); + +export type TilesetLayer = z.infer; +export type TilesetExplorerOutput = z.infer; diff --git a/src/tools/tileset-explorer-tool/TilesetExplorerTool.ts b/src/tools/tileset-explorer-tool/TilesetExplorerTool.ts new file mode 100644 index 0000000..5504df6 --- /dev/null +++ b/src/tools/tileset-explorer-tool/TilesetExplorerTool.ts @@ -0,0 +1,195 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult, Icon } from '@modelcontextprotocol/server'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { ToolMeta } from '../BaseTool.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { TILESET_EXPLORER_URI } from '../../resources/ui-apps/TilesetExplorerUIResource.js'; +import { mapAppCsp } from '../../resources/ui-apps/mapAppCsp.js'; +import { mapboxMarkIcon } from '../shared/mapboxMarkIcon.js'; +import { + DEFAULT_TILESET_ID, + TilesetExplorerInputSchema +} from './TilesetExplorerTool.input.schema.js'; +import { + TilesetExplorerOutputSchema, + type TilesetExplorerOutput, + type TilesetLayer +} from './TilesetExplorerTool.output.schema.js'; + +const MAX_LAYERS = 100; +const MAX_FIELDS = 60; +/** Fields named per layer in the text result; the rest are counted. */ +const TEXT_FIELDS = 12; + +function num(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} + +function numbers( + value: unknown, + length: N +): number[] | undefined { + return Array.isArray(value) && + value.length === length && + value.every((n) => num(n) !== undefined) + ? (value as number[]) + : undefined; +} + +/** + * `tileset_explorer_tool` — preview a Mapbox tileset's tiles on an + * interactive map: every source layer in its own color, with its fields, and + * a click to inspect a feature. + * + * Reads the tileset's TileJSON with the caller's token and returns only its + * metadata. The TileJSON `tiles` URLs carry that token, so they are never + * passed on; the page loads tiles through `mapbox://` with a public token. + * Called with `{}`, as a ChatGPT entrypoint does, it opens Mapbox Streets. + */ +export class TilesetExplorerTool extends MapboxApiBasedTool< + typeof TilesetExplorerInputSchema, + typeof TilesetExplorerOutputSchema +> { + readonly name = 'tileset_explorer_tool'; + readonly description = + "Open the Mapbox tileset explorer: preview a tileset's tiles on an interactive map, with each source layer in its own color, and list its layers, fields and zoom range. Use it to look inside a vector (MVT) or raster tileset, for example mapbox.mapbox-streets-v8 or one of the user's own. If a tileset explorer is already open and offers its own load_tileset tool, call that instead: this tool opens another explorer."; + readonly annotations = { + title: 'Mapbox Tileset Explorer', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + readonly icons: Icon[] = [mapboxMarkIcon]; + readonly meta: ToolMeta; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: TilesetExplorerInputSchema, + outputSchema: TilesetExplorerOutputSchema, + httpRequest: params.httpRequest + }); + this.meta = { + ui: { + resourceUri: TILESET_EXPLORER_URI, + csp: mapAppCsp(MapboxApiBasedTool.mapboxApiEndpoint) + }, + 'openai/ui': { + entrypoints: [{ type: 'thread' }], + preferredModelDisplayMode: 'fullscreen' + } + }; + } + + protected async execute( + input: { tileset_id?: string }, + accessToken: string + ): Promise { + const tilesetId = input.tileset_id || DEFAULT_TILESET_ID; + const url = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}v4/${tilesetId}.json` + ); + url.searchParams.set('secure', ''); + url.searchParams.set('access_token', accessToken); + + const response = await this.httpRequest(url.toString()); + if (!response.ok) { + const message = await this.getErrorMessage(response); + const hint = + response.status === 404 + ? ' Check the tileset ID (username.id); a private tileset needs a token from its own account.' + : ''; + return { + content: [ + { + type: 'text', + text: `Couldn't load tileset ${tilesetId}: ${message}.${hint}` + } + ], + isError: true + }; + } + + const output = toOutput( + tilesetId, + (await response.json()) as Record + ); + return { + content: [{ type: 'text', text: describe(output) }], + structuredContent: output, + isError: false + }; + } +} + +function toLayer(raw: unknown): TilesetLayer | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const layer = raw as Record; + if (typeof layer.id !== 'string' || !layer.id) return undefined; + const fields: Record = {}; + if (layer.fields && typeof layer.fields === 'object') { + for (const [name, value] of Object.entries(layer.fields).slice( + 0, + MAX_FIELDS + )) { + fields[name] = typeof value === 'string' ? value : ''; + } + } + return { + id: layer.id, + description: + typeof layer.description === 'string' && layer.description + ? layer.description + : undefined, + minzoom: num(layer.minzoom), + maxzoom: num(layer.maxzoom), + fields + }; +} + +function toOutput( + tilesetId: string, + tileJson: Record +): TilesetExplorerOutput { + const vectorLayers = Array.isArray(tileJson.vector_layers) + ? tileJson.vector_layers + : undefined; + return { + tilesetExplorer: true, + tileset_id: tilesetId, + name: + typeof tileJson.name === 'string' && tileJson.name + ? tileJson.name + : undefined, + type: vectorLayers ? 'vector' : 'raster', + bounds: numbers(tileJson.bounds, 4) as TilesetExplorerOutput['bounds'], + center: numbers(tileJson.center, 3) as TilesetExplorerOutput['center'], + minzoom: num(tileJson.minzoom), + maxzoom: num(tileJson.maxzoom), + layers: (vectorLayers ?? []) + .map(toLayer) + .filter((layer): layer is TilesetLayer => layer !== undefined) + .slice(0, MAX_LAYERS) + }; +} + +function describe(output: TilesetExplorerOutput): string { + const zooms = + output.minzoom !== undefined && output.maxzoom !== undefined + ? `, zoom ${output.minzoom} to ${output.maxzoom}` + : ''; + const head = `Tileset ${output.tileset_id}${output.name ? ` (${output.name})` : ''}: ${output.type}${zooms}. It is open in the tileset explorer.`; + if (output.type === 'raster') return head; + const layers = output.layers.map((layer) => { + const names = Object.keys(layer.fields); + const shown = names.slice(0, TEXT_FIELDS).join(', '); + const more = + names.length > TEXT_FIELDS ? `, +${names.length - TEXT_FIELDS} more` : ''; + return `- ${layer.id}${names.length ? `: ${shown}${more}` : ''}`; + }); + return `${head}\n${output.layers.length} source layer${output.layers.length === 1 ? '' : 's'} and their fields:\n${layers.join('\n')}`; +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index ce593df..aa538b3 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -32,6 +32,7 @@ import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; import { MatrixTool } from './matrix-tool/MatrixTool.js'; import { OptimizationTool } from './optimization-tool/OptimizationTool.js'; import { RenderMapTool } from './render-map-tool/RenderMapTool.js'; +import { TilesetExplorerTool } from './tileset-explorer-tool/TilesetExplorerTool.js'; import { ResourceReaderTool } from './resource-reader-tool/ResourceReaderTool.js'; import { ReverseGeocodeTool } from './reverse-geocode-tool/ReverseGeocodeTool.js'; import { StaticMapImageTool } from './static-map-image-tool/StaticMapImageTool.js'; @@ -73,6 +74,7 @@ export const CORE_TOOLS = [ new OptimizationTool({ httpRequest }), new RenderMapTool({ httpRequest }), new MapboxHomeTool({ httpRequest }), + new TilesetExplorerTool({ httpRequest }), new ReverseGeocodeTool({ httpRequest }), new StaticMapImageTool({ httpRequest }), new SearchAndGeocodeTool({ httpRequest }) diff --git a/test/resources/ui-apps/TilesetExplorerUIResource.test.ts b/test/resources/ui-apps/TilesetExplorerUIResource.test.ts new file mode 100644 index 0000000..7cd2118 --- /dev/null +++ b/test/resources/ui-apps/TilesetExplorerUIResource.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +// JWT with payload {"sub":"test","u":"testuser"} (base64) +const SK_TOKEN = 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + TilesetExplorerUIResource, + TILESET_EXPLORER_URI +} from '../../../src/resources/ui-apps/TilesetExplorerUIResource.js'; +import { __resetMapboxPublicTokenCache } from '../../../src/utils/mapboxPublicToken.js'; +import { resourceTextOf } from '../../utils/resultTestUtils.js'; +import type { HttpRequest } from '../../../src/utils/types.js'; + +const authed = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + http: { authInfo: { token: SK_TOKEN } as any } + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +describe('TilesetExplorerUIResource', () => { + beforeEach(() => { + __resetMapboxPublicTokenCache(); + delete process.env.MAPBOX_PUBLIC_TOKEN; + }); + + it('serves the explorer with the public token and opens it as a tab', async () => { + const httpRequest = vi.fn( + async () => + ({ + ok: true, + status: 200, + json: async () => [ + { + usage: 'pk', + default: true, + token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.pub' + } + ] + }) as unknown as Response + ); + + const result = await new TilesetExplorerUIResource({ httpRequest }).read( + TILESET_EXPLORER_URI, + authed + ); + + const entry = result.contents[0]; + expect(entry.uri).toBe(TILESET_EXPLORER_URI); + expect(entry.mimeType).toBe('text/html;profile=mcp-app'); + const html = resourceTextOf(result); + expect(html).toContain('Mapbox Tileset Explorer'); + expect(html).toContain('"pk.eyJ1IjoidGVzdHVzZXIifQ.pub"'); + expect(html).not.toContain(SK_TOKEN); + expect(entry._meta).toMatchObject({ + ui: { + csp: { + connectDomains: [ + 'https://api.mapbox.com', + 'https://events.mapbox.com' + ], + resourceDomains: ['https://api.mapbox.com'], + workerDomains: ['blob:'] + } + }, + 'openai/ui': { + preferredDisplayMode: 'fullscreen', + availableDisplayModes: ['inline', 'fullscreen'] + } + }); + }); +}); diff --git a/test/resources/ui-apps/tilesetExplorerHtml.script.test.ts b/test/resources/ui-apps/tilesetExplorerHtml.script.test.ts new file mode 100644 index 0000000..fb22305 --- /dev/null +++ b/test/resources/ui-apps/tilesetExplorerHtml.script.test.ts @@ -0,0 +1,543 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import * as vm from 'node:vm'; +import { renderTilesetExplorerHtml } from '../../../src/resources/ui-apps/tilesetExplorerHtml.js'; + +type Listener = (event?: unknown) => void; +type FakeElement = { + id: string; + hidden: boolean; + className: string; + children: FakeElement[]; + style: Record; + textContent: string; + value: string; + checked: boolean; + appendChild: (child: FakeElement) => FakeElement; + addEventListener: (type: string, fn: Listener) => void; + fire: (type: string) => void; + [key: string]: unknown; +}; + +function fakeElement(id = ''): FakeElement { + const listeners: Record = {}; + const attributes: Record = {}; + let text = ''; + const el: FakeElement = { + id, + hidden: false, + className: '', + children: [], + style: { setProperty: () => {} }, + value: '', + checked: false, + get textContent() { + return text + el.children.map((c) => c.textContent).join(''); + }, + set textContent(value: string) { + text = value; + el.children = []; + }, + appendChild: (child) => { + el.children.push(child); + return child; + }, + addEventListener: (type, fn) => { + (listeners[type] ??= []).push(fn); + }, + fire: (type) => listeners[type]?.forEach((fn) => fn({})), + setAttribute: (name: string, value: string) => { + attributes[name] = value; + }, + getAttribute: (name: string) => attributes[name] ?? null + }; + return el; +} + +const STATIC_IDS = [ + 'map', + 'panel', + 'load-form', + 'tileset-input', + 'load-button', + 'error', + 'tileset-name', + 'tileset-meta', + 'opt-boundaries', + 'opt-basemap', + 'layers-section', + 'layers-title', + 'layers-all', + 'layers-none', + 'layers', + 'feature', + 'zoom' +]; + +type LayerSpec = { + id: string; + type: string; + 'source-layer'?: string; + layout?: { visibility?: string }; +}; + +/** The parts of a GL JS map the explorer uses. */ +class FakeMap { + static last: FakeMap | undefined; + sources: Record> = {}; + layers: LayerSpec[] = []; + handlers: Record void>> = {}; + zoom: number; + rendered: unknown[] = []; + fitted: unknown; + showTileBoundaries = false; + styles: unknown[] = []; + constructor(options: { zoom: number; style: unknown }) { + this.zoom = options.zoom; + this.styles.push(options.style); + FakeMap.last = this; + } + addControl() {} + on(type: string, fn: (e?: unknown) => void) { + (this.handlers[type] ??= []).push(fn); + } + emit(type: string, event?: unknown) { + this.handlers[type]?.forEach((fn) => fn(event)); + } + addSource(id: string, source: Record) { + this.sources[id] = source; + } + getSource(id: string) { + return this.sources[id]; + } + removeSource(id: string) { + delete this.sources[id]; + } + addLayer(layer: LayerSpec) { + this.layers.push(layer); + } + getLayer(id: string) { + return this.layers.find((l) => l.id === id); + } + removeLayer(id: string) { + this.layers = this.layers.filter((l) => l.id !== id); + } + setLayoutProperty(id: string, name: string, value: string) { + const layer = this.getLayer(id)!; + layer.layout = { ...layer.layout, [name]: value }; + } + setStyle(style: unknown) { + this.styles.push(style); + this.sources = {}; + this.layers = []; + } + jumpTo(camera: { zoom: number }) { + this.zoom = camera.zoom; + } + fitBounds(bounds: unknown) { + this.fitted = bounds; + this.zoom = 3; + } + setZoom(zoom: number) { + this.zoom = zoom; + } + getZoom() { + return this.zoom; + } + getCenter() { + return { lng: 24.9, lat: 60.2 }; + } + getBounds() { + return { + getWest: () => 24, + getSouth: () => 60, + getEast: () => 25, + getNorth: () => 61 + }; + } + resize() {} + queryRenderedFeatures() { + return this.rendered; + } +} + +class FakePopup { + setLngLat() { + return this; + } + setText() { + return this; + } + addTo() { + return this; + } + remove() {} +} + +type WebMcpTool = { + name: string; + execute: (input?: unknown) => unknown; +}; + +function loadExplorer( + options: { + publicToken?: string; + hostContext?: Record; + hostCapabilities?: Record; + webMcp?: boolean; + } = {} +) { + const html = renderTilesetExplorerHtml({ + publicToken: options.publicToken ?? 'pk.test' + }); + const source = html.match(/