Compare commits

..

44 Commits

Author SHA1 Message Date
HarelM 1a5c879c75 Improve tests. 2026-07-11 15:33:31 +03:00
HarelM b16776fed3 Merge branch 'main' into increase-coverage 2026-07-10 21:40:37 +03:00
dependabot[bot] 21e141542a chore(deps): Bump qs from 6.14.2 to 6.15.3 (#1996)
Bumps [qs](https://github.com/ljharb/qs) from 6.14.2 to 6.15.3.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ljharb/qs/blob/main/CHANGELOG.md">qs's
changelog</a>.</em></p>
<blockquote>
<h2><strong>6.15.3</strong></h2>
<ul>
<li>[Fix] <code>parse</code>: enforce <code>throwOnLimitExceeded</code>
for cumulative array growth via
<code>combine</code>/<code>merge</code></li>
<li>[Fix] <code>utils</code>: respect encoding of surrogate pairs across
chunks (<a
href="https://redirect.github.com/ljharb/qs/issues/559">#559</a>)</li>
<li>[Robustness] <code>parse</code>: throw the <code>arrayLimit</code>
error before splitting oversized comma values</li>
<li>[Robustness] <code>utils.merge</code> / <code>utils.assign</code>:
avoid invoking <code>__proto__</code> setter when copying own
properties</li>
<li>[Robustness] <code>utils</code>: enforce <code>arrayLimit</code>
consistently across <code>merge</code>'s array paths</li>
<li>[Perf] <code>utils</code>: make <code>compact</code> O(n) via a
side-channel visited-set instead of <code>Array.indexOf</code></li>
<li>[Deps] update <code>side-channel</code></li>
<li>[Dev Deps] update <code>eslint</code>, <code>mock-property</code>,
<code>tape</code></li>
<li>[Tests] <code>parse</code>: characterize current lenient handling of
unbalanced bracket keys (<a
href="https://redirect.github.com/ljharb/qs/issues/558">#558</a>)</li>
</ul>
<h2><strong>6.15.2</strong></h2>
<ul>
<li>[Fix] <code>stringify</code>: skip null/undefined entries in
<code>arrayFormat: 'comma'</code> + <code>encodeValuesOnly</code>
instead of crashing in <code>encoder</code></li>
<li>[Fix] <code>stringify</code>: use configured <code>delimiter</code>
after <code>charsetSentinel</code> (<a
href="https://redirect.github.com/ljharb/qs/issues/555">#555</a>)</li>
<li>[Fix] <code>stringify</code>: apply <code>formatter</code> to
encoded key under <code>strictNullHandling</code> (<a
href="https://redirect.github.com/ljharb/qs/issues/554">#554</a>)</li>
<li>[Fix] <code>stringify</code>: skip null/undefined filter-array
entries instead of crashing in <code>encoder</code> (<a
href="https://redirect.github.com/ljharb/qs/issues/551">#551</a>)</li>
<li>[Fix] <code>parse</code>: handle nested bracket groups and add
regression tests (<a
href="https://redirect.github.com/ljharb/qs/issues/530">#530</a>)</li>
<li>[readme] fix grammar (<a
href="https://redirect.github.com/ljharb/qs/issues/550">#550</a>)</li>
<li>[Dev Deps] update <code>@ljharb/eslint-config</code></li>
<li>[Tests] add regression tests for keys containing percent-encoded
bracket text</li>
</ul>
<h2><strong>6.15.1</strong></h2>
<ul>
<li>[Fix] <code>parse</code>: <code>parameterLimit: Infinity</code> with
<code>throwOnLimitExceeded: true</code> silently drops all
parameters</li>
<li>[Deps] update <code>@ljharb/eslint-config</code></li>
<li>[Dev Deps] update <code>@ljharb/eslint-config</code>,
<code>iconv-lite</code></li>
<li>[Tests] increase coverage</li>
</ul>
<h2><strong>6.15.0</strong></h2>
<ul>
<li>[New] <code>parse</code>: add <code>strictMerge</code> option to
wrap object/primitive conflicts in an array (<a
href="https://redirect.github.com/ljharb/qs/issues/425">#425</a>, <a
href="https://redirect.github.com/ljharb/qs/issues/122">#122</a>)</li>
<li>[Fix] <code>duplicates</code> option should not apply to bracket
notation keys (<a
href="https://redirect.github.com/ljharb/qs/issues/514">#514</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ljharb/qs/commit/18d085e919dae70c8f1b200ab99323058edab2c2"><code>18d085e</code></a>
v6.15.3</li>
<li><a
href="https://github.com/ljharb/qs/commit/c38af42039d8cbf83f3e0d486c924eaa5a5d1712"><code>c38af42</code></a>
[Deps] update <code>side-channel</code></li>
<li><a
href="https://github.com/ljharb/qs/commit/adce539489fcd34cc10830cf8c4a43e90b9300b0"><code>adce539</code></a>
[Dev Deps] update <code>eslint</code>, <code>mock-property</code>,
<code>tape</code></li>
<li><a
href="https://github.com/ljharb/qs/commit/74a0f6a83af59245b4fa78785fbe709438580c97"><code>74a0f6a</code></a>
[Robustness] <code>utils</code>: enforce <code>arrayLimit</code>
consistently across <code>merge</code>'s arra...</li>
<li><a
href="https://github.com/ljharb/qs/commit/f4938f594a0a2773d3c99c0c6f5c96f30d909a2c"><code>f4938f5</code></a>
[Tests] <code>parse</code>: characterize current lenient handling of
unbalanced bracket ...</li>
<li><a
href="https://github.com/ljharb/qs/commit/5d5f723afe55a861c52cff6327f62269df35fdd1"><code>5d5f723</code></a>
[Perf] <code>utils</code>: make <code>compact</code> O(n) via a
side-channel visited-set instead of...</li>
<li><a
href="https://github.com/ljharb/qs/commit/52afe00abdb36416ee01c8d5243919b47a59af67"><code>52afe00</code></a>
[Robustness] <code>parse</code>: throw the <code>arrayLimit</code> error
before splitting oversized...</li>
<li><a
href="https://github.com/ljharb/qs/commit/963e538c740961a35ecaa0b38ee1f5c16697e208"><code>963e538</code></a>
[Fix] <code>parse</code>: enforce <code>throwOnLimitExceeded</code> for
cumulative array growth via...</li>
<li><a
href="https://github.com/ljharb/qs/commit/59da434d5de8c3d2564e4d75aeedde2e8af72369"><code>59da434</code></a>
[Fix] <code>utils</code>: respect encoding of surrogate pairs across
chunks</li>
<li><a
href="https://github.com/ljharb/qs/commit/9532969635a4bc96506370b28dd28dcab446e7ee"><code>9532969</code></a>
[Robustness] <code>utils.merge</code> / <code>utils.assign</code>: avoid
invoking <code>__proto__</code> sette...</li>
<li>Additional commits viewable in <a
href="https://github.com/ljharb/qs/compare/v6.14.2...v6.15.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=qs&package-manager=npm_and_yarn&previous-version=6.14.2&new-version=6.15.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/maplibre/maputnik/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 18:25:44 +00:00
dependabot[bot] 320f94b707 chore(deps): Bump uuid and istanbul-lib-processinfo (#1995)
Bumps [uuid](https://github.com/uuidjs/uuid) and
[istanbul-lib-processinfo](https://github.com/istanbuljs/istanbul-lib-processinfo).
These dependencies needed to be updated together.
Updates `uuid` from 8.3.2 to 14.0.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/uuidjs/uuid/releases">uuid's
releases</a>.</em></p>
<blockquote>
<h2>v14.0.1</h2>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v14.0.0...v14.0.1">14.0.1</a>
(2026-06-20)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>add types condition to node export for moduleResolution bundler (<a
href="https://redirect.github.com/uuidjs/uuid/issues/961">#961</a>) (<a
href="https://github.com/uuidjs/uuid/commit/27ffae5e867823b8c7db255975d65358fbdb1a7e">27ffae5</a>)</li>
</ul>
<h2>v14.0.0</h2>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0">14.0.0</a>
(2026-04-19)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>expect <code>crypto</code> to be global everywhere (requires
node@20+) (<a
href="https://redirect.github.com/uuidjs/uuid/issues/935">#935</a>)</li>
<li>drop node@18 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/934">#934</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>drop node@18 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/934">#934</a>) (<a
href="https://github.com/uuidjs/uuid/commit/dc4ddb87272ed2843faccd130bcc41d492688bd3">dc4ddb8</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>expect <code>crypto</code> to be global everywhere (requires
node@20+) (<a
href="https://redirect.github.com/uuidjs/uuid/issues/935">#935</a>) (<a
href="https://github.com/uuidjs/uuid/commit/f2c235f93059325fa43e1106e624b5291bb523c4">f2c235f</a>)</li>
<li>Use GITHUB_TOKEN for release-please and enable npm provenance (<a
href="https://redirect.github.com/uuidjs/uuid/issues/925">#925</a>) (<a
href="https://github.com/uuidjs/uuid/commit/ffa31383e8e4e1f0b4e22e504561272041b8738c">ffa3138</a>)</li>
</ul>
<h2>v13.0.2</h2>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v13.0.1...v13.0.2">13.0.2</a>
(2026-05-04)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>rerelease to fix provenance. (<a
href="https://github.com/uuidjs/uuid/commit/49ccb35f78c0c4ce1409dd2f1d89f83caadba10b">49ccb35</a>)</li>
</ul>
<h2>v13.0.1</h2>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v13.0.0...v13.0.1">13.0.1</a>
(2026-04-27)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>backport fix for GHSA-w5hq-g745-h8pq (<a
href="https://github.com/uuidjs/uuid/commit/9d27ddf7046ce496ef39569ff84d948eeff9cb2a">9d27ddf</a>)</li>
</ul>
<h2>v13.0.0</h2>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v12.0.0...v13.0.0">13.0.0</a>
(2025-09-08)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>make browser exports the default (<a
href="https://redirect.github.com/uuidjs/uuid/issues/901">#901</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md">uuid's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v14.0.0...v14.0.1">14.0.1</a>
(2026-06-20)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>add types condition to node export for moduleResolution bundler (<a
href="https://redirect.github.com/uuidjs/uuid/issues/961">#961</a>) (<a
href="https://github.com/uuidjs/uuid/commit/27ffae5e867823b8c7db255975d65358fbdb1a7e">27ffae5</a>)</li>
</ul>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0">14.0.0</a>
(2026-04-19)</h2>
<h3>Security</h3>
<ul>
<li>Fixes <a
href="https://github.com/uuidjs/uuid/security/advisories/GHSA-w5hq-g745-h8pq">GHSA-w5hq-g745-h8pq</a>:
<code>v3()</code>, <code>v5()</code>, and <code>v6()</code> did not
validate that writes would remain within the bounds of a caller-supplied
buffer, allowing out-of-bounds writes when an invalid
<code>offset</code> was provided. A <code>RangeError</code> is now
thrown if <code>offset &lt; 0</code> or <code>offset + 16 &gt;
buf.length</code>.</li>
</ul>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li><code>crypto</code> is now expected to be globally defined (requires
node@20+) (<a
href="https://redirect.github.com/uuidjs/uuid/issues/935">#935</a>)</li>
<li>drop node@18 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/934">#934</a>)</li>
<li>upgrade minimum supported TypeScript version to 5.4.3, in keeping
with the project's policy of supporting TypeScript versions released
within the last two years</li>
</ul>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v12.0.0...v13.0.0">13.0.0</a>
(2025-09-08)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>make browser exports the default (<a
href="https://redirect.github.com/uuidjs/uuid/issues/901">#901</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>make browser exports the default (<a
href="https://redirect.github.com/uuidjs/uuid/issues/901">#901</a>) (<a
href="https://github.com/uuidjs/uuid/commit/bce9d72a3ae5b9a3dcd8eb21ef6d1820288a427a">bce9d72</a>)</li>
</ul>
<h2><a
href="https://github.com/uuidjs/uuid/compare/v11.1.0...v12.0.0">12.0.0</a>
(2025-09-05)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>update to typescript@5.2 (<a
href="https://redirect.github.com/uuidjs/uuid/issues/887">#887</a>)</li>
<li>remove CommonJS support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/886">#886</a>)</li>
<li>drop node@16 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/883">#883</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>add node@24 to ci matrix (<a
href="https://redirect.github.com/uuidjs/uuid/issues/879">#879</a>) (<a
href="https://github.com/uuidjs/uuid/commit/42b6178aa21a593257f0a72abacd220f0b7b8a92">42b6178</a>)</li>
<li>drop node@16 support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/883">#883</a>) (<a
href="https://github.com/uuidjs/uuid/commit/0f38cf10366ab074f9328ae2021eea04d5f2e530">0f38cf1</a>)</li>
<li>remove CommonJS support (<a
href="https://redirect.github.com/uuidjs/uuid/issues/886">#886</a>) (<a
href="https://github.com/uuidjs/uuid/commit/ae786e27265f50bcf7cead196c29f1869297c42f">ae786e2</a>)</li>
<li>update to typescript@5.2 (<a
href="https://redirect.github.com/uuidjs/uuid/issues/887">#887</a>) (<a
href="https://github.com/uuidjs/uuid/commit/c7ee40598ed78584d81ab78dffded9fe5ff20b01">c7ee405</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/uuidjs/uuid/commit/70177807e9229dfacde2038dc1e722f1828f358a"><code>7017780</code></a>
chore(main): release 14.0.1 (<a
href="https://redirect.github.com/uuidjs/uuid/issues/964">#964</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/f2c3e4b513e47968f568ad1bf96417c6924e83a7"><code>f2c3e4b</code></a>
chore: fix release-please workflow (<a
href="https://redirect.github.com/uuidjs/uuid/issues/963">#963</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/27ffae5e867823b8c7db255975d65358fbdb1a7e"><code>27ffae5</code></a>
fix: add types condition to node export for moduleResolution bundler (<a
href="https://redirect.github.com/uuidjs/uuid/issues/961">#961</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/664cb3163531f49d3a5125f732d38724621bfcaa"><code>664cb31</code></a>
Remove outdated security contact information (<a
href="https://redirect.github.com/uuidjs/uuid/issues/959">#959</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/d729016037c4e1b09a522f4854a96f4f4add9047"><code>d729016</code></a>
fix(ci): checkout PR head commit in browser workflow (<a
href="https://redirect.github.com/uuidjs/uuid/issues/957">#957</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/89a5ebcc56999fc25c95350f922693b71fb11d32"><code>89a5ebc</code></a>
Workflows (<a
href="https://redirect.github.com/uuidjs/uuid/issues/948">#948</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/196e208db33d568d8d43d5b549d5d0eb3d9b0fbf"><code>196e208</code></a>
chore: fix workflow (<a
href="https://redirect.github.com/uuidjs/uuid/issues/947">#947</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/95af4489be459d6ba14d137b6fcd87ffd0953e91"><code>95af448</code></a>
chore: update workflows (<a
href="https://redirect.github.com/uuidjs/uuid/issues/946">#946</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/3b57f95555ab1b8432213264b5eaa318958fb8fe"><code>3b57f95</code></a>
chore: add workflow_dispatch (<a
href="https://redirect.github.com/uuidjs/uuid/issues/944">#944</a>)</li>
<li><a
href="https://github.com/uuidjs/uuid/commit/a433096a74e1d1815432fb1bed8115369785a1a9"><code>a433096</code></a>
chore: add 12.x and 13.x maintenance release branches (<a
href="https://redirect.github.com/uuidjs/uuid/issues/941">#941</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/uuidjs/uuid/compare/v8.3.2...v14.0.1">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for uuid since your current version.</p>
</details>
<details>
<summary>Install script changes</summary>
<p>This version adds <code>prepare</code> script that runs during
installation. Review the package contents before updating.</p>
</details>
<br />

Updates `istanbul-lib-processinfo` from 3.0.0 to 3.0.1
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/istanbuljs/istanbul-lib-processinfo/blob/master/CHANGELOG.md">istanbul-lib-processinfo's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<p>All notable changes to this project will be documented in this file.
See <a
href="https://github.com/conventional-changelog/standard-version">standard-version</a>
for commit guidelines.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/istanbuljs/istanbul-lib-processinfo/commit/bd2fd64c87f956f6e5d8ede9cc3f47f37f047145"><code>bd2fd64</code></a>
3.0.1</li>
<li><a
href="https://github.com/istanbuljs/istanbul-lib-processinfo/commit/1100563778e6d1c9c3ce0181c912df750d40475f"><code>1100563</code></a>
chore: remove <code>uuid</code> dependency</li>
<li><a
href="https://github.com/istanbuljs/istanbul-lib-processinfo/commit/79c691abb60dbb7c134c4bf220546250ebb40755"><code>79c691a</code></a>
chore(deps): update uuid</li>
<li>See full diff in <a
href="https://github.com/istanbuljs/istanbul-lib-processinfo/compare/istanbul-lib-processinfo-v3.0.0...v3.0.1">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/maplibre/maputnik/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 18:25:26 +00:00
HarelM d039dc73a5 Increase coverage to about 80% 2026-07-10 21:11:41 +03:00
HarelM 70a2db580f replace for ;; with while true. 2026-07-09 16:31:27 +03:00
HarelM b76d9cbe29 Remove circular dependency. 2026-07-09 15:38:41 +03:00
HarelM dd04c14d96 Remove maputnik assertable 2026-07-09 15:28:36 +03:00
HarelM 6c31add041 Reduce export surface 2026-07-09 15:22:41 +03:00
HarelM 60ed80651c Improve abstraction 2026-07-09 15:05:18 +03:00
HarelM c642ec5325 Remove comments 2026-07-09 15:05:18 +03:00
pre-commit-ci[bot] 0c98378243 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-09 11:44:08 +00:00
HarelM 95ba3c4428 Remove cypress related comments. 2026-07-09 14:43:58 +03:00
HarelM b112c6a2bf Split dirver and helper 2026-07-09 14:40:06 +03:00
HarelM a251faf8f7 improve abstraction 2026-07-09 14:27:33 +03:00
HarelM 8580499ec3 Fix incorrect quotes 2026-07-09 14:19:47 +03:00
HarelM 56a9840def Remove unused page 2026-07-09 14:19:23 +03:00
HarelM 3d1919738b Improve test readability 2026-07-09 14:16:12 +03:00
HarelM cc317c20a1 Move fill local storage to driver 2026-07-09 14:11:49 +03:00
HarelM a9c3da0f40 Remove hack from modal driver 2026-07-09 13:59:55 +03:00
HarelM dc5d6dd77d Remove unneeded comments. 2026-07-09 13:50:16 +03:00
HarelM 95d87ad7ef Remove export for a method that isn't used outside the file. 2026-07-09 13:41:51 +03:00
HarelM 09efbb5cd6 Fix utils path 2026-07-09 13:40:45 +03:00
HarelM 4f046cb83e Fix build 2026-07-09 13:38:54 +03:00
HarelM fb00dea285 Move playwrite stuff to utils folder 2026-07-09 13:33:29 +03:00
HarelM c00395ee11 Remove coverage file and have everything inside the fixtures.ts file. 2026-07-09 13:29:42 +03:00
pre-commit-ci[bot] 5092a924aa [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-09 10:27:14 +00:00
HarelM 7d80dccb81 Remote test related entries. 2026-07-09 13:26:24 +03:00
HarelM a93fe4d78b Fix test for regexp 2026-07-09 12:35:29 +03:00
Harel M 603a30cba8 Potential fix for pull request finding 'CodeQL / Incomplete regular expression for hostnames'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-09 12:19:45 +03:00
HarelM 6f55fda7d4 Remove docker coverage upload. 2026-07-09 11:47:40 +03:00
HarelM 8f0ee898ff Merge branch 'main' into replace-cypress-with-playwright 2026-07-09 11:43:28 +03:00
HarelM 3896c4c187 Merge branch 'relocate-e2e-tests' into replace-cypress-with-playwright 2026-07-08 22:44:19 +03:00
HarelM c0f1a72d80 Fix failing unit tests in CI 2026-07-08 22:43:34 +03:00
HarelM ac9186e7f8 test: instantiate MaputnikDriver once per describe block
Make the driver page-lazy (it resolves the running test's page on demand via
an auto fixture) so it can be created a single time at describe scope and
reused across the block's tests, matching the pre-migration ergonomics:

  const { given, get, when, then } = new MaputnikDriver();

instead of pulling `driver` out of a fixture and destructuring it in every
test. Coverage collection and dialog handling move into the auto fixture.

Also inject a bare invalid token ("zzz") in the json-editor parse-error test:
CodeMirror auto-closes brackets/quotes, so " {" no longer reliably breaks the
JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:48:44 +03:00
Harel M 8c86607100 Add alias for 'it' as 'test' in layers list spec 2026-07-08 15:35:34 +03:00
HarelM 7c5e5358cb Merge branch 'relocate-e2e-tests' into replace-cypress-with-playwright 2026-07-08 15:17:09 +03:00
HarelM 18b34d3ecd Fix lint 2026-07-08 15:16:28 +03:00
HarelM 08ebca266c Merge branch 'relocate-e2e-tests' into replace-cypress-with-playwright 2026-07-08 15:15:39 +03:00
HarelM 8344a30f5d Move things back to where this will still work. 2026-07-08 15:05:35 +03:00
Harel M 4249e9beb9 Merge branch 'main' into relocate-e2e-tests 2026-07-08 14:32:25 +03:00
HarelM c3dd253737 test: migrate e2e to Playwright and component test to Vitest browser mode
Replace Cypress with Playwright for the end-to-end suite and drop
@shellygo/cypress-test-utils in favour of a hand-written MaputnikDriver
page object that keeps the fluent then(...).shouldX() assertion style
(now async). The InputAutocomplete component test moves to Vitest browser
mode using the Playwright provider.

- e2e/maputnik-driver.ts: driver + MaputnikAssertable over Playwright
- e2e/{fixtures,coverage,global-setup,global-teardown}.ts: test fixture,
  istanbul coverage collection, and nyc report generation
- playwright.config.ts / vitest.config.ts
- Code coverage preserved: dev server is istanbul-instrumented, per-test
  window.__coverage__ is merged via nyc into coverage/coverage-final.json
- CI: Cypress jobs replaced with Playwright; docker e2e runs against the
  container via E2E_NO_WEBSERVER
- Remove Cypress deps, config and support files; update docs and .nycrc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:42:32 +03:00
HarelM f7b48139a4 test: adopt Playwright test()/test.describe() naming in e2e specs
Mechanical rename of it() -> test() and describe() -> test.describe() so the
subsequent Playwright migration diff only touches test bodies, not the
suite/case wrappers.
2026-07-08 12:42:13 +03:00
HarelM 8af1cfd5f8 test: relocate Cypress e2e suite to e2e/ ahead of Playwright migration
Pure file moves (no content changes) so git records them as renames and
history/blame is preserved through the migration that follows:
  cypress/e2e/*.cy.ts        -> e2e/*.spec.ts
  cypress/e2e/*-driver.ts    -> e2e/*-driver.ts
  cypress/fixtures/          -> e2e/fixtures/
  cypress.config.ts          -> playwright.config.ts
  InputAutocomplete.cy.tsx   -> InputAutocomplete.browser.test.tsx

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:24:03 +03:00
21 changed files with 1584 additions and 769 deletions
-1
View File
@@ -96,7 +96,6 @@ jobs:
with:
node-version-file: '.nvmrc'
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test-unit-ci
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+115 -2
View File
@@ -76,6 +76,15 @@ describe("layer editor", () => {
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }],
});
});
test("the range slider adjusts min-zoom", async () => {
// The slider starts at 1 (set via the text input above); one step right lands on 2.
await when.focus("min-zoom.input-range");
await when.typeKeys("{rightarrow}");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", minzoom: 2 }],
});
});
});
describe("max-zoom", () => {
@@ -145,6 +154,13 @@ describe("layer editor", () => {
layers: [{ id: "background:" + bgId, type: "background" }],
});
});
test("typing a hex value updates the paint color", async () => {
await when.setColorValue("background-color", "#ff0000");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", paint: { "background-color": "#ff0000" } }],
});
});
});
describe("opacity", () => {
@@ -166,8 +182,105 @@ describe("layer editor", () => {
});
describe("filter", () => {
test.skip("expand/collapse", () => {});
test.skip("compound filter", () => {});
test("compound filter", async () => {
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
await when.addFilter();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", filter: ["all", ["==", "name", ""]] }],
});
// Changing the operator updates the compound filter.
await when.selectFilterOperator("!=");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", filter: ["all", ["!=", "name", ""]] }],
});
// A second filter item extends the compound filter.
await when.addFilter();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", filter: ["all", ["!=", "name", ""], ["==", "name", ""]] }],
});
});
});
describe("functions", () => {
test("convert a property to a zoom function and add a stop", async () => {
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await when.makeZoomFunction("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }],
});
await when.addFunctionStop("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } } }],
});
// Deleting the first stop leaves the rest.
await when.deleteFunctionStop("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[10, 5], [11, 5]] } } }],
});
});
test("convert a property to a data function and edit stops", async () => {
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
// The property needs a value before it can be turned into a data function.
await when.setValue("spec-field-input:circle-blur", "1");
await when.makeDataFunction("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
type: "circle",
source: "example",
paint: {
"circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1]],
},
},
},
],
});
await when.addFunctionStop("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
type: "circle",
source: "example",
paint: {
"circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
},
},
},
],
});
await when.deleteFunctionStop("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
type: "circle",
source: "example",
paint: {
"circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
},
},
},
],
});
});
});
describe("layout", () => {
+50
View File
@@ -158,6 +158,56 @@ export class MaputnikDriver {
await this.helper.when.typeText(value);
},
makeZoomFunction: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.scrollIntoViewIfNeeded();
await container.locator(".maputnik-make-zoom-function").last().click({ force: true });
},
makeDataFunction: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.scrollIntoViewIfNeeded();
await container.locator(".maputnik-make-data-function").click({ force: true });
},
addFunctionStop: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator(".maputnik-add-stop").first().click({ force: true });
},
deleteFunctionStop: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator(".maputnik-delete-stop").first().click({ force: true });
},
addFilter: async () => {
const button = this.helper.get.elementByTestId("layer-filter-button");
await button.scrollIntoViewIfNeeded();
await button.click({ force: true });
},
selectFilterOperator: async (value: string) => {
await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value);
},
deleteFirstActiveSource: async () => {
await this.helper.get.element(".maputnik-active-source-type-editor-header-delete").first().click();
},
setColorValue: async (fieldName: string, value: string) => {
const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator(".maputnik-color");
await input.fill(value);
},
exportCreateHtml: async () => {
await this.helper.get.element(".maputnik-modal-export-buttons button").last().click();
},
exportSaveStyle: async () => {
await this.helper.stubSaveFilePicker();
await this.helper.get.element(".maputnik-modal-export-buttons button").first().click();
},
waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"),
/** Fill localStorage until we get a QuotaExceededError. */
+38 -21
View File
@@ -64,8 +64,15 @@ describe("modals", () => {
await then(get.elementByTestId("modal:export")).shouldNotExist();
});
// TODO: Work out how to download a file and check the contents
test.skip("download", () => {});
test("download HTML and save the style", async () => {
// Generate the standalone HTML export (triggers a file download).
await when.exportCreateHtml();
await then(get.elementByTestId("modal:export")).shouldExist();
// Saving the style closes the export modal.
await when.exportSaveStyle();
await then(get.elementByTestId("modal:export")).shouldNotExist();
});
});
describe("sources", () => {
@@ -74,7 +81,17 @@ describe("modals", () => {
await when.click("nav:sources");
});
test.skip("active sources", () => {});
test("active sources are listed and can be deleted", async () => {
// The "both" style ships with active sources; reopen the modal against it.
await when.setStyle("both");
await when.click("nav:sources");
const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length;
await when.deleteFirstActiveSource();
await then(
get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length)
).shouldEqual(before - 1);
});
test.skip("public source", () => {});
test("add new source", async () => {
@@ -84,8 +101,8 @@ describe("modals", () => {
await when.select("modal:sources.add.scheme_type", "tms");
await when.click("modal:sources.add.add_source");
await when.wait(200);
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({
scheme: "tms",
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: { [sourceId]: { scheme: "tms" } },
});
});
@@ -118,8 +135,8 @@ describe("modals", () => {
await when.setValue("modal:sources.add.tile_size", "128");
await when.click("modal:sources.add.add_source");
await when.wait(200);
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({
tileSize: 128,
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: { [sourceId]: { tileSize: 128 } },
});
});
});
@@ -204,8 +221,8 @@ describe("modals", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
"maputnik:openmaptiles_access_token": apiKey,
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:openmaptiles_access_token": apiKey },
});
});
@@ -213,8 +230,8 @@ describe("modals", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
"maputnik:thunderforest_access_token": apiKey,
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:thunderforest_access_token": apiKey },
});
});
@@ -222,8 +239,8 @@ describe("modals", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
"maputnik:stadia_access_token": apiKey,
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:stadia_access_token": apiKey },
});
});
@@ -231,29 +248,29 @@ describe("modals", () => {
const apiKey = "testing123";
await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey);
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
"maputnik:locationiq_access_token": apiKey,
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
metadata: { "maputnik:locationiq_access_token": apiKey },
});
});
test("style projection mercator", async () => {
await when.select("modal:settings.projection", "mercator");
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
type: "mercator",
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
projection: { type: "mercator" },
});
});
test("style projection globe", async () => {
await when.select("modal:settings.projection", "globe");
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
type: "globe",
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
projection: { type: "globe" },
});
});
test("style projection vertical-perspective", async () => {
await when.select("modal:settings.projection", "vertical-perspective");
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
type: "vertical-perspective",
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
projection: { type: "vertical-perspective" },
});
});
+17 -6
View File
@@ -62,11 +62,12 @@ function isLocator(target: unknown): target is Locator {
);
}
/** Asserts that every top-level key in `expected` deep-equals its counterpart in `actual`. */
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown>): void {
for (const key of Object.keys(expected)) {
expect(actual?.[key], `property "${key}"`).toEqual(expected[key]);
}
/**
* Asserts that `actual` recursively contains everything in `expected`: nested
* objects are matched as subsets, while arrays and primitives must match exactly.
*/
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown> | unknown[]): void {
expect(actual).toMatchObject(expected);
}
/**
@@ -130,7 +131,7 @@ export class Assertable<T> {
}
});
shouldDeepNestedInclude = (value: Record<string, unknown>) =>
shouldDeepNestedInclude = (value: Record<string, unknown> | unknown[]) =>
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
}
@@ -144,6 +145,7 @@ async function typeSequence(page: Page, text: string): Promise<void> {
del: "Delete",
tab: "Tab",
home: "Home",
rightarrow: "ArrowRight",
};
for (let i = 0; i < tokens.length; i++) {
@@ -202,6 +204,15 @@ export class PlaywrightHelper {
return new Query<T>(getter);
}
/** Stubs the File System Access "save" picker so file saves complete headlessly. */
public stubSaveFilePicker(): Promise<void> {
return this.page.evaluate(() => {
(window as any).showSaveFilePicker = async () => ({
createWritable: async () => ({ write: async () => {}, close: async () => {} }),
});
});
}
/** Entry point for fluent assertions over a Locator or a value/Query. */
public then = <T>(target: T): Assertable<T> => new Assertable(target);
+769 -683
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -124,8 +124,6 @@
"@types/string-hash": "^1.1.3",
"@types/wicg-file-system-access": "^2023.10.7",
"@vitejs/plugin-react": "5.2",
"@vitest/browser": "^4.1.10",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"cors": "^2.8.6",
"eslint": "^10.6.0",
@@ -145,9 +143,8 @@
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.1",
"uuid": "^14.0.1",
"vite": "^8.1.3",
"vite": "^7.3.2",
"vite-plugin-istanbul": "^9.0.1",
"vitest": "^4.1.10",
"vitest-browser-react": "^2.2.0"
"vitest": "^4.1.10"
}
}
@@ -1,24 +0,0 @@
import { expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";
import InputAutocomplete from "./InputAutocomplete";
const fruits = ["apple", "banana", "cherry"];
test("filters options when typing", async () => {
render(<InputAutocomplete aria-label="Fruit" options={fruits.map((f) => [f, f])} />);
const input = page.getByLabelText("Fruit");
await input.click();
const menuItems = page.getByRole("option");
await expect.element(menuItems.first()).toBeVisible();
expect(menuItems.all()).toHaveLength(3);
await input.fill("ch");
await expect.element(page.getByText("cherry")).toBeVisible();
expect(page.getByRole("option").all()).toHaveLength(1);
await page.getByText("cherry").click();
await expect.element(input).toHaveValue("cherry");
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import type { StyleSpecification } from "maplibre-gl";
import { undoMessages, redoMessages } from "./diffmessage";
const before = { version: 8, sources: {}, layers: [] } as StyleSpecification;
const after = {
version: 8,
sources: {},
layers: [{ id: "bg", type: "background" }],
} as StyleSpecification;
describe("diff messages", () => {
it("prefixes undo messages with 'Undo'", () => {
const messages = undoMessages(before, after);
expect(messages.length).toBeGreaterThan(0);
expect(messages.every((m) => m.startsWith("Undo "))).toBe(true);
});
it("prefixes redo messages with 'Redo'", () => {
const messages = redoMessages(before, after);
expect(messages.length).toBeGreaterThan(0);
expect(messages.every((m) => m.startsWith("Redo "))).toBe(true);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import type { LayerSpecification } from "maplibre-gl";
import { colorHighlightedLayer } from "./highlight";
function layer(overrides: Record<string, any>): LayerSpecification {
return { id: "l", source: "s", "source-layer": "sl", ...overrides } as unknown as LayerSpecification;
}
describe("colorHighlightedLayer", () => {
it("returns null for undefined, background and raster layers", () => {
expect(colorHighlightedLayer(undefined)).toBeNull();
expect(colorHighlightedLayer(layer({ type: "background" }))).toBeNull();
expect(colorHighlightedLayer(layer({ type: "raster" }))).toBeNull();
});
it("builds a circle highlight for circle and symbol layers", () => {
for (const type of ["circle", "symbol"]) {
const highlight = colorHighlightedLayer(layer({ type }))!;
expect(highlight).not.toBeNull();
expect(highlight.type).toBe("circle");
expect(highlight.id).toMatch(/_highlight$/);
expect((highlight.paint as any)["circle-radius"]).toBe(3);
}
});
it("builds a line highlight with an overridden width", () => {
const highlight = colorHighlightedLayer(layer({ type: "line" }))!;
expect(highlight.type).toBe("line");
expect((highlight.paint as any)["line-width"]).toBe(2);
});
it("builds a polygon highlight for fill and fill-extrusion layers", () => {
for (const type of ["fill", "fill-extrusion"]) {
const highlight = colorHighlightedLayer(layer({ type }))!;
expect(highlight.type).toBe("fill");
}
});
it("copies the source layer's filter when present, drops it otherwise", () => {
const withFilter = colorHighlightedLayer(
layer({ type: "line", filter: ["==", "class", "road"] } as any)
)!;
expect(withFilter.filter).toEqual(["==", "class", "road"]);
const withoutFilter = colorHighlightedLayer(layer({ type: "line" }))!;
expect("filter" in withoutFilter).toBe(false);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import type { LayerSpecification } from "maplibre-gl";
import { changeType, changeProperty, layerPrefix, findClosestCommonPrefix } from "./layer";
describe("changeType", () => {
it("drops paint/layout props not valid for the new type", () => {
const layer = {
id: "l",
type: "fill",
source: "s",
paint: { "fill-color": "#fff", "fill-opacity": 0.5 },
layout: { visibility: "visible" },
} as unknown as LayerSpecification;
const changed = changeType(layer, "background") as any;
expect(changed.type).toBe("background");
// fill-color is not a valid background paint property
expect(changed.paint["fill-color"]).toBeUndefined();
// visibility is valid for background layout
expect(changed.layout.visibility).toBe("visible");
});
it("keeps props that are valid for the new type", () => {
const layer = {
id: "l",
type: "line",
source: "s",
paint: { "line-color": "#000" },
} as unknown as LayerSpecification;
const changed = changeType(layer, "line") as any;
expect(changed.paint["line-color"]).toBe("#000");
});
});
describe("changeProperty", () => {
const base = { id: "l", type: "background" } as unknown as LayerSpecification;
it("sets a top-level property", () => {
const changed = changeProperty(base, null, "minzoom", 4) as any;
expect(changed.minzoom).toBe(4);
});
it("removes a top-level property when value is undefined", () => {
const changed = changeProperty({ ...base, minzoom: 4 } as any, null, "minzoom", undefined) as any;
expect("minzoom" in changed).toBe(false);
});
it("sets a grouped property", () => {
const changed = changeProperty(base, "paint", "background-color", "#fff") as any;
expect(changed.paint["background-color"]).toBe("#fff");
});
it("removes a grouped property and drops the empty group", () => {
const layer = { ...base, paint: { "background-color": "#fff" } } as any;
const changed = changeProperty(layer, "paint", "background-color", undefined) as any;
expect("paint" in changed).toBe(false);
});
it("removes a grouped property but keeps a non-empty group", () => {
const layer = { ...base, paint: { "background-color": "#fff", "background-opacity": 1 } } as any;
const changed = changeProperty(layer, "paint", "background-color", undefined) as any;
expect(changed.paint["background-color"]).toBeUndefined();
expect(changed.paint["background-opacity"]).toBe(1);
});
});
describe("layerPrefix", () => {
it("returns the segment before the first separator", () => {
expect(layerPrefix("foo-bar")).toBe("foo");
expect(layerPrefix("foo_bar")).toBe("foo");
expect(layerPrefix("foo bar")).toBe("foo");
expect(layerPrefix("single")).toBe("single");
});
});
describe("findClosestCommonPrefix", () => {
const layers = [
{ id: "aa" },
{ id: "aa-2" },
{ id: "b" },
] as unknown as LayerSpecification[];
it("finds the first index of a run sharing the prefix", () => {
expect(findClosestCommonPrefix(layers, 1)).toBe(0);
});
it("returns the index itself when the previous prefix differs", () => {
expect(findClosestCommonPrefix(layers, 2)).toBe(2);
});
it("returns the index for the first layer", () => {
expect(findClosestCommonPrefix(layers, 0)).toBe(0);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from "vitest";
import type { Map } from "maplibre-gl";
import LayerWatcher from "./layerwatcher";
function mockMap(): Map {
return {
style: {
tileManagers: {
vector: { _source: { vectorLayerIds: ["water", "roads"] } },
},
},
querySourceFeatures: () => [
{ properties: { class: "river", name: "A" } },
{ properties: { class: "canal" } },
],
} as unknown as Map;
}
describe("LayerWatcher", () => {
it("reports sources discovered on the map", () => {
const onSourcesChange = vi.fn();
const watcher = new LayerWatcher({ onSourcesChange });
watcher.analyzeMap(mockMap());
expect(onSourcesChange).toHaveBeenCalledOnce();
expect(watcher.sources).toEqual({ vector: ["water", "roads"] });
// Re-analyzing the same sources does not fire the callback again.
watcher.analyzeMap(mockMap());
expect(onSourcesChange).toHaveBeenCalledOnce();
});
it("collects vector layer field values from source features", () => {
const onVectorLayersChange = vi.fn();
const watcher = new LayerWatcher({ onVectorLayersChange });
const map = mockMap();
watcher.analyzeMap(map); // populates _sources
watcher.analyzeVectorLayerFields(map);
expect(onVectorLayersChange).toHaveBeenCalled();
expect(watcher.vectorLayers.water.class).toHaveProperty("river");
expect(watcher.vectorLayers.water.class).toHaveProperty("canal");
expect(watcher.vectorLayers.water.name).toHaveProperty("A");
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { downloadGlyphsMetadata, downloadSpriteMetadata } from "./metadata";
afterEach(() => vi.restoreAllMocks());
describe("downloadGlyphsMetadata", () => {
it("returns an empty list for an empty url", async () => {
expect(await downloadGlyphsMetadata("")).toEqual([]);
});
it("fetches the fontstacks list and de-duplicates it", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ["A", "A", "B"] })));
const fonts = await downloadGlyphsMetadata("https://example.com/{fontstack}/{range}.pbf");
expect(fonts.sort()).toEqual(["A", "B"]);
});
it("returns the default on a failed request", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false })));
expect(await downloadGlyphsMetadata("https://example.com/x/{fontstack}/{range}.pbf")).toEqual([]);
});
});
describe("downloadSpriteMetadata", () => {
it("returns the sprite icon names", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ airport: {}, park: {} }) })));
const icons = await downloadSpriteMetadata("https://example.com/sprite");
expect(icons.sort()).toEqual(["airport", "park"]);
});
it("returns an empty list for an empty base url", async () => {
expect(await downloadSpriteMetadata("")).toEqual([]);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { RevisionStore } from "./revisions";
const rev = (id: string) => ({ version: 8, id, sources: {}, layers: [] }) as any;
describe("RevisionStore", () => {
it("tracks latest/current as revisions are added", () => {
const store = new RevisionStore();
store.addRevision(rev("a"));
store.addRevision(rev("b"));
expect(store.latest.id).toBe("b");
expect(store.current.id).toBe("b");
});
it("undo and redo move through history", () => {
const store = new RevisionStore();
store.addRevision(rev("a"));
store.addRevision(rev("b"));
expect(store.undo().id).toBe("a");
expect(store.undo().id).toBe("a"); // clamped at start
expect(store.redo().id).toBe("b");
expect(store.redo().id).toBe("b"); // clamped at end
});
it("clears redo history when a new revision is added after undo", () => {
const store = new RevisionStore();
store.addRevision(rev("a"));
store.addRevision(rev("b"));
store.undo();
store.addRevision(rev("c"));
expect(store.latest.id).toBe("c");
expect(store.redo().id).toBe("c");
});
});
+12
View File
@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import sortNumerically from "./sort-numerically";
describe("sortNumerically", () => {
it("orders numbers (and numeric strings) ascending", () => {
expect([3, 1, 2].sort(sortNumerically)).toEqual([1, 2, 3]);
expect(["10", "2", "1"].sort(sortNumerically)).toEqual(["1", "2", "10"]);
expect(sortNumerically(5, 5)).toBe(0);
expect(sortNumerically(1, 2)).toBe(-1);
expect(sortNumerically(2, 1)).toBe(1);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { addSource, changeSource, deleteSource } from "./source";
const style = { version: 8, id: "s", sources: { a: { type: "vector" } }, layers: [] } as any;
describe("source helpers", () => {
it("adds a source", () => {
const result = addSource(style, "b", { type: "geojson", data: {} } as any);
expect(result.sources.b).toEqual({ type: "geojson", data: {} });
expect(result.sources.a).toBeDefined();
});
it("changes an existing source", () => {
const result = changeSource(style, "a", { type: "raster" } as any);
expect(result.sources.a).toEqual({ type: "raster" });
});
it("deletes a source without mutating the input", () => {
const result = deleteSource(style, "a");
expect(result.sources.a).toBeUndefined();
expect(style.sources.a).toBeDefined();
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import { findDefaultFromSpec } from "./spec-helper";
describe("findDefaultFromSpec", () => {
it("returns the spec default when present (even if falsy)", () => {
expect(findDefaultFromSpec({ type: "string", default: "hi" })).toBe("hi");
expect(findDefaultFromSpec({ type: "boolean", default: false })).toBe(false);
});
it("falls back to a sensible default per type", () => {
expect(findDefaultFromSpec({ type: "color" })).toBe("#000000");
expect(findDefaultFromSpec({ type: "string" })).toBe("");
// The falsy `false` fallback collapses to "" via the `|| ""` in the impl.
expect(findDefaultFromSpec({ type: "boolean" })).toBe("");
expect(findDefaultFromSpec({ type: "array" })).toEqual([]);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.stubGlobal("window", { location: { port: "8000" } });
// Avoid opening a real websocket in notifyLocalChanges().
const wsInstances: any[] = [];
vi.mock("reconnecting-websocket", () => ({
default: class {
onmessage: ((e: { data: string }) => void) | null = null;
constructor(public url: string) {
wsInstances.push(this);
}
},
}));
import { ApiStyleStore } from "./apistore";
const okJson = (body: any) => ({ ok: true, json: async () => body });
describe("ApiStyleStore", () => {
beforeEach(() => {
wsInstances.length = 0;
vi.restoreAllMocks();
});
it("init fetches style ids and wires up local change notifications", async () => {
const fetchMock = vi.fn(async () => okJson(["style-a", "style-b"]) as any);
vi.stubGlobal("fetch", fetchMock);
const onLocalStyleChange = vi.fn();
const store = new ApiStyleStore({ onLocalStyleChange });
await store.init();
expect(fetchMock).toHaveBeenCalledWith("http://localhost:8000/styles", { mode: "cors" });
expect(store.latestStyleId).toBe("style-a");
// A websocket message triggers onLocalStyleChange with a valid style.
expect(wsInstances.length).toBe(1);
wsInstances[0].onmessage!({ data: JSON.stringify({ version: 8, sources: {}, layers: [] }) });
expect(onLocalStyleChange).toHaveBeenCalledOnce();
expect(onLocalStyleChange.mock.calls[0][0].id).toBeTruthy();
});
it("init throws a friendly error when the API is unreachable", async () => {
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("boom"); }));
const store = new ApiStyleStore({});
await expect(store.init()).rejects.toThrow("Can not connect to style API");
});
it("getLatestStyle fetches the latest style and validates it", async () => {
vi.stubGlobal("fetch", vi.fn(async () => okJson(["s1"]) as any));
const store = new ApiStyleStore({});
await store.init();
vi.stubGlobal("fetch", vi.fn(async () => okJson({ version: 8, id: "s1", sources: {}, layers: [] }) as any));
const latest = await store.getLatestStyle();
expect(latest.id).toBe("s1");
});
it("getLatestStyle throws when not initialised", async () => {
const store = new ApiStyleStore({});
await expect(store.getLatestStyle()).rejects.toThrow(/init the api backend/);
});
it("save PUTs the (token-stripped) style to the API", () => {
const fetchMock = vi.fn(async () => okJson({}) as any);
vi.stubGlobal("fetch", fetchMock);
const store = new ApiStyleStore({});
const mapStyle = { version: 8, id: "s1", sources: {}, layers: [] } as any;
expect(store.save(mapStyle)).toBe(mapStyle);
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:8000/styles/s1",
expect.objectContaining({ method: "PUT", mode: "cors" })
);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// StyleStore reads/writes window.localStorage; provide a minimal in-memory mock.
class LocalStorageMock {
private store: Record<string, string> = {};
get length() {
return Object.keys(this.store).length;
}
key(i: number) {
return Object.keys(this.store)[i] ?? null;
}
getItem(k: string) {
return k in this.store ? this.store[k] : null;
}
setItem(k: string, v: string) {
this.store[k] = v;
}
removeItem(k: string) {
delete this.store[k];
}
clear() {
this.store = {};
}
}
vi.stubGlobal("window", { localStorage: new LocalStorageMock() });
// Avoid network in loadDefaultStyle.
vi.mock("../urlopen", () => ({
loadStyleUrl: vi.fn(async () => ({ version: 8, id: "default", sources: {}, layers: [] })),
}));
import { StyleStore } from "./stylestore";
const style = (id: string) => ({ version: 8, id, sources: {}, layers: [] }) as any;
describe("StyleStore", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("returns the default style when nothing is stored", async () => {
const store = new StyleStore();
const latest = await store.getLatestStyle();
expect(latest.id).toBe("default");
});
it("saves a style and reads it back as the latest", async () => {
const store = new StyleStore();
await store.save(style("abc"));
// A fresh store discovers the persisted style ids.
const reopened = new StyleStore();
const latest = await reopened.getLatestStyle();
expect(latest.id).toBe("abc");
});
it("purge removes all maputnik keys", async () => {
const store = new StyleStore();
await store.save(style("abc"));
window.localStorage.setItem("unrelated", "keep");
store.purge();
expect(window.localStorage.getItem("maputnik:style:abc")).toBeNull();
expect(window.localStorage.getItem("unrelated")).toBe("keep");
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import type { StyleSpecification } from "maplibre-gl";
import style from "./style";
function baseStyle(overrides: Partial<StyleSpecification> = {}): StyleSpecification {
return { version: 8, sources: {}, layers: [], ...overrides } as StyleSpecification;
}
describe("ensureStyleValidity", () => {
it("adds an id and strips interactive from layers", () => {
const result = style.ensureStyleValidity(
baseStyle({
layers: [{ id: "l", type: "background", interactive: true } as any],
})
);
expect(result.id).toBeTruthy();
expect("interactive" in result.layers[0]).toBe(false);
});
it("keeps an existing id", () => {
const result = style.ensureStyleValidity(baseStyle({ id: "keep-me" } as any));
expect(result.id).toBe("keep-me");
});
});
describe("generateId", () => {
it("generates a non-empty string", () => {
expect(typeof style.generateId()).toBe("string");
expect(style.generateId().length).toBeGreaterThan(0);
});
});
describe("indexOfLayer", () => {
const layers = [{ id: "a" }, { id: "b" }] as any;
it("returns the index of a matching layer", () => {
expect(style.indexOfLayer(layers, "b")).toBe(1);
});
it("returns null when not found", () => {
expect(style.indexOfLayer(layers, "missing")).toBeNull();
});
});
describe("getAccessToken", () => {
it("reads the token from metadata", () => {
const s = baseStyle({ metadata: { "maputnik:openmaptiles_access_token": "abc" } } as any);
expect(style.getAccessToken("openmaptiles", s, {})).toBe("abc");
});
it("falls back to the bundled token only when allowed", () => {
const s = baseStyle();
expect(style.getAccessToken("openmaptiles", s, {})).toBeUndefined();
expect(style.getAccessToken("openmaptiles", s, { allowFallback: true })).toBeTruthy();
});
});
describe("replaceAccessTokens", () => {
it("replaces {key} in a source url and in glyphs", () => {
const s = baseStyle({
metadata: { "maputnik:openmaptiles_access_token": "TОKEN" } as any,
sources: { openmaptiles: { type: "vector", url: "https://api.maptiler.com/x?key={key}" } } as any,
glyphs: "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}",
});
const result = style.replaceAccessTokens(s);
expect((result.sources.openmaptiles as any).url).toContain("key=TОKEN");
expect(result.glyphs).toContain("key=TОKEN");
});
it("maps thunderforest transport/outdoors sources to the thunderforest token", () => {
const s = baseStyle({
metadata: { "maputnik:thunderforest_access_token": "TF" } as any,
sources: { thunderforest_transport: { type: "vector", url: "https://tile.thunderforest.com/x?apikey={key}" } } as any,
});
const result = style.replaceAccessTokens(s);
expect((result.sources.thunderforest_transport as any).url).toContain("TF");
});
it("appends an api_key query param for stadia sources", () => {
const s = baseStyle({
metadata: { "maputnik:stadia_access_token": "ST" } as any,
sources: { basemap: { type: "vector", url: "https://tiles.stadiamaps.com/data/x.json" } } as any,
});
const result = style.replaceAccessTokens(s);
expect((result.sources.basemap as any).url).toContain("api_key=ST");
});
it("uses the locationiq token for locationiq sources", () => {
const s = baseStyle({
metadata: { "maputnik:locationiq_access_token": "LIQ" } as any,
sources: { liq: { type: "vector", url: "https://tiles.locationiq.com/v3/x?key={key}" } } as any,
});
const result = style.replaceAccessTokens(s);
expect((result.sources.liq as any).url).toContain("LIQ");
});
it("leaves sources without a url or token untouched", () => {
const s = baseStyle({
sources: {
noUrl: { type: "geojson", data: {} } as any,
noToken: { type: "vector", url: "https://api.maptiler.com/x?key={key}" } as any,
},
});
const result = style.replaceAccessTokens(s);
expect((result.sources.noToken as any).url).toContain("{key}");
});
});
describe("stripAccessTokens", () => {
it("removes provider access tokens from metadata", () => {
const s = baseStyle({
metadata: {
"maputnik:openmaptiles_access_token": "a",
"maputnik:thunderforest_access_token": "b",
"maputnik:renderer": "mlgljs",
} as any,
});
const result = style.stripAccessTokens(s);
expect(result.metadata).not.toHaveProperty("maputnik:openmaptiles_access_token");
expect(result.metadata).not.toHaveProperty("maputnik:thunderforest_access_token");
expect((result.metadata as any)["maputnik:renderer"]).toBe("mlgljs");
});
});
+1 -27
View File
@@ -1,34 +1,8 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
export default defineConfig({
plugins: [react()],
test: {
projects: [
{
extends: true,
test: {
name: "unit",
environment: "node",
include: ["src/**/*.test.{ts,tsx}"],
exclude: ["src/**/*.browser.test.{ts,tsx}"],
},
},
{
extends: true,
test: {
name: "browser",
include: ["src/**/*.browser.test.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright(),
headless: true,
instances: [{ browser: "chromium" }],
},
},
},
],
include: ["src/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["json", "lcov", "text-summary"],