@afixt/screenshot-utils 1.0.0 → 1.1.0

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/CHANGELOG.md CHANGED
@@ -1,8 +1,42 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
3
+ All notable changes to this project will be documented in this file.
4
4
 
5
- ## [1.0.0](https://github.com/AFixt/screenshot-utils/compare/v0.9.0...v1.0.0) (2026-05-29)
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.0] - 2026-05-29
11
+
12
+ First v1.0.0 release. Every acceptance criterion in `spec.md` § 23 is now
13
+ met — OpenSeadragon end-to-end integration, the ≥90% capture/transform
14
+ coverage gates, the performance suite, and the live browser-adapter tests
15
+ all run green. The public API surface in `src/index.js` is now
16
+ semver-protected.
17
+
18
+ ### Changed
19
+
20
+ - Coverage provider switched from `v8` to `istanbul`. The v8 provider did not union-merge per-file coverage across test files in this repo — a near-empty barrel import (`index.test.js`) clobbered a module's real coverage from its dedicated test (e.g. `page.js` read 80% alone, 32% in the full run) — which had forced thresholds down to a placeholder 60/50. istanbul merges correctly; browser-shipped code is excluded so its injected counters don't run in the page context (`src/adapters/**`, the `settle.runInPage` / `element.findElementInPage` page-context functions, and the `src/errors/**` barrel re-exports which are covered by `test/errors/*.test.js`).
21
+ - Coverage thresholds raised from 60/50 to an 85/85/90/85 project floor, with per-glob gates requiring ≥90% line + branch on `src/capture/**` and `src/transform/**` (spec § 23). Capture now 92.3% line / 91.1% branch; transform 95.0% / 97.4%; overall 89.8%.
22
+ - Minimum Node widened to `>=20.0.0` (was `>=22.22.1`) to match the spec § 23 / CI Node 20 + 22 matrix; with `engine-strict=true` the old floor would have failed the Node 20 jobs.
23
+
24
+ ### Added
25
+
26
+ - `playwright` added as a dev dependency so its live-browser adapter contract suite actually runs in CI (was skipped whenever Playwright was absent). CI workflows now install the Chromium binary for the integration tests.
27
+ - OpenSeadragon end-to-end integration test for `tileDzi` (spec § 23): generates a real DZI pyramid, serves it plus the OpenSeadragon UMD build over a local HTTP server, and asserts the viewer opens the `.dzi` and loads tiles with no failures in a headless browser — verifying the output is genuinely consumable by the de-facto DZI viewer, not just shaped like one. `openseadragon` added as a dev dependency.
28
+ - Unit-test backfill across `resize`, `crop`, `normalize`, and `captureLongPage` to reach the § 23 capture + transform coverage bar.
29
+
30
+ ### Fixed
31
+
32
+ - `npm run test:perf` was excluded down to zero test files by the shared Vitest `exclude` glob, so it exited non-zero and the weekly `performance.yml` workflow was effectively always failing. It now runs via a dedicated config; all six § 13.3 ceilings pass.
33
+ - The 500-iteration memory-growth test silently `return`ed (passing vacuously) when `--expose-gc` was absent. The perf config now exposes gc and the test asserts its availability, so it genuinely runs.
34
+ - Browser-teardown `afterAll` hooks no longer rely on the 10 s default timeout (which flaked under parallel Chromium contention, failing the suite as a file-level "Hook timed out"); they now allow 30 s.
35
+
36
+ ### Removed
37
+
38
+ - Deprecated `always-auth=true` from `.npmrc` (npm warns it is going away; the per-registry `_authToken` covers publish auth).
39
+ - Unused `@vitest/coverage-v8` dev dependency (provider is now istanbul).
6
40
 
7
41
  ## [0.9.0] - 2026-05-22
8
42
 
package/dist/index.js CHANGED
@@ -99,7 +99,7 @@ var require_package = __commonJS({
99
99
  access: "restricted"
100
100
  },
101
101
  engines: {
102
- node: ">=20.0.0"
102
+ node: ">=20.9.0"
103
103
  },
104
104
  keywords: [
105
105
  "screenshot",
@@ -117,7 +117,7 @@ var require_package = __commonJS({
117
117
  "revenant"
118
118
  ],
119
119
  dependencies: {
120
- sharp: "0.34.5"
120
+ sharp: "^0.35.3"
121
121
  },
122
122
  peerDependencies: {
123
123
  "@napi-rs/canvas": "^0.1.0",
@@ -963,7 +963,10 @@ var require_settle = __commonJS({
963
963
  const {
964
964
  fonts = true,
965
965
  images = true,
966
+ backgroundImages = true,
966
967
  animations = "reduce",
968
+ network = "idle",
969
+ layoutStableMs = 250,
967
970
  maxWait = 5e3,
968
971
  idleMs = 200
969
972
  } = options;
@@ -971,7 +974,10 @@ var require_settle = __commonJS({
971
974
  const report = await adapter.evaluate(runInPage, {
972
975
  fonts,
973
976
  images,
977
+ backgroundImages,
974
978
  animations,
979
+ network,
980
+ layoutStableMs,
975
981
  maxWait,
976
982
  idleMs
977
983
  });
@@ -983,6 +989,9 @@ var require_settle = __commonJS({
983
989
  const report = {
984
990
  fonts: "skipped",
985
991
  images: { total: 0, loaded: 0, failed: 0 },
992
+ backgroundImages: "skipped",
993
+ network: "skipped",
994
+ layoutStable: "skipped",
986
995
  animations: "skipped"
987
996
  };
988
997
  const withTimeout = (promise, ms, label) => Promise.race([
@@ -1037,6 +1046,103 @@ var require_settle = __commonJS({
1037
1046
  report.images.total = imgs.length;
1038
1047
  await Promise.all(imgs.map(settleOneImage));
1039
1048
  };
1049
+ const collectBackgroundUrls = () => {
1050
+ const CAP = 200;
1051
+ const urls = /* @__PURE__ */ new Set();
1052
+ const els = doc.querySelectorAll("*");
1053
+ for (let i = 0; i < els.length && urls.size < CAP; i += 1) {
1054
+ const bg = globalThis.getComputedStyle(els[i]).backgroundImage;
1055
+ if (!bg || bg === "none") {
1056
+ continue;
1057
+ }
1058
+ for (const match of bg.matchAll(/url\((['"]?)([^'")]+)\1\)/g)) {
1059
+ const url = match[2];
1060
+ if (url && !url.startsWith("data:")) {
1061
+ urls.add(url);
1062
+ }
1063
+ }
1064
+ }
1065
+ return Array.from(urls).slice(0, CAP);
1066
+ };
1067
+ const settleOneBackgroundUrl = (url) => new Promise((resolve) => {
1068
+ const img = new globalThis.Image();
1069
+ const onload = () => {
1070
+ report.backgroundImages.loaded += 1;
1071
+ resolve();
1072
+ };
1073
+ const onerror = () => {
1074
+ report.backgroundImages.failed += 1;
1075
+ resolve();
1076
+ };
1077
+ img.addEventListener("load", onload, { once: true });
1078
+ img.addEventListener("error", onerror, { once: true });
1079
+ img.src = url;
1080
+ });
1081
+ const settleBackgroundImages = async () => {
1082
+ if (!opts.backgroundImages || !doc) {
1083
+ return;
1084
+ }
1085
+ const list = collectBackgroundUrls();
1086
+ report.backgroundImages = { total: list.length, loaded: 0, failed: 0 };
1087
+ if (list.length === 0) {
1088
+ return;
1089
+ }
1090
+ await withTimeout(
1091
+ Promise.all(list.map(settleOneBackgroundUrl)),
1092
+ opts.maxWait,
1093
+ "backgroundImages"
1094
+ ).catch(() => {
1095
+ });
1096
+ };
1097
+ const settleNetwork = async () => {
1098
+ if (opts.network !== "idle" || !doc) {
1099
+ return;
1100
+ }
1101
+ const perf = globalThis.performance;
1102
+ const countResources = () => perf && perf.getEntriesByType ? perf.getEntriesByType("resource").length : 0;
1103
+ const quietMs = 200;
1104
+ const deadline = Date.now() + opts.maxWait;
1105
+ let lastCount = countResources();
1106
+ let lastChange = Date.now();
1107
+ while (Date.now() < deadline) {
1108
+ const count = countResources();
1109
+ if (count !== lastCount) {
1110
+ lastCount = count;
1111
+ lastChange = Date.now();
1112
+ }
1113
+ if (doc.readyState === "complete" && Date.now() - lastChange >= quietMs) {
1114
+ report.network = "idle";
1115
+ return;
1116
+ }
1117
+ await new Promise((resolve) => {
1118
+ setTimeout(resolve, 50);
1119
+ });
1120
+ }
1121
+ report.network = "timeout";
1122
+ };
1123
+ const settleLayout = async () => {
1124
+ if (!opts.layoutStableMs || opts.layoutStableMs <= 0 || !doc) {
1125
+ return;
1126
+ }
1127
+ const el = doc.documentElement;
1128
+ const deadline = Date.now() + opts.maxWait;
1129
+ let lastHeight = el.scrollHeight;
1130
+ let stableSince = Date.now();
1131
+ while (Date.now() < deadline) {
1132
+ await new Promise((resolve) => {
1133
+ setTimeout(resolve, 50);
1134
+ });
1135
+ const height = el.scrollHeight;
1136
+ if (height !== lastHeight) {
1137
+ lastHeight = height;
1138
+ stableSince = Date.now();
1139
+ } else if (Date.now() - stableSince >= opts.layoutStableMs) {
1140
+ report.layoutStable = "stable";
1141
+ return;
1142
+ }
1143
+ }
1144
+ report.layoutStable = "timeout";
1145
+ };
1040
1146
  const settleAnimations = async () => {
1041
1147
  if (opts.animations === "ignore" || !doc) {
1042
1148
  return;
@@ -1084,9 +1190,12 @@ var require_settle = __commonJS({
1084
1190
  setTimeout(resolve, opts.idleMs);
1085
1191
  });
1086
1192
  };
1193
+ await settleNetwork();
1087
1194
  await settleFonts();
1088
1195
  await settleImages();
1196
+ await settleBackgroundImages();
1089
1197
  await settleAnimations();
1198
+ await settleLayout();
1090
1199
  await settleIdle();
1091
1200
  return report;
1092
1201
  }
@@ -1117,20 +1226,12 @@ var require_page = __commonJS({
1117
1226
  const start = nowMs();
1118
1227
  emit(events, "capture:start", { format, fullPage });
1119
1228
  let settleMs = 0;
1120
- const warnings = [];
1229
+ let warnings = [];
1121
1230
  if (settleOpts) {
1122
1231
  const settleStart = nowMs();
1123
1232
  const report = await waitForStable(adapter, settleOpts === true ? {} : settleOpts);
1124
1233
  settleMs = nowMs() - settleStart;
1125
- if (report.fonts === "timeout") {
1126
- warnings.push("fonts did not settle within maxWait");
1127
- }
1128
- if (report.animations === "timeout") {
1129
- warnings.push("animations did not settle within maxWait");
1130
- }
1131
- if (report.images.failed > 0) {
1132
- warnings.push(`${report.images.failed} image(s) failed to load`);
1133
- }
1234
+ warnings = settleWarnings(report);
1134
1235
  }
1135
1236
  const metrics = await adapter.getMetrics();
1136
1237
  const browserFormat = decideBrowserFormat(format);
@@ -1170,6 +1271,28 @@ var require_page = __commonJS({
1170
1271
  });
1171
1272
  return result;
1172
1273
  }
1274
+ function settleWarnings(report) {
1275
+ const warnings = [];
1276
+ if (report.fonts === "timeout") {
1277
+ warnings.push("fonts did not settle within maxWait");
1278
+ }
1279
+ if (report.animations === "timeout") {
1280
+ warnings.push("animations did not settle within maxWait");
1281
+ }
1282
+ if (report.images.failed > 0) {
1283
+ warnings.push(`${report.images.failed} image(s) failed to load`);
1284
+ }
1285
+ if (report.backgroundImages && report.backgroundImages.failed > 0) {
1286
+ warnings.push(`${report.backgroundImages.failed} background image(s) failed to load`);
1287
+ }
1288
+ if (report.network === "timeout") {
1289
+ warnings.push("network did not reach idle within maxWait");
1290
+ }
1291
+ if (report.layoutStable === "timeout") {
1292
+ warnings.push("layout did not stabilize within maxWait");
1293
+ }
1294
+ return warnings;
1295
+ }
1173
1296
  function decideBrowserFormat(format) {
1174
1297
  return format === "webp" ? "png" : format;
1175
1298
  }
@@ -1427,6 +1550,7 @@ var require_long_page = __commonJS({
1427
1550
  var require_element = __commonJS({
1428
1551
  "src/capture/element.js"(exports, module) {
1429
1552
  var { CaptureTimeoutError } = require_errors();
1553
+ var { waitForStable } = require_settle();
1430
1554
  async function captureElement(adapter, options = {}) {
1431
1555
  const {
1432
1556
  selector,
@@ -1438,6 +1562,7 @@ var require_element = __commonJS({
1438
1562
  timeout = 5e3,
1439
1563
  format = "png",
1440
1564
  quality = 90,
1565
+ waitForStable: settleOpts = false,
1441
1566
  events = null
1442
1567
  } = options;
1443
1568
  if (!selector && !xpath) {
@@ -1476,6 +1601,7 @@ var require_element = __commonJS({
1476
1601
  }
1477
1602
  }
1478
1603
  let viewportRect = lookup.viewportRect;
1604
+ let documentRect = lookup.documentRect;
1479
1605
  if (scrollIntoView) {
1480
1606
  await adapter.scrollTo({
1481
1607
  x: 0,
@@ -1490,6 +1616,12 @@ var require_element = __commonJS({
1490
1616
  viewportRect = reread.viewportRect;
1491
1617
  }
1492
1618
  }
1619
+ const settled = await settleAndReread(adapter, { selector, xpath, timeout, settleOpts });
1620
+ const settleMs = settled.settleMs;
1621
+ if (settled.rect) {
1622
+ viewportRect = settled.rect.viewportRect;
1623
+ documentRect = settled.rect.documentRect;
1624
+ }
1493
1625
  const clip = {
1494
1626
  x: Math.max(0, Math.round(viewportRect.x - padding)),
1495
1627
  y: Math.max(0, Math.round(viewportRect.y - padding)),
@@ -1511,12 +1643,12 @@ var require_element = __commonJS({
1511
1643
  format,
1512
1644
  bytes,
1513
1645
  page: metrics,
1514
- timing: { settleMs: 0, captureMs, encodeMs: 0, totalMs },
1646
+ timing: { settleMs, captureMs, encodeMs: 0, totalMs },
1515
1647
  warnings: [],
1516
1648
  element: {
1517
1649
  selector: selector ?? "",
1518
1650
  xpath: xpath ?? "",
1519
- documentRect: lookup.documentRect,
1651
+ documentRect,
1520
1652
  viewportRect,
1521
1653
  verified: Boolean(snippet)
1522
1654
  }
@@ -1524,6 +1656,26 @@ var require_element = __commonJS({
1524
1656
  emit(events, "capture:complete", { format, totalMs, mode: "element" });
1525
1657
  return result;
1526
1658
  }
1659
+ async function settleAndReread(adapter, { selector, xpath, timeout, settleOpts }) {
1660
+ if (!settleOpts) {
1661
+ return { settleMs: 0, rect: null };
1662
+ }
1663
+ const settleStart = nowMs();
1664
+ await waitForStable(adapter, settleOpts === true ? {} : settleOpts);
1665
+ const settleMs = nowMs() - settleStart;
1666
+ const reread = await withTimeout(
1667
+ adapter.evaluate(findElementInPage, { selector, xpath }),
1668
+ timeout,
1669
+ { operation: "adapter.evaluate(findElement after settle)" }
1670
+ );
1671
+ if (reread && reread.found) {
1672
+ return {
1673
+ settleMs,
1674
+ rect: { viewportRect: reread.viewportRect, documentRect: reread.documentRect }
1675
+ };
1676
+ }
1677
+ return { settleMs, rect: null };
1678
+ }
1527
1679
  function findElementInPage(args) {
1528
1680
  const doc = globalThis.document;
1529
1681
  let element;
@@ -2773,7 +2925,10 @@ var require_index = __commonJS({
2773
2925
  waitForStable: Object.freeze({
2774
2926
  fonts: true,
2775
2927
  images: true,
2928
+ backgroundImages: true,
2776
2929
  animations: "reduce",
2930
+ network: "idle",
2931
+ layoutStableMs: 250,
2777
2932
  maxWait: 5e3,
2778
2933
  idleMs: 200
2779
2934
  })