diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 236d572..1665247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,8 @@ jobs: - run: pnpm run lint + - run: pnpm run test + # Builds the release zip, which is what a player installs. A build that # only emits dist/ would not catch a manifest the zip cannot carry. - name: Build diff --git a/package.json b/package.json index 74fecdd..6773a59 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,11 @@ "scripts": { "build": "vttforge build", "dev": "vttforge dev", - "lint": "vttforge lint", + "e2e": "node e2e/live-check.mjs", "format": "vttforge lint --fix", - "typecheck": "tsc --noEmit", - "e2e": "node e2e/live-check.mjs" + "lint": "vttforge lint", + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "dependencies": { "@vttforge/core": "^0.20.0" @@ -20,11 +21,12 @@ "devDependencies": { "@playwright/test": "^1.56.0", "@vttforge/cli": "^0.18.0", - "@vttforge/testing": "^0.11.0", - "@vttforge/types": "^0.7.0", + "@vttforge/testing": "^0.12.0", + "@vttforge/types": "^0.8.0", "@vttforge/vite-plugin": "^0.6.0", "typescript": "^5.4.0", - "vite": "^8.2.2" + "vite": "^8.2.2", + "vitest": "^5.0.0" }, "engines": { "node": ">=26" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90a1d61..43437c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,11 +19,11 @@ importers: specifier: ^0.18.0 version: 0.18.0 '@vttforge/testing': - specifier: ^0.11.0 - version: 0.11.0 + specifier: ^0.12.0 + version: 0.12.0 '@vttforge/types': - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^0.8.0 + version: 0.8.0 '@vttforge/vite-plugin': specifier: ^0.6.0 version: 0.6.0(rolldown@1.2.8)(vite@8.3.0) @@ -33,6 +33,9 @@ importers: vite: specifier: ^8.2.2 version: 8.3.0 + vitest: + specifier: ^5.0.0 + version: 5.0.0(vite@8.3.0) packages: @@ -341,12 +344,35 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/gensync@1.0.5': resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@vitest/mocker@5.0.0': + resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.0': + resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@vttforge/cli@0.18.0': resolution: {integrity: sha512-RnxNvmnBIPdufdznxS6woIf9p8d9NxbuuA5sn7m+sv8OowcURS+2H6qCPiTYUcavtoCATfRtzCLRpnYJpQdLNg==} engines: {node: '>=26.0.0'} @@ -355,15 +381,15 @@ packages: '@vttforge/core@0.20.0': resolution: {integrity: sha512-ehpp9DRuq6tVP2rdE0KpKqYD60JkSIJNhwr7u4Svb421kc42w9TiE6hUzgd32Go/WqezjgJHCe3lkjticcsSsQ==} - '@vttforge/testing@0.11.0': - resolution: {integrity: sha512-vtFzdel5QMQod3+afOz5WfSei70vG3da7mpwBs9p9mb+SlCqHfljP81peh/nBaVORaflOEYX04Twhdh0F3jQZg==} + '@vttforge/testing@0.12.0': + resolution: {integrity: sha512-lwIvHO2yeiY84U4/bWmaW9eFTb/1PLpnLJyy1065ZdpTXlhGkldXoM4ZJw8ZqLhwr5PG+O4g6qO0pYmMXkJtgw==} engines: {node: '>=22.0.0'} '@vttforge/types@0.5.0': resolution: {integrity: sha512-Fv2e3nywD/W0fn+D7xPxehJN273Wcym+6I4/aKgz7NmE3h1yZ+2O8M4EIfmjwSwRWmZDHghoRVpChsGDcZ3zqQ==} - '@vttforge/types@0.7.0': - resolution: {integrity: sha512-Km9ieHAkeeg9ZI4bnMb/DbQWnCGlnvUAjTzcPjx+xwoVrWN0BKmWDKTP6FuKqCDJVRJg9umgjQ/NbLRIaMUcGA==} + '@vttforge/types@0.8.0': + resolution: {integrity: sha512-WG519EU56bDHjIbVgLYHJz/9Rtuq5ywdBug5pEdfzNrpiOkSslKNcAV5Ys6Dp9tA+HP/LQmOcCok5++6CDqQNg==} '@vttforge/vite-plugin@0.6.0': resolution: {integrity: sha512-wMbHqNnunyfIHaERk/K2UglVP453/PDbaswIuJYd+QfS4jHxyQEdZHcvHPolV8uuz3vTUXgJ+RfuiHRBaEoiHw==} @@ -379,6 +405,10 @@ packages: resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==} engines: {node: '>=18'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -458,6 +488,10 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} @@ -491,10 +525,16 @@ packages: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -506,6 +546,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -647,6 +691,9 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} + magic-string@1.3.1: + resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -718,6 +765,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -725,6 +775,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + streamx@2.28.1: resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} @@ -743,6 +799,14 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -808,6 +872,52 @@ packages: yaml: optional: true + vitest@5.0.0: + resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.0 + '@vitest/browser-preview': 5.0.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.0 + '@vitest/coverage-v8': 5.0.0 + '@vitest/ui': 5.0.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + zip-stream@7.0.5: resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} engines: {node: '>=18'} @@ -1069,10 +1179,30 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/gensync@1.0.5': {} '@types/jsesc@2.5.1': {} + '@vitest/mocker@5.0.0(vite@8.3.0)': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0 + estree-walker: 3.0.3 + magic-string: 1.3.1 + optionalDependencies: + vite: 8.3.0 + + '@vitest/spy@5.0.0': {} + '@vttforge/cli@0.18.0': dependencies: '@babel/parser': 8.0.5 @@ -1089,13 +1219,13 @@ snapshots: dependencies: '@vttforge/types': 0.5.0 - '@vttforge/testing@0.11.0': + '@vttforge/testing@0.12.0': dependencies: - '@vttforge/types': 0.7.0 + '@vttforge/types': 0.8.0 '@vttforge/types@0.5.0': {} - '@vttforge/types@0.7.0': {} + '@vttforge/types@0.8.0': {} '@vttforge/vite-plugin@0.6.0(rolldown@1.2.8)(vite@8.3.0)': dependencies: @@ -1128,6 +1258,8 @@ snapshots: - bare-buffer - react-native-b4a + assertion-error@2.0.1: {} + async@3.2.6: {} b4a@1.8.1: {} @@ -1188,6 +1320,8 @@ snapshots: caniuse-lite@1.0.30001810: {} + chai@6.2.2: {} + citty@0.2.2: {} compress-commons@7.0.1: @@ -1215,8 +1349,14 @@ snapshots: empathic@2.0.1: {} + es-module-lexer@2.3.2: {} + escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + event-target-shim@5.0.1: {} events-universal@1.0.1: @@ -1227,6 +1367,8 @@ snapshots: events@3.3.0: {} + expect-type@1.4.0: {} + fast-fifo@1.3.2: {} fast-string-truncated-width@3.0.3: {} @@ -1319,6 +1461,10 @@ snapshots: lru-cache@11.5.2: {} + magic-string@1.3.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -1398,10 +1544,16 @@ snapshots: safe-buffer@5.2.1: {} + siginfo@2.0.0: {} + sisteransi@1.0.5: {} source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + streamx@2.28.1: dependencies: events-universal: 1.0.1 @@ -1443,6 +1595,10 @@ snapshots: transitivePeerDependencies: - react-native-b4a + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -1470,6 +1626,30 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + vitest@5.0.0(vite@8.3.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0(vite@8.3.0) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.3.1 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.3.0 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + zip-stream@7.0.5: dependencies: compress-commons: 7.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 40793e1..4270068 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ minimumReleaseAgeExclude: - '@vttforge/cli@0.17.0 || 0.18.0' - '@vttforge/core@0.19.0 || 0.20.0' - - '@vttforge/types@0.3.0 || 0.4.0 || 0.5.0 || 0.7.0' + - '@vttforge/types@0.3.0 || 0.4.0 || 0.5.0 || 0.7.0 || 0.8.0' - '@vttforge/vite-plugin@0.6.0' - - '@vttforge/testing@0.8.0 || 0.10.0 || 0.11.0' + - '@vttforge/testing@0.8.0 || 0.10.0 || 0.11.0 || 0.12.0' diff --git a/scripts/__tests__/profile.test.ts b/scripts/__tests__/profile.test.ts new file mode 100644 index 0000000..a68c107 --- /dev/null +++ b/scripts/__tests__/profile.test.ts @@ -0,0 +1,186 @@ +/** + * Writing a profile and reading one back. + * + * The two halves a GM depends on. An export that carries a password, or an + * import that stops on the first key it cannot write, are both worse than + * useless, so both are pinned here. + */ +import { withMockFoundry } from '@vttforge/testing/vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { PROFILE_FORMAT } from '../constants.js'; +import { applyProfile, buildProfile, isProfile, looksSecret } from '../profile.js'; + +let mock: ReturnType | undefined; + +beforeEach(() => { + mock = withMockFoundry({ + game: { system: { id: 'some-system', version: '2.1.0' }, version: '14' }, + }); +}); + +afterEach(() => { + mock?.restore(); + mock = undefined; +}); + +function register( + namespace: string, + key: string, + value: unknown, + scope: 'world' | 'client' | 'user' = 'world', +): void { + game.settings.register(namespace, key, { scope, config: true, type: String, default: value }); +} + +describe('buildProfile', () => { + it('carries every setting, with its scope', () => { + register('a-module', 'theme', 'dark', 'client'); + register('b-module', 'difficulty', 'hard'); + + const profile = buildProfile({ label: 'my table' }); + expect(profile.format).toBe(PROFILE_FORMAT); + expect(profile.label).toBe('my table'); + expect(profile.entries).toEqual([ + { id: 'a-module.theme', scope: 'client', value: 'dark' }, + { id: 'b-module.difficulty', scope: 'world', value: 'hard' }, + ]); + }); + + it('records the world it came from', () => { + register('a-module', 'theme', 'dark'); + const profile = buildProfile(); + + expect(profile.foundry).toBe('14'); + expect(profile.system).toEqual({ id: 'some-system', version: '2.1.0' }); + }); + + it('takes only the namespaces asked for', () => { + register('a-module', 'theme', 'dark'); + register('b-module', 'difficulty', 'hard'); + + expect(buildProfile({ namespaces: ['b-module'] }).entries.map((e) => e.id)).toEqual([ + 'b-module.difficulty', + ]); + }); + + it('leaves a value that names a credential out of the file', () => { + // An export is a file a GM might paste into a chat window. + register('a-module', 'apiToken', 'sk-live-0001'); + register('a-module', 'licenceKey', 'AAAA-BBBB'); + register('a-module', 'theme', 'dark'); + + expect(buildProfile().entries.map((e) => e.id)).toEqual(['a-module.theme']); + }); + + it('carries them when asked, because some are meant to travel', () => { + register('a-module', 'apiToken', 'sk-live-0001'); + + expect(buildProfile({ includeSecrets: true }).entries.map((e) => e.id)).toEqual([ + 'a-module.apiToken', + ]); + }); +}); + +describe('looksSecret', () => { + it('reads the key name, and nothing cleverer', () => { + const setting = (key: string) => ({ + id: `m.${key}`, + namespace: 'm', + key, + scope: 'world' as const, + name: undefined, + config: true, + }); + + for (const key of ['apiKey', 'accessToken', 'password', 'licenseKey', 'licenceKey']) { + expect(looksSecret(setting(key))).toBe(true); + } + for (const key of ['theme', 'difficulty', 'showTutorial']) { + expect(looksSecret(setting(key))).toBe(false); + } + }); +}); + +describe('isProfile', () => { + it('takes a profile this version can read, and nothing else', () => { + expect(isProfile({ format: PROFILE_FORMAT, entries: [] })).toBe(true); + expect(isProfile({ format: PROFILE_FORMAT + 1, entries: [] })).toBe(false); + expect(isProfile({ format: PROFILE_FORMAT })).toBe(false); + expect(isProfile(null)).toBe(false); + expect(isProfile('a string')).toBe(false); + }); +}); + +describe('applyProfile', () => { + const profile = (entries: { id: string; scope: 'world' | 'client'; value: unknown }[]) => ({ + format: PROFILE_FORMAT, + createdAt: '2026-01-01T00:00:00.000Z', + label: '', + foundry: '14', + system: { id: 'some-system', version: '2.1.0' }, + entries, + }); + + it('writes each value and names what it wrote', async () => { + register('a-module', 'theme', 'light'); + register('b-module', 'difficulty', 'easy'); + + const report = await applyProfile( + profile([ + { id: 'a-module.theme', scope: 'world', value: 'dark' }, + { id: 'b-module.difficulty', scope: 'world', value: 'hard' }, + ]), + ); + + expect(report.applied).toEqual(['a-module.theme', 'b-module.difficulty']); + expect(report.skipped).toEqual([]); + expect(game.settings.get('a-module', 'theme')).toBe('dark'); + }); + + it('skips a setting whose package is not installed here', async () => { + const report = await applyProfile(profile([{ id: 'gone.key', scope: 'world', value: 1 }])); + + expect(report.applied).toEqual([]); + expect(report.skipped).toEqual([{ id: 'gone.key', reason: 'not registered in this world' }]); + }); + + it('skips a setting that changed scope, rather than writing it somewhere else', async () => { + register('a-module', 'theme', 'light', 'client'); + + const report = await applyProfile( + profile([{ id: 'a-module.theme', scope: 'world', value: 'dark' }]), + ); + + expect(report.applied).toEqual([]); + expect(report.skipped).toEqual([{ id: 'a-module.theme', reason: 'scope changed to client' }]); + expect(game.settings.get('a-module', 'theme')).toBe('light'); + }); + + it('carries on past a value the setting refuses', async () => { + // A profile older than the world is the ordinary case. An import that + // stopped on the first bad key would be useless as a restore. + register('a-module', 'theme', 'light'); + register('b-module', 'difficulty', 'easy'); + const write = game.settings.set; + game.settings.set = (async (namespace: string, key: string, value: unknown) => { + if (key === 'theme') throw new Error('this is not a theme'); + return write(namespace, key, value); + }) as typeof game.settings.set; + + const report = await applyProfile( + profile([ + { id: 'a-module.theme', scope: 'world', value: 42 }, + { id: 'b-module.difficulty', scope: 'world', value: 'hard' }, + ]), + ).finally(() => { + // `restore()` replaces the whole `game` object, so this would go with it + // anyway. Put it back here so the patch cannot outlive the test it is + // written for. + game.settings.set = write; + }); + + expect(report.applied).toEqual(['b-module.difficulty']); + expect(report.skipped).toEqual([{ id: 'a-module.theme', reason: 'this is not a theme' }]); + expect(game.settings.get('b-module', 'difficulty')).toBe('hard'); + }); +}); diff --git a/scripts/__tests__/settings-registry.test.ts b/scripts/__tests__/settings-registry.test.ts new file mode 100644 index 0000000..9fb6a98 --- /dev/null +++ b/scripts/__tests__/settings-registry.test.ts @@ -0,0 +1,119 @@ +/** + * Reading the world's settings registry. + * + * The registry is the only way to reach a setting belonging to a package this + * module knows nothing about, which is the whole job. The mock fills it the + * way Foundry does, so these run against the same map the module reads in a + * world. + */ +import { withMockFoundry } from '@vttforge/testing/vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { listNamespaces, listSettings, readValue, writeValue } from '../settings-registry.js'; + +let mock: ReturnType | undefined; + +beforeEach(() => { + mock = withMockFoundry(); +}); + +afterEach(() => { + mock?.restore(); + mock = undefined; +}); + +/** Register one setting, the way a package does during `init`. */ +function register( + namespace: string, + key: string, + config: Partial<{ scope: 'world' | 'client' | 'user'; config: boolean; name: string }> = {}, +): void { + game.settings.register(namespace, key, { + scope: config.scope ?? 'world', + config: config.config ?? false, + type: String, + default: `${namespace}.${key}`, + ...(config.name === undefined ? {} : { name: config.name }), + }); +} + +describe('listSettings', () => { + it('narrows each entry to what the vault needs', () => { + register('some-module', 'theme', { scope: 'client', config: true, name: 'Theme' }); + + expect(listSettings()).toEqual([ + { + id: 'some-module.theme', + namespace: 'some-module', + key: 'theme', + scope: 'client', + name: 'Theme', + config: true, + }, + ]); + }); + + it('leaves core out', () => { + // Core's settings describe the world, not a package. Carrying them would + // overwrite things like the active system or the permission table. + register('core', 'moduleConfiguration'); + register('some-module', 'theme'); + + expect(listSettings().map((s) => s.id)).toEqual(['some-module.theme']); + }); + + it('keeps registration order', () => { + register('b-module', 'one'); + register('a-module', 'two'); + + expect(listSettings().map((s) => s.id)).toEqual(['b-module.one', 'a-module.two']); + }); +}); + +describe('listNamespaces', () => { + it('names each package once, sorted', () => { + register('b-module', 'one'); + register('b-module', 'two'); + register('a-module', 'three'); + register('core', 'four'); + + expect(listNamespaces()).toEqual(['a-module', 'b-module']); + }); +}); + +describe('readValue', () => { + it('reads the current value', () => { + register('some-module', 'theme'); + const [setting] = listSettings(); + + expect(readValue(setting)).toBe('some-module.theme'); + }); + + it('hands back undefined when a package throws from its own getter', () => { + // One broken setting must not stop a whole export. + register('some-module', 'theme'); + const [setting] = listSettings(); + const read = game.settings.get; + game.settings.get = () => { + throw new Error('this package is having a bad day'); + }; + + try { + expect(readValue(setting)).toBeUndefined(); + } finally { + // `restore()` replaces the whole `game` object, so this would go with it + // anyway. Put it back here so the patch cannot outlive the test it is + // written for. + game.settings.get = read; + } + }); +}); + +describe('writeValue', () => { + it('writes through Foundry, so its validation runs', async () => { + register('some-module', 'theme'); + const [setting] = listSettings(); + + await writeValue(setting, 'dark'); + expect(game.settings.get('some-module', 'theme')).toBe('dark'); + }); +}); diff --git a/scripts/updates/__tests__/check.test.ts b/scripts/updates/__tests__/check.test.ts new file mode 100644 index 0000000..f83fccd --- /dev/null +++ b/scripts/updates/__tests__/check.test.ts @@ -0,0 +1,254 @@ +/** + * The update check, and the budget that shapes it. + * + * GitHub allows a browser sixty requests an hour. Every rule here exists to + * spend as few of them as possible and to never claim a module was checked + * when it was not, so the assertions count requests as much as they read rows. + */ +import { withMockFoundry } from '@vttforge/testing/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CACHE_SETTING, MODULE_ID } from '../../constants.js'; +import { checkUpdates, EMPTY_CACHE, lastReport, type UpdateCache } from '../check.js'; + +/** One installed module, as Foundry builds a handle from a manifest. */ +interface Handle { + id: string; + title: string; + version: string; + url?: string; + manifest?: string; +} + +const realFetch = globalThis.fetch; +let mock: ReturnType | undefined; + +/** + * `game.modules` as a collection the checker can walk. + * + * The mock's own collection answers `get`, which is what registration needs. + * This walks every installed module, which is what a check needs. + */ +function modules(handles: Handle[]) { + const map = new Map(handles.map((handle) => [handle.id, handle])); + return { + get: (id: string) => map.get(id), + has: (id: string) => map.has(id), + values: () => map.values(), + get size() { + return map.size; + }, + }; +} + +function start(handles: Handle[], options: { isGM?: boolean } = {}): void { + mock = withMockFoundry({ + user: { isGM: options.isGM ?? true }, + game: { modules: modules(handles) }, + }); + game.settings.register(MODULE_ID, CACHE_SETTING, { + scope: 'world', + config: false, + type: Object, + default: EMPTY_CACHE, + }); +} + +/** A release answer, and a counter for how many requests were spent. */ +function releases(answer: (url: string) => Response): { calls: () => number } { + let calls = 0; + globalThis.fetch = vi.fn(async (input: unknown) => { + calls += 1; + return answer(String(input)); + }) as unknown as typeof globalThis.fetch; + return { calls: () => calls }; +} + +function release(version: string): Response { + return new Response(JSON.stringify({ tag_name: `v${version}`, body: 'notes' }), { status: 200 }); +} + +function cached(id: string, version: string, fetchedAt: string): UpdateCache { + return { + checkedAt: fetchedAt, + entries: { + [id]: { version, notes: '', url: '', publishedAt: null, error: null, fetchedAt }, + }, + }; +} + +const OURS: Handle = { + id: 'settings-vault', + title: 'Settings Vault', + version: '0.2.0', + url: 'https://github.com/vttforge/settings-vault', +}; + +afterEach(() => { + globalThis.fetch = realFetch; + vi.restoreAllMocks(); + mock?.restore(); + mock = undefined; +}); + +beforeEach(() => { + globalThis.fetch = realFetch; +}); + +describe('who may check', () => { + it('refuses a player before a single request is spent', async () => { + start([OURS], { isGM: false }); + const spent = releases(() => release('9.0.0')); + + await expect(checkUpdates({ force: true })).rejects.toThrow(/Only a Gamemaster/); + expect(spent.calls()).toBe(0); + }); +}); + +describe('what costs a request', () => { + it('asks once per module with a GitHub address', async () => { + start([OURS, { id: 'other', title: 'Other', version: '1.0.0', url: 'https://example.com' }]); + const spent = releases(() => release('0.3.0')); + + const report = await checkUpdates({ force: true }); + + expect(spent.calls()).toBe(1); + expect(report.rows.find((r) => r.id === 'other')?.reason).toBe( + 'no GitHub address in its manifest', + ); + }); + + it('reuses an answer younger than a day', async () => { + start([OURS]); + await game.settings.set( + MODULE_ID, + CACHE_SETTING, + cached(OURS.id, '0.9.0', new Date().toISOString()), + ); + const spent = releases(() => release('1.0.0')); + + const report = await checkUpdates(); + + expect(spent.calls()).toBe(0); + expect(report.rows[0]?.latest).toBe('0.9.0'); + }); + + it('asks again when a GM presses the button', async () => { + start([OURS]); + await game.settings.set( + MODULE_ID, + CACHE_SETTING, + cached(OURS.id, '0.9.0', new Date().toISOString()), + ); + const spent = releases(() => release('1.0.0')); + + const report = await checkUpdates({ force: true }); + + expect(spent.calls()).toBe(1); + expect(report.rows[0]?.latest).toBe('1.0.0'); + }); + + it('asks again when the answer is older than a day', async () => { + start([OURS]); + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + await game.settings.set(MODULE_ID, CACHE_SETTING, cached(OURS.id, '0.9.0', twoDaysAgo)); + const spent = releases(() => release('1.0.0')); + + await checkUpdates(); + + expect(spent.calls()).toBe(1); + }); +}); + +describe('a spent budget', () => { + it('stops at the first refusal and caches nothing for the rest', async () => { + // Every request after this one gets the same answer. Writing a cache entry + // for the rest would mark them checked for a day when none of them were. + const others: Handle[] = [1, 2, 3].map((n) => ({ + id: `module-${n}`, + title: `Module ${n}`, + version: '1.0.0', + url: `https://github.com/owner/module-${n}`, + })); + start(others); + const spent = releases( + () => new Response('{}', { status: 403, headers: { 'x-ratelimit-remaining': '0' } }), + ); + + const report = await checkUpdates({ force: true }); + + expect(spent.calls()).toBe(1); + expect(report.skippedForBudget).toBe(3); + expect(game.settings.get(MODULE_ID, CACHE_SETTING).entries).toEqual({}); + }); +}); + +describe('what a row says', () => { + it('marks a module behind its newest release', async () => { + start([OURS]); + releases(() => release('0.3.0')); + + const report = await checkUpdates({ force: true }); + + expect(report.rows[0]).toMatchObject({ installed: '0.2.0', latest: '0.3.0', outdated: true }); + expect(report.outdatedCount).toBe(1); + }); + + it('does not mark a module that is ahead of its newest release', async () => { + // A local build carrying the next version is normal while a release is + // being prepared. + start([{ ...OURS, version: '0.4.0' }]); + releases(() => release('0.3.0')); + + const report = await checkUpdates({ force: true }); + + expect(report.rows[0]?.outdated).toBe(false); + expect(report.outdatedCount).toBe(0); + }); + + it('carries the reason rather than a version it could not read', async () => { + start([OURS]); + releases(() => new Response('{}', { status: 404 })); + + const report = await checkUpdates({ force: true }); + + expect(report.rows[0]?.latest).toBeNull(); + expect(report.rows[0]?.reason).toBe('no published release'); + expect(report.rows[0]?.outdated).toBe(false); + }); + + it('records the check even when nothing needed fetching', async () => { + start([{ id: 'other', title: 'Other', version: '1.0.0' }]); + + const report = await checkUpdates({ force: true }); + + expect(report.checkedAt).not.toBeNull(); + }); +}); + +describe('lastReport', () => { + it('reads the world and asks GitHub nothing', async () => { + start([OURS]); + await game.settings.set( + MODULE_ID, + CACHE_SETTING, + cached(OURS.id, '9.0.0', new Date().toISOString()), + ); + const spent = releases(() => release('1.0.0')); + + const report = lastReport(); + + expect(spent.calls()).toBe(0); + expect(report.rows[0]?.latest).toBe('9.0.0'); + expect(report.rows[0]?.outdated).toBe(true); + }); + + it('says no check has run in a fresh world, and still lists every module', () => { + start([OURS]); + + const report = lastReport(); + + expect(report.checkedAt).toBeNull(); + expect(report.rows).toHaveLength(1); + expect(report.rows[0]?.reason).toBe('not checked yet'); + }); +}); diff --git a/scripts/updates/__tests__/github.test.ts b/scripts/updates/__tests__/github.test.ts new file mode 100644 index 0000000..0465d28 --- /dev/null +++ b/scripts/updates/__tests__/github.test.ts @@ -0,0 +1,131 @@ +/** + * Reading a repository out of a manifest, and a version out of a release. + * + * Nothing here touches Foundry. `repoFromUrl` runs on two manifest fields that + * a module author fills in by hand, and `latestRelease` reads an answer from a + * server that has every right to refuse. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { latestRelease, RateLimitError, repoFromUrl } from '../github.js'; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; + vi.restoreAllMocks(); +}); + +/** One canned answer, with the headers a rate limit is told apart by. */ +function answers( + status: number, + body: unknown = {}, + headers: Record = {}, +): typeof globalThis.fetch { + return vi.fn( + async () => + new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status, + headers, + }), + ) as unknown as typeof globalThis.fetch; +} + +describe('repoFromUrl', () => { + it('reads the owner and the repository', () => { + expect(repoFromUrl('https://github.com/vttforge/settings-vault')).toEqual({ + owner: 'vttforge', + repo: 'settings-vault', + }); + }); + + it('reads a release download URL, which is what `manifest` holds', () => { + expect( + repoFromUrl( + 'https://github.com/vttforge/settings-vault/releases/latest/download/module.json', + ), + ).toEqual({ owner: 'vttforge', repo: 'settings-vault' }); + }); + + it('drops the `.git` a clone URL carries', () => { + expect(repoFromUrl('https://github.com/owner/thing.git')).toEqual({ + owner: 'owner', + repo: 'thing', + }); + }); + + it('takes the API host, where the path is one segment longer', () => { + expect(repoFromUrl('https://api.github.com/repos/owner/thing')).toEqual({ + owner: 'owner', + repo: 'thing', + }); + expect(repoFromUrl('https://api.github.com/users/owner')).toBeUndefined(); + }); + + it('says nothing rather than guessing', () => { + // A module hosted elsewhere is reported as unchecked. Guessing an owner + // and a repository out of these would check the wrong repository. + expect(repoFromUrl(undefined)).toBeUndefined(); + expect(repoFromUrl('')).toBeUndefined(); + expect(repoFromUrl('not a url')).toBeUndefined(); + expect(repoFromUrl('https://gitlab.com/owner/thing')).toBeUndefined(); + expect(repoFromUrl('https://github.com/owner')).toBeUndefined(); + }); +}); + +describe('latestRelease', () => { + const repo = { owner: 'owner', repo: 'thing' }; + + it('reads the version, the notes and the page', async () => { + globalThis.fetch = answers(200, { + tag_name: 'v1.2.3', + body: 'Fixes a crash.', + html_url: 'https://github.com/owner/thing/releases/tag/v1.2.3', + published_at: '2026-01-02T03:04:05Z', + }); + + await expect(latestRelease(repo)).resolves.toEqual({ + version: '1.2.3', + notes: 'Fixes a crash.', + url: 'https://github.com/owner/thing/releases/tag/v1.2.3', + publishedAt: '2026-01-02T03:04:05Z', + }); + }); + + it('refuses a tag that is not a version', async () => { + // A monorepo tags per package. Comparing `@scope/name@0.6.0` to an + // installed version produces an answer with no meaning. + globalThis.fetch = answers(200, { tag_name: '@scope/name@0.6.0' }); + await expect(latestRelease(repo)).rejects.toThrow(/not a version/); + }); + + it('says a repository has no release, rather than failing obscurely', async () => { + globalThis.fetch = answers(404); + await expect(latestRelease(repo)).rejects.toThrow('no published release'); + }); + + it('tells a spent budget apart from any other refusal', async () => { + // A 403 is the budget only when the header says so. The caller stops on + // one and carries on past the other, so the two may not be lumped together. + globalThis.fetch = answers(403, {}, { 'x-ratelimit-remaining': '0' }); + await expect(latestRelease(repo)).rejects.toBeInstanceOf(RateLimitError); + + globalThis.fetch = answers(403, {}, { 'x-ratelimit-remaining': '57' }); + const other = await latestRelease(repo).catch((error: unknown) => error); + expect(other).toBeInstanceOf(Error); + expect(other).not.toBeInstanceOf(RateLimitError); + + // A 429 is always the budget, header or no header. + globalThis.fetch = answers(429); + await expect(latestRelease(repo)).rejects.toBeInstanceOf(RateLimitError); + }); + + it('fills in what a release leaves out', async () => { + globalThis.fetch = answers(200, { tag_name: '2.0' }); + await expect(latestRelease(repo)).resolves.toEqual({ + version: '2.0', + notes: '', + url: '', + publishedAt: null, + }); + }); +});