@jsenv/core 41.4.12 → 41.4.14

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.
@@ -906,6 +906,13 @@ const osFromUserAgent = (userAgent) => {
906
906
  return { name: "unknown", version: "" };
907
907
  };
908
908
 
909
+ // A browser driven by a program rather than by someone looking at it: a test
910
+ // run, a screenshot script. Chromium says so in its user-agent ("HeadlessChrome"
911
+ // / "Headless"), the others do not — so the reporter also sends
912
+ // navigator.webdriver, which every automated browser sets, and either one is
913
+ // enough to classify the client (see the headless flag on the record).
914
+ const isHeadlessUserAgent = (userAgent) => /Headless/i.test(userAgent);
915
+
909
916
  // The machine the dev server runs on, talking to itself: the main client. A
910
917
  // phone (or any other device) reaching the server over the network reports
911
918
  // with the machine's LAN address instead — which is why the ip is kept on
@@ -939,6 +946,10 @@ const jsenvPluginClientMonitoring = () => {
939
946
  userAgent,
940
947
  runtime: runtimeFromRequest(request),
941
948
  os: osFromUserAgent(userAgent),
949
+ // Automated browser (test run, script) rather than someone browsing;
950
+ // set from the user-agent here, and again from navigator.webdriver when
951
+ // a report brings it (the only signal for headless firefox/webkit).
952
+ headless: isHeadlessUserAgent(userAgent),
942
953
  // The address the reports come from; request.ipForwarded when a proxy
943
954
  // sits in between, so the client's own address is kept, not the proxy's.
944
955
  ip: request.ipForwarded || request.ip,
@@ -958,6 +969,7 @@ const jsenvPluginClientMonitoring = () => {
958
969
  client.userAgent = userAgent;
959
970
  client.runtime = runtimeFromRequest(request);
960
971
  client.os = osFromUserAgent(userAgent);
972
+ client.headless = client.headless || isHeadlessUserAgent(userAgent);
961
973
  }
962
974
  // A device changes address (wifi drop, DHCP): the record follows it.
963
975
  const ip = request.ipForwarded || request.ip;
@@ -1076,6 +1088,7 @@ const jsenvPluginClientMonitoring = () => {
1076
1088
  // parsed { name, version } so pages can show a friendly browser/OS
1077
1089
  runtime: client.runtime,
1078
1090
  os: client.os,
1091
+ headless: client.headless,
1079
1092
  ip: client.ip,
1080
1093
  // The main client — the machine the dev server runs on, talking to
1081
1094
  // itself over localhost.
@@ -1133,6 +1146,11 @@ const jsenvPluginClientMonitoring = () => {
1133
1146
  const wasOnline = !firstEver && isOnline(client);
1134
1147
  client.everSeen = true;
1135
1148
  client.lastSeen = now();
1149
+ // navigator.webdriver, reported once per batch: true for any browser under
1150
+ // automation, including the headless firefox/webkit the user-agent hides.
1151
+ if (body.webdriver === true) {
1152
+ client.headless = true;
1153
+ }
1136
1154
 
1137
1155
  updateTab(client, body.tab);
1138
1156
  pruneTabs(client);
@@ -1391,6 +1409,9 @@ const jsenvPluginPageSwitcher = () => {
1391
1409
  html: (urlInfo) => {
1392
1410
  const htmlAst = parseHtml({ html: urlInfo.content, url: urlInfo.url });
1393
1411
  injectJsenvScript(htmlAst, {
1412
+ // A module: the tree it opens is the shared page picker
1413
+ // (protocol_file/client/page_picker.js), which it imports.
1414
+ type: "module",
1394
1415
  src: clientFileUrl$1,
1395
1416
  pluginName: "jsenv:page_switcher",
1396
1417
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/core",
3
- "version": "41.4.12",
3
+ "version": "41.4.14",
4
4
  "type": "module",
5
5
  "description": "Tool to develop, test and build js projects",
6
6
  "repository": {
@@ -72,21 +72,21 @@
72
72
  "test:snapshot_clear": "npx @jsenv/filesystem clear **/tests/**/side_effects/"
73
73
  },
74
74
  "dependencies": {
75
- "@jsenv/ast": "6.9.0",
76
- "@jsenv/js-module-fallback": "1.4.39",
75
+ "@jsenv/ast": "6.9.1",
76
+ "@jsenv/js-module-fallback": "1.5.0",
77
77
  "@jsenv/plugin-bundling": "2.10.16",
78
- "@jsenv/plugin-minification": "1.7.7",
79
- "@jsenv/plugin-supervisor": "1.8.10",
80
- "@jsenv/plugin-transpilation": "1.5.80",
81
- "@jsenv/server": "17.6.0",
78
+ "@jsenv/plugin-minification": "1.7.8",
79
+ "@jsenv/plugin-supervisor": "1.8.11",
80
+ "@jsenv/plugin-transpilation": "1.6.0",
81
+ "@jsenv/server": "17.6.1",
82
82
  "@jsenv/sourcemap": "1.4.2",
83
83
  "react-table": "7.8.0"
84
84
  },
85
85
  "devDependencies": {
86
- "@babel/eslint-parser": "7.28.6",
87
- "@babel/plugin-syntax-decorators": "7.28.6",
86
+ "@babel/eslint-parser": "7.29.7",
87
+ "@babel/plugin-syntax-decorators": "7.29.7",
88
88
  "@babel/plugin-syntax-import-attributes": "7.29.7",
89
- "@babel/plugin-syntax-optional-chaining-assign": "7.28.6",
89
+ "@babel/plugin-syntax-optional-chaining-assign": "7.29.7",
90
90
  "@jsenv/abort": "workspace:*",
91
91
  "@jsenv/assert": "workspace:*",
92
92
  "@jsenv/cli": "workspace:*",
@@ -114,17 +114,17 @@
114
114
  "@jsenv/url-meta": "workspace:*",
115
115
  "@jsenv/urls": "workspace:*",
116
116
  "@jsenv/utils": "workspace:*",
117
- "@playwright/browser-chromium": "1.61.1",
118
- "@playwright/browser-firefox": "1.61.1",
119
- "@playwright/browser-webkit": "1.61.1",
117
+ "@playwright/browser-chromium": "1.62.1",
118
+ "@playwright/browser-firefox": "1.62.1",
119
+ "@playwright/browser-webkit": "1.62.1",
120
120
  "babel-plugin-transform-async-to-promises": "0.8.18",
121
121
  "eslint": "9.39.2",
122
122
  "eslint-plugin-import-x": "4.17.1",
123
123
  "eslint-plugin-react": "7.37.5",
124
- "eslint-plugin-regexp": "3.1.1",
125
- "globals": "17.7.0",
126
- "open": "11.0.0",
127
- "playwright": "1.61.1",
124
+ "eslint-plugin-regexp": "3.2.0",
125
+ "globals": "17.11.0",
126
+ "open": "11.0.1",
127
+ "playwright": "1.62.1",
128
128
  "preact": "11.0.0-beta.2",
129
129
  "prettier": "3.9.6",
130
130
  "prettier-plugin-css-order": "git+https://github.com/dmail-fork/prettier-plugin-css-order.git",
@@ -12,6 +12,7 @@ import {
12
12
 
13
13
  export const initAutoreload = ({ mainFilePath }) => {
14
14
  let debug = false;
15
+ let fullReloadRequested = false;
15
16
  const reloader = {
16
17
  urlHotMetas,
17
18
  status: {
@@ -64,6 +65,14 @@ export const initAutoreload = ({ mainFilePath }) => {
64
65
  },
65
66
  currentExecution: null,
66
67
  reload: () => {
68
+ if (fullReloadRequested) {
69
+ // The document is already being replaced; anything we would do here
70
+ // dies with the current page. Calling window.location.reload() again
71
+ // would abort the navigation already in flight (net::ERR_ABORTED).
72
+ // This happens for a single file edit: the change is sent first, then
73
+ // the prune of the urls it no longer references arrives a few ms later.
74
+ return;
75
+ }
67
76
  const someEffectIsFullReload = reloader.changes.value.some(
68
77
  (reloadMessage) => {
69
78
  if (reloadMessage.type === "full") {
@@ -90,6 +99,7 @@ export const initAutoreload = ({ mainFilePath }) => {
90
99
  },
91
100
  );
92
101
  if (someEffectIsFullReload) {
102
+ fullReloadRequested = true;
93
103
  dispatchBeforeFullReload();
94
104
  reloadHtmlPage();
95
105
  return;
@@ -185,6 +185,10 @@ const setup = () => {
185
185
  pendingActivities = [];
186
186
  const payload = JSON.stringify({
187
187
  clientId,
188
+ // The server reads browser/OS from the request headers, but a headless
189
+ // firefox/webkit looks exactly like a windowed one there; this flag is
190
+ // what tells them apart (chromium also says "Headless" in its user-agent).
191
+ webdriver: window.navigator.webdriver === true,
188
192
  tab: tabInfo({ closing }),
189
193
  activities,
190
194
  logs,
@@ -125,21 +125,6 @@
125
125
  .btn.primary:hover {
126
126
  background: #1d4ed8;
127
127
  }
128
- .picker-filter {
129
- box-sizing: border-box;
130
- width: 100%;
131
- margin-bottom: 10px;
132
- padding: 8px 10px;
133
- font: inherit;
134
- border: 1px solid #d1d5db;
135
- border-radius: 6px;
136
- }
137
- .pagepick {
138
- width: 100%;
139
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
140
- text-align: left;
141
- word-break: break-all;
142
- }
143
128
  .monitor {
144
129
  margin-left: 4px;
145
130
  padding: 2px 4px;
@@ -227,6 +212,16 @@
227
212
  <tbody id="other-rows"></tbody>
228
213
  </table>
229
214
 
215
+ <h2>Headless browsers</h2>
216
+ <p class="intro">
217
+ Browsers driven by a program — a test run, a screenshot script — rather
218
+ than by someone looking at them.
219
+ </p>
220
+ <table>
221
+ <thead id="head3"></thead>
222
+ <tbody id="headless-rows"></tbody>
223
+ </table>
224
+
230
225
  <dialog id="dlg">
231
226
  <div class="dlg-head">
232
227
  <strong id="dlg-title"></strong>
@@ -238,8 +233,11 @@
238
233
  </dialog>
239
234
 
240
235
  <script type="module">
236
+ import { openPagePicker } from "../../protocol_file/client/page_picker.js";
237
+
241
238
  const selfRows = document.getElementById("self-rows");
242
239
  const otherRows = document.getElementById("other-rows");
240
+ const headlessRows = document.getElementById("headless-rows");
243
241
  // The monitoring script (injected into this page too) stores our own id.
244
242
  let selfId = null;
245
243
  try {
@@ -254,6 +252,7 @@
254
252
  </tr>`;
255
253
  document.getElementById("head").innerHTML = headHtml;
256
254
  document.getElementById("head2").innerHTML = headHtml;
255
+ document.getElementById("head3").innerHTML = headHtml;
257
256
  const COLSPAN = 8;
258
257
 
259
258
  const ago = (ts) => {
@@ -420,16 +419,26 @@
420
419
  const render = (clients) => {
421
420
  lastClients = clients;
422
421
  const self = selfId ? clients.find((d) => d.id === selfId) : null;
423
- // Others ordered by when they first connected, so rows don't reshuffle.
422
+ // Ordered by when they first connected, so rows don't reshuffle.
423
+ const byFirstSeen = (a, b) => a.firstSeen - b.firstSeen;
424
+ // A headless browser is a program's, not a person's: it comes and goes
425
+ // with a test run, so it gets its own table instead of pushing the
426
+ // clients someone is actually browsing with out of sight.
424
427
  const others = clients
425
- .filter((d) => d !== self)
426
- .sort((a, b) => a.firstSeen - b.firstSeen);
428
+ .filter((d) => d !== self && !d.headless)
429
+ .sort(byFirstSeen);
430
+ const headless = clients
431
+ .filter((d) => d !== self && d.headless)
432
+ .sort(byFirstSeen);
427
433
  selfRows.innerHTML = self
428
434
  ? rowHtml(self)
429
435
  : `<tr><td class="empty" colspan="${COLSPAN}">This browser isn't registered yet — it will appear shortly.</td></tr>`;
430
436
  otherRows.innerHTML = others.length
431
437
  ? others.map(rowHtml).join("")
432
438
  : `<tr><td class="empty" colspan="${COLSPAN}">No other client has connected.</td></tr>`;
439
+ headlessRows.innerHTML = headless.length
440
+ ? headless.map(rowHtml).join("")
441
+ : `<tr><td class="empty" colspan="${COLSPAN}">No headless browser has connected.</td></tr>`;
433
442
  };
434
443
 
435
444
  // Dialogs (all tabs / recent activity / user agent).
@@ -514,68 +523,33 @@
514
523
  // dev server gone — ignore
515
524
  }
516
525
  };
517
- // The .html pages available to navigate to (fetched once, lazily).
518
- let navigablePages = null;
519
- const loadPages = async () => {
520
- if (navigablePages) {
521
- return navigablePages;
522
- }
523
- try {
524
- navigablePages = await (await fetch("/.internal/pages.json")).json();
525
- } catch {
526
- navigablePages = [];
527
- }
528
- return navigablePages;
529
- };
530
526
  const shortLabel = (d) => {
531
527
  const parts = [d.os && d.os.name, d.runtime && d.runtime.name].filter(
532
528
  Boolean,
533
529
  );
534
530
  return parts.length ? parts.join(" ") : d.id.slice(0, 8);
535
531
  };
536
- // A searchable list of pages; picking one navigates the client's active
537
- // tab there (tabId from activeTab so only that tab moves).
538
- const openNavigateDialog = async (client) => {
539
- dlgTitle.textContent = `Navigate ${shortLabel(client)} to…`;
540
- dlgCopy.style.display = "none";
541
- dlgBody.innerHTML = `
542
- <input class="picker-filter" id="page-filter" type="search" placeholder="Filter pages…" />
543
- <ul class="list" id="page-list"></ul>`;
544
- dlg.showModal();
545
- const pages = await loadPages();
546
- const listEl = document.getElementById("page-list");
547
- const filterEl = document.getElementById("page-filter");
548
- const renderPages = (query) => {
549
- const needle = query.trim().toLowerCase();
550
- const matches = needle
551
- ? pages.filter((p) => p.toLowerCase().includes(needle))
552
- : pages;
553
- listEl.innerHTML = matches.length
554
- ? matches
555
- .slice(0, 300)
556
- .map(
557
- (p) =>
558
- `<li><button class="btn pagepick" data-url="${escapeHtml(p)}">${escapeHtml(p)}</button></li>`,
559
- )
560
- .join("")
561
- : '<li class="muted">No page matches.</li>';
562
- };
563
- renderPages("");
564
- filterEl.oninput = () => renderPages(filterEl.value);
565
- filterEl.focus();
566
- listEl.onclick = (event) => {
567
- const button = event.target.closest("button.pagepick");
568
- if (!button) {
569
- return;
570
- }
571
- sendCommand({
572
- clientId: client.id,
573
- tabId: client.activeTab ? client.activeTab.id : null,
574
- type: "navigate",
575
- url: button.getAttribute("data-url"),
576
- });
577
- dlg.close();
578
- };
532
+ // Which page to send a client to is the same question cmd+K asks on any
533
+ // dev-served page, so it is the same tree that answers it (see
534
+ // protocol_file/client/page_picker.js) — filters, folding and all. Only
535
+ // the ending differs: the url goes to the client's active tab (tabId from
536
+ // activeTab, so no other tab moves) instead of to this window. No href on
537
+ // the rows for that very reason: a click here must not take THIS browser
538
+ // to a page meant for another one.
539
+ const openNavigateDialog = (client) => {
540
+ openPagePicker({
541
+ title: `Navigate ${shortLabel(client)} to…`,
542
+ placeholder: "Go to page…",
543
+ hereUrl: client.activeTab ? client.activeTab.url : null,
544
+ onPick: (page) => {
545
+ sendCommand({
546
+ clientId: client.id,
547
+ tabId: client.activeTab ? client.activeTab.id : null,
548
+ type: "navigate",
549
+ url: page.url,
550
+ });
551
+ },
552
+ });
579
553
  };
580
554
 
581
555
  document.addEventListener("click", async (event) => {
@@ -140,6 +140,13 @@ const osFromUserAgent = (userAgent) => {
140
140
  return { name: "unknown", version: "" };
141
141
  };
142
142
 
143
+ // A browser driven by a program rather than by someone looking at it: a test
144
+ // run, a screenshot script. Chromium says so in its user-agent ("HeadlessChrome"
145
+ // / "Headless"), the others do not — so the reporter also sends
146
+ // navigator.webdriver, which every automated browser sets, and either one is
147
+ // enough to classify the client (see the headless flag on the record).
148
+ const isHeadlessUserAgent = (userAgent) => /Headless/i.test(userAgent);
149
+
143
150
  // The machine the dev server runs on, talking to itself: the main client. A
144
151
  // phone (or any other device) reaching the server over the network reports
145
152
  // with the machine's LAN address instead — which is why the ip is kept on
@@ -173,6 +180,10 @@ export const jsenvPluginClientMonitoring = () => {
173
180
  userAgent,
174
181
  runtime: runtimeFromRequest(request),
175
182
  os: osFromUserAgent(userAgent),
183
+ // Automated browser (test run, script) rather than someone browsing;
184
+ // set from the user-agent here, and again from navigator.webdriver when
185
+ // a report brings it (the only signal for headless firefox/webkit).
186
+ headless: isHeadlessUserAgent(userAgent),
176
187
  // The address the reports come from; request.ipForwarded when a proxy
177
188
  // sits in between, so the client's own address is kept, not the proxy's.
178
189
  ip: request.ipForwarded || request.ip,
@@ -192,6 +203,7 @@ export const jsenvPluginClientMonitoring = () => {
192
203
  client.userAgent = userAgent;
193
204
  client.runtime = runtimeFromRequest(request);
194
205
  client.os = osFromUserAgent(userAgent);
206
+ client.headless = client.headless || isHeadlessUserAgent(userAgent);
195
207
  }
196
208
  // A device changes address (wifi drop, DHCP): the record follows it.
197
209
  const ip = request.ipForwarded || request.ip;
@@ -310,6 +322,7 @@ export const jsenvPluginClientMonitoring = () => {
310
322
  // parsed { name, version } so pages can show a friendly browser/OS
311
323
  runtime: client.runtime,
312
324
  os: client.os,
325
+ headless: client.headless,
313
326
  ip: client.ip,
314
327
  // The main client — the machine the dev server runs on, talking to
315
328
  // itself over localhost.
@@ -367,6 +380,11 @@ export const jsenvPluginClientMonitoring = () => {
367
380
  const wasOnline = !firstEver && isOnline(client);
368
381
  client.everSeen = true;
369
382
  client.lastSeen = now();
383
+ // navigator.webdriver, reported once per batch: true for any browser under
384
+ // automation, including the headless firefox/webkit the user-agent hides.
385
+ if (body.webdriver === true) {
386
+ client.headless = true;
387
+ }
370
388
 
371
389
  updateTab(client, body.tab);
372
390
  pruneTabs(client);