Compare commits

..

13 Commits

Author SHA1 Message Date
renovate-bot e6036fd831 chore(deps): update all non-major dependencies (#14)
Release / upload-to-gitea-release (push) Has been skipped
Release / generate-dungeon (push) Failing after 1m25s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2fcoverage-v8/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5?slim=true) |
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`10.2.0` → `10.2.1`](https://renovatebot.com/diffs/npm/eslint/10.2.0/10.2.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/10.2.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/10.2.0/10.2.1?slim=true) |
| [puppeteer](https://github.com/puppeteer/puppeteer/tree/main#readme) ([source](https://github.com/puppeteer/puppeteer)) | [`24.41.0` → `24.42.0`](https://renovatebot.com/diffs/npm/puppeteer/24.41.0/24.42.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/puppeteer/24.42.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/puppeteer/24.41.0/24.42.0?slim=true) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/vitest/4.1.4/4.1.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/4.1.4/4.1.5?slim=true) |

---

### Release Notes

<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-v8)</summary>

### [`v4.1.5`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.5)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)

#####    🚀 Experimental Features

- **coverage**: Istanbul to support `instrumenter` option  -  by [@&#8203;BartWaardenburg](https://github.com/BartWaardenburg) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;10119](https://github.com/vitest-dev/vitest/issues/10119) [<samp>(0e0ff)</samp>](https://github.com/vitest-dev/vitest/commit/0e0ff41c7)

#####    🐞 Bug Fixes

- \--project negation excludes browser instances  -  by [@&#8203;felamaslen](https://github.com/felamaslen) in [#&#8203;10131](https://github.com/vitest-dev/vitest/issues/10131) [<samp>(9423d)</samp>](https://github.com/vitest-dev/vitest/commit/9423dc084)
- Project color label on html reporter  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10142](https://github.com/vitest-dev/vitest/issues/10142) [<samp>(596f7)</samp>](https://github.com/vitest-dev/vitest/commit/596f73986)
- Fix `vi.defineHelper` called as object method  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10163](https://github.com/vitest-dev/vitest/issues/10163) [<samp>(122c2)</samp>](https://github.com/vitest-dev/vitest/commit/122c25b5b)
- Alias `agent` reporter to `minimal`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10157](https://github.com/vitest-dev/vitest/issues/10157) [<samp>(663b9)</samp>](https://github.com/vitest-dev/vitest/commit/663b99fe3)
- Respect diff config options in soft assertions  -  by [@&#8203;Copilot](https://github.com/Copilot), **sheremet-va** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(9787d)</samp>](https://github.com/vitest-dev/vitest/commit/9787dedad)
- Respect diff config options in soft assertions "  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(7dc6d)</samp>](https://github.com/vitest-dev/vitest/commit/7dc6d54fd)
- **ast-collect**: Recognize \_*vi\_import* prefix in static test discovery  -  by [@&#8203;Yejneshwar](https://github.com/Yejneshwar) in [#&#8203;10129](https://github.com/vitest-dev/vitest/issues/10129) [<samp>(32546)</samp>](https://github.com/vitest-dev/vitest/commit/325463ab2)
- **coverage**: Descriptive error message when reports directory is removed during test run  -  by [@&#8203;DaveT1991](https://github.com/DaveT1991) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;10117](https://github.com/vitest-dev/vitest/issues/10117) [<samp>(14133)</samp>](https://github.com/vitest-dev/vitest/commit/1413382e1)
- **snapshot**: Increase default snapshot max output length  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;10150](https://github.com/vitest-dev/vitest/issues/10150) [<samp>(21e66)</samp>](https://github.com/vitest-dev/vitest/commit/21e66ff63)
- **ui**: Fix jsx/tsx syntax highlight  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10152](https://github.com/vitest-dev/vitest/issues/10152) [<samp>(f1b1f)</samp>](https://github.com/vitest-dev/vitest/commit/f1b1f6c7b)
- **web-worker**: Support MessagePort objects referenced inside postMessage data  -  by [@&#8203;whitphx](https://github.com/whitphx) and **Claude Opus 4.6 (1M context)** in [#&#8203;9927](https://github.com/vitest-dev/vitest/issues/9927) and [#&#8203;10124](https://github.com/vitest-dev/vitest/issues/10124) [<samp>(7ad7d)</samp>](https://github.com/vitest-dev/vitest/commit/7ad7d39af)
- **api**: Make test-specification options writable  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10154](https://github.com/vitest-dev/vitest/issues/10154) [<samp>(6abd5)</samp>](https://github.com/vitest-dev/vitest/commit/6abd557b7)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)

</details>

<details>
<summary>eslint/eslint (eslint)</summary>

### [`v10.2.1`](https://github.com/eslint/eslint/releases/tag/v10.2.1)

[Compare Source](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)

#### Bug Fixes

- [`14be92b`](https://github.com/eslint/eslint/commit/14be92b6d1fa0923b8923830f2208e5e2705b002) fix: model generator yield resumption paths in code path analysis ([#&#8203;20665](https://github.com/eslint/eslint/issues/20665)) (sethamus)
- [`84a19d2`](https://github.com/eslint/eslint/commit/84a19d2c32255db6b9cfc08644a607aae6d5cb62) fix: no-async-promise-executor false positives for shadowed Promise ([#&#8203;20740](https://github.com/eslint/eslint/issues/20740)) (xbinaryx)
- [`af764af`](https://github.com/eslint/eslint/commit/af764af0ec38225755fbf8a6f207f0c77b595a8d) fix: clarify language and processor validation errors ([#&#8203;20729](https://github.com/eslint/eslint/issues/20729)) (Pixel998)
- [`e251b89`](https://github.com/eslint/eslint/commit/e251b89a38280973e468a4a9386c138f4f55d10d) fix: update eslint ([#&#8203;20715](https://github.com/eslint/eslint/issues/20715)) (renovate\[bot])

#### Documentation

- [`ca92ca0`](https://github.com/eslint/eslint/commit/ca92ca0fb4599e8de1e2fb914e695fe7397cbe63) docs: reuse markdown-it instance for markdown filter ([#&#8203;20768](https://github.com/eslint/eslint/issues/20768)) (Amaresh  S M)
- [`57d2ee2`](https://github.com/eslint/eslint/commit/57d2ee213305cee0cb55ef08e0480b57396269a9) docs:  Enable Eleventy incremental mode for watch ([#&#8203;20767](https://github.com/eslint/eslint/issues/20767)) (Amaresh  S M)
- [`c1621b9`](https://github.com/eslint/eslint/commit/c1621b915742276e5f4b25efe790ca62296330dc) docs: fix typos in code-path-analyzer.js ([#&#8203;20700](https://github.com/eslint/eslint/issues/20700)) (Ayush Shukla)
- [`1418d52`](https://github.com/eslint/eslint/commit/1418d522d10bde1960f4942afb548bc7160ec49e) docs: Update README (GitHub Actions Bot)
- [`39771e6`](https://github.com/eslint/eslint/commit/39771e6e600f0b0617fdeafff6dd07e4211ffde6) docs: Update README (GitHub Actions Bot)
- [`71e0469`](https://github.com/eslint/eslint/commit/71e04693def2df57268f08f3072a2749df6bf438) docs: fix incomplete JSDoc param description in no-shadow rule ([#&#8203;20728](https://github.com/eslint/eslint/issues/20728)) (kuldeep kumar)
- [`22119ce`](https://github.com/eslint/eslint/commit/22119ceb93e28f62262fc1d98ff1b1442d6e2dbf) docs: clarify scope of for-direction rule with dead code examples ([#&#8203;20723](https://github.com/eslint/eslint/issues/20723)) (Amaresh  S M)
- [`8f3fb77`](https://github.com/eslint/eslint/commit/8f3fb77f122a5641d1833cad5d93f3f54fa3be0b) docs: document `meta.docs.dialects` ([#&#8203;20718](https://github.com/eslint/eslint/issues/20718)) (Pixel998)

#### Chores

- [`7ddfea9`](https://github.com/eslint/eslint/commit/7ddfea9c4f62add1588c5c0b0da568c299246383) chore: update dependency prettier to v3.8.2 ([#&#8203;20770](https://github.com/eslint/eslint/issues/20770)) (renovate\[bot])
- [`fac40e1`](https://github.com/eslint/eslint/commit/fac40e1de2ba7646cc7cd2d3f93fbdd1f8819001) ci: bump pnpm/action-setup from 5.0.0 to 6.0.0 ([#&#8203;20763](https://github.com/eslint/eslint/issues/20763)) (dependabot\[bot])
- [`7246f92`](https://github.com/eslint/eslint/commit/7246f923332522d8b3d46b6ee646fce88535f3fb) test: add tests for SuppressionsService.load() error handling ([#&#8203;20734](https://github.com/eslint/eslint/issues/20734)) (kuldeep kumar)
- [`4f34b1e`](https://github.com/eslint/eslint/commit/4f34b1e592b0f63d766d9903998e8e36eb49d3aa) chore: update pnpm/action-setup action to v5 ([#&#8203;20762](https://github.com/eslint/eslint/issues/20762)) (renovate\[bot])
- [`51080eb`](https://github.com/eslint/eslint/commit/51080eb5c98d619434e4835dbe9f1c6654aca3b8) test: processor service ([#&#8203;20731](https://github.com/eslint/eslint/issues/20731)) (kuldeep kumar)
- [`e7e1889`](https://github.com/eslint/eslint/commit/e7e1889fca9b6044e08f41b38df20a1ce45808c8) chore: remove stale babel-eslint10 fixture and test  ([#&#8203;20727](https://github.com/eslint/eslint/issues/20727)) (kuldeep kumar)
- [`4e1a87c`](https://github.com/eslint/eslint/commit/4e1a87cb8fb90e309524bc36bc5f31b9f9cfaa76) test: remove redundant async/await in flat config array tests ([#&#8203;20722](https://github.com/eslint/eslint/issues/20722)) (Pixel998)
- [`066eabb`](https://github.com/eslint/eslint/commit/066eabb3643b12931f991594969bcc0028f71a5f) test: add rule metadata coverage for `languages` and `docs.dialects` ([#&#8203;20717](https://github.com/eslint/eslint/issues/20717)) (Pixel998)

</details>

<details>
<summary>puppeteer/puppeteer (puppeteer)</summary>

### [`v24.42.0`](https://github.com/puppeteer/puppeteer/blob/HEAD/CHANGELOG.md#24420-2026-04-20)

[Compare Source](https://github.com/puppeteer/puppeteer/compare/puppeteer-v24.41.0...puppeteer-v24.42.0)

##### ♻️ Chores

- **puppeteer:** Synchronize puppeteer versions

##### Dependencies

- The following workspace dependencies were updated
  - dependencies
    - puppeteer-core bumped from 24.41.0 to 24.42.0

##### 🎉 Features

- add metadata to extensions object ([#&#8203;14870](https://github.com/puppeteer/puppeteer/issues/14870)) ([d3e190e](https://github.com/puppeteer/puppeteer/commit/d3e190e8aea051bf1cfdfb466909bfeca3b453c9))
- **cdp:** support autofilling address ([#&#8203;14826](https://github.com/puppeteer/puppeteer/issues/14826)) ([c2acadc](https://github.com/puppeteer/puppeteer/commit/c2acadc29e28846d09a8d0e60879c44a6c2e0b05))
- implement URL blocklist to restrict access to unauthorized sites ([#&#8203;14873](https://github.com/puppeteer/puppeteer/issues/14873)) ([8ad881c](https://github.com/puppeteer/puppeteer/commit/8ad881c61895f2689ae2aaddef5f37586000aa88))

##### 🛠️ Fixes

- remove PartitionAllocSchedulerLoopQuarantineTaskControlledPurge from disabled features ([#&#8203;14872](https://github.com/puppeteer/puppeteer/issues/14872)) ([c9909a5](https://github.com/puppeteer/puppeteer/commit/c9909a56e6b6d0bcbf8bfb3a6af2b496e6fc489f))
- roll to Chrome 147.0.7727.57 ([#&#8203;14869](https://github.com/puppeteer/puppeteer/issues/14869)) ([51c4305](https://github.com/puppeteer/puppeteer/commit/51c4305c0bdefd4e6aca385c9c1097e7a4923cfb))

</details>

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzYuMCIsInVwZGF0ZWRJblZlciI6IjQzLjE1MC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://git.keligrubb.com/keligrubb/scrollsmith/pulls/14
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-04-29 20:24:15 +00:00
renovate-bot b42210cb0c Update dependency puppeteer to v24.41.0 (#13)
Release / upload-to-gitea-release (push) Has been skipped
Release / generate-dungeon (push) Failing after 49s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [puppeteer](https://github.com/puppeteer/puppeteer/tree/main#readme) ([source](https://github.com/puppeteer/puppeteer)) | [`24.40.0` → `24.41.0`](https://renovatebot.com/diffs/npm/puppeteer/24.40.0/24.41.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/puppeteer/24.41.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/puppeteer/24.40.0/24.41.0?slim=true) |

---

### Release Notes

<details>
<summary>puppeteer/puppeteer (puppeteer)</summary>

### [`v24.41.0`](https://github.com/puppeteer/puppeteer/blob/HEAD/CHANGELOG.md#24410-2026-04-15)

[Compare Source](https://github.com/puppeteer/puppeteer/compare/puppeteer-v24.40.0...puppeteer-v24.41.0)

##### 🎉 Features

- add support for Issues ([#&#8203;14845](https://github.com/puppeteer/puppeteer/issues/14845)) ([6e8dbe7](https://github.com/puppeteer/puppeteer/commit/6e8dbe7a998a3619f089f549009ebcb860389fdd))
- adds extension realms api ([#&#8203;14824](https://github.com/puppeteer/puppeteer/issues/14824)) ([c14f4ae](https://github.com/puppeteer/puppeteer/commit/c14f4ae7ee65fd95a4a1f9d722e73f67c37da44b))
- API to list installed browser extensions and trigger extension actions ([#&#8203;14821](https://github.com/puppeteer/puppeteer/issues/14821)) ([d6395ef](https://github.com/puppeteer/puppeteer/commit/d6395ef88103a50cb2b2c43f61953ab6a495a8c3))
- implement console event on web workers ([#&#8203;14784](https://github.com/puppeteer/puppeteer/issues/14784)) ([fa6158a](https://github.com/puppeteer/puppeteer/commit/fa6158a1dfa327df8dc8eea1eb22c49efefb3be5))
- roll to Chrome 147.0.7727.24 ([#&#8203;14797](https://github.com/puppeteer/puppeteer/issues/14797)) ([ee81786](https://github.com/puppeteer/puppeteer/commit/ee81786a8285e20afdee70e4fb8660df4d6748b0))
- roll to Firefox 149.0 ([#&#8203;14799](https://github.com/puppeteer/puppeteer/issues/14799)) ([9fd5ceb](https://github.com/puppeteer/puppeteer/commit/9fd5ceb054b0508bd8f4b14ed950a011a31f101a))
- **webmcp:** add hook for tool invocation ([#&#8203;14835](https://github.com/puppeteer/puppeteer/issues/14835)) ([cf8169d](https://github.com/puppeteer/puppeteer/commit/cf8169d5dee0bbff06118c211969eb94849f6bbd))
- **webmcp:** add hook for tool response ([#&#8203;14841](https://github.com/puppeteer/puppeteer/issues/14841)) ([6fb05bc](https://github.com/puppeteer/puppeteer/commit/6fb05bc9e45fb2735a1c44b59ad868af2fb1ee9b))
- **webmcp:** add initial API to inspect tool registrations ([#&#8203;14814](https://github.com/puppeteer/puppeteer/issues/14814)) ([655c996](https://github.com/puppeteer/puppeteer/commit/655c996fed21d4ac7f5df841aef0c6b246ba2e9d))
- **webmcp:** add WebMCPTool execute support ([#&#8203;14851](https://github.com/puppeteer/puppeteer/issues/14851)) ([8f95117](https://github.com/puppeteer/puppeteer/commit/8f95117960af969ee31595406f985c924eb67bf1))
- **webmcp:** expose WebMCPToolCall in WebMCPToolCallResult ([#&#8203;14848](https://github.com/puppeteer/puppeteer/issues/14848)) ([242ac0b](https://github.com/puppeteer/puppeteer/commit/242ac0b2d364e9463f2b0e37f26d8bd0cfdf4d3e))
- **webmcp:** Switch from WebMCPInvocationStatus Success to Completed ([#&#8203;14859](https://github.com/puppeteer/puppeteer/issues/14859)) ([375e636](https://github.com/puppeteer/puppeteer/commit/375e636beedaa5fef53d5f198fa70229d47155b5))

##### Dependencies

- The following workspace dependencies were updated
  - dependencies
    - puppeteer-core bumped from 24.40.0 to 24.41.0

##### 🛠️ Fixes

- add missing onRelease to Mutex and add tests ([#&#8203;14818](https://github.com/puppeteer/puppeteer/issues/14818)) ([bf1e972](https://github.com/puppeteer/puppeteer/commit/bf1e9722eef723c80250119d81fd9d9e0596c074))
- make `Target.asPage` return the same Page instance ([#&#8203;14862](https://github.com/puppeteer/puppeteer/issues/14862)) ([e484a91](https://github.com/puppeteer/puppeteer/commit/e484a918c432859efbf57a74b4957097b13f8575))
- remove RenderDocument from disabled Chrome features ([#&#8203;14745](https://github.com/puppeteer/puppeteer/issues/14745)) ([a48eba2](https://github.com/puppeteer/puppeteer/commit/a48eba24dcb2663da543bdf1f4597a2c1a56f0ff))
- roll to Chrome 147.0.7727.50 ([#&#8203;14819](https://github.com/puppeteer/puppeteer/issues/14819)) ([2be3002](https://github.com/puppeteer/puppeteer/commit/2be30023994ee2e7ebb15e43dc0e2804256f8ca2))
- roll to Chrome 147.0.7727.56 ([#&#8203;14842](https://github.com/puppeteer/puppeteer/issues/14842)) ([fdb3c64](https://github.com/puppeteer/puppeteer/commit/fdb3c64f8bfcff367eab862c0309f9c4bf6d6f20))
- roll to Firefox 149.0.2 ([#&#8203;14838](https://github.com/puppeteer/puppeteer/issues/14838)) ([55359a3](https://github.com/puppeteer/puppeteer/commit/55359a3d7383c03a9d9de7ff8e24b613655694e8))
- without azimuthAngle the altitudeAngle should no be specified ([#&#8203;14781](https://github.com/puppeteer/puppeteer/issues/14781)) ([6f9d975](https://github.com/puppeteer/puppeteer/commit/6f9d9752207d55be0e3b0d10ba9a416a81af4694))

##### 📄 Documentation

- update extension and realm docs ([#&#8203;14867](https://github.com/puppeteer/puppeteer/issues/14867)) ([080379b](https://github.com/puppeteer/puppeteer/commit/080379bf24c6bd021d664bcf993457542cf76dcc))
- **webmcp:** Add JS example to WebMCP class ([#&#8203;14852](https://github.com/puppeteer/puppeteer/issues/14852)) ([c514dba](https://github.com/puppeteer/puppeteer/commit/c514dba3316e742f2d2b428fd72a49890c0a2255))
- **webmcp:** Add missing documentation ([#&#8203;14849](https://github.com/puppeteer/puppeteer/issues/14849)) ([76b08d2](https://github.com/puppeteer/puppeteer/commit/76b08d20305283b9a2a2ccd5cf9c2f5bad63e337))
- **webmcp:** tune tags and descriptions ([#&#8203;14844](https://github.com/puppeteer/puppeteer/issues/14844)) ([5782958](https://github.com/puppeteer/puppeteer/commit/578295815ca559790626b8aeeddeea9f9cef67be))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjIuMCIsInVwZGF0ZWRJblZlciI6IjQzLjEyMi4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://git.keligrubb.com/keligrubb/scrollsmith/pulls/13
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-04-15 15:12:33 +00:00
keligrubb 2cb57aed41 fix(release): use artifact actions v3 on Gitea mirrors. (#11)
Release / generate-dungeon (push) Successful in 3m53s
Release / upload-to-gitea-release (push) Successful in 11s
v4+ relies on @actions/artifact v2, which this runner rejects. Download
uses path: . so release PDFs land in the job root for ls *.pdf.

Reviewed-on: #11
Co-authored-by: keligrubb <keligrubb324@gmail.com>
Co-committed-by: keligrubb <keligrubb324@gmail.com>
2026-04-15 03:07:49 +00:00
keligrubb 1e7c5855e5 Point Gitea Actions at git.keligrubb.com action mirrors. (#10)
Release / generate-dungeon (push) Failing after 2m41s
Release / upload-to-gitea-release (push) Has been skipped
Use self-hosted mirrors for checkout, setup-node, upload-artifact, and
download-artifact. Pin upload/download to v3 so artifact uploads work on
runners that do not support @actions/artifact v2 (GHES-style); set
download path to the workspace root for the release upload script.

Reviewed-on: #10
Co-authored-by: keligrubb <keligrubb324@gmail.com>
Co-committed-by: keligrubb <keligrubb324@gmail.com>
2026-04-15 03:02:55 +00:00
keligrubb 7ea9f93dc8 Improve LLM client immutability and CI model defaults. (#9)
Release / generate-dungeon (push) Failing after 2m30s
Release / upload-to-gitea-release (push) Has been skipped
Replace mutable Ollama model export with a const fallback and initializeModel return value, resolving the model from the environment after optional API discovery. Use a for-of loop over attempt indices instead of let in the retry path.

Continue PDF generation when map image generation or upscaling fails, and avoid mutating request headers in place.

Document Open WebUI-style URLs in the README, pin OLLAMA_MODEL in the Gitea release workflow, and adjust integration and unit tests for the new initialization behavior.

Reviewed-on: #9
Co-authored-by: keligrubb <keligrubb324@gmail.com>
Co-committed-by: keligrubb <keligrubb324@gmail.com>
2026-04-15 02:45:25 +00:00
renovate-bot 4428cd4cb8 Update all non-major dependencies (#8)
Release / generate-dungeon (push) Failing after 44s
Release / upload-to-gitea-release (push) Has been skipped
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.0.18` → `4.1.4`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.0.18/4.1.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2fcoverage-v8/4.1.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2fcoverage-v8/4.0.18/4.1.4?slim=true) |
| [dotenv](https://github.com/motdotla/dotenv) | [`17.3.1` → `17.4.2`](https://renovatebot.com/diffs/npm/dotenv/17.3.1/17.4.2) | ![age](https://developer.mend.io/api/mc/badges/age/npm/dotenv/17.4.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/dotenv/17.3.1/17.4.2?slim=true) |
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`10.0.1` → `10.2.0`](https://renovatebot.com/diffs/npm/eslint/10.0.1/10.2.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/10.2.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/10.0.1/10.2.0?slim=true) |
| [globals](https://github.com/sindresorhus/globals) | [`17.3.0` → `17.5.0`](https://renovatebot.com/diffs/npm/globals/17.3.0/17.5.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/globals/17.5.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/globals/17.3.0/17.5.0?slim=true) |
| [puppeteer](https://github.com/puppeteer/puppeteer/tree/main#readme) ([source](https://github.com/puppeteer/puppeteer)) | [`24.37.5` → `24.40.0`](https://renovatebot.com/diffs/npm/puppeteer/24.37.5/24.40.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/puppeteer/24.40.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/puppeteer/24.37.5/24.40.0?slim=true) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.0.18` → `4.1.4`](https://renovatebot.com/diffs/npm/vitest/4.0.18/4.1.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/4.1.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/4.0.18/4.1.4?slim=true) |

---

### Release Notes

<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-v8)</summary>

### [`v4.1.4`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.4)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.3...v4.1.4)

#####    🚀 Experimental Features

- **coverage**:
  - Default to text reporter `skipFull` if agent detected  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10018](https://github.com/vitest-dev/vitest/issues/10018) [<samp>(53757)</samp>](https://github.com/vitest-dev/vitest/commit/53757804c)
- **experimental**:
  - Expose `assertion` as a public field  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10095](https://github.com/vitest-dev/vitest/issues/10095) [<samp>(a120e)</samp>](https://github.com/vitest-dev/vitest/commit/a120e3ab8)
  - Support aria snapshot  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **Claude Opus 4.6 (1M context)**, [@&#8203;AriPerkkio](https://github.com/AriPerkkio), **Codex** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9668](https://github.com/vitest-dev/vitest/issues/9668) [<samp>(d4fbb)</samp>](https://github.com/vitest-dev/vitest/commit/d4fbb5cc9)
- **reporter**:
  - Add filterMeta option to json reporter  -  by [@&#8203;nami8824](https://github.com/nami8824) and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10078](https://github.com/vitest-dev/vitest/issues/10078) [<samp>(b77de)</samp>](https://github.com/vitest-dev/vitest/commit/b77de968e)

#####    🐞 Bug Fixes

- Use "black" foreground for labeled terminal message to ensure contrast  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10076](https://github.com/vitest-dev/vitest/issues/10076) [<samp>(203f0)</samp>](https://github.com/vitest-dev/vitest/commit/203f07af7)
- Make `expect(..., message)` consistent as error message prefix  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;10068](https://github.com/vitest-dev/vitest/issues/10068) [<samp>(a1b5f)</samp>](https://github.com/vitest-dev/vitest/commit/a1b5f0f4f)
- Do not hoist imports whose names match class properties .  -  by [@&#8203;SunsetFi](https://github.com/SunsetFi) in [#&#8203;10093](https://github.com/vitest-dev/vitest/issues/10093) and [#&#8203;10094](https://github.com/vitest-dev/vitest/issues/10094) [<samp>(0fc4b)</samp>](https://github.com/vitest-dev/vitest/commit/0fc4b47e0)
- **browser**: Spread user server options into browser Vite server in project  -  by [@&#8203;GoldStrikeArch](https://github.com/GoldStrikeArch) in [#&#8203;10049](https://github.com/vitest-dev/vitest/issues/10049) [<samp>(65c9d)</samp>](https://github.com/vitest-dev/vitest/commit/65c9d55eb)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.3...v4.1.4)

### [`v4.1.3`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.3)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.2...v4.1.3)

#####    🚀 Experimental Features

- Add `experimental.preParse` flag  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10070](https://github.com/vitest-dev/vitest/issues/10070) [<samp>(78273)</samp>](https://github.com/vitest-dev/vitest/commit/7827363bd)
- Support `browser.locators.exact` option  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10013](https://github.com/vitest-dev/vitest/issues/10013) [<samp>(48799)</samp>](https://github.com/vitest-dev/vitest/commit/487990a19)
- Add `TestAttachment.bodyEncoding`  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9969](https://github.com/vitest-dev/vitest/issues/9969) [<samp>(89ca0)</samp>](https://github.com/vitest-dev/vitest/commit/89ca0e254)
- Support custom snapshot matcher  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **Claude Sonnet 4.6** and **Codex** in [#&#8203;9973](https://github.com/vitest-dev/vitest/issues/9973) [<samp>(59b0e)</samp>](https://github.com/vitest-dev/vitest/commit/59b0e6411)

#####    🐞 Bug Fixes

- Advance fake timers with `expect.poll` interval  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;10022](https://github.com/vitest-dev/vitest/issues/10022) [<samp>(3f5bf)</samp>](https://github.com/vitest-dev/vitest/commit/3f5bfa365)
- Add `@vitest/coverage-v8` and `@vitest/coverage-istanbul` as optional dependency  -  by [@&#8203;alan-agius4](https://github.com/alan-agius4) in [#&#8203;10025](https://github.com/vitest-dev/vitest/issues/10025) [<samp>(146d4)</samp>](https://github.com/vitest-dev/vitest/commit/146d4f0a0)
- Fix `defineHelper` for webkit async stack trace + update playwright 1.59.0  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10036](https://github.com/vitest-dev/vitest/issues/10036) [<samp>(5a5fa)</samp>](https://github.com/vitest-dev/vitest/commit/5a5fa49fe)
- Fix suite hook throwing errors for unused auto test-scoped fixture  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;10035](https://github.com/vitest-dev/vitest/issues/10035) [<samp>(39865)</samp>](https://github.com/vitest-dev/vitest/commit/398657e8d)
- **expect**:
  - Remove `JestExtendError.context` from verbose error reporting  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9983](https://github.com/vitest-dev/vitest/issues/9983) [<samp>(66751)</samp>](https://github.com/vitest-dev/vitest/commit/66751c9e8)
  - Don't leak "runner" types  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10004](https://github.com/vitest-dev/vitest/issues/10004) [<samp>(ec204)</samp>](https://github.com/vitest-dev/vitest/commit/ec2045543)
- **snapshot**:
  - Fix flagging obsolete snapshots for snapshot properties mismatch  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;9986](https://github.com/vitest-dev/vitest/issues/9986) [<samp>(6b869)</samp>](https://github.com/vitest-dev/vitest/commit/6b869156b)
  - Export custom snapshot matcher helper from `vitest`  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;10042](https://github.com/vitest-dev/vitest/issues/10042) [<samp>(691d3)</samp>](https://github.com/vitest-dev/vitest/commit/691d341fd)
- **ui**:
  - Don't leak vite types  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10005](https://github.com/vitest-dev/vitest/issues/10005) [<samp>(fdff1)</samp>](https://github.com/vitest-dev/vitest/commit/fdff1bf9a)
- **vm**:
  - Fix external module resolve error with deps optimizer query  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;10024](https://github.com/vitest-dev/vitest/issues/10024) [<samp>(9dbf4)</samp>](https://github.com/vitest-dev/vitest/commit/9dbf47786)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.2...v4.1.3)

### [`v4.1.2`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.2)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.1...v4.1.2)

This release bumps Vitest's `flatted` version and removes version pinning to resolve `flatted`'s CVE related issues ([#&#8203;9975](https://github.com/vitest-dev/vitest/issues/9975)).

#####    🐞 Bug Fixes

- Don't resolve `setupFiles` from parent directory  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9960](https://github.com/vitest-dev/vitest/issues/9960) [<samp>(7aa93)</samp>](https://github.com/vitest-dev/vitest/commit/7aa937776)
- Ensure sequential mock/unmock resolution  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9830](https://github.com/vitest-dev/vitest/issues/9830) [<samp>(7c065)</samp>](https://github.com/vitest-dev/vitest/commit/7c06598db)
- **browser**: Take failure screenshot if `toMatchScreenshot` can't capture a stable screenshot  -  by [@&#8203;macarie](https://github.com/macarie) in [#&#8203;9847](https://github.com/vitest-dev/vitest/issues/9847) [<samp>(faace)</samp>](https://github.com/vitest-dev/vitest/commit/faace1fbe)
- **coverage**: Correct `coverageConfigDefaults` values and types  -  by [@&#8203;Arthie](https://github.com/Arthie) in [#&#8203;9940](https://github.com/vitest-dev/vitest/issues/9940) [<samp>(b3c99)</samp>](https://github.com/vitest-dev/vitest/commit/b3c992cb2)
- **pretty-format**: Fix output limit over counting  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9965](https://github.com/vitest-dev/vitest/issues/9965) [<samp>(d3b7a)</samp>](https://github.com/vitest-dev/vitest/commit/d3b7a40fa)
- Disable colors if agent is detected  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9851](https://github.com/vitest-dev/vitest/issues/9851) [<samp>(6f97b)</samp>](https://github.com/vitest-dev/vitest/commit/6f97b55dd)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.1...v4.1.2)

### [`v4.1.1`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.1)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.0...v4.1.1)

#####    🚀 Features

- **experimental**:
  - Expose `matchesTags` to test if the current filter matches tags  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9913](https://github.com/vitest-dev/vitest/issues/9913) [<samp>(eec53)</samp>](https://github.com/vitest-dev/vitest/commit/eec53d9f5)
  - Introduce `experimental.vcsProvider`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9928](https://github.com/vitest-dev/vitest/issues/9928) [<samp>(56115)</samp>](https://github.com/vitest-dev/vitest/commit/561150036)

#####    🐞 Bug Fixes

- Mark `TestProject.testFilesList` internal properly  -  by [@&#8203;sapphi-red](https://github.com/sapphi-red) in [#&#8203;9867](https://github.com/vitest-dev/vitest/issues/9867) [<samp>(54f26)</samp>](https://github.com/vitest-dev/vitest/commit/54f2660f5)
- Detect fixture that returns without calling `use`  -  by [@&#8203;oilater](https://github.com/oilater) in [#&#8203;9831](https://github.com/vitest-dev/vitest/issues/9831) and [#&#8203;9861](https://github.com/vitest-dev/vitest/issues/9861) [<samp>(633ae)</samp>](https://github.com/vitest-dev/vitest/commit/633ae2303)
- Drop vite 8.beta support  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9862](https://github.com/vitest-dev/vitest/issues/9862) [<samp>(b78f5)</samp>](https://github.com/vitest-dev/vitest/commit/b78f5389d)
- Type regression in vi.mocked() static class methods  -  by [@&#8203;purepear](https://github.com/purepear) and [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9857](https://github.com/vitest-dev/vitest/issues/9857) [<samp>(90926)</samp>](https://github.com/vitest-dev/vitest/commit/90926641b)
- Properly re-evaluate actual modules of mocked external  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9898](https://github.com/vitest-dev/vitest/issues/9898) [<samp>(ae5ec)</samp>](https://github.com/vitest-dev/vitest/commit/ae5ec03ef)
- Preserve coverage report when html reporter overlaps  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9889](https://github.com/vitest-dev/vitest/issues/9889) [<samp>(2d81a)</samp>](https://github.com/vitest-dev/vitest/commit/2d81ad897)
- Provide `vi.advanceTimers` to the preview provider  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9891](https://github.com/vitest-dev/vitest/issues/9891) [<samp>(1bc3e)</samp>](https://github.com/vitest-dev/vitest/commit/1bc3e63be)
- Don't leak event listener in playwright provider  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9910](https://github.com/vitest-dev/vitest/issues/9910) [<samp>(d9355)</samp>](https://github.com/vitest-dev/vitest/commit/d93550ff7)
- Open browser in `--standalone` mode without running tests  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9911](https://github.com/vitest-dev/vitest/issues/9911) [<samp>(e78ad)</samp>](https://github.com/vitest-dev/vitest/commit/e78adcf97)
- Guard disposable and optional `body`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9912](https://github.com/vitest-dev/vitest/issues/9912) [<samp>(6fdb2)</samp>](https://github.com/vitest-dev/vitest/commit/6fdb2ba61)
- Resolve `retry.condition` RegExp serialization issue  -  by [@&#8203;nstepien](https://github.com/nstepien) and [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9942](https://github.com/vitest-dev/vitest/issues/9942) [<samp>(7b605)</samp>](https://github.com/vitest-dev/vitest/commit/7b6054328)
- **collect**:
  - Don't treat extra props on `test` return as tests  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9871](https://github.com/vitest-dev/vitest/issues/9871) [<samp>(141e7)</samp>](https://github.com/vitest-dev/vitest/commit/141e72aa1)
- **coverage**:
  - Simplify provider types  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9931](https://github.com/vitest-dev/vitest/issues/9931) [<samp>(aaf9f)</samp>](https://github.com/vitest-dev/vitest/commit/aaf9f18ae)
  - Load built-in provider without module runner  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9939](https://github.com/vitest-dev/vitest/issues/9939) [<samp>(bf892)</samp>](https://github.com/vitest-dev/vitest/commit/bf8920817)
- **expect**:
  - Soft assertions continue after .resolves/.rejects promise errors  -  by [@&#8203;mixelburg](https://github.com/mixelburg), **Maks Pikov**, **Claude Opus 4.6 (1M context)** and [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9843](https://github.com/vitest-dev/vitest/issues/9843) [<samp>(6d74b)</samp>](https://github.com/vitest-dev/vitest/commit/6d74b4948)
  - Fix sinon-chai style API  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9943](https://github.com/vitest-dev/vitest/issues/9943) [<samp>(0f08d)</samp>](https://github.com/vitest-dev/vitest/commit/0f08dda2c)
- **pretty-format**:
  - Limit output for large object  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6 (1M context)** in [#&#8203;9949](https://github.com/vitest-dev/vitest/issues/9949) [<samp>(0d5f9)</samp>](https://github.com/vitest-dev/vitest/commit/0d5f9d6ef)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.0...v4.1.1)

### [`v4.1.0`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.0)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.0.18...v4.1.0)

Vitest 4.1 is out!

This release page lists all changes made to the project during the 4.1 beta. To get a review of all the new features, read our [blog post](https://vitest.dev/blog/vitest-4-1).

#####    🚀 Features

- Return a disposable from doMock()  -  by [@&#8203;kirkwaiblinger](https://github.com/kirkwaiblinger) in [#&#8203;9332](https://github.com/vitest-dev/vitest/issues/9332) [<samp>(e3e65)</samp>](https://github.com/vitest-dev/vitest/commit/e3e659a96)
- Added chai style assertions  -  by [@&#8203;ronnakamoto](https://github.com/ronnakamoto) and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8842](https://github.com/vitest-dev/vitest/issues/8842) [<samp>(841df)</samp>](https://github.com/vitest-dev/vitest/commit/841df9ac5)
- Update to sinon/fake-timers v15 and add `setTickMode` to timer controls  -  by [@&#8203;atscott](https://github.com/atscott) and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8726](https://github.com/vitest-dev/vitest/issues/8726) [<samp>(4b480)</samp>](https://github.com/vitest-dev/vitest/commit/4b480aaed)
- Expose matcher types  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9448](https://github.com/vitest-dev/vitest/issues/9448) [<samp>(3e4b9)</samp>](https://github.com/vitest-dev/vitest/commit/3e4b913b1)
- Add `toTestSpecification` to reported tasks  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9464](https://github.com/vitest-dev/vitest/issues/9464) [<samp>(1a470)</samp>](https://github.com/vitest-dev/vitest/commit/1a4705da9)
- Show a warning if `vi.mock` or `vi.hoisted` are declared outside of top level of the module  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9387](https://github.com/vitest-dev/vitest/issues/9387) [<samp>(5db54)</samp>](https://github.com/vitest-dev/vitest/commit/5db54a468)
- Track and display expectedly failed tests (.fails) in UI and CLI  -  by [@&#8203;Copilot](https://github.com/Copilot), **sheremet-va** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9476](https://github.com/vitest-dev/vitest/issues/9476) [<samp>(77d75)</samp>](https://github.com/vitest-dev/vitest/commit/77d75fd34)
- Support tags  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9478](https://github.com/vitest-dev/vitest/issues/9478) [<samp>(de7c8)</samp>](https://github.com/vitest-dev/vitest/commit/de7c8a521)
- Implement `aroundEach` and `aroundAll` hooks  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9450](https://github.com/vitest-dev/vitest/issues/9450) [<samp>(2a8cb)</samp>](https://github.com/vitest-dev/vitest/commit/2a8cb9dc2)
- Stabilize experimental features  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9529](https://github.com/vitest-dev/vitest/issues/9529) [<samp>(b5fd2)</samp>](https://github.com/vitest-dev/vitest/commit/b5fd2a16a)
- Accept `new` or `all` in `--update` flag  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9543](https://github.com/vitest-dev/vitest/issues/9543) [<samp>(a5acf)</samp>](https://github.com/vitest-dev/vitest/commit/a5acf28a5)
- Support `meta` in test options  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9535](https://github.com/vitest-dev/vitest/issues/9535) [<samp>(7d622)</samp>](https://github.com/vitest-dev/vitest/commit/7d622e3d1)
- Support type inference with a new `test.extend` syntax  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9550](https://github.com/vitest-dev/vitest/issues/9550) [<samp>(e5385)</samp>](https://github.com/vitest-dev/vitest/commit/e53854fcc)
- Support vite 8 beta, fix type issues in the config with different vite versions  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9587](https://github.com/vitest-dev/vitest/issues/9587) [<samp>(99028)</samp>](https://github.com/vitest-dev/vitest/commit/990281dfd)
- Add assertion helper to hide internal stack traces  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9594](https://github.com/vitest-dev/vitest/issues/9594) [<samp>(eeb0a)</samp>](https://github.com/vitest-dev/vitest/commit/eeb0ae2f8)
- Store failure screenshots using artifacts API  -  by [@&#8203;macarie](https://github.com/macarie) in [#&#8203;9588](https://github.com/vitest-dev/vitest/issues/9588) [<samp>(24603)</samp>](https://github.com/vitest-dev/vitest/commit/24603e3c4)
- Allow `vitest list` to statically collect tests instead of running files to collect them  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9630](https://github.com/vitest-dev/vitest/issues/9630) [<samp>(7a8e7)</samp>](https://github.com/vitest-dev/vitest/commit/7a8e7fc20)
- Add `--detect-async-leaks`  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9528](https://github.com/vitest-dev/vitest/issues/9528) [<samp>(c594d)</samp>](https://github.com/vitest-dev/vitest/commit/c594d4af3)
- Implement `mockThrow` and `mockThrowOnce`  -  by [@&#8203;thor-juhasz](https://github.com/thor-juhasz) and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9512](https://github.com/vitest-dev/vitest/issues/9512) [<samp>(61917)</samp>](https://github.com/vitest-dev/vitest/commit/619179fb7)
- Support `update: "none"` and add docs about snapshots behavior on CI  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9700](https://github.com/vitest-dev/vitest/issues/9700) [<samp>(05f18)</samp>](https://github.com/vitest-dev/vitest/commit/05f1854e2)
- Support playwright `launchOptions` with `connectOptions`  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9702](https://github.com/vitest-dev/vitest/issues/9702) [<samp>(f0ff1)</samp>](https://github.com/vitest-dev/vitest/commit/f0ff1b2a0)
- Add `page/locator.mark` API to enhance playwright trace  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9652](https://github.com/vitest-dev/vitest/issues/9652) [<samp>(d0ee5)</samp>](https://github.com/vitest-dev/vitest/commit/d0ee546fe)
- **api**:
  - Support tests starting or ending with `test` in `experimental_parseSpecification`  -  by [@&#8203;jgillick](https://github.com/jgillick) and **Jeremy Gillick** in [#&#8203;9235](https://github.com/vitest-dev/vitest/issues/9235) [<samp>(2f367)</samp>](https://github.com/vitest-dev/vitest/commit/2f367fad3)
  - Add filters to `createSpecification`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9336](https://github.com/vitest-dev/vitest/issues/9336) [<samp>(c8e6c)</samp>](https://github.com/vitest-dev/vitest/commit/c8e6c7fbf)
  - Expose `runTestFiles` as alternative to `runTestSpecifications`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9443](https://github.com/vitest-dev/vitest/issues/9443) [<samp>(43d76)</samp>](https://github.com/vitest-dev/vitest/commit/43d761821)
  - Add `allowWrite` and `allowExec` options to `api`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9350](https://github.com/vitest-dev/vitest/issues/9350) [<samp>(20e00)</samp>](https://github.com/vitest-dev/vitest/commit/20e00ef78)
  - Allow passing down test cases to `toTestSpecification`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9627](https://github.com/vitest-dev/vitest/issues/9627) [<samp>(6f17d)</samp>](https://github.com/vitest-dev/vitest/commit/6f17d5ddf)
- **browser**:
  - Add `userEvent.wheel` API  -  by [@&#8203;macarie](https://github.com/macarie) in [#&#8203;9188](https://github.com/vitest-dev/vitest/issues/9188) [<samp>(66080)</samp>](https://github.com/vitest-dev/vitest/commit/660801979)
  - Add `filterNode` option to prettyDOM for filtering browser assertion error output  -  by [@&#8203;Copilot](https://github.com/Copilot), **sheremet-va** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9475](https://github.com/vitest-dev/vitest/issues/9475) [<samp>(d3220)</samp>](https://github.com/vitest-dev/vitest/commit/d3220fcd8)
  - Support playwright persistent context  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **Claude Opus 4.6** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9229](https://github.com/vitest-dev/vitest/issues/9229) [<samp>(f865d)</samp>](https://github.com/vitest-dev/vitest/commit/f865d2ba4)
  - Added `detailsPanelPosition` option and button  -  by [@&#8203;shairez](https://github.com/shairez) in [#&#8203;9525](https://github.com/vitest-dev/vitest/issues/9525) [<samp>(c8a31)</samp>](https://github.com/vitest-dev/vitest/commit/c8a31147c)
  - Use BlazeDiff instead of pixelmatch  -  by [@&#8203;macarie](https://github.com/macarie) in [#&#8203;9514](https://github.com/vitest-dev/vitest/issues/9514) [<samp>(30936)</samp>](https://github.com/vitest-dev/vitest/commit/309362089)
  - Add `findElement` and enable strict mode in webdriverio and preview  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9677](https://github.com/vitest-dev/vitest/issues/9677) [<samp>(c3f37)</samp>](https://github.com/vitest-dev/vitest/commit/c3f37721c)
- **cli**:
  - Add [@&#8203;bomb](https://github.com/bomb).sh/tab completions  -  by [@&#8203;AmirSa12](https://github.com/AmirSa12) and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8639](https://github.com/vitest-dev/vitest/issues/8639) [<samp>(200f3)</samp>](https://github.com/vitest-dev/vitest/commit/200f31704)
- **coverage**:
  - Support `ignore start/stop` ignore hints  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9204](https://github.com/vitest-dev/vitest/issues/9204) [<samp>(e59c9)</samp>](https://github.com/vitest-dev/vitest/commit/e59c94ba6)
  - Add `coverage.changed` option to report only changed files  -  by [@&#8203;kykim00](https://github.com/kykim00) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9521](https://github.com/vitest-dev/vitest/issues/9521) [<samp>(1d939)</samp>](https://github.com/vitest-dev/vitest/commit/1d9392c67)
- **experimental**:
  - Add `onModuleRunner` hook to `worker.init`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9286](https://github.com/vitest-dev/vitest/issues/9286) [<samp>(e977f)</samp>](https://github.com/vitest-dev/vitest/commit/e977f3deb)
  - Option to disable the module runner  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9210](https://github.com/vitest-dev/vitest/issues/9210) [<samp>(9be61)</samp>](https://github.com/vitest-dev/vitest/commit/9be6121ee)
  - Add `importDurations: { limit, print }` options  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **Claude Opus 4.6** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9401](https://github.com/vitest-dev/vitest/issues/9401) [<samp>(7e10f)</samp>](https://github.com/vitest-dev/vitest/commit/7e10fb356)
  - Add print and fail thresholds for `importDurations`  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9533](https://github.com/vitest-dev/vitest/issues/9533) [<samp>(3f7a5)</samp>](https://github.com/vitest-dev/vitest/commit/3f7a5f8f8)
- **fixtures**:
  - Pass down file context to `beforeAll`/`afterAll`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9572](https://github.com/vitest-dev/vitest/issues/9572) [<samp>(c8339)</samp>](https://github.com/vitest-dev/vitest/commit/c83395f2c)
- **reporters**:
  - Add `agent` reporter to reduce ai agent token usage  -  by [@&#8203;cpojer](https://github.com/cpojer) in [#&#8203;9779](https://github.com/vitest-dev/vitest/issues/9779) [<samp>(3e9e0)</samp>](https://github.com/vitest-dev/vitest/commit/3e9e096a2)
- **runner**:
  - Enhance `retry` options  -  by [@&#8203;MazenSamehR](https://github.com/MazenSamehR), **Matan Shavit**, [@&#8203;AriPerkkio](https://github.com/AriPerkkio) and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9370](https://github.com/vitest-dev/vitest/issues/9370) [<samp>(9e4cf)</samp>](https://github.com/vitest-dev/vitest/commit/9e4cfd295)
- **ui**:
  - Allow run individual test/suites  -  by [@&#8203;userquin](https://github.com/userquin) in [#&#8203;9465](https://github.com/vitest-dev/vitest/issues/9465) [<samp>(73b10)</samp>](https://github.com/vitest-dev/vitest/commit/73b10f1b9)
  - Add project filter/sort support  -  by [@&#8203;userquin](https://github.com/userquin) in [#&#8203;8689](https://github.com/vitest-dev/vitest/issues/8689) [<samp>(0c7ea)</samp>](https://github.com/vitest-dev/vitest/commit/0c7eaac16)
  - Add duration sorting to explorer  -  by [@&#8203;julianhahn](https://github.com/julianhahn) and [@&#8203;cursoragent](https://github.com/cursoragent) in [#&#8203;9603](https://github.com/vitest-dev/vitest/issues/9603) [<samp>(209b1)</samp>](https://github.com/vitest-dev/vitest/commit/209b1b0e1)
  - Implement filter for slow tests  -  by [@&#8203;DerYeger](https://github.com/DerYeger) and [@&#8203;userquin](https://github.com/userquin) in [#&#8203;9705](https://github.com/vitest-dev/vitest/issues/9705) [<samp>(8880c)</samp>](https://github.com/vitest-dev/vitest/commit/8880c907a)
- **vitest**:
  - Add run summary in GitHub Actions Reporter  -  by [@&#8203;macarie](https://github.com/macarie) and **jhnance** in [#&#8203;9579](https://github.com/vitest-dev/vitest/issues/9579) [<samp>(96bfc)</samp>](https://github.com/vitest-dev/vitest/commit/96bfc8345)

#####    🐞 Bug Fixes

- Deprecate several vitest/\* entry points  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9347](https://github.com/vitest-dev/vitest/issues/9347) [<samp>(fd459)</samp>](https://github.com/vitest-dev/vitest/commit/fd45928be)
- Use `meta.url` in `createRequire`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9441](https://github.com/vitest-dev/vitest/issues/9441) [<samp>(e3422)</samp>](https://github.com/vitest-dev/vitest/commit/e34225563)
- Preact browser mode init example of render function not async  -  by [@&#8203;WuMingDao](https://github.com/WuMingDao) in [#&#8203;9375](https://github.com/vitest-dev/vitest/issues/9375) [<samp>(2bea5)</samp>](https://github.com/vitest-dev/vitest/commit/2bea549c7)
- Deprecate unused types in matcher context  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9449](https://github.com/vitest-dev/vitest/issues/9449) [<samp>(20f87)</samp>](https://github.com/vitest-dev/vitest/commit/20f8753a2)
- Handle `external/noExternal` during `configEnvironment` hook  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9508](https://github.com/vitest-dev/vitest/issues/9508) [<samp>(59ea2)</samp>](https://github.com/vitest-dev/vitest/commit/59ea27c1c)
- Replace default ssr environment runner with Vitest server module runner  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9506](https://github.com/vitest-dev/vitest/issues/9506) [<samp>(cd5db)</samp>](https://github.com/vitest-dev/vitest/commit/cd5db660c)
- Propagate experimental CLI options to child projects  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9531](https://github.com/vitest-dev/vitest/issues/9531) [<samp>(b624f)</samp>](https://github.com/vitest-dev/vitest/commit/b624fae53)
- Show a warning when `browser.isolate` is used  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9410](https://github.com/vitest-dev/vitest/issues/9410) [<samp>(3d48e)</samp>](https://github.com/vitest-dev/vitest/commit/3d48ebcb9)
- Fix `vi.mock({ spy: true })` node v8 coverage  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **hi-ogawa** and **Claude Opus 4.6** in [#&#8203;9541](https://github.com/vitest-dev/vitest/issues/9541) [<samp>(687b6)</samp>](https://github.com/vitest-dev/vitest/commit/687b633c1)
- Don't show internal ssr handler in errors  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9547](https://github.com/vitest-dev/vitest/issues/9547) [<samp>(76c43)</samp>](https://github.com/vitest-dev/vitest/commit/76c4397b5)
- Close vitest if it failed to start  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9573](https://github.com/vitest-dev/vitest/issues/9573) [<samp>(728ba)</samp>](https://github.com/vitest-dev/vitest/commit/728ba617f)
- Fix ssr environment runner in project  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9584](https://github.com/vitest-dev/vitest/issues/9584) [<samp>(09006)</samp>](https://github.com/vitest-dev/vitest/commit/090064f97)
- Trim trailing white spaces in code block  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9591](https://github.com/vitest-dev/vitest/issues/9591) [<samp>(f78be)</samp>](https://github.com/vitest-dev/vitest/commit/f78bea992)
- Support inline snapshot inside test.for/each  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9590](https://github.com/vitest-dev/vitest/issues/9590) [<samp>(615fd)</samp>](https://github.com/vitest-dev/vitest/commit/615fd521e)
- Apply source maps for external module stack trace  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9152](https://github.com/vitest-dev/vitest/issues/9152) [<samp>(79e20)</samp>](https://github.com/vitest-dev/vitest/commit/79e20d5a3)
- Remove the `.name` from statically collected test  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9596](https://github.com/vitest-dev/vitest/issues/9596) [<samp>(b66ff)</samp>](https://github.com/vitest-dev/vitest/commit/b66ff691a)
- Don't suppress warnings on pnp  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9602](https://github.com/vitest-dev/vitest/issues/9602) [<samp>(89cbd)</samp>](https://github.com/vitest-dev/vitest/commit/89cbdaea3)
- Support snapshot with `expect.soft`  -  by [@&#8203;iumehara](https://github.com/iumehara), [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9231](https://github.com/vitest-dev/vitest/issues/9231) [<samp>(3eb2c)</samp>](https://github.com/vitest-dev/vitest/commit/3eb2cd541)
- Log seed when only `sequence.shuffle.tests` is enabled  -  by [@&#8203;kaigritun](https://github.com/kaigritun), **Kai Gritun** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9576](https://github.com/vitest-dev/vitest/issues/9576) [<samp>(8182b)</samp>](https://github.com/vitest-dev/vitest/commit/8182b77ad)
- Externalize `expect/src/utils` from `vitest`  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9616](https://github.com/vitest-dev/vitest/issues/9616) [<samp>(48739)</samp>](https://github.com/vitest-dev/vitest/commit/487398422)
- Ignore test.override during static collection  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9620](https://github.com/vitest-dev/vitest/issues/9620) [<samp>(09174)</samp>](https://github.com/vitest-dev/vitest/commit/0917470ce)
- Increase stacktrace limit for `--detect-async-leaks`  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9638](https://github.com/vitest-dev/vitest/issues/9638) [<samp>(9fd4c)</samp>](https://github.com/vitest-dev/vitest/commit/9fd4ce533)
- Hanging-reporter link in cli  -  by [@&#8203;flx-sta](https://github.com/flx-sta) in [#&#8203;9649](https://github.com/vitest-dev/vitest/issues/9649) [<samp>(7c103)</samp>](https://github.com/vitest-dev/vitest/commit/7c103055c)
- Fix teardown timeout of `aroundEach/All` when inner `aroundEach/All` throws  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9657](https://github.com/vitest-dev/vitest/issues/9657) [<samp>(4ec6c)</samp>](https://github.com/vitest-dev/vitest/commit/4ec6cb305)
- Fix ui mode / html reporter and coverage integration  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9626](https://github.com/vitest-dev/vitest/issues/9626) [<samp>(86fad)</samp>](https://github.com/vitest-dev/vitest/commit/86fad4b42)
- Don't continue when `aroundEach/All` setup timed out  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9670](https://github.com/vitest-dev/vitest/issues/9670) [<samp>(bb013)</samp>](https://github.com/vitest-dev/vitest/commit/bb013d54b)
- Align `VitestRunnerConfig` optional fields with `SerializedConfig`  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9661](https://github.com/vitest-dev/vitest/issues/9661) [<samp>(79520)</samp>](https://github.com/vitest-dev/vitest/commit/79520d82d)
- Handle Symbol values in format utility  -  by [@&#8203;nami8824](https://github.com/nami8824) in [#&#8203;9658](https://github.com/vitest-dev/vitest/issues/9658) [<samp>(0583f)</samp>](https://github.com/vitest-dev/vitest/commit/0583f067e)
- Deprecate `toBe*` spy assertions in favor of `toHaveBeen*` (and `toThrowError`)  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9665](https://github.com/vitest-dev/vitest/issues/9665) [<samp>(4d390)</samp>](https://github.com/vitest-dev/vitest/commit/4d390dfe9)
- Don't propagate nested `aroundEach/All` errors but aggregate them on runner  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9673](https://github.com/vitest-dev/vitest/issues/9673) [<samp>(b6365)</samp>](https://github.com/vitest-dev/vitest/commit/b63653f5a)
- Show a better error if there is a pending dynamic import  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9676](https://github.com/vitest-dev/vitest/issues/9676) [<samp>(7ef5c)</samp>](https://github.com/vitest-dev/vitest/commit/7ef5cf4b7)
- Preserve stack trace of `resolves/rejects` chained assertion error  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9679](https://github.com/vitest-dev/vitest/issues/9679) [<samp>(c6151)</samp>](https://github.com/vitest-dev/vitest/commit/c61511d4a)
- Handle module-sync condition in vmThreads/vmForks require  -  by [@&#8203;lesleh](https://github.com/lesleh) in [#&#8203;9650](https://github.com/vitest-dev/vitest/issues/9650) and [#&#8203;9651](https://github.com/vitest-dev/vitest/issues/9651) [<samp>(bb203)</samp>](https://github.com/vitest-dev/vitest/commit/bb20389f4)
- Hooks should respect `maxConcurrency`  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9653](https://github.com/vitest-dev/vitest/issues/9653) [<samp>(16d13)</samp>](https://github.com/vitest-dev/vitest/commit/16d13d981)
- Recursively autospy module object  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9687](https://github.com/vitest-dev/vitest/issues/9687) [<samp>(695a8)</samp>](https://github.com/vitest-dev/vitest/commit/695a86b41)
- Remove trailing spaces from diff error log  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9680](https://github.com/vitest-dev/vitest/issues/9680) [<samp>(395d1)</samp>](https://github.com/vitest-dev/vitest/commit/395d1a29e)
- Respect project `resolve.conditions` for externals  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9717](https://github.com/vitest-dev/vitest/issues/9717) [<samp>(1d498)</samp>](https://github.com/vitest-dev/vitest/commit/1d4987498)
- Use object for WeakMap instead of a symbol to support webcontainers  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9731](https://github.com/vitest-dev/vitest/issues/9731) [<samp>(c5225)</samp>](https://github.com/vitest-dev/vitest/commit/c52259330)
- Fix re-mocking virtual module  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9748](https://github.com/vitest-dev/vitest/issues/9748) [<samp>(3cbbb)</samp>](https://github.com/vitest-dev/vitest/commit/3cbbb17f1)
- Cancelling should stop current test immediately  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9729](https://github.com/vitest-dev/vitest/issues/9729) [<samp>(0cb2f)</samp>](https://github.com/vitest-dev/vitest/commit/0cb2f7239)
- Make `mockObject` change backwards compatible  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9744](https://github.com/vitest-dev/vitest/issues/9744) [<samp>(84c69)</samp>](https://github.com/vitest-dev/vitest/commit/84c69497f)
- Fix `URL.name` on jsdom  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9767](https://github.com/vitest-dev/vitest/issues/9767) [<samp>(031f3)</samp>](https://github.com/vitest-dev/vitest/commit/031f3a374)
- Save and restore module graph in blob reporter  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9740](https://github.com/vitest-dev/vitest/issues/9740) [<samp>(84355)</samp>](https://github.com/vitest-dev/vitest/commit/843554bf0)
- Don't silence reporter errors from test runtime events handler in normal run and --merge-reports  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9727](https://github.com/vitest-dev/vitest/issues/9727) [<samp>(4072d)</samp>](https://github.com/vitest-dev/vitest/commit/4072d0132)
- Fix `vi.importActual()` for virtual modules  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9772](https://github.com/vitest-dev/vitest/issues/9772) [<samp>(1e89e)</samp>](https://github.com/vitest-dev/vitest/commit/1e89ec020)
- Throw `FixtureAccessError` if suite hook accesses undefined fixture  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9786](https://github.com/vitest-dev/vitest/issues/9786) [<samp>(fc2ce)</samp>](https://github.com/vitest-dev/vitest/commit/fc2cea2b7)
- Allow hyphens in project config file name pattern  -  by [@&#8203;Koutaro-Hanabusa](https://github.com/Koutaro-Hanabusa) and [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9760](https://github.com/vitest-dev/vitest/issues/9760) [<samp>(33e96)</samp>](https://github.com/vitest-dev/vitest/commit/33e96311a)
- Manual and redirect mock shouldn't `load` or `transform` original module  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9774](https://github.com/vitest-dev/vitest/issues/9774) [<samp>(a8216)</samp>](https://github.com/vitest-dev/vitest/commit/a8216b001)
- `hideSkippedTests` should not hide `test.todo`  -  by [@&#8203;oilater](https://github.com/oilater) in [#&#8203;9562](https://github.com/vitest-dev/vitest/issues/9562) and [#&#8203;9781](https://github.com/vitest-dev/vitest/issues/9781) [<samp>(8181e)</samp>](https://github.com/vitest-dev/vitest/commit/8181e06e7)
- Allow catch/finally for async assertion  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9827](https://github.com/vitest-dev/vitest/issues/9827) [<samp>(031f0)</samp>](https://github.com/vitest-dev/vitest/commit/031f02a89)
- Resolve fixture overrides from test's suite in `beforeEach` hooks  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9826](https://github.com/vitest-dev/vitest/issues/9826) [<samp>(99e52)</samp>](https://github.com/vitest-dev/vitest/commit/99e52fe58)
- Use isAgent check, not just TTY, for watch mode  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9841](https://github.com/vitest-dev/vitest/issues/9841) [<samp>(c3cac)</samp>](https://github.com/vitest-dev/vitest/commit/c3cac1c1b)
- Use `performance.now` to measure test timeout duration  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9795](https://github.com/vitest-dev/vitest/issues/9795) [<samp>(f48a6)</samp>](https://github.com/vitest-dev/vitest/commit/f48a60114)
- Correctly identify concurrent test during static analysis  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9846](https://github.com/vitest-dev/vitest/issues/9846) [<samp>(1de0a)</samp>](https://github.com/vitest-dev/vitest/commit/1de0aa22d)
- **browser**:
  - Avoid updating screenshots when `toMatchScreenshot` passes  -  by [@&#8203;macarie](https://github.com/macarie) in [#&#8203;9289](https://github.com/vitest-dev/vitest/issues/9289) [<samp>(46aab)</samp>](https://github.com/vitest-dev/vitest/commit/46aabaa44)
  - Hide injected data-testid attributes  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9503](https://github.com/vitest-dev/vitest/issues/9503) [<samp>(c8d2c)</samp>](https://github.com/vitest-dev/vitest/commit/c8d2c411c)
  - Throw an error if iframe was reloaded  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9516](https://github.com/vitest-dev/vitest/issues/9516) [<samp>(73a81)</samp>](https://github.com/vitest-dev/vitest/commit/73a81f880)
  - Encode projectName in browser client URL  -  by [@&#8203;dkkim0122](https://github.com/dkkim0122) in [#&#8203;9523](https://github.com/vitest-dev/vitest/issues/9523) [<samp>(5b164)</samp>](https://github.com/vitest-dev/vitest/commit/5b16483c3)
  - Don't take failure screenshot if tests have artifacts created by `toMatchScreenshot`  -  by [@&#8203;macarie](https://github.com/macarie) in [#&#8203;9552](https://github.com/vitest-dev/vitest/issues/9552) [<samp>(83ca0)</samp>](https://github.com/vitest-dev/vitest/commit/83ca02547)
  - Remove `--remote-debugging-address` from chrome args  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9712](https://github.com/vitest-dev/vitest/issues/9712) [<samp>(f09bb)</samp>](https://github.com/vitest-dev/vitest/commit/f09bb5c32)
  - Make sure userEvent actions support `ensureAwaited`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9732](https://github.com/vitest-dev/vitest/issues/9732) [<samp>(97685)</samp>](https://github.com/vitest-dev/vitest/commit/9768517b8)
  - Types of `getCDPSession` and `cdp()`  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9716](https://github.com/vitest-dev/vitest/issues/9716) [<samp>(689a2)</samp>](https://github.com/vitest-dev/vitest/commit/689a22a1b)
  - Skip esbuild.legalComments when using rolldown-vite  -  by [@&#8203;Copilot](https://github.com/Copilot), **hi-ogawa** and [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9803](https://github.com/vitest-dev/vitest/issues/9803) [<samp>(3505f)</samp>](https://github.com/vitest-dev/vitest/commit/3505fa5a3)
- **chai**:
  - Don't allow `deepEqual` in the config because it's not serializable  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9666](https://github.com/vitest-dev/vitest/issues/9666) [<samp>(9ee99)</samp>](https://github.com/vitest-dev/vitest/commit/9ee999d73)
- **coverage**:
  - Infer transform mode for uncovered files  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9435](https://github.com/vitest-dev/vitest/issues/9435) [<samp>(f3967)</samp>](https://github.com/vitest-dev/vitest/commit/f396792d6)
  - `thresholds.autoUpdate` to preserve ending whitespace  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9436](https://github.com/vitest-dev/vitest/issues/9436) [<samp>(7e534)</samp>](https://github.com/vitest-dev/vitest/commit/7e534a0b6)
- **deps**:
  - Update all non-major dependencies  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9192](https://github.com/vitest-dev/vitest/issues/9192) [<samp>(90c30)</samp>](https://github.com/vitest-dev/vitest/commit/90c302f3b)
  - Update all non-major dependencies  -  in [#&#8203;9485](https://github.com/vitest-dev/vitest/issues/9485) [<samp>(c0118)</samp>](https://github.com/vitest-dev/vitest/commit/c01186022)
  - Update all non-major dependencies  -  in [#&#8203;9567](https://github.com/vitest-dev/vitest/issues/9567) [<samp>(13c9e)</samp>](https://github.com/vitest-dev/vitest/commit/13c9e022b)
- **docs**:
  - Fix old `/config/#option` hash links causing hydration errors  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **Claude Opus 4.6** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9610](https://github.com/vitest-dev/vitest/issues/9610) [<samp>(a603c)</samp>](https://github.com/vitest-dev/vitest/commit/a603c3a30)
- **expect**:
  - `toMatchObject(Map/Set)` should expect `Map/Set` on left hand side  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9532](https://github.com/vitest-dev/vitest/issues/9532) [<samp>(381da)</samp>](https://github.com/vitest-dev/vitest/commit/381da4a9d)
  - Fix objectContaining with proxy  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9554](https://github.com/vitest-dev/vitest/issues/9554) [<samp>(7ce34)</samp>](https://github.com/vitest-dev/vitest/commit/7ce3417b1)
  - Support arbitrary value equality for `toThrow` and make Error detection robust  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9570](https://github.com/vitest-dev/vitest/issues/9570) [<samp>(de215)</samp>](https://github.com/vitest-dev/vitest/commit/de215c19c)
- **mock**:
  - Inject helpers after hashbang if present  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9545](https://github.com/vitest-dev/vitest/issues/9545) [<samp>(65432)</samp>](https://github.com/vitest-dev/vitest/commit/65432a74b)
- **mocker**:
  - Update vite's peer dependency range  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9808](https://github.com/vitest-dev/vitest/issues/9808) [<samp>(36f9a)</samp>](https://github.com/vitest-dev/vitest/commit/36f9a81a2)
- **reporter**:
  - `dot` reporter leaves pending tests  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9684](https://github.com/vitest-dev/vitest/issues/9684) [<samp>(4d793)</samp>](https://github.com/vitest-dev/vitest/commit/4d7938a56)
- **runner**:
  - Mark repeated tests as finished on last run  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9707](https://github.com/vitest-dev/vitest/issues/9707) [<samp>(cc735)</samp>](https://github.com/vitest-dev/vitest/commit/cc735970a)
- **spy**:
  - Support deep partial in vi.mocked  -  by [@&#8203;j2h30728](https://github.com/j2h30728) in [#&#8203;8152](https://github.com/vitest-dev/vitest/issues/8152) and [#&#8203;9493](https://github.com/vitest-dev/vitest/issues/9493) [<samp>(71cb5)</samp>](https://github.com/vitest-dev/vitest/commit/71cb51ffc)
  - Fallback to object accessor if descriptor's value is `undefined`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9511](https://github.com/vitest-dev/vitest/issues/9511) [<samp>(6f181)</samp>](https://github.com/vitest-dev/vitest/commit/6f18103fa)
  - Throw correct errors when shorthand methods are used on a class  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9513](https://github.com/vitest-dev/vitest/issues/9513) [<samp>(5d0fd)</samp>](https://github.com/vitest-dev/vitest/commit/5d0fd3b62)
- **types**:
  - `bench.reporters` no longer gives type errors when passing file name string paths  -  by [@&#8203;Bertie690](https://github.com/Bertie690) in [#&#8203;9695](https://github.com/vitest-dev/vitest/issues/9695) [<samp>(093c8)</samp>](https://github.com/vitest-dev/vitest/commit/093c8f6b5)
- **ui**:
  - Process artifact attachments when generating HTML reporter  -  by [@&#8203;macarie](https://github.com/macarie) in [#&#8203;9472](https://github.com/vitest-dev/vitest/issues/9472) [<samp>(96eb9)</samp>](https://github.com/vitest-dev/vitest/commit/96eb92826)
  - Don't fail if --ui and --root are specified together  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9536](https://github.com/vitest-dev/vitest/issues/9536) [<samp>(d9305)</samp>](https://github.com/vitest-dev/vitest/commit/d93055fc7)

#####    🏎 Performance

- **pretty-format**: Combine DOMElement plugins  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9581](https://github.com/vitest-dev/vitest/issues/9581) [<samp>(da85a)</samp>](https://github.com/vitest-dev/vitest/commit/da85a3267)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.0.17...v4.1.0)

</details>

<details>
<summary>motdotla/dotenv (dotenv)</summary>

### [`v17.4.2`](https://github.com/motdotla/dotenv/blob/HEAD/CHANGELOG.md#1742-2026-04-12)

[Compare Source](https://github.com/motdotla/dotenv/compare/v17.4.1...v17.4.2)

##### Changed

- Improved skill files - tightened up details ([#&#8203;1009](https://github.com/motdotla/dotenv/pull/1009))

### [`v17.4.1`](https://github.com/motdotla/dotenv/blob/HEAD/CHANGELOG.md#1741-2026-04-05)

[Compare Source](https://github.com/motdotla/dotenv/compare/v17.4.0...v17.4.1)

##### Changed

- Change text `injecting` to `injected` ([#&#8203;1005](https://github.com/motdotla/dotenv/pull/1005))

### [`v17.4.0`](https://github.com/motdotla/dotenv/blob/HEAD/CHANGELOG.md#1740-2026-04-01)

[Compare Source](https://github.com/motdotla/dotenv/compare/v17.3.1...v17.4.0)

##### Added

- Add `skills/` folder with focused agent skills: `skills/dotenv/SKILL.md` (core usage) and `skills/dotenvx/SKILL.md` (encryption, multiple environments, variable expansion) for AI coding agent discovery via the skills.sh ecosystem (`npx skills add motdotla/dotenv`)

##### Changed

- Tighten up logs: `◇ injecting env (14) from .env` ([#&#8203;1003](https://github.com/motdotla/dotenv/pull/1003))

</details>

<details>
<summary>eslint/eslint (eslint)</summary>

### [`v10.2.0`](https://github.com/eslint/eslint/releases/tag/v10.2.0)

[Compare Source](https://github.com/eslint/eslint/compare/v10.1.0...v10.2.0)

#### Features

- [`586ec2f`](https://github.com/eslint/eslint/commit/586ec2f43092779acc957866db4abe999112d1e1) feat: Add `meta.languages` support to rules ([#&#8203;20571](https://github.com/eslint/eslint/issues/20571)) (Copilot)
- [`14207de`](https://github.com/eslint/eslint/commit/14207dee3939dc87cfa8b2fcfc271fff2cfd6471) feat: add `Temporal` to `no-obj-calls` ([#&#8203;20675](https://github.com/eslint/eslint/issues/20675)) (Pixel998)
- [`bbb2c93`](https://github.com/eslint/eslint/commit/bbb2c93a2b31bd30924f32fe69a9acf41f9dfe35) feat: add Temporal to ES2026 globals ([#&#8203;20672](https://github.com/eslint/eslint/issues/20672)) (Pixel998)

#### Bug Fixes

- [`542cb3e`](https://github.com/eslint/eslint/commit/542cb3e6442a4e6ee3457c799e2a0ee23bef0c6a) fix: update first-party dependencies ([#&#8203;20714](https://github.com/eslint/eslint/issues/20714)) (Francesco Trotta)

#### Documentation

- [`a2af743`](https://github.com/eslint/eslint/commit/a2af743ea60f683d0e0de9d98267c1e7e4f5e412) docs: add `language` to configuration objects ([#&#8203;20712](https://github.com/eslint/eslint/issues/20712)) (Francesco Trotta)
- [`845f23f`](https://github.com/eslint/eslint/commit/845f23f1370892bf07d819497ac518c9e65090d6) docs: Update README (GitHub Actions Bot)
- [`5fbcf59`](https://github.com/eslint/eslint/commit/5fbcf5958b897cc4df5d652924d18428db37f7ee) docs: remove `sourceType` from ts playground link ([#&#8203;20477](https://github.com/eslint/eslint/issues/20477)) (Tanuj Kanti)
- [`8702a47`](https://github.com/eslint/eslint/commit/8702a474659be786b6b1392e5e7c0c56355ae4a4) docs: Update README (GitHub Actions Bot)
- [`ddeaded`](https://github.com/eslint/eslint/commit/ddeaded2ab36951383ff67c60fb64ec68d29a46a) docs: Update README (GitHub Actions Bot)
- [`2b44966`](https://github.com/eslint/eslint/commit/2b4496691266547784a7f7ad1989ce53381bab91) docs: add Major Releases section to Manage Releases ([#&#8203;20269](https://github.com/eslint/eslint/issues/20269)) (Milos Djermanovic)
- [`eab65c7`](https://github.com/eslint/eslint/commit/eab65c700ebb16a6e790910c720450c9908961fd) docs: update `eslint` versions in examples ([#&#8203;20664](https://github.com/eslint/eslint/issues/20664)) (루밀LuMir)
- [`3e4a299`](https://github.com/eslint/eslint/commit/3e4a29903bf31f0998e45ad9128a265bce1edc56) docs: update ESM Dependencies policies with note for own-usage packages ([#&#8203;20660](https://github.com/eslint/eslint/issues/20660)) (Milos Djermanovic)

#### Chores

- [`8120e30`](https://github.com/eslint/eslint/commit/8120e30f833474f47acc061d24d164e9f022264f) refactor: extract no unmodified loop condition ([#&#8203;20679](https://github.com/eslint/eslint/issues/20679)) (kuldeep kumar)
- [`46e8469`](https://github.com/eslint/eslint/commit/46e8469786be1b2bbb522100e1d44624d98d3745) chore: update dependency markdownlint-cli2 to ^0.22.0 ([#&#8203;20697](https://github.com/eslint/eslint/issues/20697)) (renovate\[bot])
- [`01ed3aa`](https://github.com/eslint/eslint/commit/01ed3aa68477f81a7188e1498cf4906e02015b7c) test: add unit tests for unicode utilities ([#&#8203;20622](https://github.com/eslint/eslint/issues/20622)) (Manish chaudhary)
- [`811f493`](https://github.com/eslint/eslint/commit/811f4930f82ee2b6ac8eae75cade9bed63de0781) ci: remove `--legacy-peer-deps` from types integration tests ([#&#8203;20667](https://github.com/eslint/eslint/issues/20667)) (Milos Djermanovic)
- [`6b86fcf`](https://github.com/eslint/eslint/commit/6b86fcfc5c75d6a3b8a2cf7bcdb3ef60635a9a03) chore: update dependency npm-run-all2 to v8 ([#&#8203;20663](https://github.com/eslint/eslint/issues/20663)) (renovate\[bot])
- [`632c4f8`](https://github.com/eslint/eslint/commit/632c4f83bf32b77981c7d395cacddd1bb172ee25) chore: add `prettier` update commit to `.git-blame-ignore-revs` ([#&#8203;20662](https://github.com/eslint/eslint/issues/20662)) (루밀LuMir)
- [`b0b0f21`](https://github.com/eslint/eslint/commit/b0b0f21927e03ba092400e3c70d7058f537765c8) chore: update dependency eslint-plugin-regexp to ^3.1.0 ([#&#8203;20659](https://github.com/eslint/eslint/issues/20659)) (Milos Djermanovic)
- [`228a2dd`](https://github.com/eslint/eslint/commit/228a2dd4b272c17f516ee3541f1dd69eca0a8ab0) chore: update dependency eslint-plugin-eslint-plugin to ^7.3.2 ([#&#8203;20661](https://github.com/eslint/eslint/issues/20661)) (Milos Djermanovic)
- [`3ab4d7e`](https://github.com/eslint/eslint/commit/3ab4d7e244df244102de9d0d250b2ff12456a785) test: Add tests for eslintrc-style keys ([#&#8203;20645](https://github.com/eslint/eslint/issues/20645)) (kuldeep kumar)

### [`v10.1.0`](https://github.com/eslint/eslint/releases/tag/v10.1.0)

[Compare Source](https://github.com/eslint/eslint/compare/v10.0.3...v10.1.0)

#### Features

- [`ff4382b`](https://github.com/eslint/eslint/commit/ff4382be349035acdb170627a2dc92828e134562) feat: apply fix for `no-var` in `TSModuleBlock` ([#&#8203;20638](https://github.com/eslint/eslint/issues/20638)) (Tanuj Kanti)
- [`0916995`](https://github.com/eslint/eslint/commit/0916995b51528872b15ba4fedb24172cf25fcb3f) feat: Implement api support for bulk-suppressions ([#&#8203;20565](https://github.com/eslint/eslint/issues/20565)) (Blake Sager)

#### Bug Fixes

- [`2b8824e`](https://github.com/eslint/eslint/commit/2b8824e6be4223980e929a20025602df20d05ea2) fix: Prevent `no-var` autofix when a variable is used before declaration ([#&#8203;20464](https://github.com/eslint/eslint/issues/20464)) (Amaresh  S M)
- [`e58b4bf`](https://github.com/eslint/eslint/commit/e58b4bff167e79afd067d1b0ee9360bec2d3393e) fix: update eslint ([#&#8203;20597](https://github.com/eslint/eslint/issues/20597)) (renovate\[bot])

#### Documentation

- [`b7b57fe`](https://github.com/eslint/eslint/commit/b7b57fe9942c572ff651230f1f96cefed787ca52) docs: use correct JSDoc link in require-jsdoc.md ([#&#8203;20641](https://github.com/eslint/eslint/issues/20641)) (mkemna-clb)
- [`58e4cfc`](https://github.com/eslint/eslint/commit/58e4cfc7dbf0fe40c73f09bf0ff94ad944d0ba0e) docs: add deprecation notice partial ([#&#8203;20639](https://github.com/eslint/eslint/issues/20639)) (Milos Djermanovic)
- [`7143dbf`](https://github.com/eslint/eslint/commit/7143dbf99df27c61edf1552da981794e99a0b2f2) docs: update v9 migration guide for `@eslint/js` usage ([#&#8203;20540](https://github.com/eslint/eslint/issues/20540)) (fnx)
- [`035fc4f`](https://github.com/eslint/eslint/commit/035fc4fbe506e3e4524882cf50db37a4e430adf4) docs: note that `globalReturn` applies only with `sourceType: "script"` ([#&#8203;20630](https://github.com/eslint/eslint/issues/20630)) (Milos Djermanovic)
- [`e972c88`](https://github.com/eslint/eslint/commit/e972c88ab7474a74191ee99ac2558b00d0427a8a) docs: merge ESLint option descriptions into type definitions ([#&#8203;20608](https://github.com/eslint/eslint/issues/20608)) (Francesco Trotta)
- [`7f10d84`](https://github.com/eslint/eslint/commit/7f10d8440137f0cfd75f18f4746ba6a1c621b953) docs: Update README (GitHub Actions Bot)
- [`aeed007`](https://github.com/eslint/eslint/commit/aeed0078ca2f73d4744cc522102178d45b5be64e) docs: open playground link in new tab ([#&#8203;20602](https://github.com/eslint/eslint/issues/20602)) (Tanuj Kanti)
- [`a0d1a37`](https://github.com/eslint/eslint/commit/a0d1a3772679d3d74bb860fc65b5b58678acd452) docs: Add AI Usage Policy ([#&#8203;20510](https://github.com/eslint/eslint/issues/20510)) (Nicholas C. Zakas)

#### Chores

- [`a9f9cce`](https://github.com/eslint/eslint/commit/a9f9cce82d80b540a0e3549d0e91c16df28740d8) chore: update dependency eslint-plugin-unicorn to ^63.0.0 ([#&#8203;20584](https://github.com/eslint/eslint/issues/20584)) (Milos Djermanovic)
- [`1f42bd7`](https://github.com/eslint/eslint/commit/1f42bd7876ae4192cf7f7f4faf73b4ef3d2563cb) chore: update `prettier` to 3.8.1 ([#&#8203;20651](https://github.com/eslint/eslint/issues/20651)) (루밀LuMir)
- [`c0a6f4a`](https://github.com/eslint/eslint/commit/c0a6f4a2b4169edeca2a81bf7b47783e39ade366) chore: update dependency [@&#8203;eslint/json](https://github.com/eslint/json) to ^1.2.0 ([#&#8203;20652](https://github.com/eslint/eslint/issues/20652)) (renovate\[bot])
- [`cc43f79`](https://github.com/eslint/eslint/commit/cc43f795c42e5ec2f19bb43b1f6d534ef2e469f3) chore: update dependency c8 to v11 ([#&#8203;20650](https://github.com/eslint/eslint/issues/20650)) (renovate\[bot])
- [`2ce4635`](https://github.com/eslint/eslint/commit/2ce4635b036ff2665c7009afddf9c0fb2274dceb) chore: update dependency [@&#8203;eslint/json](https://github.com/eslint/json) to v1 ([#&#8203;20649](https://github.com/eslint/eslint/issues/20649)) (renovate\[bot])
- [`f0406ee`](https://github.com/eslint/eslint/commit/f0406eedcc3dc415babbbf6bbdb5db1eebfd487b) chore: update dependency markdownlint-cli2 to ^0.21.0 ([#&#8203;20646](https://github.com/eslint/eslint/issues/20646)) (renovate\[bot])
- [`dbb4c95`](https://github.com/eslint/eslint/commit/dbb4c9582a00bac604d5c6ac671bb7111468a846) chore: remove trunk ([#&#8203;20478](https://github.com/eslint/eslint/issues/20478)) (sethamus)
- [`c672a2a`](https://github.com/eslint/eslint/commit/c672a2a70579fddf1c6ce33dfa712d705726e1c9) test: fix CLI test for empty output file ([#&#8203;20640](https://github.com/eslint/eslint/issues/20640)) (kuldeep kumar)
- [`c7ada24`](https://github.com/eslint/eslint/commit/c7ada2455680036bbfc42fcb1511ff28afe3c587) ci: bump pnpm/action-setup from 4.3.0 to 4.4.0 ([#&#8203;20636](https://github.com/eslint/eslint/issues/20636)) (dependabot\[bot])
- [`07c4b8b`](https://github.com/eslint/eslint/commit/07c4b8b4a9f49145e60a3448dd57853213ed4de3) test: fix `RuleTester` test without test runners ([#&#8203;20631](https://github.com/eslint/eslint/issues/20631)) (Francesco Trotta)
- [`079bba7`](https://github.com/eslint/eslint/commit/079bba7ff17d0a99fdffe32bf991d005ba797fae) test: Add tests for `isValidWithUnicodeFlag` ([#&#8203;20601](https://github.com/eslint/eslint/issues/20601)) (Manish chaudhary)
- [`5885ae6`](https://github.com/eslint/eslint/commit/5885ae66216bcee9310bbf73786b7d7d5774aeaf) ci: unpin Node.js 25.x in CI ([#&#8203;20615](https://github.com/eslint/eslint/issues/20615)) (Copilot)
- [`f65e5d3`](https://github.com/eslint/eslint/commit/f65e5d3c0df65fdb317ad6d23f7ae113c5f4b6d7) chore: update pnpm/action-setup digest to [`b906aff`](https://github.com/eslint/eslint/commit/b906aff) ([#&#8203;20610](https://github.com/eslint/eslint/issues/20610)) (renovate\[bot])

### [`v10.0.3`](https://github.com/eslint/eslint/releases/tag/v10.0.3)

[Compare Source](https://github.com/eslint/eslint/compare/v10.0.2...v10.0.3)

#### Bug Fixes

- [`e511b58`](https://github.com/eslint/eslint/commit/e511b58d5ecd63a232b87743614867f4eaadbba4) fix: update eslint ([#&#8203;20595](https://github.com/eslint/eslint/issues/20595)) (renovate\[bot])
- [`f4c9cf9`](https://github.com/eslint/eslint/commit/f4c9cf9b8dc5642de555a09295933464080d722a) fix: include variable name in `no-useless-assignment` message ([#&#8203;20581](https://github.com/eslint/eslint/issues/20581)) (sethamus)
- [`ee9ff31`](https://github.com/eslint/eslint/commit/ee9ff31cee13712d2be2a6b5c0a4a54449fe9fe1) fix: update dependency minimatch to ^10.2.4 ([#&#8203;20562](https://github.com/eslint/eslint/issues/20562)) (Milos Djermanovic)

#### Documentation

- [`9fc31b0`](https://github.com/eslint/eslint/commit/9fc31b03ef05abfc4f0f449b22947029d51a72f6) docs: Update README (GitHub Actions Bot)
- [`4efaa36`](https://github.com/eslint/eslint/commit/4efaa367c62d5a45dd21e246e4a506e11dd51758) docs: add info box for `eslint-plugin-eslint-comments` ([#&#8203;20570](https://github.com/eslint/eslint/issues/20570)) (DesselBane)
- [`23b2759`](https://github.com/eslint/eslint/commit/23b2759dd5cd70976ab2e8f4a1cf86ffe4b9f65d) docs: add v10 migration guide link to Use docs index ([#&#8203;20577](https://github.com/eslint/eslint/issues/20577)) (Pixel998)
- [`80259a9`](https://github.com/eslint/eslint/commit/80259a9b0d9e29596a5ef0e1e5269031636cacdb) docs: Remove deprecated eslintrc documentation files ([#&#8203;20472](https://github.com/eslint/eslint/issues/20472)) (Copilot)
- [`9b9b4ba`](https://github.com/eslint/eslint/commit/9b9b4baf7f0515d28290464ea754d7e7dc350395) docs: fix typo in no-await-in-loop documentation ([#&#8203;20575](https://github.com/eslint/eslint/issues/20575)) (Pixel998)
- [`e7d72a7`](https://github.com/eslint/eslint/commit/e7d72a77e5e1277690a505160137aebd5985909a) docs: document TypeScript 5.3 minimum supported version ([#&#8203;20547](https://github.com/eslint/eslint/issues/20547)) (sethamus)

#### Chores

- [`ef8fb92`](https://github.com/eslint/eslint/commit/ef8fb924bfabc2e239b46b2d7b3c37319b03084e) chore: package.json update for eslint-config-eslint release (Jenkins)
- [`e8f2104`](https://github.com/eslint/eslint/commit/e8f21040f675753e92df8e04f2dbd03addb92985) chore: updates for v9.39.4 release (Jenkins)
- [`5cd1604`](https://github.com/eslint/eslint/commit/5cd1604cea5734bc235155a1a1add9f08ae83370) refactor: simplify isCombiningCharacter helper ([#&#8203;20524](https://github.com/eslint/eslint/issues/20524)) (Huáng Jùnliàng)
- [`70ff1d0`](https://github.com/eslint/eslint/commit/70ff1d07a8e7eba9e70b67ea55fcf2e47cdc9b2d) chore: eslint-config-eslint require Node `^20.19.0 || ^22.13.0 || >=24` ([#&#8203;20586](https://github.com/eslint/eslint/issues/20586)) (Milos Djermanovic)
- [`e32df71`](https://github.com/eslint/eslint/commit/e32df71a569d5f4aca13079dedd4ae76ea05168a) chore: update eslint-plugin-eslint-comments, remove legacy-peer-deps ([#&#8203;20576](https://github.com/eslint/eslint/issues/20576)) (Milos Djermanovic)
- [`53ca6ee`](https://github.com/eslint/eslint/commit/53ca6eeed87262ebddd20636107f486badabcc1f) chore: disable `eslint-comments/no-unused-disable` rule ([#&#8203;20578](https://github.com/eslint/eslint/issues/20578)) (Milos Djermanovic)
- [`e121895`](https://github.com/eslint/eslint/commit/e1218957452e223af27ace1f9d031ab421aec08f) ci: pin Node.js 25.6.1 ([#&#8203;20559](https://github.com/eslint/eslint/issues/20559)) (Milos Djermanovic)
- [`efc5aef`](https://github.com/eslint/eslint/commit/efc5aef2f9a05f01d5cad53dcb91e7f2c575e295) chore: update `tsconfig.json` in `eslint-config-eslint` ([#&#8203;20551](https://github.com/eslint/eslint/issues/20551)) (Francesco Trotta)

### [`v10.0.2`](https://github.com/eslint/eslint/releases/tag/v10.0.2)

[Compare Source](https://github.com/eslint/eslint/compare/v10.0.1...v10.0.2)

#### Bug Fixes

- [`2b72361`](https://github.com/eslint/eslint/commit/2b723616a4daeacd4605f11b4d087d4a7cae5c74) fix: update `ajv` to `6.14.0` to address security vulnerabilities ([#&#8203;20537](https://github.com/eslint/eslint/issues/20537)) (루밀LuMir)

#### Documentation

- [`13eeedb`](https://github.com/eslint/eslint/commit/13eeedbbd16218b0da1425b78cb284937fd964ca) docs: link rule type explanation to CLI option --fix-type ([#&#8203;20548](https://github.com/eslint/eslint/issues/20548)) (Mike McCready)
- [`98cbf6b`](https://github.com/eslint/eslint/commit/98cbf6ba53a1fb2028d25078c7049a538d0e392c) docs: update migration guide per Program range change ([#&#8203;20534](https://github.com/eslint/eslint/issues/20534)) (Huáng Jùnliàng)
- [`61a2405`](https://github.com/eslint/eslint/commit/61a24054411fa56ce74bef554846caa9d8cb01f5) docs: add missing semicolon in vars-on-top rule example ([#&#8203;20533](https://github.com/eslint/eslint/issues/20533)) (Abilash)

#### Chores

- [`951223b`](https://github.com/eslint/eslint/commit/951223b29669885643f7854d7c824288ba962d7e) chore: update dependency [@&#8203;eslint/eslintrc](https://github.com/eslint/eslintrc) to ^3.3.4 ([#&#8203;20553](https://github.com/eslint/eslint/issues/20553)) (renovate\[bot])
- [`6aa1afe`](https://github.com/eslint/eslint/commit/6aa1afe6694f3fd7f82116109a5ef2ad18ece074) chore: update dependency eslint-plugin-jsdoc to ^62.7.0 ([#&#8203;20536](https://github.com/eslint/eslint/issues/20536)) (Milos Djermanovic)

</details>

<details>
<summary>sindresorhus/globals (globals)</summary>

### [`v17.5.0`](https://github.com/sindresorhus/globals/releases/tag/v17.5.0)

[Compare Source](https://github.com/sindresorhus/globals/compare/v17.4.0...v17.5.0)

- Update globals (2026-04-12) ([#&#8203;342](https://github.com/sindresorhus/globals/issues/342))  [`5d84602`](https://github.com/sindresorhus/globals/commit/5d84602)

***

### [`v17.4.0`](https://github.com/sindresorhus/globals/releases/tag/v17.4.0)

[Compare Source](https://github.com/sindresorhus/globals/compare/v17.3.0...v17.4.0)

- Update globals (2026-03-01) ([#&#8203;338](https://github.com/sindresorhus/globals/issues/338))  [`d43a051`](https://github.com/sindresorhus/globals/commit/d43a051)

***

</details>

<details>
<summary>puppeteer/puppeteer (puppeteer)</summary>

### [`v24.40.0`](https://github.com/puppeteer/puppeteer/blob/HEAD/CHANGELOG.md#24400-2026-03-19)

[Compare Source](https://github.com/puppeteer/puppeteer/compare/puppeteer-v24.39.1...puppeteer-v24.40.0)

##### ♻️ Chores

- **puppeteer:** Synchronize puppeteer versions

##### Dependencies

- The following workspace dependencies were updated
  - dependencies
    - puppeteer-core bumped from 24.39.1 to 24.40.0

##### 🎉 Features

- support PUPPETEER\_DANGEROUS\_NO\_SANDBOX environment variable ([#&#8203;14756](https://github.com/puppeteer/puppeteer/issues/14756)) ([2a8276e](https://github.com/puppeteer/puppeteer/commit/2a8276ee095d6f9676a6d2ea82541127cc9f7f1f))

##### 🛠️ Fixes

- roll to Chrome 146.0.7680.153 ([#&#8203;14787](https://github.com/puppeteer/puppeteer/issues/14787)) ([443e87f](https://github.com/puppeteer/puppeteer/commit/443e87f263cdc3578d6867ab72960f3c9979f72a))
- roll to Chrome 146.0.7680.80 ([#&#8203;14778](https://github.com/puppeteer/puppeteer/issues/14778)) ([14685a0](https://github.com/puppeteer/puppeteer/commit/14685a0e090671eb1d1db2dc9e4ec60117b8cfc3))

### [`v24.39.1`](https://github.com/puppeteer/puppeteer/blob/HEAD/CHANGELOG.md#24400-2026-03-19)

[Compare Source](https://github.com/puppeteer/puppeteer/compare/puppeteer-v24.39.0...puppeteer-v24.39.1)

##### ♻️ Chores

- **puppeteer:** Synchronize puppeteer versions

##### Dependencies

- The following workspace dependencies were updated
  - dependencies
    - puppeteer-core bumped from 24.39.1 to 24.40.0

##### 🎉 Features

- support PUPPETEER\_DANGEROUS\_NO\_SANDBOX environment variable ([#&#8203;14756](https://github.com/puppeteer/puppeteer/issues/14756)) ([2a8276e](https://github.com/puppeteer/puppeteer/commit/2a8276ee095d6f9676a6d2ea82541127cc9f7f1f))

##### 🛠️ Fixes

- roll to Chrome 146.0.7680.153 ([#&#8203;14787](https://github.com/puppeteer/puppeteer/issues/14787)) ([443e87f](https://github.com/puppeteer/puppeteer/commit/443e87f263cdc3578d6867ab72960f3c9979f72a))
- roll to Chrome 146.0.7680.80 ([#&#8203;14778](https://github.com/puppeteer/puppeteer/issues/14778)) ([14685a0](https://github.com/puppeteer/puppeteer/commit/14685a0e090671eb1d1db2dc9e4ec60117b8cfc3))

### [`v24.39.0`](https://github.com/puppeteer/puppeteer/blob/HEAD/CHANGELOG.md#24391-2026-03-13)

[Compare Source](https://github.com/puppeteer/puppeteer/compare/puppeteer-v24.38.0...puppeteer-v24.39.0)

##### ♻️ Chores

- **puppeteer:** Synchronize puppeteer versions

##### Dependencies

- The following workspace dependencies were updated
  - dependencies
    - puppeteer-core bumped from 24.39.0 to 24.39.1

##### 🛠️ Fixes

- roll to Chrome 146.0.7680.72 ([#&#8203;14764](https://github.com/puppeteer/puppeteer/issues/14764)) ([177e3ed](https://github.com/puppeteer/puppeteer/commit/177e3ed44a0066c0252d7429fadd8fb82a81281f))
- roll to Chrome 146.0.7680.76 ([#&#8203;14777](https://github.com/puppeteer/puppeteer/issues/14777)) ([0751a83](https://github.com/puppeteer/puppeteer/commit/0751a83632d224695ae1f655405b2ec838774d33))
- roll to Firefox 148.0.2 ([#&#8203;14763](https://github.com/puppeteer/puppeteer/issues/14763)) ([e658f4e](https://github.com/puppeteer/puppeteer/commit/e658f4eec9656ff2ab97cdcd98f1fb33c8b06304))

### [`v24.38.0`](https://github.com/puppeteer/puppeteer/blob/HEAD/CHANGELOG.md#24390-2026-03-10)

[Compare Source](https://github.com/puppeteer/puppeteer/compare/puppeteer-v24.37.5...puppeteer-v24.38.0)

##### ♻️ Chores

- **puppeteer:** Synchronize puppeteer versions

##### Dependencies

- The following workspace dependencies were updated
  - dependencies
    - puppeteer-core bumped from 24.38.0 to 24.39.0

##### 🎉 Features

- expose Page.hasDevTools ([#&#8203;14758](https://github.com/puppeteer/puppeteer/issues/14758)) ([5ed7e77](https://github.com/puppeteer/puppeteer/commit/5ed7e7784a3e23bd1b42b8f0d041a74709a1bf4e))

##### 🛠️ Fixes

- roll to Chrome 146.0.7680.66 ([#&#8203;14752](https://github.com/puppeteer/puppeteer/issues/14752)) ([60ace04](https://github.com/puppeteer/puppeteer/commit/60ace04425d1ad4e99732298ed51839f09adcb0a))

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://git.keligrubb.com/keligrubb/scrollsmith/pulls/8
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-04-15 02:23:49 +00:00
renovate-bot 90eb88d26e Update https://github.com/actions/upload-artifact action to v7 (#7)
Release / upload-to-gitea-release (push) Has been skipped
Release / generate-dungeon (push) Failing after 3m4s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [https://github.com/actions/upload-artifact](https://github.com/actions/upload-artifact) | action | major | `v6` → `v7` |

---

### Release Notes

<details>
<summary>actions/upload-artifact (https://github.com/actions/upload-artifact)</summary>

### [`v7`](https://github.com/actions/upload-artifact/compare/v6...v7)

[Compare Source](https://github.com/actions/upload-artifact/compare/v6...v7)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My41My4wIiwidXBkYXRlZEluVmVyIjoiNDMuNTMuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: #7
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-03-04 18:20:51 +00:00
keligrubb fb2ffeb4fe migrate-to-gitea-actions (#6)
Release / generate-dungeon (push) Failing after 31m44s
Release / upload-to-gitea-release (push) Has been skipped
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #6
2026-03-04 17:37:03 +00:00
keligrubb 01d1b369b7 replace woodpecker with gitea actions (#5)
Release / generate-dungeon (push) Failing after 47s
Release / upload-to-gitea-release (push) Has been skipped
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #5
2026-03-04 17:16:19 +00:00
keligrubb 5e7369cd25 Merge pull request 'Configure Renovate' (#1) from renovate/configure into main
ci/woodpecker/cron/release Pipeline failed
Reviewed-on: #1
2026-02-22 03:27:38 +00:00
renovate-bot 3ef8f05e1d Add renovate.json 2026-02-22 03:26:50 +00:00
keligrubb 9bd0ded5a6 improve ci 2026-02-21 22:25:02 -05:00
keligrubb 83eee20b2c add testing suite 2026-02-21 22:19:21 -05:00
29 changed files with 4887 additions and 1765 deletions
+43
View File
@@ -0,0 +1,43 @@
name: PR
on:
pull_request:
jobs:
lint:
name: lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.keligrubb.com/actions/checkout@v6
- name: Setup Node
uses: https://git.keligrubb.com/actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
test-coverage:
name: test-coverage
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://git.keligrubb.com/actions/checkout@v6
- name: Setup Node
uses: https://git.keligrubb.com/actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Test with coverage
run: npm run test:coverage
+87
View File
@@ -0,0 +1,87 @@
name: Release
on:
push:
branches: [main]
schedule:
- cron: "0 0 * * *"
jobs:
generate-dungeon:
name: generate-dungeon
runs-on: ubuntu-latest
container:
image: ghcr.io/puppeteer/puppeteer:latest
env:
OLLAMA_API_URL: https://ai.keligrubb.com/api/chat/completions
OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
OLLAMA_MODEL: qwen3.5-122b-a10b
COMFYUI_URL: http://192.168.1.124:8188
steps:
- name: Checkout
uses: https://git.keligrubb.com/actions/checkout@v6
- name: Setup Node
uses: https://git.keligrubb.com/actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Generate dungeon PDF
run: npm start
- name: Upload PDF artifact
uses: https://git.keligrubb.com/actions/upload-artifact@v3
with:
name: release-pdf
path: "*.pdf"
upload-to-gitea-release:
name: upload-to-gitea-release
runs-on: ubuntu-latest
needs: generate-dungeon
env:
GITEA_TOKEN: ${{ secrets.SCROLLSMITH_GITEA_TOKEN }}
steps:
- name: Download PDF artifact
uses: https://git.keligrubb.com/actions/download-artifact@v3
with:
name: release-pdf
path: .
- name: Create release and upload PDF
run: |
api_base="https://git.keligrubb.com/api/v1/repos/${{ gitea.repository }}"
pdf=$(ls *.pdf | head -n1)
tag=$(date +%F)
echo "Creating release for tag $tag..."
create_resp=$(curl -s -w "%{http_code}" -o /tmp/create.json -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
"$api_base/releases")
echo "Create release HTTP status: $create_resp"
echo "Fetching release ID..."
release_id=$(curl -s \
-H "Authorization: token $GITEA_TOKEN" \
"$api_base/releases/tags/$tag" |
awk -F: '/"id"[ ]*:/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
echo "Release ID = $release_id"
echo "Checking if asset $pdf already exists..."
assets=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"$api_base/releases/$release_id/assets")
echo "Assets response: $assets"
if echo "$assets" | grep -q "\"name\":\"$pdf\""; then
echo "Asset $pdf already uploaded, skipping."
exit 0
fi
echo "Uploading $pdf to release $release_id..."
upload_resp=$(curl -s -w "%{http_code}" -o /tmp/upload.json -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$pdf" \
"$api_base/releases/$release_id/assets")
echo "Upload HTTP status: $upload_resp"
echo "Upload response: $(cat /tmp/upload.json)"
+5 -1
View File
@@ -1,7 +1,10 @@
*.pdf
*.png
.env
node_modules/**
node_modules/
# Coverage and test artifacts
coverage/
# macOS dotfiles
.DS_Store
@@ -11,4 +14,5 @@ node_modules/**
.Trashes
.AppleDouble
.LSOverride
.env.example
-78
View File
@@ -1,78 +0,0 @@
workspace:
base: /woodpecker
path: src
when:
- event: cron
branch: main
- event: pull_request
- event: push
branch: main
steps:
- name: lint
image: node:22
when:
event:
- pull_request
- push
commands:
- npm ci
- npm run lint
- name: generate-dungeon
image: ghcr.io/puppeteer/puppeteer:latest
when:
event:
- cron
environment:
OLLAMA_API_URL:
from_secret: OLLAMA_API_URL
OLLAMA_API_KEY:
from_secret: OLLAMA_API_KEY
COMFYUI_URL:
from_secret: COMFYUI_URL
commands:
- npm ci
- npm start
- name: upload-to-gitea-release
image: curlimages/curl:latest
when:
event:
- cron
environment:
GITEA_TOKEN:
from_secret: GITEA_TOKEN
commands:
- pdf=$(ls *.pdf | head -n1)
- tag=$(date +%F)
- |
echo "Creating release for tag $tag..."
create_resp=$(curl -s -w "%{http_code}" -o /tmp/create.json -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases)
echo "Create release HTTP status: $create_resp"
echo "Fetching release ID..."
release_id=$(curl -s \
-H "Authorization: token $GITEA_TOKEN" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/tags/$tag |
awk -F: '/"id"[ ]*:/ {gsub(/[^0-9]/,"",$2); print $2; exit}')
echo "Release ID = $release_id"
echo "Checking if asset $pdf already exists..."
assets=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$release_id/assets)
echo "Assets response: $assets"
if echo "$assets" | grep -q "\"name\":\"$pdf\""; then
echo "Asset $pdf already uploaded, skipping."
exit 0
fi
echo "Uploading $pdf to release $release_id..."
upload_resp=$(curl -s -w "%{http_code}" -o /tmp/upload.json -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$pdf" \
https://git.keligrubb.com/api/v1/repos/keligrubb/scrollsmith/releases/$release_id/assets)
echo "Upload HTTP status: $upload_resp"
echo "Upload response: $(cat /tmp/upload.json)"
+23 -8
View File
@@ -2,7 +2,7 @@
[![status-badge](https://ci.keligrubb.com/api/badges/2/status.svg)](https://ci.keligrubb.com/repos/2)
Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon PDFs automatically. It uses an Ollama LLM server to create dungeon content, proofreads and refines it, then formats it into a structured PDF with maps, rooms, encounters, treasure, and NPCs.
Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon PDFs automatically. It calls an LLM (Open WebUI `/api/chat/completions` or Ollama `/api/generate`, inferred from `OLLAMA_API_URL`), proofreads and refines the result, then builds a structured PDF with maps, rooms, encounters, treasure, and NPCs.
---
@@ -14,20 +14,21 @@ Scrollsmith is a Node.js tool for generating Dungeons & Dragons one-page dungeon
3. JSON conversion: output strictly valid JSON for PDF generation
- Automatically generates a PDF named after the dungeon title
- PDF layout includes three columns: map & hooks, rooms, encounters & treasure & NPCs
- Easy to integrate with a local Ollama server
- Open WebUI or Ollama
---
## Requirements
- Node.js 22+
- Ollama server running and accessible
- LLM endpoint (see below)
- Gitea Releases (optional) for PDF uploads
- `.env` file with:
```env
OLLAMA_API_URL=http://localhost:3000/api/chat/completions
OLLAMA_API_URL=https://your-openwebui-host/api/chat/completions
OLLAMA_API_KEY=your_api_key_here
OLLAMA_MODEL=qwen3.5-122b-a10b
COMFYUI_URL=http://192.168.1.124:8188
```
@@ -56,17 +57,18 @@ OLLAMA_API_URL=http://localhost:11434/api/generate
### Open WebUI API
For Open WebUI API calls, set:
```env
OLLAMA_API_URL=http://localhost:3000/api/chat/completions
OLLAMA_API_URL=https://your-openwebui-host/api/chat/completions
OLLAMA_API_KEY=your_open_webui_api_key
OLLAMA_MODEL=qwen3.5-122b-a10b
```
> Note: The API type is automatically inferred from the endpoint URL. If the URL contains `/api/chat/completions`, it uses Open WebUI API. If it contains `/api/generate`, it uses direct Ollama API. No `OLLAMA_API_TYPE` environment variable is required.
> Note: The API type is inferred from the URL (`/api/chat/completions` vs `/api/generate`); no `OLLAMA_API_TYPE` variable.
---
## Usage
1. Make sure your Ollama server is running and `.env` is configured.
1. Configure `.env` and ensure the LLM endpoint is reachable.
2. Run:
```bash
@@ -79,6 +81,19 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
---
## Project structure
- **`index.js`** Entry point: env, model init, dungeon + image + PDF.
- **`src/`** Application modules:
- `dungeonGenerator.js` LLM-backed dungeon content generation and validation.
- `dungeonTemplate.js` HTML template and layout for the PDF.
- `ollamaClient.js` Ollama/Open WebUI API client and text cleaning.
- `imageGenerator.js` Map image generation (Ollama + optional ComfyUI).
- `generatePDF.js` Puppeteer-based PDF generation from the template.
- **`test/`** Unit tests (`test/unit/`) and integration tests (`test/integration/`).
---
## Example Output
* `the-tomb-of-shadows.pdf`
@@ -92,7 +107,7 @@ Optional: update the map path in `index.js` if you have a local dungeon map.
## Notes
* Make sure your Ollama server is accessible and API key is valid.
* Open WebUI needs a valid `OLLAMA_API_KEY` when the server requires it.
* Dungeon generation can take a few minutes depending on your LLM response time.
---
-1186
View File
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -3,5 +3,26 @@ import globals from "globals";
import { defineConfig } from "eslint/config";
export default defineConfig([
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.node } },
{
files: ["**/*.{js,mjs,cjs}"],
plugins: { js },
extends: ["js/recommended"],
languageOptions: { globals: globals.node },
rules: {
"no-unused-vars": ["error", { varsIgnorePattern: "^_" }],
},
},
{
files: ["test/**/*.js"],
languageOptions: {
globals: {
...globals.node,
describe: "readonly",
it: "readonly",
test: "readonly",
expect: "readonly",
vi: "readonly",
},
},
},
]);
+8 -11
View File
@@ -1,8 +1,8 @@
import "dotenv/config";
import { generateDungeon } from "./dungeonGenerator.js";
import { generateDungeonImages } from "./imageGenerator.js";
import { generatePDF } from "./generatePDF.js";
import { OLLAMA_MODEL, initializeModel } from "./ollamaClient.js";
import { generateDungeon } from "./src/dungeonGenerator.js";
import { generateDungeonImages } from "./src/imageGenerator.js";
import { generatePDF } from "./src/generatePDF.js";
import { initializeModel } from "./src/ollamaClient.js";
// Utility to create a filesystem-safe filename from the dungeon title
function slugify(text) {
@@ -18,18 +18,15 @@ function slugify(text) {
throw new Error("OLLAMA_API_URL environment variable is required");
}
console.log("Using Ollama API URL:", process.env.OLLAMA_API_URL);
// Initialize model (will fetch default from API or use fallback)
await initializeModel();
console.log("Using Ollama model:", OLLAMA_MODEL);
const model = await initializeModel();
console.log("Using Ollama model:", model);
// Generate the dungeon data
const dungeonData = await generateDungeon();
// Generate dungeon map image (uses dungeonData.flavor)
const mapPath = await generateDungeonImages(dungeonData);
dungeonData.map = mapPath;
if (mapPath) dungeonData.map = mapPath;
// Generate PDF filename based on the title
const filename = `${slugify(dungeonData.title)}.pdf`;
+1609 -228
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -4,8 +4,10 @@
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test:integration": "node --test test/integration.test.js",
"test": "vitest run",
"test:unit": "vitest run --exclude '**/integration/**'",
"test:coverage": "vitest run --coverage --exclude '**/integration/**'",
"test:integration": "vitest run --config vitest.integration.config.js",
"lint": "eslint .",
"start": "node index.js"
},
@@ -18,8 +20,10 @@
"sharp": "^0.34.3"
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"eslint": "^9.34.0",
"globals": "^17.0.0"
"@eslint/js": "^10.0.0",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^10.0.0",
"globals": "^17.0.0",
"vitest": "^4.0.18"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}
+290
View File
@@ -0,0 +1,290 @@
import {
extractCanonicalNames,
validateContentCompleteness,
validateContentQuality,
validateContentStructure,
validateNarrativeCoherence,
} from "./validation.js";
export function validateNameConsistency(dungeonData) {
const canonicalNames = extractCanonicalNames(dungeonData);
const fixes = [];
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
canonicalNames.npcs.forEach(canonicalName => {
if (dungeonData.flavor) {
const original = dungeonData.flavor;
dungeonData.flavor = dungeonData.flavor.replace(new RegExp(escapeRe(canonicalName), 'gi'), canonicalName);
if (original !== dungeonData.flavor) fixes.push(`Fixed NPC name in flavor text: ${canonicalName}`);
}
if (dungeonData.hooksRumors) {
dungeonData.hooksRumors = dungeonData.hooksRumors.map(hook => {
const original = hook;
const fixed = hook.replace(new RegExp(escapeRe(canonicalName), 'gi'), canonicalName);
if (original !== fixed) fixes.push(`Fixed NPC name in hook: ${canonicalName}`);
return fixed;
});
}
if (dungeonData.encounters) {
dungeonData.encounters.forEach(encounter => {
if (encounter.details) {
const original = encounter.details;
encounter.details = encounter.details.replace(new RegExp(escapeRe(canonicalName), 'gi'), canonicalName);
if (original !== encounter.details) fixes.push(`Fixed NPC name in encounter: ${canonicalName}`);
}
});
}
if (dungeonData.plotResolutions) {
dungeonData.plotResolutions = dungeonData.plotResolutions.map(resolution => {
const original = resolution;
const fixed = resolution.replace(new RegExp(escapeRe(canonicalName), 'gi'), canonicalName);
if (original !== fixed) fixes.push(`Fixed NPC name in plot resolution: ${canonicalName}`);
return fixed;
});
}
});
canonicalNames.rooms.forEach(canonicalRoom => {
if (dungeonData.encounters) {
dungeonData.encounters.forEach(encounter => {
if (encounter.details) {
const original = encounter.details;
encounter.details = encounter.details.replace(new RegExp(escapeRe(canonicalRoom), 'gi'), canonicalRoom);
if (original !== encounter.details) fixes.push(`Fixed room name in encounter: ${canonicalRoom}`);
}
});
}
});
return fixes;
}
export function standardizeEncounterLocations(encounters, rooms) {
if (!encounters || !rooms) return { encounters, fixes: [] };
const roomNames = rooms.map(r => r.name.trim());
const fixes = [];
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const fixedEncounters = encounters.map(encounter => {
if (!encounter.details) return encounter;
const standardized = roomNames.reduce((details, roomName) => {
const roomNameRegex = new RegExp(`^${escapeRe(roomName)}\\s*:?\\s*`, 'i');
if (!roomNameRegex.test(details)) return details;
const hasColon = details.match(new RegExp(`^${escapeRe(roomName)}:`, 'i'));
if (hasColon) return details;
fixes.push(`Standardized location format for encounter: ${encounter.name}`);
return details.replace(roomNameRegex, `${roomName}: `);
}, encounter.details.trim());
return standardized !== encounter.details ? { ...encounter, details: standardized } : encounter;
});
return { encounters: fixedEncounters, fixes };
}
export function fixStructureIssues(dungeonData) {
const fixes = [];
if (dungeonData.rooms) {
dungeonData.rooms.forEach((room, i) => {
if (!room.name || !room.name.trim()) {
const desc = room.description || '';
const nameMatch = desc.match(/^([A-Z][^.!?]{5,30}?)(?:\s|\.|:)/);
if (nameMatch) {
room.name = nameMatch[1].trim();
fixes.push(`Extracted room name from description: "${room.name}"`);
} else {
room.name = `Room ${i + 1}`;
fixes.push(`Added default name for room ${i + 1}`);
}
}
const words = room.name.split(/\s+/);
if (words.length > 6) {
const original = room.name;
room.name = words.slice(0, 6).join(' ');
fixes.push(`Truncated room name: "${original}" -> "${room.name}"`);
}
});
}
if (dungeonData.encounters) {
dungeonData.encounters.forEach((encounter, i) => {
if (!encounter.name || !encounter.name.trim()) {
const details = encounter.details || '';
const nameMatch = details.match(/^([^:]+):\s*(.+)$/);
if (nameMatch) {
encounter.name = nameMatch[1].trim();
encounter.details = nameMatch[2].trim();
fixes.push(`Extracted encounter name from details: "${encounter.name}"`);
} else {
encounter.name = `Encounter ${i + 1}`;
fixes.push(`Added default name for encounter ${i + 1}`);
}
}
const words = encounter.name.split(/\s+/);
if (words.length > 6) {
const original = encounter.name;
encounter.name = words.slice(0, 6).join(' ');
fixes.push(`Truncated encounter name: "${original}" -> "${encounter.name}"`);
}
});
}
if (dungeonData.npcs) {
dungeonData.npcs.forEach((npc, i) => {
if (!npc.name || !npc.name.trim()) {
const trait = npc.trait || '';
const nameMatch = trait.match(/^([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})(?:\s|:)/);
if (nameMatch) {
npc.name = nameMatch[1].trim();
fixes.push(`Extracted NPC name from trait: "${npc.name}"`);
} else {
npc.name = `NPC ${i + 1}`;
fixes.push(`Added default name for NPC ${i + 1}`);
}
}
const words = npc.name.split(/\s+/);
if (words.length > 4) {
const original = npc.name;
npc.name = words.slice(0, 4).join(' ');
fixes.push(`Truncated NPC name: "${original}" -> "${npc.name}"`);
}
});
}
return fixes;
}
export function fixMissingContent(dungeonData) {
const fixes = [];
if (!dungeonData.npcs || dungeonData.npcs.length < 4) {
if (!dungeonData.npcs) dungeonData.npcs = [];
const factionName = dungeonData.coreConcepts?.match(/Primary Faction[:\s]+([^.]+)/i)?.[1]?.trim() || 'the primary faction';
while (dungeonData.npcs.length < 4) {
dungeonData.npcs.push({
name: `NPC ${dungeonData.npcs.length + 1}`,
trait: `A member of ${factionName.toLowerCase()} with unknown motives.`
});
fixes.push(`Added fallback NPC ${dungeonData.npcs.length}`);
}
}
if (!dungeonData.encounters || dungeonData.encounters.length < 6) {
if (!dungeonData.encounters) dungeonData.encounters = [];
if (dungeonData.encounters.length > 0 && dungeonData.rooms?.length > 0) {
const dynamicElement = dungeonData.coreConcepts?.match(/Dynamic Element[:\s]+([^.]+)/i)?.[1]?.trim() || 'strange occurrences';
const conflict = dungeonData.coreConcepts?.match(/Central Conflict[:\s]+([^.]+)/i)?.[1]?.trim() || 'a threat';
while (dungeonData.encounters.length < 6) {
const roomIndex = dungeonData.encounters.length % dungeonData.rooms.length;
const roomName = dungeonData.rooms[roomIndex]?.name || 'Unknown Location';
const fallbackNames = [
`${roomName} Guardian`, `${roomName} Threat`, `${roomName} Challenge`,
`${dynamicElement.split(' ')[0]} Manifestation`, `${conflict.split(' ')[0]} Encounter`, `${roomName} Hazard`
];
dungeonData.encounters.push({
name: fallbackNames[dungeonData.encounters.length % fallbackNames.length],
details: `${roomName}: An encounter related to ${dynamicElement.toLowerCase()} occurs here.`
});
fixes.push(`Added fallback encounter: "${dungeonData.encounters[dungeonData.encounters.length - 1].name}"`);
}
}
}
if (!dungeonData.treasure || dungeonData.treasure.length < 4) {
if (!dungeonData.treasure) dungeonData.treasure = [];
while (dungeonData.treasure.length < 4) {
dungeonData.treasure.push({
name: `Treasure ${dungeonData.treasure.length + 1}`,
description: `A mysterious item found in the dungeon.`
});
fixes.push(`Added fallback treasure ${dungeonData.treasure.length}`);
}
}
if (!dungeonData.randomEvents || dungeonData.randomEvents.length < 6) {
if (!dungeonData.randomEvents) dungeonData.randomEvents = [];
if (dungeonData.randomEvents.length > 0 && dungeonData.coreConcepts) {
const dynamicElement = dungeonData.coreConcepts.match(/Dynamic Element[:\s]+([^.]+)/i)?.[1]?.trim() || 'strange occurrences';
const conflict = dungeonData.coreConcepts.match(/Central Conflict[:\s]+([^.]+)/i)?.[1]?.trim() || 'a mysterious threat';
const fallbackEvents = [
{ name: 'Environmental Shift', description: `The ${dynamicElement.toLowerCase()} causes unexpected changes in the environment.` },
{ name: 'Conflict Manifestation', description: `A sign of ${conflict.toLowerCase()} appears, requiring immediate attention.` },
{ name: 'Dungeon Shift', description: `The dungeon shifts, revealing a previously hidden passage or danger.` },
{ name: 'Faction Messenger', description: `An NPC from the primary faction appears with urgent information.` },
{ name: 'Power Fluctuation', description: `The power source fluctuates, creating temporary hazards or opportunities.` },
{ name: 'Echoes of the Past', description: `Echoes of past events manifest, providing clues or complications.` }
];
while (dungeonData.randomEvents.length < 6) {
dungeonData.randomEvents.push(fallbackEvents[dungeonData.randomEvents.length % fallbackEvents.length]);
fixes.push(`Added fallback random event: "${dungeonData.randomEvents[dungeonData.randomEvents.length - 1].name}"`);
}
}
}
if (!dungeonData.plotResolutions || dungeonData.plotResolutions.length < 4) {
if (!dungeonData.plotResolutions) dungeonData.plotResolutions = [];
while (dungeonData.plotResolutions.length < 4) {
dungeonData.plotResolutions.push(`The adventurers could resolve the central conflict through decisive action.`);
fixes.push(`Added fallback plot resolution ${dungeonData.plotResolutions.length}`);
}
}
return fixes;
}
export function fixNarrativeCoherence(dungeonData) {
const fixes = [];
if (dungeonData.encounters && dungeonData.rooms) {
const roomNames = dungeonData.rooms.map(r => r.name.trim().toLowerCase());
dungeonData.encounters.forEach(encounter => {
if (!encounter.details) return;
const locationMatch = encounter.details.match(/^([^:]+):/);
if (locationMatch) {
const locName = locationMatch[1].trim().toLowerCase();
const matches = roomNames.some(rn =>
locName === rn || locName.includes(rn) || rn.includes(locName) ||
locName.split(/\s+/).some(word => rn.includes(word))
);
if (!matches && roomNames.length > 0) {
const roomIdx = Math.floor(Math.random() * roomNames.length);
const roomName = dungeonData.rooms[roomIdx].name;
encounter.details = encounter.details.replace(/^[^:]+:\s*/, `${roomName}: `);
fixes.push(`Fixed unknown location in encounter "${encounter.name}" to "${roomName}"`);
}
}
});
}
return fixes;
}
export function validateAndFixContent(dungeonData) {
const allFixes = [];
const nameFixes = validateNameConsistency(dungeonData);
allFixes.push(...nameFixes);
const structureFixes = fixStructureIssues(dungeonData);
allFixes.push(...structureFixes);
if (dungeonData.encounters && dungeonData.rooms) {
const roomNames = dungeonData.rooms.map(r => r.name.trim());
dungeonData.encounters.forEach((encounter, idx) => {
if (!encounter.details) return;
if (!encounter.details.match(/^[^:]+:\s/)) {
const roomIdx = idx % roomNames.length;
const roomName = roomNames[roomIdx];
encounter.details = `${roomName}: ${encounter.details}`;
allFixes.push(`Added location "${roomName}" to encounter "${encounter.name}"`);
}
});
const locationResult = standardizeEncounterLocations(dungeonData.encounters, dungeonData.rooms);
dungeonData.encounters = locationResult.encounters;
allFixes.push(...locationResult.fixes);
}
const coherenceFixes = fixNarrativeCoherence(dungeonData);
allFixes.push(...coherenceFixes);
const contentFixes = fixMissingContent(dungeonData);
allFixes.push(...contentFixes);
const allIssues = [
...validateContentCompleteness(dungeonData),
...validateContentQuality(dungeonData),
...validateContentStructure(dungeonData),
...validateNarrativeCoherence(dungeonData),
];
if (allFixes.length > 0) {
console.log("\n[Validation] Applied fixes:");
allFixes.forEach(fix => console.log(` - ${fix}`));
}
if (allIssues.length > 0) {
console.log("\n[Validation] Content quality issues found (not auto-fixable):");
allIssues.forEach(issue => console.warn(`${issue}`));
} else {
console.log("\n[Validation] Content quality checks passed");
}
return dungeonData;
}
+106
View File
@@ -0,0 +1,106 @@
export function deduplicateRoomsByName(rooms) {
if (!rooms || rooms.length === 0) return [];
const seenNames = new Set();
return rooms.filter(room => {
if (!room || !room.name) return false;
const nameLower = room.name.toLowerCase().trim();
if (seenNames.has(nameLower)) {
console.warn(`Duplicate room name detected: "${room.name}", skipping duplicate`);
return false;
}
seenNames.add(nameLower);
return true;
});
}
export function padNpcsToMinimum(parsedNpcs, coreConcepts, minCount = 4) {
const factionName = coreConcepts?.match(/Primary Faction[:\s]+([^.]+)/i)?.[1]?.trim() || 'the primary faction';
if (!parsedNpcs || parsedNpcs.length >= minCount || parsedNpcs.length === 0) return parsedNpcs || [];
const list = [...parsedNpcs];
while (list.length < minCount) {
list.push({
name: `NPC ${list.length + 1}`,
trait: `A member of ${factionName.toLowerCase()} with unknown motives.`
});
}
return list;
}
export function buildEncountersList(parsedEncounters, rooms, coreConcepts) {
const dynamicElement = coreConcepts?.match(/Dynamic Element[:\s]+([^.]+)/i)?.[1]?.trim() || 'strange occurrences';
const conflict = coreConcepts?.match(/Central Conflict[:\s]+([^.]+)/i)?.[1]?.trim() || 'a threat';
const fallbackNames = (roomName) => [
`${roomName} Guardian`,
`${roomName} Threat`,
`${roomName} Challenge`,
`${dynamicElement.split(' ')[0]} Manifestation`,
`${conflict.split(' ')[0]} Encounter`,
`${roomName} Hazard`
];
if (parsedEncounters.length > 0 && parsedEncounters.length < 6) {
return [
...parsedEncounters,
...Array.from({ length: 6 - parsedEncounters.length }, (_, i) => {
const roomIndex = (parsedEncounters.length + i) % rooms.length;
const roomName = rooms[roomIndex]?.name || 'Unknown Location';
return {
name: fallbackNames(roomName)[(parsedEncounters.length + i) % 6],
details: `An encounter related to ${dynamicElement.toLowerCase()} occurs here.`
};
})
];
}
if (parsedEncounters.length === 0) {
return Array.from({ length: 6 }, (_, i) => {
const roomName = rooms[i % rooms.length]?.name || 'Unknown Location';
return { name: `${roomName} Encounter`, details: `An encounter related to ${dynamicElement.toLowerCase()} occurs here.` };
});
}
return parsedEncounters;
}
export function mergeRandomEventsWithFallbacks(parsedEvents, coreConcepts, maxCount = 6) {
const dynamicElement = coreConcepts?.match(/Dynamic Element[:\s]+([^.]+)/i)?.[1]?.trim() || 'strange occurrences';
const conflict = (coreConcepts?.match(/Central Conflict[:\s]+([^.]+)/i)?.[1]?.trim() || 'a mysterious threat').toLowerCase();
const fallbackEvents = [
{ name: 'Environmental Shift', description: `The ${dynamicElement.toLowerCase()} causes unexpected changes in the environment.` },
{ name: 'Conflict Manifestation', description: `A sign of ${conflict} appears, requiring immediate attention.` },
{ name: 'Dungeon Shift', description: `The dungeon shifts, revealing a previously hidden passage or danger.` },
{ name: 'Faction Messenger', description: `An NPC from the primary faction appears with urgent information.` },
{ name: 'Power Fluctuation', description: `The power source fluctuates, creating temporary hazards or opportunities.` },
{ name: 'Echoes of the Past', description: `Echoes of past events manifest, providing clues or complications.` }
];
const truncated = (parsedEvents || []).slice(0, maxCount);
if (truncated.length > 0 && truncated.length < maxCount) {
return [
...truncated,
...Array.from({ length: maxCount - truncated.length }, (_, i) =>
fallbackEvents[(truncated.length + i) % fallbackEvents.length])
];
}
return truncated;
}
export function limitIntermediateRooms(rooms, maxCount = 3) {
if (rooms.length > maxCount) {
console.warn(`Expected exactly ${maxCount} intermediate locations but got ${rooms.length}, limiting to first ${maxCount}`);
}
return rooms.slice(0, maxCount);
}
export function fixRoomPlaceholderName(room) {
if (!room) return room;
if (room.name && (room.name.toLowerCase().includes('room name') || room.name.toLowerCase() === 'room name')) {
const desc = room.description || '';
const nameMatch = desc.match(/^([^:]+?)(?:\s+Description|\s*:)/i) || desc.match(/^([A-Z][^.!?]{5,40}?)(?:\s+is\s|\.)/);
if (nameMatch) {
room.name = nameMatch[1].trim().replace(/^(The|A|An)\s+/i, '').trim();
room.description = desc.replace(new RegExp(`^${nameMatch[1]}\\s*(Description|:)?\\s*`, 'i'), '').trim();
} else {
const words = desc.split(/\s+/).slice(0, 4).join(' ');
room.name = words.replace(/^(The|A|An)\s+/i, '').trim();
}
}
return room;
}
+323
View File
@@ -0,0 +1,323 @@
import { callOllama } from "./ollamaClient.js";
import { cleanText } from "./textUtils.js";
import {
parseList,
parseObjects,
parseMainContentSections,
parseRandomEventsRaw,
splitCombinedEncounters,
} from "./parsing.js";
import {
deduplicateRoomsByName,
padNpcsToMinimum,
buildEncountersList,
mergeRandomEventsWithFallbacks,
limitIntermediateRooms,
fixRoomPlaceholderName,
} from "./dungeonBuild.js";
import { validateAndFixContent } from "./contentFixes.js";
export async function generateDungeon() {
// Step 1: Titles
const generatedTitles = await callOllama(
`Generate 50 short, punchy dungeon titles (max 5 words each), numbered as a plain text list.
Each title should come from a different style or theme. Make the set varied and evocative. For example:
- OSR / classic tabletop: gritty, mysterious, old-school
- Mörk Borg: dark, apocalyptic, foreboding
- Pulpy fantasy: adventurous, dramatic, larger-than-life
- Mildly sci-fi: alien, technological, strange
- Weird fantasy: uncanny, surreal, unsettling
- Whimsical: fun, quirky, playful
CRITICAL: Ensure all spelling is correct. Double-check all words before outputting.
Avoid repeating materials or adjectives. Absolutely do not use the words "Obsidian" or "Clockwork" in any title. Do not include explanations, markdown, or preambles. Do not include the style or theme in parenthesis. Only the 50 numbered titles. Do not include the theme in the name of the title (no parenthesis).`,
undefined, 5, "Step 1: Titles"
);
console.log("Generated Titles:", generatedTitles);
const titlesList = parseList(generatedTitles);
const title = titlesList[Math.floor(Math.random() * titlesList.length)];
console.log("Selected title:", title);
// Step 2: Core Concepts
const coreConceptsRaw = await callOllama(
`For a dungeon titled "${title}", generate three core concepts: a central conflict, a primary faction, and a major environmental hazard or dynamic element.
Output as a numbered list with bolded headings. Plain text only. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.
Example:
1. **Central Conflict:** The dungeon's power source is failing, causing reality to warp.
2. **Primary Faction:** A group of rival cultists trying to seize control of the power source.
3. **Dynamic Element:** Zones of temporal distortion that cause random, brief time shifts.`,
undefined, 5, "Step 2: Core Concepts"
);
const coreConcepts = coreConceptsRaw;
console.log("Core Concepts:", coreConcepts);
// Step 3: Flavor Text & Hooks
const flavorHooksRaw = await callOllama(
`Based on the title "${title}" and these core concepts:
${coreConcepts}
Write a single evocative paragraph describing the location. Maximum 2 sentences. Maximum 50-60 words. Then, generate 4-5 short adventure hooks or rumors.
The hooks should reference the central conflict, faction, and dynamic element. Hooks should suggest different approaches (stealth, diplomacy, force, exploration) and create anticipation.
EXAMPLE OF GOOD HOOK:
"A merchant's cart was found abandoned near the entrance, its cargo of rare herbs scattered. The merchant's journal mentions strange lights in the depths and a warning about 'the watchers'."
CRITICAL: Hooks must be concise to fit in a single column on a one-page dungeon layout. Each hook must be 25-30 words maximum. Be specific with details, not vague.
CRITICAL: Ensure all spelling is correct. Double-check all words, especially proper nouns and technical terms.
Output two sections labeled "Description:" and "Hooks & Rumors:". Use a numbered list for the hooks. Plain text only. Do not use em-dashes (—) anywhere in the output. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
undefined, 5, "Step 3: Flavor & Hooks"
);
const [flavorSection, hooksSection] = flavorHooksRaw.split(/Hooks & Rumors[:\n]/i);
const rawFlavor = cleanText(flavorSection.replace(/Description[:\n]*/i, ""));
const flavorWords = rawFlavor.split(/\s+/);
const flavor = flavorWords.length > 60 ? flavorWords.slice(0, 60).join(' ') + '...' : rawFlavor;
const hooksRumors = parseList(hooksSection || "").map(h => h.replace(/^[^:]+:\s*/, '').trim());
console.log("Flavor Text:", flavor);
console.log("Hooks & Rumors:", hooksRumors);
// Step 4: Key Rooms
const keyRoomsRaw = await callOllama(
`Based on the title "${title}", description "${flavor}", and these core concepts:
${coreConcepts}
Generate two key rooms that define the dungeon's narrative arc.
CRITICAL: These rooms need rich environmental and tactical details with multiple interaction possibilities.
EXAMPLE OF GOOD ROOM DESCRIPTION:
"Chamber of Echoes: Flickering torchlight casts dancing shadows across moss-covered walls. A constant dripping echoes from stalactites overhead, and the air smells of damp earth and ozone. Three stone pillars provide cover, while a raised dais in the center offers high ground. A rusted lever on the west wall controls a hidden portcullis. The floor is slick with moisture, making movement difficult."
1. Entrance Room: Give it a name (max 5 words) and a description (25-35 words) that MUST include:
- Immediate observable features and environmental details (lighting, sounds, smells, textures, temperature, visibility)
- Interactable elements that players can use (levers, objects, portals, mechanisms, environmental hazards)
- Tactical considerations (cover, elevation, movement restrictions, line of sight)
- Sets the tone and introduces the environmental hazard/dynamic element
2. Climax Room: Give it a name (max 5 words) and a description (25-35 words) that MUST include:
- Connection to the primary faction and the central conflict
- Rich environmental and tactical details
- Multiple approach options or solutions
- Tactical considerations and environmental factors that affect gameplay
EXACT FORMAT REQUIRED - each room on its own numbered line:
1. Room Name: Description text here.
2. Room Name: Description text here.
CRITICAL: Ensure all spelling is correct. Double-check all words before outputting.
CRITICAL: Be specific and concrete. Avoid vague words like "some", "various", "several" without details.
Output ONLY the two numbered items, one per line. Use colons (:) to separate room names from descriptions, not em-dashes. Do not use em-dashes (—) anywhere. Do not combine items. Do not use bolded headings. Do not include any intro or other text. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
undefined, 5, "Step 4: Key Rooms"
);
const [entranceSection, climaxSection] = keyRoomsRaw.split(/\n?2[).] /);
const entranceRoom = parseObjects(entranceSection, "rooms")[0];
const climaxRoom = parseObjects(`1. ${climaxSection}`, "rooms")[0];
if (entranceRoom) fixRoomPlaceholderName(entranceRoom);
if (climaxRoom) fixRoomPlaceholderName(climaxRoom);
console.log("Entrance Room:", entranceRoom);
console.log("Climax Room:", climaxRoom);
// Step 5: Main Content (Locations, Encounters, NPCs, Treasures, Random Events)
const mainContentRaw = await callOllama(
`Based on the following dungeon elements and the need for narrative flow:
Title: "${title}"
Description: "${flavor}"
Core Concepts:
${coreConcepts}
Entrance Room: ${JSON.stringify(entranceRoom)}
Climax Room: ${JSON.stringify(climaxRoom)}
Generate the rest of the dungeon's content to fill the space between the entrance and the climax. CRITICAL: All content must fit on a single one-page dungeon layout with three columns. Keep descriptions rich and evocative with tactical/environmental details.
- **Strictly 3 Locations (EXACTLY 3, no more, no less):** Each with a name (max 6 words) and a description (25-35 words). Each room MUST include:
- Rich environmental features that affect gameplay (lighting, sounds, smells, textures, temperature, visibility)
- Interactable elements that players can use (levers, objects, portals, mechanisms, environmental hazards)
- Multiple approaches or solutions to challenges in the room
- Tactical considerations (cover, elevation, movement restrictions, line of sight)
- Hidden aspects discoverable through interaction or investigation
Format as "Name: description" using colons, NOT em-dashes.
EXAMPLE LOCATION:
"Whispering Gallery: Dim phosphorescent fungi line the walls, casting an eerie green glow. The air hums with a low-frequency vibration that makes conversation difficult. Two collapsed pillars create natural cover, while a narrow ledge 10 feet up offers a sniper position. A hidden pressure plate near the entrance triggers a portcullis trap."
- **Strictly 6 Encounters:** Numbered 1-6 (for d6 rolling). Name (max 6 words) and details (2 sentences MAX, approximately 25-40 words). Each encounter MUST:
- Start with the room/location name followed by a colon, then the details (e.g., "Location Name: Details text")
- The location name must match one of the actual room names from this dungeon
- Include environmental hazards/opportunities (cover, elevation, traps, interactable objects, terrain features)
- Include tactical considerations (positioning, line of sight, escape routes, bottlenecks, high ground)
- Offer multiple resolution options (combat, negotiation, stealth, puzzle-solving, environmental manipulation, timing-based solutions)
- Include consequences and outcomes tied to player choices
- Integrate with the environmental dynamic element from core concepts
- At least two encounters must be directly tied to the primary faction
Format as "Name: Location Name: details" using colons, NOT em-dashes. CRITICAL: Always start encounter details with the location name and a colon.
EXAMPLE ENCOUNTER:
"Guardian Golem: Chamber of Echoes: The golem activates when the lever is pulled, blocking the exit. It's vulnerable to water damage from the dripping stalactites. Players can use the pillars for cover or try to disable it by breaking the rune on its back. If defeated peacefully, it reveals a hidden passage."
- **Strictly 4-5 NPCs:** Proper name (max 4 words) and a description (50-65 words). Each NPC MUST include:
- Clear motivation or goal
- Relationship to primary faction
- How they can help or hinder the party
- Quirks or memorable traits
- Multiple interaction possibilities (negotiation, intimidation, help, betrayal)
- One NPC should be a key figure tied to the central conflict
- One should be a member of the primary faction, one should be a potential ally, one should be a rival
Format as "Name: description" using colons, NOT em-dashes.
EXAMPLE NPC:
"Kaelen the Warden: A former guard who was left behind when the faction retreated. He knows the secret passages but demands the party help him escape. He's paranoid and checks over his shoulder constantly. Can be bribed with food or convinced through shared stories of betrayal. Will turn on the party if he thinks they're working with the faction."
- **Strictly 4-5 Treasures:** Name (max 5 words) and a description (30-40 words). Each treasure MUST:
- Include a clear danger or side-effect
- Be connected to a specific encounter, NPC, or room
- Have story significance beyond just value
- Have potential for creative use beyond obvious purpose
- Some should be cursed, have activation requirements, or serve dual purposes
Format as "Name — Description" using em-dash.
EXAMPLE TREASURE:
"Whispering Blade — This dagger amplifies the wielder's voice to a deafening roar when drawn. Found in the Guardian Golem's chamber, it was used to command the construct. The blade is cursed: each use permanently reduces the wielder's hearing. Can be used to shatter glass or stun enemies, but the curse cannot be removed."
- **Strictly 1 Random Events Table:** A d6 table (EXACTLY 6 entries, no more, no less) with random events/wandering encounters. Each entry MUST:
- Have a short, evocative event name (max 4 words)
- Provide interesting complications or opportunities (not just combat)
- Tie to the core concepts and dynamic element
- Add replayability and surprise
- Description should be 15-20 words maximum
- Be UNIQUE and DIFFERENT from each other (no duplicates or generic placeholders)
- Be SPECIFIC to this dungeon's theme, conflict, and dynamic element
Format as numbered 1-6 list under "Random Events:" label. Each event must be formatted as "Event Name: Description text" using colons, NOT em-dashes.
CRITICAL: Each item must be on its own numbered line. DO NOT combine multiple items into a single numbered entry.
EXACT FORMAT REQUIRED (DO NOT use placeholder names like "Location Name", "NPC Name", or "Treasure Name" - use actual creative names):
Locations:
1. Actual Room Name: Description text.
2. Actual Room Name: Description text.
3. Actual Room Name: Description text.
Encounters:
1. Actual Encounter Name: Actual Room Name: Details text.
2. Actual Encounter Name: Actual Room Name: Details text.
3. Actual Encounter Name: Actual Room Name: Details text.
4. Actual Encounter Name: Actual Room Name: Details text.
5. Actual Encounter Name: Actual Room Name: Details text.
6. Actual Encounter Name: Actual Room Name: Details text.
NPCs:
1. Actual Character Name: Description text.
2. Actual Character Name: Description text.
3. Actual Character Name: Description text.
4. Actual Character Name: Description text.
Treasures:
1. Actual Item Name — Description text.
2. Actual Item Name — Description text.
3. Actual Item Name — Description text.
4. Actual Item Name — Description text.
Random Events:
1. Event Name: Event description.
2. Event Name: Event description.
3. Event Name: Event description.
4. Event Name: Event description.
5. Event Name: Event description.
6. Event Name: Event description.
CRITICAL: Every name must be unique and creative. Never use generic placeholders like "Location Name", "NPC Name", "Encounter Name", or "Treasure Name". Use actual descriptive names that fit the dungeon's theme.
CRITICAL: Ensure all spelling is correct. Double-check all words, especially proper nouns, character names, and location names. Verify consistency of names across all sections.
CRITICAL: Location name matching - When writing encounters, the location name in the encounter details MUST exactly match one of the room names you've created (Entrance Room, Climax Room, or one of the 3 Locations). Double-check that every encounter location matches an actual room name.
CRITICAL: Avoid vague language - Do not use words like "some", "various", "several", "many", "few", "things", "stuff", "items", or "objects" without specific details. Be concrete and specific in all descriptions.
CRITICAL: All names required - Every room, encounter, NPC, and treasure MUST have a name. Do not leave names blank or use placeholders. If you cannot think of a name, create one based on the dungeon's theme.
CRITICAL: You MUST output exactly five separate sections with these exact labels on their own lines:
"Locations:"
"Encounters:"
"NPCs:"
"Treasures:"
"Random Events:"
Each section must start with its label on its own line, followed by numbered items. Do NOT combine sections. Do NOT embed encounters in location descriptions. Each item must be on its own numbered line. Do not use any bolding, preambles, or extra text. Do not use em-dashes (—) in encounters or NPCs, only use colons for those sections. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
undefined, 5, "Step 5: Main Content"
);
const { intermediateRoomsSection, encountersSection, npcsSection, treasureSection, randomEventsSection } =
parseMainContentSections(mainContentRaw);
const intermediateRooms = parseObjects(intermediateRoomsSection.replace(/Locations:/i, ""), "rooms");
const limitedIntermediateRooms = limitIntermediateRooms(intermediateRooms, 3);
const allRooms = [entranceRoom, ...limitedIntermediateRooms, climaxRoom].filter(Boolean);
const rooms = deduplicateRoomsByName(allRooms);
const parsedEncounters = splitCombinedEncounters(parseObjects(encountersSection || "", "encounters"));
const parsedNpcs = parseObjects(npcsSection || "", "npcs");
const treasure = parseObjects(treasureSection || "", "treasure");
const npcs = padNpcsToMinimum(parsedNpcs, coreConcepts, 4);
const encounters = buildEncountersList(parsedEncounters, rooms, coreConcepts);
const randomEventsFilteredMapped = parseRandomEventsRaw(randomEventsSection || "");
const randomEvents = mergeRandomEventsWithFallbacks(randomEventsFilteredMapped, coreConcepts, 6);
[[encounters, 6, 'encounters'], [npcs, 4, 'NPCs'], [treasure, 4, 'treasures'], [randomEvents, 6, 'random events']]
.filter(([arr, expected]) => arr.length < expected && arr.length > 0)
.forEach(([arr, expected, name]) => console.warn(`Expected at least ${expected} ${name} but got ${arr.length}`));
console.log("Rooms:", rooms);
console.log("Encounters:", encounters);
console.log("NPCs:", npcs);
console.log("Treasure:", treasure);
console.log("Random Events:", randomEvents);
// Step 6: Player Choices and Consequences
const factionName = coreConcepts.match(/Primary Faction[:\s]+([^.]+)/i)?.[1]?.trim() || 'the primary faction';
const npcNamesList = npcs.map(n => n.name).join(", ");
const plotResolutionsRaw = await callOllama(
`Based on all of the following elements, suggest 4-5 possible, non-conflicting story climaxes or plot resolutions for adventurers exploring this location. Each resolution must provide a meaningful choice with a tangible consequence, directly related to the Central Conflict, the Primary Faction, or the NPCs.
Dungeon Elements:
${JSON.stringify({ title, flavor, hooksRumors, rooms, encounters, treasure, npcs, coreConcepts }, null, 2)}
CRITICAL: This content must fit in a single column on a one-page dungeon layout. Keep descriptions meaningful but concise.
Start each item with phrases like "The adventurers could" or "The adventurers might". Do not use "PCs" or "player characters" - always use "adventurers" instead.
EXAMPLE PLOT RESOLUTION:
"The adventurers could ally with the primary faction, gaining access to their resources but becoming enemies of the rival group. This choice unlocks new areas but closes off diplomatic solutions with other NPCs."
IMPORTANT: When referencing NPCs, use these exact names with correct spelling: ${npcNamesList}. When referencing the faction, use: ${factionName}. Ensure all names are spelled consistently and correctly.
CRITICAL: Double-check all spelling before outputting. Verify all proper nouns match exactly as provided above.
Each resolution MUST:
- Offer meaningful choice with clear consequences
- Integrate NPCs, faction dynamics, and player actions
- Include failure states or unexpected outcomes as options
- Reflect different approaches players might take
Keep each item to 1-2 sentences MAX (approximately 15-25 words). Be extremely concise. Output as a numbered list, plain text only. Do not use em-dashes (—) anywhere in the output. Absolutely do not use the word "Obsidian" or "obsidian" anywhere in the output.`,
undefined, 5, "Step 6: Plot Resolutions"
);
const plotResolutions = parseList(plotResolutionsRaw);
console.log("Plot Resolutions:", plotResolutions);
// Step 7: Validation and Content Fixing
console.log("\n[Validation] Running content validation and fixes...");
const dungeonData = {
title,
flavor,
map: "map.png",
hooksRumors,
rooms,
encounters,
treasure,
npcs,
plotResolutions,
randomEvents,
coreConcepts
};
const validatedData = validateAndFixContent(dungeonData);
console.log("\nDungeon generation complete!");
return validatedData;
}
+50 -104
View File
@@ -2,7 +2,7 @@ function pickRandom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function escapeHtml(text) {
export function escapeHtml(text) {
if (!text) return '';
const map = {
'&': '&amp;',
@@ -14,6 +14,37 @@ function escapeHtml(text) {
return String(text).replace(/[&<>"']/g, m => map[m]);
}
/** Truncate to at most maxSentences, then optionally by maxChars; return immutable result. */
export function truncateText(text, maxSentences, maxChars) {
const t = text || '';
const sentences = t.match(/[^.!?]+[.!?]+/g) || [t];
const afterSentences = sentences.length > maxSentences
? sentences.slice(0, maxSentences).join(' ').trim()
: t;
if (afterSentences.length <= maxChars) return afterSentences;
const trimmed = afterSentences.substring(0, maxChars - 3).trim();
const lastPeriod = trimmed.lastIndexOf('.');
return (lastPeriod > maxChars * 0.8 ? trimmed.substring(0, lastPeriod + 1) : trimmed + '...');
}
/** Parse event (object or string) into { name, description } with optional truncation. */
export function parseEventForDisplay(event, index) {
const pair = typeof event === 'object' && event?.name != null && event?.description != null
? { name: event.name, description: event.description }
: typeof event === 'string'
? (() => {
const colonMatch = event.match(/^([^:]+):\s*(.+)$/);
if (colonMatch) return { name: colonMatch[1].trim(), description: colonMatch[2].trim() };
const words = event.split(/\s+/);
return words.length > 3
? { name: words.slice(0, 2).join(' '), description: words.slice(2).join(' ') }
: { name: `Event ${index + 1}`, description: event };
})()
: { name: `Event ${index + 1}`, description: String(event || '') };
const description = truncateText(pair.description, 999, 200);
return { name: pair.name, description };
}
export function dungeonTemplate(data) {
const bodyFonts = [
"'Lora', serif",
@@ -255,43 +286,7 @@ export function dungeonTemplate(data) {
<table class="encounters-table">
<tbody>
${data.randomEvents.map((event, index) => {
// Handle both object format {name, description} and string format
let eventName = '';
let eventDesc = '';
if (typeof event === 'object' && event.name && event.description) {
eventName = event.name;
eventDesc = event.description;
} else if (typeof event === 'string') {
// Try to parse "Event Name: Description" format
const colonMatch = event.match(/^([^:]+):\s*(.+)$/);
if (colonMatch) {
eventName = colonMatch[1].trim();
eventDesc = colonMatch[2].trim();
} else {
// Fallback: use first few words as name, rest as description
const words = event.split(/\s+/);
if (words.length > 3) {
eventName = words.slice(0, 2).join(' ');
eventDesc = words.slice(2).join(' ');
} else {
eventName = `Event ${index + 1}`;
eventDesc = event;
}
}
} else {
eventName = `Event ${index + 1}`;
eventDesc = String(event || '');
}
// Truncate description to prevent overflow (similar to encounters)
if (eventDesc.length > 200) {
eventDesc = eventDesc.substring(0, 197).trim();
const lastPeriod = eventDesc.lastIndexOf('.');
if (lastPeriod > 150) {
eventDesc = eventDesc.substring(0, lastPeriod + 1);
} else {
eventDesc += '...';
}
}
const { name: eventName, description: eventDesc } = parseEventForDisplay(event, index);
return `
<tr>
<td>${index + 1}</td>
@@ -309,22 +304,7 @@ export function dungeonTemplate(data) {
<div class="section-block">
<h2>Locations</h2>
${data.rooms.map(room => {
let desc = room.description || '';
// Truncate to 1 sentence max to prevent overflow
const sentences = desc.match(/[^.!?]+[.!?]+/g) || [desc];
if (sentences.length > 1) {
desc = sentences.slice(0, 1).join(' ').trim();
}
// Also limit by character count (~100 chars for tighter fit)
if (desc.length > 100) {
desc = desc.substring(0, 97).trim();
const lastPeriod = desc.lastIndexOf('.');
if (lastPeriod > 70) {
desc = desc.substring(0, lastPeriod + 1);
} else {
desc += '...';
}
}
const desc = truncateText(room.description || '', 1, 100);
return `
<div class="room">
<h3>${escapeHtml(room.name)}</h3>
@@ -343,38 +323,21 @@ export function dungeonTemplate(data) {
<table class="encounters-table">
<tbody>
${data.encounters.map((encounter, index) => {
// Truncate details to 4 sentences max to prevent overflow
let details = encounter.details || '';
// Remove encounter name if it appears at start
if (details.toLowerCase().startsWith(encounter.name.toLowerCase())) {
details = details.substring(encounter.name.length).replace(/^:\s*/, '').trim();
}
// Remove location prefix if present (format: "Location Name: description")
// Handle multiple colons - strip the first one that looks like a location
const locationMatch = details.match(/^([^:]+):\s*(.+)$/);
if (locationMatch) {
const potentialLocation = locationMatch[1].trim();
// If it looks like a location name (capitalized, not too long), remove it
if (potentialLocation.length > 3 && potentialLocation.length < 50 && /^[A-Z]/.test(potentialLocation)) {
details = locationMatch[2].trim();
}
}
// Split into sentences and keep only first 4
const sentences = details.match(/[^.!?]+[.!?]+/g) || [details];
if (sentences.length > 4) {
details = sentences.slice(0, 4).join(' ').trim();
}
// Also limit by character count as fallback (max ~350 chars)
if (details.length > 350) {
details = details.substring(0, 347).trim();
// Try to end at a sentence boundary
const lastPeriod = details.lastIndexOf('.');
if (lastPeriod > 280) {
details = details.substring(0, lastPeriod + 1);
} else {
details += '...';
}
}
const raw = (encounter.details || '').trim();
const withoutName = raw.toLowerCase().startsWith(encounter.name.toLowerCase())
? raw.substring(encounter.name.length).replace(/^:\s*/, '').trim()
: raw;
const locationMatch = withoutName.match(/^([^:]+):\s*(.+)$/);
const withoutLocation = locationMatch
? (() => {
const potential = locationMatch[1].trim();
if (potential.length > 3 && potential.length < 50 && /^[A-Z]/.test(potential)) {
return locationMatch[2].trim();
}
return withoutName;
})()
: withoutName;
const details = truncateText(withoutLocation, 4, 350);
return `
<tr>
<td>${index + 1}</td>
@@ -432,24 +395,7 @@ export function dungeonTemplate(data) {
<div class="section-block">
<h2>Plot Resolutions</h2>
${data.plotResolutions.map(resolution => {
// Truncate to 1 sentence max to prevent overflow (more aggressive)
let text = resolution || '';
// Split into sentences and keep only first 1
const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
if (sentences.length > 1) {
text = sentences.slice(0, 1).join(' ').trim();
}
// Also limit by character count as fallback (max ~120 chars for tighter fit)
if (text.length > 120) {
text = text.substring(0, 117).trim();
// Try to end at a sentence boundary
const lastPeriod = text.lastIndexOf('.');
if (lastPeriod > 90) {
text = text.substring(0, lastPeriod + 1);
} else {
text += '...';
}
}
const text = truncateText(resolution || '', 1, 120);
return `
<div class="plot-resolution">
${escapeHtml(text)}
+17 -17
View File
@@ -2,7 +2,7 @@ import sharp from 'sharp';
import path from "path";
import { mkdir, writeFile } from "fs/promises";
import { fileURLToPath } from "url";
import { callOllama, OLLAMA_MODEL } from "./ollamaClient.js";
import { callOllama } from "./ollamaClient.js";
const COMFYUI_ENABLED = process.env.COMFYUI_ENABLED !== 'false';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -63,7 +63,7 @@ Input:
${flavor}
Output:`,
OLLAMA_MODEL,
undefined,
3,
"Generate Visual Prompt"
);
@@ -239,22 +239,22 @@ export async function generateDungeonImages({ flavor }) {
return path.join(__dirname, "dungeon_upscaled.png");
}
const finalPrompt = await generateVisualPrompt(flavor);
console.log("Engineered visual prompt:\n", finalPrompt);
try {
const finalPrompt = await generateVisualPrompt(flavor);
console.log("Engineered visual prompt:\n", finalPrompt);
const baseFilename = `dungeon.png`;
const upscaledFilename = `dungeon_upscaled.png`;
const baseFilename = `dungeon.png`;
const upscaledFilename = `dungeon_upscaled.png`;
const filepath = await generateImageViaComfyUI(finalPrompt, baseFilename);
if (!filepath) {
throw new Error("Failed to generate dungeon image.");
const filepath = await generateImageViaComfyUI(finalPrompt, baseFilename);
if (!filepath) return null;
const upscaledPath = await upscaleImage(filepath, upscaledFilename, 1456, 1024);
if (!upscaledPath) return null;
return upscaledPath;
} catch (err) {
console.warn("Map image failed:", err.message);
return null;
}
// Upscale 2x (half of A4 at 300dpi)
const upscaledPath = await upscaleImage(filepath, upscaledFilename, 1456, 1024);
if (!upscaledPath) {
throw new Error("Failed to upscale dungeon image.");
}
return upscaledPath;
}
+39 -35
View File
@@ -1,42 +1,46 @@
import { cleanText } from "./textUtils.js";
const OLLAMA_API_URL = process.env.OLLAMA_API_URL;
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY;
export let OLLAMA_MODEL = process.env.OLLAMA_MODEL || "gemma3:latest";
export const OLLAMA_MODEL = "qwen3.5-122b-a10b";
function effectiveModel(explicit) {
if (explicit !== undefined && explicit !== null) return explicit;
return process.env.OLLAMA_MODEL || OLLAMA_MODEL;
}
export async function initializeModel() {
if (process.env.OLLAMA_MODEL) return;
if (process.env.OLLAMA_MODEL) return process.env.OLLAMA_MODEL;
try {
const isOpenWebUI = OLLAMA_API_URL?.includes("/api/chat/completions");
const baseUrl = OLLAMA_API_URL?.replace(/\/api\/.*$/, "");
const apiUrl = process.env.OLLAMA_API_URL;
const isOpenWebUI = apiUrl?.includes("/api/chat/completions");
const baseUrl = apiUrl?.replace(/\/api\/.*$/, "");
const url = isOpenWebUI ? `${baseUrl}/api/v1/models` : `${baseUrl}/api/tags`;
const headers = isOpenWebUI && OLLAMA_API_KEY
? { "Authorization": `Bearer ${OLLAMA_API_KEY}` }
const headers = isOpenWebUI && process.env.OLLAMA_API_KEY
? { "Authorization": `Bearer ${process.env.OLLAMA_API_KEY}` }
: {};
const res = await fetch(url, { headers });
if (res.ok) {
const data = await res.json();
const model = isOpenWebUI
const model = isOpenWebUI
? data.data?.[0]?.id || data.data?.[0]?.name
: data.models?.[0]?.name;
if (model) {
OLLAMA_MODEL = model;
process.env.OLLAMA_MODEL = model;
console.log(`Using default model: ${model}`);
return model;
}
}
} catch {
console.warn(`Could not fetch default model, using: ${OLLAMA_MODEL}`);
// fall through to warn below
}
console.warn(`Could not fetch default model, using: ${OLLAMA_MODEL}`);
return OLLAMA_MODEL;
}
function cleanText(str) {
return str
.replace(/^#+\s*/gm, "")
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/[*_`]/g, "")
.replace(/\s+/g, " ")
.trim();
}
export { cleanText };
function inferApiType(url) {
export function inferApiType(url) {
if (!url) return "ollama-generate";
if (url.includes("/api/chat/completions")) return "open-webui";
if (url.includes("/api/chat")) return "ollama-chat";
@@ -50,8 +54,10 @@ async function sleep(ms) {
async function callOllamaBase(prompt, model, retries, stepName, apiType) {
const isUsingOpenWebUI = apiType === "open-webui";
const isUsingOllamaChat = apiType === "ollama-chat";
const resolvedModel = effectiveModel(model);
const attempts = Array.from({ length: retries }, (_, index) => index + 1);
for (let attempt = 1; attempt <= retries; attempt++) {
for (const attempt of attempts) {
try {
const promptCharCount = prompt.length;
const promptWordCount = prompt.split(/\s+/).length;
@@ -63,14 +69,17 @@ async function callOllamaBase(prompt, model, retries, stepName, apiType) {
`Prompt: ${promptCharCount} chars, ~${promptWordCount} words`,
);
const headers = { "Content-Type": "application/json" };
if (isUsingOpenWebUI && OLLAMA_API_KEY) {
headers["Authorization"] = `Bearer ${OLLAMA_API_KEY}`;
}
const headers =
isUsingOpenWebUI && process.env.OLLAMA_API_KEY
? {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.OLLAMA_API_KEY}`,
}
: { "Content-Type": "application/json" };
const body = isUsingOpenWebUI || isUsingOllamaChat
? { model, messages: [{ role: "user", content: prompt }] }
: { model, prompt, stream: false };
? { model: resolvedModel, messages: [{ role: "user", content: prompt }] }
: { model: resolvedModel, prompt, stream: false };
const response = await fetch(OLLAMA_API_URL, {
method: "POST",
@@ -79,13 +88,8 @@ async function callOllamaBase(prompt, model, retries, stepName, apiType) {
});
if (!response.ok) {
let errorDetails = "";
try {
const errorData = await response.text();
errorDetails = errorData ? `: ${errorData}` : "";
} catch {
// Ignore errors reading error response
}
const errorData = await response.text().catch(() => null);
const errorDetails = errorData ? `: ${errorData}` : "";
throw new Error(
`Ollama request failed: ${response.status} ${response.statusText}${errorDetails}`,
);
@@ -118,7 +122,7 @@ async function callOllamaBase(prompt, model, retries, stepName, apiType) {
export async function callOllama(
prompt,
model = OLLAMA_MODEL,
model,
retries = 5,
stepName = "unknown",
) {
@@ -128,7 +132,7 @@ export async function callOllama(
export async function callOllamaExplicit(
prompt,
model = OLLAMA_MODEL,
model,
retries = 5,
stepName = "unknown",
apiType = "ollama-generate",
+234
View File
@@ -0,0 +1,234 @@
import { cleanText } from "./textUtils.js";
export function parseList(raw) {
if (!raw) return [];
const NUMBERED_ITEM_REGEX = /\d+[).]\s+([\s\S]+?)(?=\s*\d+[).]\s+|$)/g;
const items = Array.from(raw.matchAll(NUMBERED_ITEM_REGEX))
.map(match => match[1].trim())
.filter(Boolean)
.map(cleanText)
.filter(Boolean);
return items.length > 0
? items
: raw
.split(/\n?\d+[).]\s+/)
.map(line => cleanText(line))
.filter(Boolean);
}
export function parseObjects(raw, type = "rooms") {
const cleanedRaw = raw.replace(/Intermediate Rooms:/i, "").replace(/Climax Room:/i, "").trim();
const mapper = (entry) => {
if (type === "encounters") {
const parts = entry.split(/:/);
if (parts.length >= 3) {
const name = parts[0].trim();
if (name.toLowerCase().includes('location name') || name.toLowerCase().includes('encounter name')) return null;
return { name, details: parts.slice(1).join(":").trim() };
}
if (parts.length === 2) {
const name = parts[0].trim();
if (name.toLowerCase().includes('location name') || name.toLowerCase().includes('encounter name')) return null;
return { name, details: parts[1].trim() };
}
return null;
}
if (type === "treasure") {
const parts = entry.split(/[—]/);
if (parts.length >= 2) {
const cleanName = parts[0].trim();
if (cleanName.toLowerCase().includes('treasure name') || cleanName.toLowerCase().includes('actual ')) return null;
const desc = parts.slice(1).join(' ').trim().replace(/^description\s*:?\s*/i, '').trim();
return { name: cleanName, description: desc };
}
}
const [name, ...descParts] = entry.split(/[-–—:]/);
const cleanName = name.trim();
if (cleanName.toLowerCase().includes('location name') ||
cleanName.toLowerCase().includes('npc name') ||
cleanName.toLowerCase().includes('treasure name') ||
cleanName.toLowerCase().includes('actual ')) return null;
const desc = type === "npcs"
? descParts.join(" ").trim().replace(/^description\s*:?\s*/i, '').trim()
: descParts.join(" ").trim();
const obj = { name: cleanName };
if (type === "rooms") return { ...obj, description: desc };
if (type === "npcs") return { ...obj, trait: desc };
if (type === "treasure") return { ...obj, description: desc };
return null;
};
return cleanedRaw.split(/\n?\d+[).]\s+/).map(cleanText).filter(Boolean).map(mapper).filter(Boolean);
}
export const parseEncounterText = (text, idx) => {
const encounterMatch = text.match(/Encounter\s+(\d+)\s+(.+?)\s+(?:Room Name|Location)\s+(.+?)\s+Details\s+(.+)/i);
if (encounterMatch) {
const [, , name, location, details] = encounterMatch;
return { name: name.trim(), details: `${location.trim()}: ${details.trim()}` };
}
const colonFormat = text.match(/Encounter\s+\d+\s+(.+?):\s*(.+?):\s*(.+)/i);
if (colonFormat) {
const [, name, location, details] = colonFormat;
return { name: name.trim(), details: `${location.trim()}: ${details.trim()}` };
}
const match = text.match(/^(\d+)\s+(.+?)(?::\s*(.+))?$/);
if (match) {
const [, , name, details] = match;
return name && details ? { name: name.trim(), details: details.trim() } : null;
}
const colonSplit = text.split(/[:]/);
if (colonSplit.length > 1) {
return {
name: colonSplit[0].replace(/^\d+\s+|Encounter\s+\d+\s+/i, "").trim(),
details: colonSplit.slice(1).join(":").trim()
};
}
const nameMatch = text.match(/^\d+\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)/);
if (nameMatch) {
return {
name: nameMatch[1],
details: text.replace(/^\d+\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\s*/, "").trim()
};
}
return { name: `Encounter ${idx + 1}`, details: text.replace(/^\d+\s+|Encounter\s+\d+\s+/i, "").trim() };
};
export const splitCombinedEncounters = (encounters) => {
if (encounters.length === 0) return [];
const shouldSplit = encounters.length === 1 && (encounters[0].name === "1" || encounters[0].details?.match(/\d+\s+[A-Z]/) || encounters[0].details?.includes('Encounter'));
if (!shouldSplit) return encounters;
console.warn("Encounters appear combined, attempting to split...");
const combinedText = encounters[0].details || "";
const split = combinedText.split(/(?=Encounter\s+\d+|\d+\s+[A-Z][a-z])/i).filter(Boolean);
return (split.length > 1 || (split.length === 1 && combinedText.length > 100))
? split.map((text, idx) => parseEncounterText(text, idx)).filter(e => e?.name && e?.details?.length > 10)
: encounters;
};
function _splitCombinedNPCs(npcs) {
const shouldSplit = npcs.length === 1 && npcs[0].trait?.length > 80;
if (!shouldSplit) return npcs;
console.warn("NPCs appear combined, attempting to split...");
const split = npcs[0].trait.split(/(?=[A-Z][a-z]+\s+[A-Z][a-z]+\s*:)/).filter(Boolean);
return split.length > 1
? split.map(text => {
const [name, ...traitParts] = text.split(/[:]/);
return { name: name.trim(), trait: traitParts.join(":").trim() };
}).filter(n => n.name && n.trait?.length > 10)
: npcs;
}
function parseTreasureText(text, idx, splitTreasures) {
if (idx === splitTreasures.length - 1 && text.length < 40) {
return { name: splitTreasures[idx - 1]?.split(/\s+/).slice(-2).join(" ") || `Treasure ${idx}`, description: text };
}
const dashSplit = text.split(/[—]/);
if (dashSplit.length === 2) return { name: dashSplit[0].trim(), description: dashSplit[1].trim() };
if (text.length < 30 && /^[A-Z]/.test(text)) return { name: text.trim(), description: "" };
return null;
}
function _splitCombinedTreasures(treasure) {
const shouldSplit = treasure.length === 1 && treasure[0].description?.length > 60;
if (!shouldSplit) return treasure;
console.warn("Treasures appear combined, attempting to split...");
const split = treasure[0].description.split(/\s+—\s+/).filter(Boolean);
if (split.length <= 1) return treasure;
const parsed = split.map((text, idx) => parseTreasureText(text, idx, split)).filter(t => t?.name && t?.description);
if (parsed.length > 0) return parsed;
const nameDescPairs = treasure[0].description.match(/([A-Z][^—]+?)\s+—\s+([^—]+?)(?=\s+[A-Z][^—]+\s+—|$)/g);
return nameDescPairs
? nameDescPairs.map(pair => {
const match = pair.match(/([^—]+)\s+—\s+(.+)/);
return match ? { name: match[1].trim(), description: match[2].trim() } : null;
}).filter(t => t)
: treasure;
}
export function parseRandomEventsRaw(rawSection) {
const parsed = parseList(rawSection || "");
return parsed
.filter(e =>
e &&
e.toLowerCase() !== 'a random event occurs' &&
e.toLowerCase() !== 'a random event occurs.' &&
!e.toLowerCase().includes('placeholder') &&
e.length > 10
)
.map((e, index) => {
const cleaned = e.replace(/^(Event\s+\d+[:\s]+|Random\s+Event[:\s]+|Random\s+Events?[:\s]+)/i, '').trim();
const colonMatch = cleaned.match(/^([^:]+):\s*(.+)$/);
if (colonMatch) {
const name = colonMatch[1].trim();
const description = colonMatch[2].trim();
if (name.toLowerCase().includes('event name') || name.toLowerCase().includes('placeholder')) return null;
return { name, description };
}
const words = cleaned.split(/\s+/);
if (words.length > 3) {
return { name: words.slice(0, 2).join(' '), description: words.slice(2).join(' ') };
}
return { name: `Event ${index + 1}`, description: cleaned };
})
.filter(Boolean);
}
export function parseMainContentSections(mainContentRaw) {
const initialSplit = mainContentRaw.split(/Encounters:|NPCs:|Treasures?:|Random Events:/i);
const withRandom = (!initialSplit[4] && mainContentRaw.toLowerCase().includes('random'))
? (() => {
const randomMatch = mainContentRaw.match(/Random Events?[:\s]*\n?([^]*?)(?=Locations?:|Encounters?:|NPCs?:|Treasures?:|$)/i);
return randomMatch ? [...initialSplit.slice(0, 4), randomMatch[1]] : initialSplit;
})()
: initialSplit;
const withNpcs = (!withRandom[2] && mainContentRaw.toLowerCase().includes('npc'))
? (() => {
const npcMatch = mainContentRaw.match(/NPCs?[:\s]*\n?([^]*?)(?=Treasures?:|Random Events?:|Locations?:|Encounters?:|$)/i);
return npcMatch ? [withRandom[0], withRandom[1], npcMatch[1], withRandom[3], withRandom[4]] : withRandom;
})()
: withRandom;
const inter = withNpcs[0];
const enc = (withNpcs[1] || '').trim();
if (enc || !inter.includes('Encounter')) {
return {
intermediateRoomsSection: inter,
encountersSection: enc,
npcsSection: withNpcs[2],
treasureSection: withNpcs[3],
randomEventsSection: withNpcs[4],
};
}
const encounterMatches = inter.match(/Encounter\s+\d+[^]*?(?=Encounter\s+\d+|NPCs?:|Treasures?:|Random Events?:|Location \d+|$)/gi);
if (!encounterMatches || encounterMatches.length === 0) {
return {
intermediateRoomsSection: inter,
encountersSection: enc,
npcsSection: withNpcs[2],
treasureSection: withNpcs[3],
randomEventsSection: withNpcs[4],
};
}
const encountersSection = encounterMatches.map((m, i) => {
const match = m.match(/Encounter\s+(\d+)\s+(.+?)\s+(?:Room Name|Location)\s+(.+?)\s+Details\s+(.+)/i);
if (match) {
const [, num, name, location, details] = match;
return `${num}. ${name.trim()}: ${location.trim()}: ${details.trim().substring(0, 200)}`;
}
const simpleMatch = m.match(/Encounter\s+(\d+)\s+(.+?)\s+([A-Z][^:]+?)\s+Details\s+(.+)/i);
if (simpleMatch) {
const [, num, name, location, details] = simpleMatch;
return `${num}. ${name.trim()}: ${location.trim()}: ${details.trim().substring(0, 200)}`;
}
return `${i + 1}. ${m.trim()}`;
}).join('\n');
const intermediateRoomsSection = inter.replace(/Encounter\s+\d+[^]*?(?=Encounter\s+\d+|NPCs?:|Treasures?:|Random Events?:|Location \d+|$)/gi, '');
return {
intermediateRoomsSection,
encountersSection,
npcsSection: withNpcs[2],
treasureSection: withNpcs[3],
randomEventsSection: withNpcs[4],
};
}
+10
View File
@@ -0,0 +1,10 @@
/** Strip markdown artifacts and normalize whitespace. Pure, no side effects. */
export function cleanText(str) {
if (!str) return "";
return str
.replace(/^#+\s*/gm, "")
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/[*_`]/g, "")
.replace(/\s+/g, " ")
.trim();
}
+120
View File
@@ -0,0 +1,120 @@
export function extractCanonicalNames(dungeonData) {
const names = { npcs: [], rooms: [], factions: [] };
if (dungeonData.npcs) {
dungeonData.npcs.forEach(npc => { if (npc.name) names.npcs.push(npc.name.trim()); });
}
if (dungeonData.rooms) {
dungeonData.rooms.forEach(room => { if (room.name) names.rooms.push(room.name.trim()); });
}
if (dungeonData.coreConcepts) {
const factionMatch = dungeonData.coreConcepts.match(/Primary Faction[:\s]+([^.]+)/i);
if (factionMatch) names.factions.push(factionMatch[1].trim());
}
return names;
}
export function validateContentCompleteness(dungeonData) {
const issues = [];
const checks = [
['title', 0, 'Missing title'],
['flavor', 20, 'Flavor text too short'],
['hooksRumors', 4, 'Expected at least 4 hooks'],
['rooms', 5, 'Expected at least 5 rooms'],
['encounters', 6, 'Expected at least 6 encounters'],
['npcs', 4, 'Expected at least 4 NPCs'],
['treasure', 4, 'Expected at least 4 treasures'],
['randomEvents', 6, 'Expected 6 random events'],
['plotResolutions', 4, 'Expected at least 4 plot resolutions']
];
checks.forEach(([key, min, msg]) => {
const val = dungeonData[key];
if (!val || (Array.isArray(val) ? val.length < min : val.trim().length < min)) {
issues.push(`${msg}${Array.isArray(val) ? `, got ${val?.length || 0}` : ''}`);
}
});
dungeonData.rooms?.forEach((r, i) => {
if (!r.description || r.description.trim().length < 20) {
issues.push(`Room ${i + 1} (${r.name}) description too short`);
}
});
dungeonData.encounters?.forEach((e, i) => {
if (!e.details || e.details.trim().length < 30) {
issues.push(`Encounter ${i + 1} (${e.name}) details too short`);
}
});
dungeonData.npcs?.forEach((n, i) => {
if (!n.trait || n.trait.trim().length < 30) {
issues.push(`NPC ${i + 1} (${n.name}) description too short`);
}
});
return issues;
}
export function validateContentQuality(dungeonData) {
const issues = [];
const vagueWords = /\b(some|various|several|many|few|things|stuff|items|objects)\b/gi;
const checkVague = (text, ctx) => {
if (!text) return;
const matches = text.match(vagueWords);
if (matches?.length > 2) {
issues.push(`${ctx} contains vague language: "${matches.slice(0, 3).join('", "')}"`);
}
};
checkVague(dungeonData.flavor, 'Flavor text');
dungeonData.rooms?.forEach(r => checkVague(r.description, `Room "${r.name}"`));
dungeonData.encounters?.forEach(e => checkVague(e.details, `Encounter "${e.name}"`));
dungeonData.npcs?.forEach(n => checkVague(n.trait, `NPC "${n.name}"`));
dungeonData.rooms?.forEach(r => {
if (r.description?.length < 50) {
issues.push(`Room "${r.name}" description too short`);
}
});
return issues;
}
export function validateContentStructure(dungeonData) {
const issues = [];
dungeonData.rooms?.forEach((r, i) => {
if (!r.name?.trim()) issues.push(`Room ${i + 1} missing name`);
if (r.name?.split(/\s+/).length > 6) issues.push(`Room "${r.name}" name too long`);
});
dungeonData.encounters?.forEach((e, i) => {
if (!e.name?.trim()) issues.push(`Encounter ${i + 1} missing name`);
if (e.name?.split(/\s+/).length > 6) issues.push(`Encounter "${e.name}" name too long`);
if (e.details && !e.details.match(/^[^:]+:\s/)) {
issues.push(`Encounter "${e.name}" details missing location prefix`);
}
});
dungeonData.npcs?.forEach((n, i) => {
if (!n.name?.trim()) issues.push(`NPC ${i + 1} missing name`);
if (n.name?.split(/\s+/).length > 4) issues.push(`NPC "${n.name}" name too long`);
});
return issues;
}
export function validateNarrativeCoherence(dungeonData) {
const issues = [];
const factionMatch = dungeonData.coreConcepts?.match(/Primary Faction[:\s]+([^.]+)/i);
const factionName = factionMatch?.[1]?.trim();
if (dungeonData.encounters && dungeonData.rooms) {
const roomNames = dungeonData.rooms.map(r => r.name.trim().toLowerCase());
dungeonData.encounters.forEach(e => {
const locMatch = e.details?.match(/^([^:]+):/);
if (locMatch) {
const locName = locMatch[1].trim().toLowerCase();
if (!roomNames.some(rn => locName.includes(rn) || rn.includes(locName))) {
issues.push(`Encounter "${e.name}" references unknown location "${locMatch[1]}"`);
}
}
});
}
if (factionName) {
const factionLower = factionName.toLowerCase();
const refs = (dungeonData.npcs?.filter(n => n.trait?.toLowerCase().includes(factionLower)).length ?? 0)
+ (dungeonData.encounters?.filter(e => e.details?.toLowerCase().includes(factionLower)).length ?? 0);
if (refs < 2) {
issues.push(`Faction "${factionName}" poorly integrated (${refs} references)`);
}
}
return issues;
}
-91
View File
@@ -1,91 +0,0 @@
import { test } from "node:test";
import assert from "node:assert";
import { generateDungeon } from "../dungeonGenerator.js";
import { generatePDF } from "../generatePDF.js";
import fs from "fs/promises";
import path from "path";
const OLLAMA_API_URL = process.env.OLLAMA_API_URL;
test("Integration tests", { skip: !OLLAMA_API_URL }, async (t) => {
let dungeonData;
await t.test("Generate dungeon", async () => {
dungeonData = await generateDungeon();
assert(dungeonData, "Dungeon data should be generated");
});
await t.test("Title is 2-4 words, no colons", () => {
assert(dungeonData.title, "Title should exist");
const words = dungeonData.title.split(/\s+/);
assert(words.length >= 2 && words.length <= 4, `Title should be 2-4 words, got ${words.length}: "${dungeonData.title}"`);
assert(!dungeonData.title.includes(":"), `Title should not contain colons: "${dungeonData.title}"`);
});
await t.test("Flavor text is ≤60 words", () => {
assert(dungeonData.flavor, "Flavor text should exist");
const words = dungeonData.flavor.split(/\s+/);
assert(words.length <= 60, `Flavor text should be ≤60 words, got ${words.length}`);
});
await t.test("Hooks have no title prefixes", () => {
assert(dungeonData.hooksRumors, "Hooks should exist");
dungeonData.hooksRumors.forEach((hook, i) => {
assert(!hook.match(/^[^:]+:\s/), `Hook ${i + 1} should not have title prefix: "${hook}"`);
});
});
await t.test("Exactly 6 random events", () => {
assert(dungeonData.randomEvents, "Random events should exist");
assert.strictEqual(dungeonData.randomEvents.length, 6, `Should have exactly 6 random events, got ${dungeonData.randomEvents.length}`);
});
await t.test("Encounter details don't include encounter name", () => {
assert(dungeonData.encounters, "Encounters should exist");
dungeonData.encounters.forEach((encounter) => {
if (encounter.details) {
const detailsLower = encounter.details.toLowerCase();
const nameLower = encounter.name.toLowerCase();
assert(!detailsLower.startsWith(nameLower), `Encounter "${encounter.name}" details should not start with encounter name: "${encounter.details}"`);
}
});
});
await t.test("Treasure uses em-dash format, no 'description' text", () => {
assert(dungeonData.treasure, "Treasure should exist");
dungeonData.treasure.forEach((item, i) => {
if (typeof item === "object" && item.description) {
assert(!item.description.toLowerCase().startsWith("description"), `Treasure ${i + 1} description should not start with 'description': "${item.description}"`);
}
});
});
await t.test("NPCs have no 'description' text", () => {
assert(dungeonData.npcs, "NPCs should exist");
dungeonData.npcs.forEach((npc, i) => {
if (npc.trait) {
assert(!npc.trait.toLowerCase().startsWith("description"), `NPC ${i + 1} trait should not start with 'description': "${npc.trait}"`);
}
});
});
await t.test("PDF fits on one page", async () => {
const testPdfPath = path.join(process.cwd(), "test-output.pdf");
try {
await generatePDF(dungeonData, testPdfPath);
const pdfBuffer = await fs.readFile(testPdfPath);
// Check PDF page count by counting "%%EOF" markers (rough estimate)
const pdfText = pdfBuffer.toString("binary");
const pageCount = (pdfText.match(/\/Type\s*\/Page[^s]/g) || []).length;
// Should be 1 page for content, or 2 if map exists
const expectedPages = dungeonData.map ? 2 : 1;
assert(pageCount <= expectedPages, `PDF should have ≤${expectedPages} page(s), got ${pageCount}`);
} finally {
try {
await fs.unlink(testPdfPath);
} catch {
// Ignore cleanup errors
}
}
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect, beforeAll } from "vitest";
import { generateDungeon } from "../../src/dungeonGenerator.js";
import { generatePDF } from "../../src/generatePDF.js";
import fs from "fs/promises";
import path from "path";
const hasOllama = !!process.env.OLLAMA_API_URL;
describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", { timeout: 120000 }, () => {
const fixture = {};
beforeAll(async () => {
fixture.dungeon = await generateDungeon();
});
it("generates dungeon data", () => {
expect(fixture.dungeon).toBeDefined();
});
it("title is 2-4 words, no colons", () => {
expect(fixture.dungeon.title).toBeTruthy();
const words = fixture.dungeon.title.split(/\s+/);
expect(words.length).toBeGreaterThanOrEqual(2);
expect(words.length).toBeLessThanOrEqual(4);
expect(fixture.dungeon.title).not.toContain(":");
});
it("flavor text is ≤60 words", () => {
expect(fixture.dungeon.flavor).toBeTruthy();
const words = fixture.dungeon.flavor.split(/\s+/);
expect(words.length).toBeLessThanOrEqual(60);
});
it("hooks have no title prefixes", () => {
expect(fixture.dungeon.hooksRumors).toBeDefined();
fixture.dungeon.hooksRumors.forEach((hook) => {
expect(hook).not.toMatch(/^[^:]+:\s/);
});
});
it("has exactly 6 random events", () => {
expect(fixture.dungeon.randomEvents).toBeDefined();
expect(fixture.dungeon.randomEvents.length).toBe(6);
});
it("encounter details do not start with encounter name", () => {
expect(fixture.dungeon.encounters).toBeDefined();
fixture.dungeon.encounters.forEach((encounter) => {
if (encounter.details) {
const detailsLower = encounter.details.toLowerCase();
const nameLower = encounter.name.toLowerCase();
expect(detailsLower.startsWith(nameLower)).toBe(false);
}
});
});
it("treasure descriptions do not start with 'description'", () => {
expect(fixture.dungeon.treasure).toBeDefined();
fixture.dungeon.treasure.forEach((item) => {
if (typeof item === "object" && item.description) {
expect(item.description.toLowerCase().startsWith("description")).toBe(false);
}
});
});
it("NPC traits do not start with 'description'", () => {
expect(fixture.dungeon.npcs).toBeDefined();
fixture.dungeon.npcs.forEach((npc) => {
if (npc.trait) {
expect(npc.trait.toLowerCase().startsWith("description")).toBe(false);
}
});
});
it("PDF fits on one page", async () => {
const testPdfPath = path.join(process.cwd(), "test-output.pdf");
try {
await generatePDF(fixture.dungeon, testPdfPath);
const pdfBuffer = await fs.readFile(testPdfPath);
const pdfText = pdfBuffer.toString("binary");
const pageCount = (pdfText.match(/\/Type\s*\/Page[^s]/g) || []).length;
const expectedPages = fixture.dungeon.map ? 2 : 1;
expect(pageCount).toBeLessThanOrEqual(expectedPages);
} finally {
try {
await fs.unlink(testPdfPath);
} catch {
// Ignore cleanup errors
}
}
}, 60000);
});
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../src/ollamaClient.js", () => ({ callOllama: vi.fn() }));
const { callOllama } = await import("../../src/ollamaClient.js");
const { generateDungeon } = await import("../../src/dungeonGenerator.js");
describe("generateDungeon (mocked Ollama)", () => {
beforeEach(() => {
vi.mocked(callOllama)
.mockResolvedValueOnce(
"1. Dark Hall\n2. Lost Mines\n3. Shadow Keep"
)
.mockResolvedValueOnce(
"Central Conflict: The power source fails. Primary Faction: The Guard. Dynamic Element: Temporal rifts."
)
.mockResolvedValueOnce(
"Description:\nA dark place under the earth.\nHooks & Rumors:\n1. A merchant vanished near the entrance.\n2. Strange lights in the depths.\n3. The Guard seeks the artifact.\n4. Rifts cause brief time skips."
)
.mockResolvedValueOnce(
"1. Entrance Hall: A dark entrance with torches and damp walls. Pillars offer cover. The air smells of earth.\n2. Climax Chamber: The final room where the power source pulses. The Guard holds the artifact. Multiple approaches possible."
)
.mockResolvedValueOnce(
`Locations:
1. Corridor: A long corridor with flickering lights.
2. Chamber: A side chamber with debris.
3. Shrine: A small shrine to the old gods.
Encounters:
1. Patrol: Hall: Guard patrol passes through.
2. Rift: Corridor: A temporal rift causes disorientation.
3. Ambush: Chamber: Bandits lie in wait.
4. Guardian: Shrine: A warden challenges intruders.
5. Boss: Climax Chamber: The leader defends the artifact.
6. Trap: Corridor: A pressure plate triggers darts.
NPCs:
1. Captain: Leader of the Guard, stern and duty-bound.
2. Scout: Young scout, curious about the rifts.
3. Priest: Keeper of the shrine, knows old lore.
4. Merchant: Survivor who lost his cargo.
Treasures:
1. Artifact: The power source core.
2. Journal: Captain's log with tactical notes.
3. Key: Opens the climax chamber.
4. Gem: A glowing temporal crystal.
Random Events:
1. Rift Shift: Time skips forward one hour.
2. Guard Patrol: A patrol approaches.
3. Echo: Voices from the past echo.
4. Light Flicker: Lights go out for a moment.
5. Distant Cry: Someone calls for help.
6. Dust Fall: Ceiling dust falls, revealing a hidden symbol.`
)
.mockResolvedValueOnce(
"1. The adventurers could ally with the Guard and secure the artifact.\n2. They might destroy the source and end the rifts.\n3. They could bargain with the faction for passage.\n4. They might flee and seal the entrance."
);
});
it("returns dungeon data with all required fields", async () => {
const result = await generateDungeon();
expect(result).toBeDefined();
expect(result.title).toBeTruthy();
expect(result.flavor).toBeTruthy();
expect(result.hooksRumors).toBeDefined();
expect(Array.isArray(result.rooms)).toBe(true);
expect(Array.isArray(result.encounters)).toBe(true);
expect(Array.isArray(result.npcs)).toBe(true);
expect(Array.isArray(result.treasure)).toBe(true);
expect(Array.isArray(result.randomEvents)).toBe(true);
expect(Array.isArray(result.plotResolutions)).toBe(true);
}, 10000);
});
describe("generateDungeon with fewer items (mocked Ollama)", () => {
beforeEach(() => {
vi.mocked(callOllama)
.mockResolvedValueOnce("1. Dark Hall\n2. Lost Mines")
.mockResolvedValueOnce("Central Conflict: War. Primary Faction: Guard. Dynamic Element: Magic.")
.mockResolvedValueOnce("Description: A place.\nHooks & Rumors:\n1. One.\n2. Two.\n3. Three.\n4. Four.")
.mockResolvedValueOnce("1. Entrance: First room.\n2. Climax: Final room.")
.mockResolvedValueOnce(
`Locations:
1. Corridor: A corridor.
2. Chamber: A chamber.
Encounters:
1. Patrol: Corridor: A patrol.
2. Ambush: Chamber: Bandits.
NPCs:
1. Captain: Leader.
2. Scout: Scout.
Treasures:
1. Gold: Coins.
2. Gem: A gem.
Random Events:
1. Rift Shift: Time skips.
2. Guard Patrol: Patrol approaches.`
)
.mockResolvedValueOnce("1. The adventurers could win.\n2. They might flee.");
});
it("pads random events and encounters when step 5 returns fewer than 6", async () => {
const result = await generateDungeon();
expect(result.randomEvents.length).toBe(6);
expect(result.encounters.length).toBe(6);
expect(result.npcs.length).toBeGreaterThanOrEqual(4);
}, 10000);
it("builds six encounters from scratch when step 5 returns none", async () => {
vi.mocked(callOllama)
.mockResolvedValueOnce("1. Dark Hall\n2. Lost Mines")
.mockResolvedValueOnce("Central Conflict: War. Primary Faction: Guard. Dynamic Element: Magic.")
.mockResolvedValueOnce("Description: A place.\nHooks & Rumors:\n1. One.\n2. Two.\n3. Three.\n4. Four.")
.mockResolvedValueOnce("1. Entrance: First room.\n2. Climax: Final room.")
.mockResolvedValueOnce(
`Locations:
1. Corridor: A corridor.
2. Chamber: A chamber.
Encounters:
NPCs:
1. Captain: Leader.
Treasures:
1. Gold: Coins.
Random Events:
1. Rift: Time skips.`
)
.mockResolvedValueOnce("1. The adventurers could win.");
const result = await generateDungeon();
expect(result.encounters.length).toBe(6);
expect(result.encounters.every((e) => e.name && e.details)).toBe(true);
}, 10000);
it("handles random events with no colon and short text (fallback name)", async () => {
vi.mocked(callOllama)
.mockResolvedValueOnce("1. Dark Hall\n2. Lost Mines")
.mockResolvedValueOnce("Central Conflict: War. Primary Faction: Guard. Dynamic Element: Magic.")
.mockResolvedValueOnce("Description: A place.\nHooks & Rumors:\n1. One.\n2. Two.\n3. Three.\n4. Four.")
.mockResolvedValueOnce("1. Entrance: First room.\n2. Climax: Final room.")
.mockResolvedValueOnce(
`Locations:
1. Corridor: A corridor.
2. Chamber: A chamber.
Encounters:
1. Patrol: Corridor: A patrol.
2. Ambush: Chamber: Bandits.
NPCs:
1. Captain: Leader.
2. Scout: Scout.
Treasures:
1. Gold: Coins.
2. Gem: A gem.
Random Events:
1. One two three
2. Event Name: Placeholder event
3. Rift Shift Time Skips Forward One Hour
4. Rift Shift: Time skips forward one hour.`
)
.mockResolvedValueOnce("1. The adventurers could win.\n2. They might flee.");
const result = await generateDungeon();
expect(result.randomEvents.length).toBe(6);
expect(result.randomEvents.some((e) => e.name && e.description)).toBe(true);
}, 10000);
});
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
import { describe, it, expect } from "vitest";
import {
escapeHtml,
truncateText,
parseEventForDisplay,
dungeonTemplate,
} from "../../src/dungeonTemplate.js";
describe("escapeHtml", () => {
it("returns empty string for empty input", () => {
expect(escapeHtml("")).toBe("");
});
it("returns empty string for null/undefined-like", () => {
expect(escapeHtml(null)).toBe("");
expect(escapeHtml(undefined)).toBe("");
});
it("escapes & < > \" '", () => {
expect(escapeHtml("&")).toBe("&amp;");
expect(escapeHtml("<")).toBe("&lt;");
expect(escapeHtml(">")).toBe("&gt;");
expect(escapeHtml('"')).toBe("&quot;");
expect(escapeHtml("'")).toBe("&#039;");
expect(escapeHtml('<script>&"\'</script>')).toBe(
"&lt;script&gt;&amp;&quot;&#039;&lt;/script&gt;"
);
});
it("leaves normal text unchanged", () => {
expect(escapeHtml("Hello World")).toBe("Hello World");
});
});
describe("truncateText", () => {
it("returns empty for empty input", () => {
expect(truncateText("", 1, 100)).toBe("");
});
it("returns text when within sentence and char limits", () => {
const one = "One sentence.";
expect(truncateText(one, 1, 100)).toBe(one);
});
it("truncates to maxSentences", () => {
const three = "First. Second. Third.";
expect(truncateText(three, 1, 500)).toBe("First.");
expect(truncateText(three, 2, 500)).toContain("First.");
expect(truncateText(three, 2, 500)).toContain("Second.");
});
it("truncates by maxChars and ends at sentence boundary when possible", () => {
const long = "A short bit. Then a much longer sentence that goes past the limit we set.";
const out = truncateText(long, 99, 30);
expect(out.length).toBeLessThanOrEqual(33);
expect(out === "A short bit." || out.endsWith("...")).toBe(true);
});
it("appends ... when no sentence boundary near end", () => {
const noPeriod = "No period here and more text";
expect(truncateText(noPeriod, 1, 15)).toMatch(/\.\.\.$/);
});
});
describe("parseEventForDisplay", () => {
it("returns object name and description when given object", () => {
const event = { name: "Event A", description: "Something happened." };
const got = parseEventForDisplay(event, 0);
expect(got.name).toBe("Event A");
expect(got.description).toContain("Something");
});
it('parses "Name: Description" string', () => {
const got = parseEventForDisplay("Fire: The room catches fire.", 0);
expect(got.name).toBe("Fire");
expect(got.description).toContain("catches fire");
});
it("splits string without colon into first two words as name, rest as description", () => {
const got = parseEventForDisplay("One Two Three Four", 0);
expect(got.name).toBe("One Two");
expect(got.description).toBe("Three Four");
});
it("uses fallback Event N and full string for short string", () => {
const got = parseEventForDisplay("Hi", 2);
expect(got.name).toBe("Event 3");
expect(got.description).toBe("Hi");
});
it("handles non-string non-object with index", () => {
const got = parseEventForDisplay(null, 1);
expect(got.name).toBe("Event 2");
expect(got.description).toBe("");
});
});
describe("dungeonTemplate", () => {
it("produces HTML with title and main sections for minimal data", () => {
const data = {
title: "Test Dungeon",
flavor: "A dark place.",
hooksRumors: ["Hook one.", "Hook two."],
rooms: [{ name: "Room 1", description: "A room." }],
encounters: [{ name: "Encounter 1", details: "Hall: Something happens." }],
npcs: [{ name: "NPC 1", trait: "A guard." }],
treasure: [{ name: "Gold", description: "Shiny." }],
randomEvents: [{ name: "Event 1", description: "Something." }],
plotResolutions: ["Resolution one."],
};
const html = dungeonTemplate(data);
expect(html).toContain("Test Dungeon");
expect(html).toContain("A dark place.");
expect(html).toContain("Room 1");
expect(html).toContain("Encounter 1");
expect(html).toContain("NPC 1");
expect(html).toContain("Gold");
expect(html).toContain("Event 1");
expect(html).toContain("Resolution one.");
expect(html).toContain("<!DOCTYPE html>");
});
it("includes map page when data.map is data URL", () => {
const data = {
title: "With Map",
flavor: "Flavor.",
map: "data:image/png;base64,abc123",
hooksRumors: ["H1"],
rooms: [],
encounters: [],
npcs: [],
treasure: [],
randomEvents: [],
plotResolutions: [],
};
const html = dungeonTemplate(data);
expect(html).toContain("map-page");
expect(html).toContain("data:image/png;base64,abc123");
});
it("omits flavor paragraph when flavor is empty", () => {
const data = {
title: "No Flavor",
flavor: "",
hooksRumors: ["H1"],
rooms: [],
encounters: [],
npcs: [],
treasure: [],
randomEvents: [],
plotResolutions: [],
};
const html = dungeonTemplate(data);
expect(html).toContain("No Flavor");
expect(html).not.toMatch(/<p class="flavor">/);
});
it("renders treasure as string Name — Desc", () => {
const data = {
title: "T",
flavor: "F",
hooksRumors: [],
rooms: [],
encounters: [],
npcs: [],
treasure: ["Gold — Shiny coins."],
randomEvents: [],
plotResolutions: [],
};
const html = dungeonTemplate(data);
expect(html).toContain("Gold");
expect(html).toContain("Shiny coins");
});
it("renders NPC as string Name: Trait", () => {
const data = {
title: "T",
flavor: "F",
hooksRumors: [],
rooms: [],
encounters: [],
npcs: ["Guard: A stern guard."],
treasure: [],
randomEvents: [],
plotResolutions: [],
};
const html = dungeonTemplate(data);
expect(html).toContain("Guard");
expect(html).toContain("stern guard");
});
it("strips location prefix from encounter details when it looks like a location name", () => {
const data = {
title: "T",
flavor: "F",
hooksRumors: [],
rooms: [{ name: "Grand Hall", description: "Big." }],
encounters: [
{ name: "E1", details: "Grand Hall: The fight happens here in the hall." },
],
npcs: [],
treasure: [],
randomEvents: [],
plotResolutions: [],
};
const html = dungeonTemplate(data);
expect(html).toContain("The fight happens here");
});
it("renders encounter details without name when details start with encounter name", () => {
const data = {
title: "T",
flavor: "F",
hooksRumors: [],
rooms: [],
encounters: [
{ name: "Goblin Attack", details: "Goblin Attack: They strike." },
],
npcs: [],
treasure: [],
randomEvents: [],
plotResolutions: [],
};
const html = dungeonTemplate(data);
expect(html).toContain("They strike");
});
});
+265
View File
@@ -0,0 +1,265 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
cleanText,
inferApiType,
callOllama,
callOllamaExplicit,
initializeModel,
OLLAMA_MODEL,
} from "../../src/ollamaClient.js";
describe("cleanText", () => {
it("strips markdown headers", () => {
expect(cleanText("# Title")).toBe("Title");
expect(cleanText("## Sub")).toBe("Sub");
});
it("replaces bold with plain text", () => {
expect(cleanText("**bold**")).toBe("bold");
});
it("removes asterisks and underscores", () => {
expect(cleanText("*a* _b_")).toBe("a b");
});
it("collapses whitespace to single spaces and trims", () => {
expect(cleanText(" a b \n c ")).toBe("a b c");
});
});
describe("inferApiType", () => {
it("returns ollama-generate for null/undefined/empty string", () => {
expect(inferApiType(null)).toBe("ollama-generate");
expect(inferApiType(undefined)).toBe("ollama-generate");
expect(inferApiType("")).toBe("ollama-generate");
});
it("returns open-webui for URL with /api/chat/completions", () => {
expect(inferApiType("http://host/api/chat/completions")).toBe("open-webui");
});
it("returns ollama-chat for URL with /api/chat", () => {
expect(inferApiType("http://host/api/chat")).toBe("ollama-chat");
});
it("returns ollama-generate for plain base URL", () => {
expect(inferApiType("http://localhost:11434")).toBe("ollama-generate");
});
});
describe("callOllama (mocked fetch)", () => {
const originalFetch = globalThis.fetch;
const originalEnv = process.env.OLLAMA_API_URL;
beforeEach(() => {
process.env.OLLAMA_API_URL = "http://localhost:11434";
globalThis.fetch = vi.fn();
});
afterEach(() => {
process.env.OLLAMA_API_URL = originalEnv;
globalThis.fetch = originalFetch;
});
it("returns cleaned text from ollama-generate response", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ response: "**Hello** world" }),
});
const result = await callOllama("Hi", undefined, 1, "test");
expect(result).toBe("Hello world");
});
it("throws on non-ok response", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Error",
text: () => Promise.resolve("server error"),
});
await expect(callOllama("Hi", undefined, 1, "test")).rejects.toThrow("Ollama request failed");
});
it("throws on non-ok response when response.text() rejects", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: false,
status: 502,
statusText: "Bad Gateway",
text: () => Promise.reject(new Error("body read error")),
});
await expect(callOllama("Hi", undefined, 1, "test")).rejects.toThrow("Ollama request failed");
});
it("retries on failure then succeeds", async () => {
vi.mocked(globalThis.fetch)
.mockRejectedValueOnce(new Error("network error"))
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ response: "Retry ok" }),
});
const result = await callOllama("Hi", undefined, 2, "test");
expect(result).toBe("Retry ok");
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
});
describe("callOllamaExplicit (mocked fetch)", () => {
const originalFetch = globalThis.fetch;
const originalUrl = process.env.OLLAMA_API_URL;
const originalKey = process.env.OLLAMA_API_KEY;
beforeEach(() => {
globalThis.fetch = vi.fn();
});
afterEach(() => {
process.env.OLLAMA_API_URL = originalUrl;
process.env.OLLAMA_API_KEY = originalKey;
globalThis.fetch = originalFetch;
});
it("returns content from open-webui response shape", async () => {
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "**Open** answer" } }],
}),
});
const result = await callOllamaExplicit(
"Hi",
"model",
1,
"test",
"open-webui"
);
expect(result).toBe("Open answer");
});
it("sends Authorization header when open-webui and OLLAMA_API_KEY set", async () => {
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
process.env.OLLAMA_API_KEY = "secret-key";
process.env.OLLAMA_MODEL = "";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "ok" } }],
}),
});
await callOllamaExplicit("Hi", "model", 1, "test", "open-webui");
const [, opts] = vi.mocked(globalThis.fetch).mock.calls[0];
expect(opts?.headers?.Authorization).toBe("Bearer secret-key");
});
it("returns content from ollama-chat response shape", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({ message: { content: "Chat **reply**" } }),
});
const result = await callOllamaExplicit(
"Hi",
"model",
1,
"test",
"ollama-chat"
);
expect(result).toBe("Chat reply");
});
it("throws when response has no content", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
await expect(
callOllamaExplicit("Hi", "model", 1, "test", "ollama-generate")
).rejects.toThrow("No response from Ollama");
});
});
describe("initializeModel (mocked fetch)", () => {
const originalFetch = globalThis.fetch;
const originalEnv = process.env.OLLAMA_API_URL;
const originalOllamaModel = process.env.OLLAMA_MODEL;
beforeEach(() => {
process.env.OLLAMA_API_URL = "http://localhost:11434";
process.env.OLLAMA_MODEL = "";
globalThis.fetch = vi.fn();
});
afterEach(() => {
process.env.OLLAMA_API_URL = originalEnv;
process.env.OLLAMA_MODEL = originalOllamaModel;
globalThis.fetch = originalFetch;
});
it("does not fetch when OLLAMA_MODEL is set", async () => {
process.env.OLLAMA_MODEL = "existing-model";
await initializeModel();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("does not set env when fetch returns not ok", async () => {
process.env.OLLAMA_MODEL = "";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: false,
status: 404,
json: () => Promise.resolve({}),
});
const resolved = await initializeModel();
expect(resolved).toBe(OLLAMA_MODEL);
expect(process.env.OLLAMA_MODEL).toBe("");
});
it("fetches /api/tags when OLLAMA_MODEL not set", async () => {
process.env.OLLAMA_MODEL = "";
process.env.OLLAMA_API_URL = "http://localhost:11434";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ models: [{ name: "test-model" }] }),
});
const resolved = await initializeModel();
expect(globalThis.fetch).toHaveBeenCalled();
const [url, opts] = vi.mocked(globalThis.fetch).mock.calls[0];
expect(String(url)).toMatch(/\/api\/tags$/);
expect(opts?.method || "GET").toBe("GET");
expect(resolved).toBe("test-model");
expect(process.env.OLLAMA_MODEL).toBe("test-model");
});
it("fetches /api/v1/models when URL has open-webui path and sets model from data.data id", async () => {
process.env.OLLAMA_MODEL = "";
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [{ id: "webui-model" }] }),
});
await initializeModel();
const [url] = vi.mocked(globalThis.fetch).mock.calls[0];
expect(String(url)).toMatch(/\/api\/v1\/models$/);
expect(process.env.OLLAMA_MODEL).toBe("webui-model");
});
it("sets model from data.data[0].name when id missing", async () => {
process.env.OLLAMA_MODEL = "";
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [{ name: "webui-model-name" }] }),
});
await initializeModel();
expect(process.env.OLLAMA_MODEL).toBe("webui-model-name");
});
it("catches fetch failure and warns", async () => {
vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("network"));
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await initializeModel();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Could not fetch default model"));
warn.mockRestore();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
exclude: ["**/node_modules/**", "**/integration/**", "**/integration.test.js"],
environment: "node",
},
coverage: {
provider: "v8",
reporter: ["text", "text-summary"],
include: [
"src/textUtils.js",
"src/parsing.js",
"src/validation.js",
"src/dungeonBuild.js",
"src/contentFixes.js",
"src/dungeonGenerator.js",
"src/dungeonTemplate.js",
"src/ollamaClient.js",
],
exclude: ["test/**", "**/*.config.js", "index.js"],
thresholds: {
statements: 85,
branches: 85,
functions: 85,
lines: 85,
},
},
});
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/integration/**/*.test.js"],
exclude: ["**/node_modules/**"],
environment: "node",
testTimeout: 120000,
},
});