@aryanduntley/pwa-debug 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,8 @@ The goal is to eliminate the "user is the AI's eyes and hands" loop. Today, debu
29
29
  > - **PWA Runtime Diagnostics** — service-worker lifecycle + versions, CacheStorage contents + age, installability gaps, a live capability matrix, IndexedDB/web-storage inspection, update-propagation / version-skew analysis, and a one-shot runtime-state snapshot.
30
30
  >
31
31
  > **Verified on Linux** (the full suite live-tested against a real PWA). macOS/Windows code paths are implemented with unit coverage but still need real-machine retest ([help wanted](#help-wanted-macos--windows-verification)). Firefox is not supported (it doesn't speak CDP).
32
+ >
33
+ > ⚠️ **macOS / Windows users, beware:** these platforms have **never been run on real hardware** — only unit-tested with injected fakes. Expect rough edges (browser detection, profile paths, native-messaging registration, system-default resolution). Please [open an issue](../../issues) with the failing command and its output — bug reports from real macOS/Windows machines are the single most useful contribution right now. See [Help wanted](#help-wanted-macos--windows-verification) for the specific things to try.
32
34
 
33
35
  ## How it differs from `chrome-devtools-mcp`
34
36
 
package/dist/main.js CHANGED
@@ -12797,12 +12797,18 @@ const browserUrlFor = (port) => `http://127.0.0.1:${port}`;
12797
12797
  * Chromium flags for a fresh launch — shared by the exec-by-path and flatpak
12798
12798
  * builders so the two command forms differ ONLY in the command prefix, never in
12799
12799
  * the flag set. --no-first-run / --no-default-browser-check keep it non-interactive.
12800
+ *
12801
+ * `extraArgs` are caller-supplied Chromium startup flags (e.g.
12802
+ * --enable-speech-dispatcher) appended AFTER the managed flags so the caller can
12803
+ * override a non-essential default; they only matter on a cold spawn (this path),
12804
+ * never on an attach.
12800
12805
  */
12801
- const freshFlags = (port, userDataDir) => Object.freeze([
12806
+ const freshFlags = (port, userDataDir, extraArgs = []) => Object.freeze([
12802
12807
  `--remote-debugging-port=${port}`,
12803
12808
  `--user-data-dir=${userDataDir}`,
12804
12809
  '--no-first-run',
12805
12810
  '--no-default-browser-check',
12811
+ ...extraArgs,
12806
12812
  ]);
12807
12813
  /**
12808
12814
  * Chromium flags for a sandbox launch (dedicated profile + preloaded extension).
@@ -12826,7 +12832,7 @@ const freshFlags = (port, userDataDir) => Object.freeze([
12826
12832
  * preloads pwa-debug, while the profile's other extensions stay enabled. No-op
12827
12833
  * under 'manual-guided' (the flag is already omitted there).
12828
12834
  */
12829
- const sandboxFlags = (port, userDataDir, extensionPath, strategy, isolate) => {
12835
+ const sandboxFlags = (port, userDataDir, extensionPath, strategy, isolate, extraArgs = []) => {
12830
12836
  const extensionFlags = strategy === 'manual-guided'
12831
12837
  ? []
12832
12838
  : [
@@ -12844,6 +12850,7 @@ const sandboxFlags = (port, userDataDir, extensionPath, strategy, isolate) => {
12844
12850
  '--no-default-browser-check',
12845
12851
  '--disable-session-crashed-bubble',
12846
12852
  '--hide-crash-restore-bubble',
12853
+ ...extraArgs,
12847
12854
  ]);
12848
12855
  };
12849
12856
  /**
@@ -12865,9 +12872,12 @@ const flatpakRun = (appId, browserFlags) => Object.freeze({
12865
12872
  /**
12866
12873
  * Fresh launch (sub-state c): bring up the debug port on the user's profile.
12867
12874
  */
12868
- const buildFreshSpawnArgs = (execPath, port, userDataDir) => Object.freeze({ cmd: execPath, args: freshFlags(port, userDataDir) });
12875
+ const buildFreshSpawnArgs = (execPath, port, userDataDir, extraArgs = []) => Object.freeze({
12876
+ cmd: execPath,
12877
+ args: freshFlags(port, userDataDir, extraArgs),
12878
+ });
12869
12879
  /** Fresh launch for a flatpak browser: `flatpak run <app-id> <fresh flags>`. */
12870
- const buildFreshFlatpakArgs = (appId, port, userDataDir) => flatpakRun(appId, freshFlags(port, userDataDir));
12880
+ const buildFreshFlatpakArgs = (appId, port, userDataDir, extraArgs = []) => flatpakRun(appId, freshFlags(port, userDataDir, extraArgs));
12871
12881
  /**
12872
12882
  * New-window launch (sub-state b): re-invoke the binary so it opens a window
12873
12883
  * in the already-running session via IPC. No debug port — that requires a full
@@ -12891,9 +12901,9 @@ const buildNewWindowFlatpakArgs = (appId) => flatpakRun(appId, Object.freeze(['-
12891
12901
  * Applied to sandbox modes only — an 'existing'-mode launch is the user's real
12892
12902
  * profile, where a genuine restore prompt should be left intact.
12893
12903
  */
12894
- const buildSandboxSpawnArgs = (execPath, port, userDataDir, extensionPath, strategy, isolate = true) => Object.freeze({
12904
+ const buildSandboxSpawnArgs = (execPath, port, userDataDir, extensionPath, strategy, isolate = true, extraArgs = []) => Object.freeze({
12895
12905
  cmd: execPath,
12896
- args: sandboxFlags(port, userDataDir, extensionPath, strategy, isolate),
12906
+ args: sandboxFlags(port, userDataDir, extensionPath, strategy, isolate, extraArgs),
12897
12907
  });
12898
12908
  /**
12899
12909
  * Sandbox launch for a flatpak browser: `flatpak run <app-id> <sandbox flags>`.
@@ -12902,7 +12912,7 @@ const buildSandboxSpawnArgs = (execPath, port, userDataDir, extensionPath, strat
12902
12912
  * (`flatpak override --user --filesystem=host <app-id>`) for these to resolve
12903
12913
  * inside the sandbox — the same prerequisite the NMH path documents.
12904
12914
  */
12905
- const buildSandboxFlatpakArgs = (appId, port, userDataDir, extensionPath, strategy, isolate = true) => flatpakRun(appId, sandboxFlags(port, userDataDir, extensionPath, strategy, isolate));
12915
+ const buildSandboxFlatpakArgs = (appId, port, userDataDir, extensionPath, strategy, isolate = true, extraArgs = []) => flatpakRun(appId, sandboxFlags(port, userDataDir, extensionPath, strategy, isolate, extraArgs));
12906
12916
 
12907
12917
  /**
12908
12918
  * 'existing'-mode launch: the graceful-degradation triad orchestrated over the
@@ -12948,9 +12958,10 @@ const launchExisting = async (input, deps) => {
12948
12958
  });
12949
12959
  }
12950
12960
  // spawn-fresh
12961
+ const extraArgs = input.extraArgs ?? [];
12951
12962
  const { cmd, args } = input.appId
12952
- ? buildFreshFlatpakArgs(input.appId, input.port, input.userDataDir)
12953
- : buildFreshSpawnArgs(input.execPath, input.port, input.userDataDir);
12963
+ ? buildFreshFlatpakArgs(input.appId, input.port, input.userDataDir, extraArgs)
12964
+ : buildFreshSpawnArgs(input.execPath, input.port, input.userDataDir, extraArgs);
12954
12965
  const { pid } = await deps.spawnBrowser(cmd, args);
12955
12966
  // Chromium 136+ ignores --remote-debugging-port on the default profile: the
12956
12967
  // browser comes up (pwa-debug extension still usable) but the port never
@@ -13012,9 +13023,10 @@ const launchSandbox = async (input, deps) => {
13012
13023
  await deps.seedDeveloperMode(input.userDataDir);
13013
13024
  // Default to isolation (clean-room) when unset; false lets other extensions coexist.
13014
13025
  const isolate = input.isolateExtensions ?? true;
13026
+ const extraArgs = input.extraArgs ?? [];
13015
13027
  const { cmd, args } = input.appId
13016
- ? buildSandboxFlatpakArgs(input.appId, input.port, input.userDataDir, input.extensionPath, input.loadStrategy, isolate)
13017
- : buildSandboxSpawnArgs(input.execPath, input.port, input.userDataDir, input.extensionPath, input.loadStrategy, isolate);
13028
+ ? buildSandboxFlatpakArgs(input.appId, input.port, input.userDataDir, input.extensionPath, input.loadStrategy, isolate, extraArgs)
13029
+ : buildSandboxSpawnArgs(input.execPath, input.port, input.userDataDir, input.extensionPath, input.loadStrategy, isolate, extraArgs);
13018
13030
  const { pid } = await deps.spawnBrowser(cmd, args);
13019
13031
  if (input.mode === 'sandbox-temp') {
13020
13032
  deps.registerTempProfile(input.userDataDir);
@@ -14020,6 +14032,7 @@ const inputSchema$5 = {
14020
14032
  mode: enumType(MODES).optional(),
14021
14033
  packaging: enumType(PACKAGINGS).optional(),
14022
14034
  isolateExtensions: booleanType().optional(),
14035
+ extraArgs: arrayType(stringType()).optional(),
14023
14036
  };
14024
14037
  const isSandboxMode = (mode) => mode === 'sandbox-persistent' || mode === 'sandbox-temp';
14025
14038
  /**
@@ -14214,6 +14227,7 @@ const launchBrowserCore = async (args, platform, env, deps) => {
14214
14227
  ...(args.isolateExtensions !== undefined
14215
14228
  ? { isolateExtensions: args.isolateExtensions }
14216
14229
  : {}),
14230
+ ...(args.extraArgs !== undefined ? { extraArgs: args.extraArgs } : {}),
14217
14231
  ...(target.appId !== undefined ? { appId: target.appId } : {}),
14218
14232
  ...(snapPkg ? { snapPackage: snapPkg } : {}),
14219
14233
  });
@@ -14238,6 +14252,7 @@ const launchBrowserCore = async (args, platform, env, deps) => {
14238
14252
  userDataDir,
14239
14253
  debugPortBlockedOnDefaultProfile: portBlocked,
14240
14254
  ...(target.appId !== undefined ? { appId: target.appId } : {}),
14255
+ ...(args.extraArgs !== undefined ? { extraArgs: args.extraArgs } : {}),
14241
14256
  });
14242
14257
  deps.recordLaunch(result, port);
14243
14258
  return launchOk(result, target, alternatives, args.packaging !== undefined);
@@ -14264,7 +14279,7 @@ const launchBrowserHandler = async (args, ctx) => launchBrowserCore(args, proces
14264
14279
  });
14265
14280
  const launchBrowserTool = Object.freeze({
14266
14281
  name: 'pdl_launch_browser',
14267
- description: "Launch or attach to a Chromium-family browser with a live remote-debugging port, for use alongside chrome-devtools-mcp. Modes: mode='existing' (default) targets the user's normal profile and degrades gracefully — (a) port already live → attach; (b) running without a debug port → opens a NEW WINDOW in the existing session (never kills it), attached:false + degradation message; (c) not running → spawns fresh with --remote-debugging-port + --user-data-dir=<your profile>. mode='sandbox-persistent' spawns a dedicated, persistent dev profile at ~/.pwa-debug/profiles/<browser>/ beside your normal browser, with the pwa-debug extension PRELOADED (no reload needed); mode='sandbox-temp' is the same but in a throwaway mkdtemp profile cleaned up on host shutdown. Sandbox modes always work standalone (separate profile → no lock collision) and both pwa-debug + CDP tools are available. Args: browser? (chrome|chromium|edge|brave|vivaldi|opera; defaults to system-default), port? (default 9222), mode?, packaging? (native|snap|flatpak). When the same browser is installed under multiple packagings (e.g. snap AND flatpak chromium), pass packaging to pick one; without it the default preference is native > snap > flatpak and next_steps lists the alternatives so you can re-target. isolateExtensions? (sandbox modes only, default true): true pins the dedicated profile to ONLY the pwa-debug extension (clean room — every other extension is disabled); pass false to let other extensions coexist (pwa-debug still preloads, while extensions already in the persistent profile or Load-unpacked/installed after launch stay enabled) — use this to debug a PWA alongside other extensions or to test your own extension with pwa-debug. existing mode already keeps all your normal-profile extensions. Linux is first-class; macOS/Windows deferred. Follow next_steps[] — it carries the chrome-devtools-mcp registration snippet, the profile location, the flatpak onboarding steps, or the degradation guidance.",
14282
+ description: "Launch or attach to a Chromium-family browser with a live remote-debugging port, for use alongside chrome-devtools-mcp. Modes: mode='existing' (default) targets the user's normal profile and degrades gracefully — (a) port already live → attach; (b) running without a debug port → opens a NEW WINDOW in the existing session (never kills it), attached:false + degradation message; (c) not running → spawns fresh with --remote-debugging-port + --user-data-dir=<your profile>. mode='sandbox-persistent' spawns a dedicated, persistent dev profile at ~/.pwa-debug/profiles/<browser>/ beside your normal browser, with the pwa-debug extension PRELOADED (no reload needed); mode='sandbox-temp' is the same but in a throwaway mkdtemp profile cleaned up on host shutdown. Sandbox modes always work standalone (separate profile → no lock collision) and both pwa-debug + CDP tools are available. Args: browser? (chrome|chromium|edge|brave|vivaldi|opera; defaults to system-default), port? (default 9222), mode?, packaging? (native|snap|flatpak). When the same browser is installed under multiple packagings (e.g. snap AND flatpak chromium), pass packaging to pick one; without it the default preference is native > snap > flatpak and next_steps lists the alternatives so you can re-target. isolateExtensions? (sandbox modes only, default true): true pins the dedicated profile to ONLY the pwa-debug extension (clean room — every other extension is disabled); pass false to let other extensions coexist (pwa-debug still preloads, while extensions already in the persistent profile or Load-unpacked/installed after launch stay enabled) — use this to debug a PWA alongside other extensions or to test your own extension with pwa-debug. existing mode already keeps all your normal-profile extensions. extraArgs? (string[]): extra Chromium startup flags appended after pwa-debug's managed flags — e.g. extraArgs=['--enable-speech-dispatcher'] to enable system TTS/speech-dispatcher, or any other --flag the browser needs at startup. Applied only on a COLD spawn (mode='existing' when the browser isn't already running, or either sandbox mode); they have no effect when attaching to an already-live port or opening a new window in a running session, since startup flags are read once at process start — fully quit the browser (or use a sandbox mode) for them to take effect. Linux is first-class; macOS/Windows deferred. Follow next_steps[] — it carries the chrome-devtools-mcp registration snippet, the profile location, the flatpak onboarding steps, or the degradation guidance.",
14268
14283
  inputSchema: inputSchema$5,
14269
14284
  handler: launchBrowserHandler,
14270
14285
  });
@@ -14856,6 +14871,90 @@ const registerChromeDevtoolsTool = Object.freeze({
14856
14871
  handler: registerChromeDevtoolsHandler,
14857
14872
  });
14858
14873
 
14874
+ // The SW waits up to timeout_ms (capped below) for the page to finish loading
14875
+ // before replying, so the IPC budget must comfortably exceed it.
14876
+ const NAV_MAX_LOAD_TIMEOUT_MS = 12_000;
14877
+ const NAV_IPC_TIMEOUT_MS = NAV_MAX_LOAD_TIMEOUT_MS + 3_000;
14878
+ const navigateInputSchema = {
14879
+ extension_id: stringType().min(1).optional(),
14880
+ tab_id: numberType().int().optional(),
14881
+ url: stringType().min(1),
14882
+ timeout_ms: numberType().int().positive().max(NAV_MAX_LOAD_TIMEOUT_MS).optional(),
14883
+ };
14884
+ const newTabInputSchema = {
14885
+ extension_id: stringType().min(1).optional(),
14886
+ url: stringType().min(1),
14887
+ active: booleanType().optional(),
14888
+ timeout_ms: numberType().int().positive().max(NAV_MAX_LOAD_TIMEOUT_MS).optional(),
14889
+ };
14890
+ /**
14891
+ * Shared IPC round-trip for the navigation tools: resolve the target NMH, send a
14892
+ * single request envelope to the SW, and normalize transport vs. handler errors.
14893
+ * The SW handler (request_router) drives chrome.tabs and waits for load-complete.
14894
+ */
14895
+ const sendNavRequest = async (ctx, extensionId, tool, payload, nextSteps) => {
14896
+ const target = resolveTarget$1(ctx, extensionId);
14897
+ if (!target.ok) {
14898
+ return errorResponse(target.error, [
14899
+ 'Call host_status to see activeConnections. If empty, ensure host_register_extension has been called and the extension reloaded at chrome://extensions.',
14900
+ ]);
14901
+ }
14902
+ const env = Object.freeze({
14903
+ type: 'request',
14904
+ requestId: randomUUID(),
14905
+ tool,
14906
+ extensionId: target.extensionId,
14907
+ payload,
14908
+ });
14909
+ let response;
14910
+ try {
14911
+ response = await ctx.ipcServer.request(target.extensionId, env, {
14912
+ timeoutMs: NAV_IPC_TIMEOUT_MS,
14913
+ });
14914
+ }
14915
+ catch (err) {
14916
+ return errorResponse(`${tool} failed: ${err.message}`, [
14917
+ `IPC request did not complete. Confirm the SW is connected and the ${tool} handler is wired in the SW.`,
14918
+ ]);
14919
+ }
14920
+ if (response.error) {
14921
+ return errorResponse(`${tool} nmh error: ${response.error.message}`, [
14922
+ 'The SW rejected the navigation (invalid URL, no active tab, or chrome.tabs threw). Pass an explicit tab_id, or open a tab first.',
14923
+ ]);
14924
+ }
14925
+ const data = {
14926
+ extensionId: target.extensionId,
14927
+ ...response.payload,
14928
+ };
14929
+ return okResponse(data, [...nextSteps]);
14930
+ };
14931
+ const navigateHandler = async (args, ctx) => sendNavRequest(ctx, args.extension_id, 'pdl_navigate', {
14932
+ url: args.url,
14933
+ ...(args.tab_id !== undefined ? { tab_id: args.tab_id } : {}),
14934
+ ...(args.timeout_ms !== undefined ? { timeout_ms: args.timeout_ms } : {}),
14935
+ }, [
14936
+ 'Navigated the active (or given) tab. Returns { tabId, url, status }: status "complete" = page finished loading; "loading" = navigation happened but the load was still in flight when the wait elapsed (raise timeout_ms, or poll session_ping). pwa-debug capture/inspection tools now target the new page.',
14937
+ ]);
14938
+ const newTabHandler = async (args, ctx) => sendNavRequest(ctx, args.extension_id, 'pdl_new_tab', {
14939
+ url: args.url,
14940
+ ...(args.active !== undefined ? { active: args.active } : {}),
14941
+ ...(args.timeout_ms !== undefined ? { timeout_ms: args.timeout_ms } : {}),
14942
+ }, [
14943
+ 'Opened a new tab. Returns { tabId, url, status, created:true }. Pass the returned tabId to other pwa-debug tools (react_tree, console_tail, pdl_click, …) to target this tab; without tab_id they use the active tab.',
14944
+ ]);
14945
+ const navigateTool = Object.freeze({
14946
+ name: 'pdl_navigate',
14947
+ description: "Navigate a browser tab to a URL, driven through the pwa-debug extension's service worker (chrome.tabs.update) — NO CDP, so it works on the user's real, logged-in profile without chrome-devtools-mcp being registered or attached. Targets the active tab in the last-focused window unless tab_id is given. The URL may omit the scheme (https:// is assumed); a javascript: URL is rejected. Waits for the page to reach document 'complete' (up to timeout_ms, default 10000, max 12000) before returning { extensionId, tabId, url, status, windowId? }, where status is 'complete' or 'loading' (load still in flight at timeout — the navigation still happened). Args: { url: non-empty string, tab_id?, timeout_ms?, extension_id? }. With no extension_id/tab_id, targets the single connected NMH and the active tab. CALL host_status FIRST to confirm a connection.",
14948
+ inputSchema: navigateInputSchema,
14949
+ handler: navigateHandler,
14950
+ });
14951
+ const newTabTool = Object.freeze({
14952
+ name: 'pdl_new_tab',
14953
+ description: "Open a NEW browser tab at a URL via the pwa-debug extension's service worker (chrome.tabs.create) — NO CDP, works on the user's real profile with no chrome-devtools-mcp dependency. The URL may omit the scheme (https:// is assumed); a javascript: URL is rejected. active? controls foreground vs. background (defaults to foreground). Waits for the page to reach document 'complete' (up to timeout_ms, default 10000, max 12000) before returning { extensionId, tabId, url, status, windowId?, created:true }. Pass the returned tabId to other pwa-debug tools to target this tab specifically. Args: { url: non-empty string, active?, timeout_ms?, extension_id? }. With no extension_id, targets the single connected NMH. CALL host_status FIRST to confirm a connection.",
14954
+ inputSchema: newTabInputSchema,
14955
+ handler: newTabHandler,
14956
+ });
14957
+
14859
14958
  const ACTION_IPC_TIMEOUT_MS = 5000;
14860
14959
  // Locator + routing fields shared by every action tool.
14861
14960
  const locatorSchema = {
@@ -15026,6 +15125,8 @@ const TOOLS = Object.freeze([
15026
15125
  storageGetTool,
15027
15126
  idbListTool,
15028
15127
  idbQueryTool,
15128
+ navigateTool,
15129
+ newTabTool,
15029
15130
  launchBrowserTool,
15030
15131
  browserStatusTool,
15031
15132
  closeBrowserTool,
@@ -176,6 +176,110 @@ const dispatchToTabClassified = async (tabId, req, opts = {}) => {
176
176
  return { ok: true, response };
177
177
  };
178
178
 
179
+ // SW-side navigation primitives — wraps chrome.tabs URL navigation so the request
180
+ // router stays a thin orchestrator. SW-handled (NOT page-world): chrome.tabs is
181
+ // only reachable in the extension service worker, and driving the tab to a new URL
182
+ // is a browser action, not an in-page read. This is the pwa-debug counterpart to
183
+ // chrome-devtools-mcp's navigate_page/new_page — it works through the loaded
184
+ // extension on the user's real profile, with no CDP attach required.
185
+ const DEFAULT_LOAD_TIMEOUT_MS = 10_000;
186
+ // A bare javascript: URL would run script in the target page context on navigate;
187
+ // reject it. Everything else (http(s), file:, about:, data:, chrome:, localhost
188
+ // shorthand) is allowed — this is a debugging driver, not a sandbox.
189
+ const JAVASCRIPT_SCHEME = /^javascript:/i;
190
+ const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
191
+ /**
192
+ * Normalize a caller-supplied URL: trim, reject empty / javascript:, and assume
193
+ * https:// when no scheme is given (so `example.com` works). Returns null when the
194
+ * input is unusable so the caller can surface a clear error.
195
+ */
196
+ const normalizeUrl = (raw) => {
197
+ const trimmed = raw.trim();
198
+ if (trimmed.length === 0)
199
+ return null;
200
+ if (JAVASCRIPT_SCHEME.test(trimmed))
201
+ return null;
202
+ return HAS_SCHEME.test(trimmed) ? trimmed : `https://${trimmed}`;
203
+ };
204
+ /** Active tab id in the last-focused window, or undefined when there is none. */
205
+ const activeTabId = async () => {
206
+ const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
207
+ return tabs[0]?.id;
208
+ };
209
+ /**
210
+ * Resolve once the tab reports document 'complete', or 'timeout' after timeoutMs.
211
+ * The onUpdated listener is added BEFORE the caller triggers the load so the
212
+ * 'complete' event can't be missed; it is always removed and the timer cleared.
213
+ */
214
+ const waitForComplete = (tabId, timeoutMs) => new Promise((resolve) => {
215
+ let settled = false;
216
+ const settle = (outcome) => {
217
+ if (settled)
218
+ return;
219
+ settled = true;
220
+ chrome.tabs.onUpdated.removeListener(onUpdated);
221
+ clearTimeout(timer);
222
+ resolve(outcome);
223
+ };
224
+ const onUpdated = (id, info) => {
225
+ if (id === tabId && info.status === 'complete')
226
+ settle('complete');
227
+ };
228
+ const timer = setTimeout(() => settle('timeout'), timeoutMs);
229
+ chrome.tabs.onUpdated.addListener(onUpdated);
230
+ });
231
+ /**
232
+ * Navigate the active (or given) tab to url and wait for it to finish loading.
233
+ * Throws on an invalid url or when no tab can be resolved. The listener is armed
234
+ * before chrome.tabs.update so the fresh load's 'complete' is observed.
235
+ */
236
+ const navigateTab = async (input) => {
237
+ const url = normalizeUrl(input.url);
238
+ if (url === null) {
239
+ throw new Error(`navigate: invalid or unsupported url: ${JSON.stringify(input.url)}`);
240
+ }
241
+ const tabId = input.tabId ?? (await activeTabId());
242
+ if (tabId === undefined) {
243
+ throw new Error('navigate: no active tab (open a tab or pass tab_id)');
244
+ }
245
+ const pending = waitForComplete(tabId, input.timeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS);
246
+ const tab = await chrome.tabs.update(tabId, { url });
247
+ const outcome = await pending;
248
+ return {
249
+ tabId,
250
+ url,
251
+ ...(tab?.windowId !== undefined ? { windowId: tab.windowId } : {}),
252
+ status: outcome === 'complete' ? 'complete' : 'loading',
253
+ };
254
+ };
255
+ /**
256
+ * Open a new tab at url and wait for it to finish loading. active defaults to
257
+ * Chrome's behavior (foreground). Throws on an invalid url or when Chrome returns
258
+ * no tab id.
259
+ */
260
+ const openNewTab = async (input) => {
261
+ const url = normalizeUrl(input.url);
262
+ if (url === null) {
263
+ throw new Error(`new_tab: invalid or unsupported url: ${JSON.stringify(input.url)}`);
264
+ }
265
+ const tab = await chrome.tabs.create({
266
+ url,
267
+ ...(input.active !== undefined ? { active: input.active } : {}),
268
+ });
269
+ const tabId = tab.id;
270
+ if (tabId === undefined) {
271
+ throw new Error('new_tab: chrome did not return a tab id');
272
+ }
273
+ const outcome = await waitForComplete(tabId, input.timeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS);
274
+ return {
275
+ tabId,
276
+ url,
277
+ ...(tab.windowId !== undefined ? { windowId: tab.windowId } : {}),
278
+ status: outcome === 'complete' ? 'complete' : 'loading',
279
+ created: true,
280
+ };
281
+ };
282
+
179
283
  const compilePatternList = (sources, fieldPathPrefix) => {
180
284
  if (sources === undefined || sources.length === 0) {
181
285
  return { ok: true, value: [] };
@@ -1610,6 +1714,50 @@ const handleSessionRecord = async (env) => {
1610
1714
  }
1611
1715
  return response.payload;
1612
1716
  };
1717
+ // --- Navigation tools (pdl_navigate / pdl_new_tab) ---------------------------
1718
+ // SW-handled, NOT page-world: chrome.tabs drives the browser to a URL. Forwarded
1719
+ // to the sw_navigation module, which validates the URL and waits for load.
1720
+ const readTimeoutMs = (r) => typeof r['timeout_ms'] === 'number' &&
1721
+ Number.isInteger(r['timeout_ms']) &&
1722
+ r['timeout_ms'] > 0
1723
+ ? r['timeout_ms']
1724
+ : undefined;
1725
+ const handleNavigate = async (env) => {
1726
+ const raw = env.payload;
1727
+ if (raw === null || typeof raw !== 'object') {
1728
+ throw new Error('pdl_navigate: payload must be { url: non-empty string, tab_id?, timeout_ms? }');
1729
+ }
1730
+ const r = raw;
1731
+ const url = r['url'];
1732
+ if (typeof url !== 'string' || url.length === 0) {
1733
+ throw new Error('pdl_navigate: payload must include { url: non-empty string }');
1734
+ }
1735
+ const tabId = readTabId(raw);
1736
+ const timeoutMs = readTimeoutMs(r);
1737
+ return navigateTab({
1738
+ url,
1739
+ ...(tabId !== undefined ? { tabId } : {}),
1740
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
1741
+ });
1742
+ };
1743
+ const handleNewTab = async (env) => {
1744
+ const raw = env.payload;
1745
+ if (raw === null || typeof raw !== 'object') {
1746
+ throw new Error('pdl_new_tab: payload must be { url: non-empty string, active?, timeout_ms? }');
1747
+ }
1748
+ const r = raw;
1749
+ const url = r['url'];
1750
+ if (typeof url !== 'string' || url.length === 0) {
1751
+ throw new Error('pdl_new_tab: payload must include { url: non-empty string }');
1752
+ }
1753
+ const active = typeof r['active'] === 'boolean' ? r['active'] : undefined;
1754
+ const timeoutMs = readTimeoutMs(r);
1755
+ return openNewTab({
1756
+ url,
1757
+ ...(active !== undefined ? { active } : {}),
1758
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
1759
+ });
1760
+ };
1613
1761
  // --- Path 7 interaction action tools (pdl_*) ---------------------------------
1614
1762
  // One generic handler per ACTION_TOOL_SPECS entry: extract tab_id for routing,
1615
1763
  // forward the locator + params payload to the page-world unchanged.
@@ -1643,6 +1791,8 @@ const makeActionRequestHandler = (tool) => async (env) => {
1643
1791
  const actionRequestHandlers = Object.freeze(Object.fromEntries(ACTION_TOOL_SPECS.map((s) => [s.tool, makeActionRequestHandler(s.tool)])));
1644
1792
  const HANDLERS = Object.freeze({
1645
1793
  ...actionRequestHandlers,
1794
+ pdl_navigate: handleNavigate,
1795
+ pdl_new_tab: handleNewTab,
1646
1796
  session_ping: handleSessionPing,
1647
1797
  recent_events: handleRecentEvents,
1648
1798
  evaluate: handleEvaluate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryanduntley/pwa-debug",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -63,8 +63,8 @@
63
63
  "@types/winreg": "^1.2.36",
64
64
  "rollup": "^4.27.4",
65
65
  "tslib": "^2.8.1",
66
- "@pwa-debug/extension": "0.1.6",
67
- "@pwa-debug/shared": "0.1.6"
66
+ "@pwa-debug/extension": "0.1.8",
67
+ "@pwa-debug/shared": "0.1.8"
68
68
  },
69
69
  "scripts": {
70
70
  "typecheck": "tsc --noEmit",