diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 861730c..6cc29c9 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -39,3 +39,19 @@ jobs: # Install dependencies - name: ๐Ÿ“ฆ Install dependencies run: npm ci + + # Setup PocketBase + - name: ๐Ÿ—„๏ธ Download and setup PocketBase + run: npm run test:e2e:setup + + # Start PocketBase + - name: ๐Ÿš€ Start PocketBase + run: ./.pocketbase/pocketbase serve & + + # Wait for PocketBase to be ready + - name: โณ Wait for PocketBase + run: | + until curl -s --fail http://localhost:8090/api/health; do + echo 'Waiting for PocketBase...' + sleep 5 + done diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8d0de95..2ab3ef2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -51,6 +51,30 @@ jobs: - name: ๐Ÿงช Type check run: npm run typecheck + # Run tests + - name: ๐Ÿงช Run unit tests + run: npm run test:unit + + # Setup PocketBase + - name: ๐Ÿ—„๏ธ Download and setup PocketBase + run: npm run test:e2e:setup + + # Start PocketBase + - name: ๐Ÿš€ Start PocketBase + run: ./.pocketbase/pocketbase serve & + + # Wait for PocketBase to be ready + - name: โณ Wait for PocketBase + run: | + until curl -s --fail http://localhost:8090/api/health; do + echo 'Waiting for PocketBase...' + sleep 5 + done + + # Run tests + - name: ๐Ÿงช Run e2e tests + run: npm run test:e2e + # Create release - name: ๐Ÿš€ Create release if: github.repository == 'pawcoding/astro-integration-pocketbase' diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5edfe6d..9adaf97 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,11 +1,15 @@ name: ๐Ÿงช Test code -# Run this on every push except master and next + on: + # Run this on every push except master and next push: branches: - "**" - "!master" - "!next" + # Run this every week to make sure the latest PocketBase version still works + schedule: + - cron: "0 4 * * 6" env: HUSKY: 0 @@ -51,3 +55,27 @@ jobs: # Type check - name: ๐Ÿงช Type check run: npm run typecheck + + # Run tests + - name: ๐Ÿงช Run unit tests + run: npm run test:unit + + # Setup PocketBase + - name: ๐Ÿ—„๏ธ Download and setup PocketBase + run: npm run test:e2e:setup + + # Start PocketBase + - name: ๐Ÿš€ Start PocketBase + run: ./.pocketbase/pocketbase serve & + + # Wait for PocketBase to be ready + - name: โณ Wait for PocketBase + run: | + until curl -s --fail http://localhost:8090/api/health; do + echo 'Waiting for PocketBase...' + sleep 5 + done + + # Run tests + - name: ๐Ÿงช Run e2e tests + run: npm run test:e2e diff --git a/.gitignore b/.gitignore index 59fc7a6..5f1ebef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # build output dist/ .eslintcache +coverage/ # generated types .astro/ @@ -24,3 +25,6 @@ pnpm-debug.log* # jetbrains setting folder .idea/ + +# PocketBase folder +.pocketbase/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 13d9e03..0e8978b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -15,6 +15,9 @@ }, "rules": { // Correctness (in addition to the default rules) + "typescript/no-base-to-string": "off", + "typescript/restrict-template-expressions": "off", + "typescript/unbound-method": "off", "promise/no-new-statics": "error", "promise/valid-params": "error", // Perf @@ -115,8 +118,8 @@ "eslint/no-redeclare": "error", "eslint/no-self-compare": "error", "eslint/no-throw-literal": "error", - "eslint/require-await": "warn", "eslint/symbol-description": "error", + "typescript/ban-ts-comment": "warn", "typescript/no-unsafe-argument": "error", "typescript/no-unsafe-assignment": "error", "typescript/no-unsafe-call": "error", @@ -174,7 +177,6 @@ "promise/avoid-new": "warn", "promise/no-nesting": "warn", "promise/no-return-wrap": "error", - "promise/prefer-await-to-callbacks": "warn", "promise/prefer-await-to-then": "warn", "promise/prefer-catch": "warn", "typescript/array-type": [ @@ -240,6 +242,14 @@ "eslint/max-lines": "off", "eslint/max-lines-per-function": "off", "eslint/max-nested-callbacks": "off", + "promise/avoid-new": "off", + "typescript/no-unsafe-argument": "off", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-call": "off", + "typescript/no-unsafe-return": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-type-assertion": "off", + "typescript/no-explicit-any": "off", // Vitest specific rules "vitest/no-import-node-test": "warn", "vitest/prefer-to-be-falsy": "warn", diff --git a/lint-staged.config.js b/lint-staged.config.js index b7703f8..c570997 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -2,6 +2,6 @@ * @type {import('lint-staged').Configuration} */ export default { - "!(*.ts)": "prettier --write", + "!(*.ts|*.ts.snap)": "prettier --write", "*.ts": ["oxlint", "prettier --write"] }; diff --git a/package-lock.json b/package-lock.json index 3dc3ed7..0ef3807 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@commitlint/cli": "20.1.0", "@commitlint/config-conventional": "20.0.0", "@types/node": "24.9.1", + "@vitest/coverage-v8": "4.0.3", + "@vitest/ui": "4.0.3", "astro": "5.15.1", "eventsource": "4.0.0", "globals": "16.4.0", @@ -21,7 +23,8 @@ "prettier": "3.6.2", "prettier-plugin-organize-imports": "4.3.0", "prettier-plugin-packagejson": "2.5.19", - "typescript": "5.9.3" + "typescript": "5.9.3", + "vitest": "4.0.3" }, "peerDependencies": { "astro": "^5.0.0", @@ -167,6 +170,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@capsizecss/unpack": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-3.0.0.tgz", @@ -1341,6 +1354,16 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1348,6 +1371,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@oslojs/encoding": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", @@ -1479,6 +1513,13 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -1904,6 +1945,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -1914,6 +1962,17 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/conventional-commits-parser": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.1.tgz", @@ -1934,6 +1993,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2009,6 +2075,172 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.3.tgz", + "integrity": "sha512-I+MlLwyJRBjmJr1kFYSxoseINbIdpxIAeK10jmXgB0FUtIfdYsvM3lGAvBu5yk8WPyhefzdmbCHCc1idFbNRcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.3", + "ast-v8-to-istanbul": "^0.3.5", + "debug": "^4.4.3", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.3", + "vitest": "4.0.3" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.3.tgz", + "integrity": "sha512-v3eSDx/bF25pzar6aEJrrdTXJduEBU3uSGXHslIdGIpJVP8tQQHV6x1ZfzbFQ/bLIomLSbR/2ZCfnaEGkWkiVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.3", + "@vitest/utils": "4.0.3", + "chai": "^6.0.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.3.tgz", + "integrity": "sha512-evZcRspIPbbiJEe748zI2BRu94ThCBE+RkjCpVF8yoVYuTV7hMe+4wLF/7K86r8GwJHSmAPnPbZhpXWWrg1qbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.19" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.3.tgz", + "integrity": "sha512-N7gly/DRXzxa9w9sbDXwD9QNFYP2hw90LLLGDobPNwiWgyW95GMxsCt29/COIKKh3P7XJICR38PSDePenMBtsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.3.tgz", + "integrity": "sha512-1/aK6fPM0lYXWyGKwop2Gbvz1plyTps/HDbIIJXYtJtspHjpXIeB3If07eWpVH4HW7Rmd3Rl+IS/+zEAXrRtXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.3.tgz", + "integrity": "sha512-amnYmvZ5MTjNCP1HZmdeczAPLRD6iOm9+2nMRUGxbe/6sQ0Ymur0NnR9LIrWS8JA3wKE71X25D6ya/3LN9YytA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.3", + "magic-string": "^0.30.19", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.3.tgz", + "integrity": "sha512-82vVL8Cqz7rbXaNUl35V2G7xeNMAjBdNOVaHbrzznT9BmiCiPOzhf0FhU3eP41nP1bLDm/5wWKZqkG4nyU95DQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.3.tgz", + "integrity": "sha512-HURRrgGVzz2GQ2Imurp55FA+majHXgCXMzcwtojUZeRsAXyHNgEvxGRJf4QQY4kJeVakiugusGYeUqBgZ/xylg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/utils": "4.0.3", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.3" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.3.tgz", + "integrity": "sha512-qV6KJkq8W3piW6MDIbGOmn1xhvcW4DuA07alqaQ+vdx7YA49J85pnwnxigZVQFQw3tWnQNRKWwhz5wbP6iv/GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.3", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2177,6 +2409,35 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/astro": { "version": "5.15.1", "resolved": "https://registry.npmjs.org/astro/-/astro-5.15.1.tgz", @@ -2398,6 +2659,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", + "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2869,9 +3140,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -3196,6 +3467,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3226,6 +3507,13 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3256,6 +3544,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/flattie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", @@ -3414,6 +3709,16 @@ "uncrypto": "^0.1.3" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -3814,6 +4119,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports/node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -4073,6 +4439,22 @@ "source-map-js": "^1.2.0" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5309,6 +5691,13 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5904,6 +6293,13 @@ "@types/hast": "^3.0.4" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5917,6 +6313,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -6027,6 +6438,20 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -6083,6 +6508,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -6126,6 +6564,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", @@ -6134,14 +6579,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -6151,11 +6596,14 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -6166,9 +6614,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "peer": true, @@ -6179,6 +6627,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -6200,6 +6658,16 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -6641,6 +7109,7 @@ "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6762,6 +7231,105 @@ } } }, + "node_modules/vitest": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.3.tgz", + "integrity": "sha512-IUSop8jgaT7w0g1yOM/35qVtKjr/8Va4PrjzH1OUb0YH4c3OXB2lCZDkMAB6glA8T5w8S164oJGsbcmAecr4sA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.0.3", + "@vitest/mocker": "4.0.3", + "@vitest/pretty-format": "4.0.3", + "@vitest/runner": "4.0.3", + "@vitest/snapshot": "4.0.3", + "@vitest/spy": "4.0.3", + "@vitest/utils": "4.0.3", + "debug": "^4.4.3", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.19", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.3", + "@vitest/browser-preview": "4.0.3", + "@vitest/browser-webdriverio": "4.0.3", + "@vitest/ui": "4.0.3", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -6782,6 +7350,23 @@ "node": ">=4" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", @@ -6836,7 +7421,6 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index d09d6df..466d6a2 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,22 @@ "lint": "oxlint", "lint:fix": "oxlint --fix", "prepare": "husky", + "test": "vitest run", + "test:e2e": "vitest run $(find test -name '*.e2e-spec.ts')", + "test:e2e:pocketbase": "npm run test:e2e:setup && ./.pocketbase/pocketbase serve", + "test:e2e:setup": "./scripts/setup-pocketbase.sh", + "test:e2e:watch": "vitest watch $(find test -name '*.e2e-spec.ts')", + "test:unit": "vitest run $(find test -name '*.spec.ts')", + "test:unit:watch": "vitest watch $(find test -name '*.spec.ts')", + "test:watch": "vitest watch", "typecheck": "npx tsc --noEmit" }, "devDependencies": { "@commitlint/cli": "20.1.0", "@commitlint/config-conventional": "20.0.0", "@types/node": "24.9.1", + "@vitest/coverage-v8": "4.0.3", + "@vitest/ui": "4.0.3", "astro": "5.15.1", "eventsource": "4.0.0", "globals": "16.4.0", @@ -51,7 +61,8 @@ "prettier": "3.6.2", "prettier-plugin-organize-imports": "4.3.0", "prettier-plugin-packagejson": "2.5.19", - "typescript": "5.9.3" + "typescript": "5.9.3", + "vitest": "4.0.3" }, "peerDependencies": { "astro": "^5.0.0", diff --git a/scripts/setup-pocketbase.sh b/scripts/setup-pocketbase.sh new file mode 100755 index 0000000..ee7c61b --- /dev/null +++ b/scripts/setup-pocketbase.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# Define the local .pocketbase directory +POCKETBASE_DIR="$(pwd)/.pocketbase" + +# Remove the existing .pocketbase directory if it exists +rm -rf "$POCKETBASE_DIR" + +# Create the .pocketbase directory if it doesn't exist +mkdir -p "$POCKETBASE_DIR" + +# Change to the .pocketbase directory +cd "$POCKETBASE_DIR" + +# Get the latest release tag from PocketBase GitHub releases +latest_release=$(curl -s https://api.github.com/repos/pocketbase/pocketbase/releases/latest | grep -oP '"tag_name": "\K(.*)(?=")') + +# Determine the architecture +arch=$(uname -m) +if [ "$arch" == "x86_64" ]; then + arch="amd64" +elif [ "$arch" == "aarch64" ]; then + arch="arm64" +else + echo "Unsupported architecture: $arch" + exit 1 +fi + +# Construct the download URL +download_url="https://github.com/pocketbase/pocketbase/releases/download/${latest_release}/pocketbase_${latest_release#v}_linux_${arch}.zip" + +# Download the latest release +echo "Downloading PocketBase ${latest_release}..." +curl -s -L -o pocketbase.zip $download_url + +# Check if the download was successful +if [ $? -ne 0 ]; then + echo "Failed to download PocketBase release." + exit 1 +fi + +# Extract the executable from the zip file +echo "Extracting PocketBase ${latest_release}..." +unzip -qq pocketbase.zip +if [ $? -ne 0 ]; then + echo "Failed to unzip PocketBase release." + exit 1 +fi + +# Make the executable file executable +chmod +x pocketbase + +# Clean up +rm pocketbase.zip + +# Setup admin user +echo "Setting up admin user..." +./pocketbase superuser upsert test@pawcode.de test1234 +if [ $? -ne 0 ]; then + echo "Failed to setup admin user." + exit 1 +fi + +echo "PocketBase ${latest_release} has been downloaded and is ready to use." diff --git a/src/core/refresh-collections-realtime.ts b/src/core/refresh-collections-realtime.ts index b0f2653..615d2f8 100644 --- a/src/core/refresh-collections-realtime.ts +++ b/src/core/refresh-collections-realtime.ts @@ -1,5 +1,6 @@ import type { AstroIntegrationLogger, BaseIntegrationHooks } from "astro"; import { EventSource } from "eventsource"; +import { TOOLBAR_EVENT } from "../toolbar/constants/toolbar-events"; import type { PocketBaseIntegrationOptions } from "../types/pocketbase-integration-options.type"; import { getSuperuserToken } from "../utils/get-superuser-token"; import { mapCollectionsToWatch } from "../utils/map-collections-to-watch"; @@ -10,7 +11,10 @@ export function refreshCollectionsRealtime( logger, refreshContent, toolbar - }: Parameters[0] + }: Pick< + Parameters[0], + "logger" | "refreshContent" | "toolbar" + > ): EventSource | undefined { // Check if collections should be watched const collectionsMap = mapCollectionsToWatch(options.collectionsToWatch); @@ -38,7 +42,7 @@ export function refreshCollectionsRealtime( let refreshEnabled = true; // Enable or disable real-time updates via the toolbar - toolbar.on("astro-integration-pocketbase:real-time", (enabled: boolean) => { + toolbar.on(TOOLBAR_EVENT.REAL_TIME, (enabled: boolean) => { refreshEnabled = enabled; }); @@ -47,7 +51,6 @@ export function refreshCollectionsRealtime( let isConnected = false; // Log potential errors - // oxlint-disable-next-line prefer-await-to-callbacks eventSource.addEventListener("error", (error) => { isConnected = false; diff --git a/src/core/refresh-collections.ts b/src/core/refresh-collections.ts index 77ada55..87fb6e0 100644 --- a/src/core/refresh-collections.ts +++ b/src/core/refresh-collections.ts @@ -1,4 +1,5 @@ import type { BaseIntegrationHooks } from "astro"; +import { TOOLBAR_EVENT } from "../toolbar/constants/toolbar-events"; /** * Listen for the refresh event of the toolbar. @@ -8,7 +9,10 @@ export function handleRefreshCollections({ toolbar, refreshContent, logger -}: Parameters[0]): void { +}: Pick< + Parameters[0], + "toolbar" | "refreshContent" | "logger" +>): void { if (!refreshContent) { return; } @@ -16,30 +20,27 @@ export function handleRefreshCollections({ logger.info("Setting up refresh listener for PocketBase integration"); // Listen for the refresh event of the toolbar - toolbar.on( - "astro-integration-pocketbase:refresh", - async ({ force }: { force: boolean }) => { - // Send a loading state to the toolbar - toolbar.send("astro-integration-pocketbase:refresh", { - loading: true - }); + toolbar.on(TOOLBAR_EVENT.REFRESH, async ({ force }: { force: boolean }) => { + // Send a loading state to the toolbar + toolbar.send(TOOLBAR_EVENT.REFRESH, { + loading: true + }); - // Refresh content loaded by the PocketBase loader - logger.info( - `Refreshing ${force ? "all " : ""}content loaded by PocketBase loader` - ); - await refreshContent({ - loaders: ["pocketbase-loader"], - context: { - source: "astro-integration-pocketbase", - force: force - } - }); + // Refresh content loaded by the PocketBase loader + logger.info( + `Refreshing ${force ? "all " : ""}content loaded by PocketBase loader` + ); + await refreshContent({ + loaders: ["pocketbase-loader"], + context: { + source: "astro-integration-pocketbase", + force: force + } + }); - // Reset the loading state in the toolbar - toolbar.send("astro-integration-pocketbase:refresh", { - loading: false - }); - } - ); + // Reset the loading state in the toolbar + toolbar.send(TOOLBAR_EVENT.REFRESH, { + loading: false + }); + }); } diff --git a/src/pocketbase-integration.ts b/src/pocketbase-integration.ts index 762b134..bb5248d 100644 --- a/src/pocketbase-integration.ts +++ b/src/pocketbase-integration.ts @@ -2,6 +2,7 @@ import type { AstroIntegration } from "astro"; import type { EventSource } from "eventsource"; import { fileURLToPath } from "node:url"; import { handleRefreshCollections, refreshCollectionsRealtime } from "./core"; +import { TOOLBAR_EVENT } from "./toolbar/constants/toolbar-events"; import type { ToolbarOptions } from "./toolbar/types/options"; import type { PocketBaseIntegrationOptions } from "./types/pocketbase-integration-options.type"; @@ -53,7 +54,7 @@ export function pocketbaseIntegration( // Send settings to the toolbar on initialization setupOptions.toolbar.onAppInitialized("pocketbase-entry", () => { - setupOptions.toolbar.send("astro-integration-pocketbase:settings", { + setupOptions.toolbar.send(TOOLBAR_EVENT.SETTINGS, { hasContentLoader: !!setupOptions.refreshContent, realtime: !!eventSource, baseUrl: options.url diff --git a/src/toolbar/constants/toolbar-events.ts b/src/toolbar/constants/toolbar-events.ts new file mode 100644 index 0000000..986cf4e --- /dev/null +++ b/src/toolbar/constants/toolbar-events.ts @@ -0,0 +1,19 @@ +/** + * Events sent to / from the toolbar + */ +export const TOOLBAR_EVENT = { + /** + * Toolbar -> Integration: Trigger a refresh of the collections + * + * Integration -> Toolbar: Notify about the refresh status + */ + REFRESH: "astro-integration-pocketbase:refresh", + /** + * Integration -> Toolbar: Send settings on initialization + */ + SETTINGS: "astro-integration-pocketbase:settings", + /** + * Toolbar -> Integration: Enable / disable real-time updates + */ + REAL_TIME: "astro-integration-pocketbase:real-time" +}; diff --git a/src/toolbar/dom/create-header.ts b/src/toolbar/dom/create-header.ts index eece921..4f143f1 100644 --- a/src/toolbar/dom/create-header.ts +++ b/src/toolbar/dom/create-header.ts @@ -2,6 +2,7 @@ import type { ToolbarServerHelpers } from "astro"; import type { DevToolbarButton } from "astro/runtime/client/dev-toolbar/ui-library/button.js"; import type { DevToolbarWindow } from "astro/runtime/client/dev-toolbar/ui-library/window.js"; import { default as packageJson } from "../../../package.json"; +import { TOOLBAR_EVENT } from "../constants/toolbar-events"; import type { ToolbarOptions } from "../types/options"; /** @@ -105,16 +106,10 @@ export function createHeader( ); // Send the toggle state to the server - server.send( - "astro-integration-pocketbase:real-time", - realTimeToggle.input.checked - ); + server.send(TOOLBAR_EVENT.REAL_TIME, realTimeToggle.input.checked); }); // Send the initial toggle state to the server - server.send( - "astro-integration-pocketbase:real-time", - realTimeToggle.input.checked - ); + server.send(TOOLBAR_EVENT.REAL_TIME, realTimeToggle.input.checked); windowElement.querySelector(".toggle-container")?.append(realTimeToggle); } @@ -128,27 +123,24 @@ export function createHeader( } refresh.addEventListener("click", () => { - server.send("astro-integration-pocketbase:refresh", { force: false }); + server.send(TOOLBAR_EVENT.REFRESH, { force: false }); }); refresh.addEventListener("contextmenu", (event) => { event.preventDefault(); - server.send("astro-integration-pocketbase:refresh", { force: true }); + server.send(TOOLBAR_EVENT.REFRESH, { force: true }); }); - server.on( - "astro-integration-pocketbase:refresh", - ({ loading }: { loading?: boolean }) => { - // Show loading state while refreshing content - if (loading) { - refresh.textContent = "Refreshing content..."; - refresh.buttonStyle = "gray"; - refresh.style.pointerEvents = "none"; - } else { - refresh.textContent = "Refresh content"; - refresh.buttonStyle = "green"; - refresh.style.pointerEvents = "unset"; - } + server.on(TOOLBAR_EVENT.REFRESH, ({ loading }: { loading?: boolean }) => { + // Show loading state while refreshing content + if (loading) { + refresh.textContent = "Refreshing content..."; + refresh.buttonStyle = "gray"; + refresh.style.pointerEvents = "none"; + } else { + refresh.textContent = "Refresh content"; + refresh.buttonStyle = "green"; + refresh.style.pointerEvents = "unset"; } - ); + }); } } diff --git a/src/toolbar/init-toolbar.ts b/src/toolbar/init-toolbar.ts index 4951d91..78fb793 100644 --- a/src/toolbar/init-toolbar.ts +++ b/src/toolbar/init-toolbar.ts @@ -7,6 +7,7 @@ import type { ToolbarAppEventTarget, ToolbarServerHelpers } from "astro/runtime/client/dev-toolbar/helpers.js"; +import { TOOLBAR_EVENT } from "./constants/toolbar-events"; import { createEntities, createHeader, createPlaceholder } from "./dom"; import type { Entity } from "./types/entity"; import type { ToolbarOptions } from "./types/options"; @@ -33,13 +34,10 @@ export function initToolbar( }; // Update the options and refresh the toolbar - server.on( - "astro-integration-pocketbase:settings", - (updatedOptions: ToolbarOptions) => { - options = updatedOptions; - createPocketBaseWindow(); - } - ); + server.on(TOOLBAR_EVENT.SETTINGS, (updatedOptions: ToolbarOptions) => { + options = updatedOptions; + createPocketBaseWindow(); + }); // Create the window (for every page navigation) createPocketBaseWindow(); diff --git a/test/_mocks/batch-requests.ts b/test/_mocks/batch-requests.ts new file mode 100644 index 0000000..66e9bae --- /dev/null +++ b/test/_mocks/batch-requests.ts @@ -0,0 +1,50 @@ +import { assert } from "vitest"; + +export async function sendBatchRequest( + requests: Array<{ + method: "POST" | "DELETE"; + url: string; + body?: Record; + }>, + url: string, + superuserToken: string +): Promise { + const batchRequest = await fetch(new URL(`api/batch`, url), { + method: "POST", + headers: { + Authorization: superuserToken, + "Content-Type": "application/json" + }, + body: JSON.stringify({ requests }) + }); + + assert(batchRequest.status === 200, "Failed to send batch request."); + + return batchRequest.json(); +} + +export async function enableBatchApi( + url: string, + superuserToken: string +): Promise { + const updateSettingsRequest = await fetch(new URL(`api/settings`, url), { + method: "PATCH", + headers: { + Authorization: superuserToken, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + batch: { + enabled: true, + maxBodySize: 0, + maxRequests: 300, + timeout: 3 + } + }) + }); + + assert( + updateSettingsRequest.status === 200, + "Failed to update settings for batch processing." + ); +} diff --git a/test/_mocks/check-e2e-connection.ts b/test/_mocks/check-e2e-connection.ts new file mode 100644 index 0000000..ac4a364 --- /dev/null +++ b/test/_mocks/check-e2e-connection.ts @@ -0,0 +1,9 @@ +export async function checkE2eConnection(): Promise { + try { + await fetch("http://localhost:8090/api/health"); + } catch { + throw new Error( + "E2E connection failed. Make sure the PocketBase instance is running on http://localhost:8090." + ); + } +} diff --git a/test/_mocks/create-integration-options.ts b/test/_mocks/create-integration-options.ts new file mode 100644 index 0000000..2dd15bd --- /dev/null +++ b/test/_mocks/create-integration-options.ts @@ -0,0 +1,14 @@ +import type { PocketBaseIntegrationOptions } from "../../src/types/pocketbase-integration-options.type"; + +export function createIntegrationOptions( + options?: Partial +): PocketBaseIntegrationOptions { + return { + url: "http://127.0.0.1:8090", + superuserCredentials: { + email: "test@pawcode.de", + password: "test1234" + }, + ...options + }; +} diff --git a/test/_mocks/create-pocketbase-entry.ts b/test/_mocks/create-pocketbase-entry.ts new file mode 100644 index 0000000..c1db2f8 --- /dev/null +++ b/test/_mocks/create-pocketbase-entry.ts @@ -0,0 +1,14 @@ +import { randomUUID } from "crypto"; + +export function createPocketbaseEntry( + entry?: Record +): Record { + return { + id: Math.random().toString(36).slice(2, 17), + collectionId: Math.random().toString(36).slice(2, 17), + collectionName: "test", + customId: randomUUID(), + updated: new Date().toISOString().replace("T", " "), + ...entry + }; +} diff --git a/test/_mocks/create-toolbar-mock.ts b/test/_mocks/create-toolbar-mock.ts new file mode 100644 index 0000000..24357c8 --- /dev/null +++ b/test/_mocks/create-toolbar-mock.ts @@ -0,0 +1,42 @@ +import type { BaseIntegrationHooks } from "astro"; +import { vi } from "vitest"; + +export function createToolbarMock(): { + toolbar: Parameters[0]["toolbar"]; + send: (event: string, data: any) => void; + receive: ReturnType; +} { + const receive = vi.fn(); + + let listeners: { + [event: string]: Array<(data: any) => void>; + } = {}; + + return { + toolbar: { + send: (event: string, data: any) => { + receive(event, data); + }, + on: (event: string, callback: (data: any) => void) => { + if (!listeners[event]) { + listeners[event] = []; + } + listeners[event].push(callback); + }, + onAppInitialized: () => { + throw new Error("Method not implemented."); + }, + onAppToggled: () => { + throw new Error("Method not implemented."); + } + }, + send: (event: string, data: any) => { + if (listeners[event]) { + for (const callback of listeners[event]) { + callback(data); + } + } + }, + receive + }; +} diff --git a/test/_mocks/delete-collection.ts b/test/_mocks/delete-collection.ts new file mode 100644 index 0000000..2f5cd9a --- /dev/null +++ b/test/_mocks/delete-collection.ts @@ -0,0 +1,20 @@ +import { assert } from "vitest"; + +export async function deleteCollection( + url: string, + collectionName: string, + superuserToken: string +): Promise { + const deleteRequest = await fetch( + new URL(`api/collections/${collectionName}`, url), + { + method: "DELETE", + headers: { + Authorization: superuserToken, + "Content-Type": "application/json" + } + } + ); + + assert(deleteRequest.status === 204, "Deleting collection failed."); +} diff --git a/test/_mocks/delete-entry.ts b/test/_mocks/delete-entry.ts new file mode 100644 index 0000000..7725bb4 --- /dev/null +++ b/test/_mocks/delete-entry.ts @@ -0,0 +1,40 @@ +import { assert } from "vitest"; +import { sendBatchRequest } from "./batch-requests"; + +export async function deleteEntries( + entryIds: Array, + url: string, + collectionName: string, + superuserToken: string +): Promise { + const requests = entryIds.map((entryId) => ({ + method: "DELETE" as const, + url: `/api/collections/${collectionName}/records/${entryId}` + })); + + const batchResponse = await sendBatchRequest(requests, url, superuserToken); + + assert( + batchResponse.length === entryIds.length, + "Failed to delete all entries in batch request." + ); +} + +export async function deleteEntry( + entryId: string, + url: string, + collectionName: string, + superuserToken: string +): Promise { + const deleteRequest = await fetch( + new URL(`api/collections/${collectionName}/records/${entryId}`, url), + { + method: "DELETE", + headers: { + Authorization: superuserToken + } + } + ); + + assert(deleteRequest.status === 204, "Deleting entry failed."); +} diff --git a/test/_mocks/insert-collection.ts b/test/_mocks/insert-collection.ts new file mode 100644 index 0000000..c8334e1 --- /dev/null +++ b/test/_mocks/insert-collection.ts @@ -0,0 +1,22 @@ +import { assert } from "console"; + +export async function insertCollection( + fields: Array<{ name: string; type: string }>, + url: string, + collectionName: string, + superuserToken: string +): Promise { + const insertRequest = await fetch(new URL(`api/collections`, url), { + method: "POST", + headers: { + Authorization: superuserToken, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + name: collectionName, + fields: [...fields] + }) + }); + + assert(insertRequest.status === 200, "Collection is not available."); +} diff --git a/test/_mocks/insert-entry.ts b/test/_mocks/insert-entry.ts new file mode 100644 index 0000000..4efe153 --- /dev/null +++ b/test/_mocks/insert-entry.ts @@ -0,0 +1,52 @@ +import { assert } from "vitest"; +import { sendBatchRequest } from "./batch-requests"; + +export async function insertEntries( + data: Array>, + url: string, + collectionName: string, + superuserToken: string +): Promise>> { + const requests = data.map((entry) => ({ + method: "POST" as const, + url: `/api/collections/${collectionName}/records`, + body: entry + })); + + const batchResponse = await sendBatchRequest(requests, url, superuserToken); + + assert( + batchResponse.length === data.length, + "Failed to insert all entries in batch request." + ); + + const dbEntries: Array> = []; + for (const entry of batchResponse) { + dbEntries.push(entry.body); + } + return dbEntries; +} + +export async function insertEntry( + data: Record, + url: string, + collectionName: string, + superuserToken: string +): Promise> { + const insertRequest = await fetch( + new URL(`api/collections/${collectionName}/records`, url), + { + method: "POST", + headers: { + Authorization: superuserToken, + "Content-Type": "application/json" + }, + body: JSON.stringify(data) + } + ); + + const entry = await insertRequest.json(); + assert(entry.id, "Entry ID is not available."); + + return entry; +} diff --git a/test/_mocks/logger.mock.ts b/test/_mocks/logger.mock.ts new file mode 100644 index 0000000..2adce04 --- /dev/null +++ b/test/_mocks/logger.mock.ts @@ -0,0 +1,15 @@ +import type { AstroIntegrationLogger } from "astro"; +import { vi } from "vitest"; + +export class LoggerMock implements AstroIntegrationLogger { + public options!: any; + public label = "mock"; + + public fork(_label: string): AstroIntegrationLogger { + return this; + } + public info = vi.fn(); + public warn = vi.fn(); + public error = vi.fn(); + public debug = vi.fn(); +} diff --git a/test/_mocks/wait-until-connected.ts b/test/_mocks/wait-until-connected.ts new file mode 100644 index 0000000..b54c864 --- /dev/null +++ b/test/_mocks/wait-until-connected.ts @@ -0,0 +1,11 @@ +import type { EventSource } from "eventsource"; + +export async function waitUntilConnected( + eventSource: EventSource +): Promise { + return new Promise((resolve) => { + eventSource.addEventListener("PB_CONNECT", () => { + resolve(undefined); + }); + }); +} diff --git a/test/core/refresh-collections-realtime.e2e-spec.ts b/test/core/refresh-collections-realtime.e2e-spec.ts new file mode 100644 index 0000000..8343f40 --- /dev/null +++ b/test/core/refresh-collections-realtime.e2e-spec.ts @@ -0,0 +1,190 @@ +import type { BaseIntegrationHooks } from "astro"; +import { EventSource } from "eventsource"; +import { randomUUID } from "node:crypto"; +import { + afterEach, + assert, + beforeEach, + describe, + expect, + inject, + it, + vi +} from "vitest"; +import { refreshCollectionsRealtime } from "../../src/core"; +import { TOOLBAR_EVENT } from "../../src/toolbar/constants/toolbar-events"; +import { createIntegrationOptions } from "../_mocks/create-integration-options"; +import { createToolbarMock } from "../_mocks/create-toolbar-mock"; +import { deleteCollection } from "../_mocks/delete-collection"; +import { deleteEntry } from "../_mocks/delete-entry"; +import { insertCollection } from "../_mocks/insert-collection"; +import { insertEntry } from "../_mocks/insert-entry"; +import { LoggerMock } from "../_mocks/logger.mock"; +import { waitUntilConnected } from "../_mocks/wait-until-connected"; + +vi.mock("../../src/utils/map-collections-to-watch"); +vi.mock("../../src/utils/get-superuser-token"); + +describe("refreshCollectionsRealtime", async () => { + const mctw = await import("../../src/utils/map-collections-to-watch"); + const gst = await import("../../src/utils/get-superuser-token"); + + const superuserToken = inject("superuserToken"); + + let toolbarMock: ReturnType; + let context: Pick< + Parameters[0], + "logger" | "refreshContent" | "toolbar" + >; + + beforeEach(() => { + toolbarMock = createToolbarMock(); + + context = { + logger: new LoggerMock(), + toolbar: toolbarMock.toolbar, + refreshContent: vi.fn().mockResolvedValue(undefined) + }; + }); + + describe("SSE connection", () => { + beforeEach(() => { + gst.getSuperuserToken = vi.fn().mockResolvedValue(superuserToken); + }); + + it("should not establish connection when no collections are to be watched", () => { + mctw.mapCollectionsToWatch = vi.fn().mockReturnValueOnce(undefined); + + const options = createIntegrationOptions(); + const result = refreshCollectionsRealtime(options, context); + + expect(result).toBeUndefined(); + }); + + it("should not establish connection when `refreshContent` is not defined", () => { + mctw.mapCollectionsToWatch = vi.fn().mockReturnValueOnce(new Map()); + + const options = createIntegrationOptions(); + const result = refreshCollectionsRealtime(options, { + ...context, + refreshContent: undefined + }); + + expect(result).toBeUndefined(); + }); + + it("should establish connection", async () => { + mctw.mapCollectionsToWatch = vi.fn().mockReturnValueOnce(new Map()); + + const options = createIntegrationOptions(); + const result = refreshCollectionsRealtime(options, context); + + assert(!!result, "EventSource was not created"); + expect(result).toBeInstanceOf(EventSource); + expect(result.readyState).toBe(EventSource.CONNECTING); + + await waitUntilConnected(result); + + expect(result.readyState).toBe(EventSource.OPEN); + }); + }); + + describe("real-time updates", () => { + let collectionName: string; + let options: ReturnType; + + beforeEach(async () => { + collectionName = randomUUID().replaceAll("-", ""); + + options = createIntegrationOptions({ + collectionsToWatch: [collectionName] + }); + + await insertCollection([], options.url, collectionName, superuserToken); + }); + + afterEach(async () => { + await deleteCollection(options.url, collectionName, superuserToken); + }); + + it("should refresh collection on realtime update", async () => { + mctw.mapCollectionsToWatch = vi + .fn() + .mockReturnValueOnce(new Map([[collectionName, ["test"]]])); + + // Create the SSE connection + const result = refreshCollectionsRealtime(options, context); + assert(!!result, "EventSource was not created"); + + // Wait until connected + await waitUntilConnected(result); + expect(context.refreshContent).not.toHaveBeenCalled(); + + // Insert an entry to trigger the update + await insertEntry({}, options.url, collectionName, superuserToken); + + // Wait until the refreshContent method was called + await new Promise((resolve) => setTimeout(resolve, 100)); + vi.waitFor(() => { + expect(context.refreshContent).toHaveBeenCalled(); + }); + + expect(context.refreshContent).toHaveBeenCalledExactlyOnceWith({ + loaders: ["pocketbase-loader"], + context: { + source: "astro-integration-pocketbase", + collection: ["test"], + data: expect.anything() + } + }); + }); + + it("should not refresh when realtime updates are disabled", async () => { + mctw.mapCollectionsToWatch = vi + .fn() + .mockReturnValueOnce(new Map([[collectionName, ["test"]]])); + + // Create the SSE connection + const result = refreshCollectionsRealtime(options, context); + assert(!!result, "EventSource was not created"); + + // Wait until connected + await waitUntilConnected(result); + expect(context.refreshContent).not.toHaveBeenCalled(); + + // Disable realtime updates + toolbarMock.send(TOOLBAR_EVENT.REAL_TIME, false); + + // Insert an entry to trigger the update + const entry = await insertEntry( + {}, + options.url, + collectionName, + superuserToken + ); + + // Wait until the realtime update is received + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(context.refreshContent).not.toHaveBeenCalled(); + + // Enable realtime updates + toolbarMock.send(TOOLBAR_EVENT.REAL_TIME, true); + + await deleteEntry( + entry.id as string, + options.url, + collectionName, + superuserToken + ); + + // Wait until the refreshContent method was called + await new Promise((resolve) => setTimeout(resolve, 100)); + vi.waitFor(() => { + expect(context.refreshContent).toHaveBeenCalled(); + }); + + expect(context.refreshContent).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/test/core/refresh-collections.spec.ts b/test/core/refresh-collections.spec.ts new file mode 100644 index 0000000..7f138cd --- /dev/null +++ b/test/core/refresh-collections.spec.ts @@ -0,0 +1,80 @@ +import type { AstroIntegrationLogger } from "astro"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { handleRefreshCollections } from "../../src/core"; +import { TOOLBAR_EVENT } from "../../src/toolbar/constants/toolbar-events"; +import { createToolbarMock } from "../_mocks/create-toolbar-mock"; +import { LoggerMock } from "../_mocks/logger.mock"; + +describe("handleRefreshColections", () => { + let logger: AstroIntegrationLogger; + let toolbarMock: ReturnType; + let refreshContent: ReturnType; + + beforeEach(() => { + logger = new LoggerMock(); + toolbarMock = createToolbarMock(); + refreshContent = vi.fn().mockResolvedValue(undefined); + }); + + it("should do nothing if `refreshContent` is missing", () => { + handleRefreshCollections({ + toolbar: toolbarMock.toolbar, + refreshContent: undefined, + logger + }); + + expect(logger.info).not.toHaveBeenCalled(); + }); + + it("should refresh content on toolbar event", async () => { + handleRefreshCollections({ + toolbar: toolbarMock.toolbar, + refreshContent, + logger + }); + + // Simulate toolbar event + toolbarMock.send(TOOLBAR_EVENT.REFRESH, { force: true }); + + // Wait for async operations to complete + await Promise.resolve(); + + expect(refreshContent).toHaveBeenCalledExactlyOnceWith({ + loaders: ["pocketbase-loader"], + context: { + source: "astro-integration-pocketbase", + force: true + } + }); + }); + + it("should send loading states to the toolbar", async () => { + handleRefreshCollections({ + toolbar: toolbarMock.toolbar, + refreshContent, + logger + }); + + // Simulate toolbar event + toolbarMock.send(TOOLBAR_EVENT.REFRESH, { force: false }); + + // Wait for async operations to complete + await Promise.resolve(); + + expect(toolbarMock.receive).toHaveBeenCalledTimes(2); + expect(toolbarMock.receive).toHaveBeenNthCalledWith( + 1, + TOOLBAR_EVENT.REFRESH, + { + loading: true + } + ); + expect(toolbarMock.receive).toHaveBeenNthCalledWith( + 2, + TOOLBAR_EVENT.REFRESH, + { + loading: false + } + ); + }); +}); diff --git a/test/global-setup.ts b/test/global-setup.ts new file mode 100644 index 0000000..9b6c9c9 --- /dev/null +++ b/test/global-setup.ts @@ -0,0 +1,56 @@ +import { assert } from "vitest"; +import type { TestProject } from "vitest/node"; +import { getSuperuserToken } from "../src/utils/get-superuser-token"; +import { enableBatchApi } from "./_mocks/batch-requests"; +import { checkE2eConnection } from "./_mocks/check-e2e-connection"; +import { createIntegrationOptions } from "./_mocks/create-integration-options"; +import { LoggerMock } from "./_mocks/logger.mock"; + +/** + * Setup function for e2e tests. + * + * When e2e tests are being run, this function verifies that the PocketBase instance is running, + * and retrieves a superuser token that is then provided to the tests. + */ +export async function setup(project: TestProject): Promise { + // Only needed for e2e tests + const files = (project.vitest as { filenamePattern?: Array }) + .filenamePattern; + if (files && !files.some((file) => file.includes("e2e-spec"))) { + return; + } + + // Verify that PocketBase instance is running + await checkE2eConnection(); + + // Create options + const options = createIntegrationOptions(); + assert(options.superuserCredentials, "Superuser credentials are not set."); + assert( + !("impersonateToken" in options.superuserCredentials), + "Impersonate token should not be used in tests." + ); + + // Get superuser token + const token = await getSuperuserToken( + options.url, + options.superuserCredentials, + new LoggerMock() + ); + assert(token, "Superuser token is not available."); + + // Provide superuser token to tests + project.provide("superuserToken", token); + + // Enable batch API for e2e tests + await enableBatchApi(options.url, token); +} + +declare module "vitest" { + export interface ProvidedContext { + /** + * Superuser token for PocketBase instance. + */ + superuserToken: string; + } +} diff --git a/test/utils/get-superuser-token.e2e-spec.ts b/test/utils/get-superuser-token.e2e-spec.ts new file mode 100644 index 0000000..6f1f03f --- /dev/null +++ b/test/utils/get-superuser-token.e2e-spec.ts @@ -0,0 +1,80 @@ +import type { AstroIntegrationLogger } from "astro"; +import { assert, beforeEach, describe, expect, it, vi } from "vitest"; +import { getSuperuserToken } from "../../src/utils/get-superuser-token"; +import { createIntegrationOptions } from "../_mocks/create-integration-options"; +import { LoggerMock } from "../_mocks/logger.mock"; + +describe("getSuperuserToken", () => { + const options = createIntegrationOptions(); + let logger: AstroIntegrationLogger; + + beforeEach(() => { + logger = new LoggerMock(); + }); + + it("should return undefined if superuser credentials are invalid", async () => { + const result = await getSuperuserToken( + options.url, + { + email: "invalid", + password: "invalid" + }, + logger + ); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalled(); + }); + + it("should return token if fetch request is successful", async () => { + assert(options.superuserCredentials, "Superuser credentials are not set."); + assert( + !("impersonateToken" in options.superuserCredentials), + "Impersonate token should not be used in tests." + ); + + const result = await getSuperuserToken( + options.url, + options.superuserCredentials, + logger + ); + + expect(result).toBeDefined(); + }); + + it("should retry on rate limit error", async () => { + assert(options.superuserCredentials, "Superuser credentials are not set."); + assert( + !("impersonateToken" in options.superuserCredentials), + "Impersonate token should not be used in tests." + ); + + vi.useFakeTimers({ + toFake: ["setTimeout"] + }); + vi.spyOn(global, "fetch") + .mockResolvedValueOnce(new Response(undefined, { status: 429 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "test-token" }), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + + const promise = getSuperuserToken( + options.url, + options.superuserCredentials, + logger + ); + + // Fast-forward time to speed up retries + await vi.runAllTimersAsync(); + + const result = await promise; + + expect(result).toBeDefined(); + expect(global.fetch).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); +}); diff --git a/test/utils/map-collections-to-watch.spec.ts b/test/utils/map-collections-to-watch.spec.ts new file mode 100644 index 0000000..d36b6af --- /dev/null +++ b/test/utils/map-collections-to-watch.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { mapCollectionsToWatch } from "../../src/utils/map-collections-to-watch"; + +describe("mapCollectionsToWatch", () => { + it("should do nothing if not collectionsToWatch are provided", () => { + const undefinedResult = mapCollectionsToWatch(undefined); + expect(undefinedResult).toBeUndefined(); + + const emptyArrayResult = mapCollectionsToWatch([]); + expect(emptyArrayResult).toBeUndefined(); + + const emptyObjectResult = mapCollectionsToWatch({}); + expect(emptyObjectResult).toBeUndefined(); + }); + + it("should map array of collections", () => { + const result = mapCollectionsToWatch(["posts", "comments"]); + + expect(result).toEqual( + new Map([ + ["posts", ["posts"]], + ["comments", ["comments"]] + ]) + ); + }); + + describe("object of collections", () => { + it("should map simple object of collections", () => { + const result = mapCollectionsToWatch({ + posts: true, + comments: true + }); + + expect(result).toEqual( + new Map([ + ["posts", ["posts"]], + ["comments", ["comments"]] + ]) + ); + }); + + it("should map complex object of collections", () => { + const result = mapCollectionsToWatch({ + posts: ["posts", "profiles"], + comments: true, + users: ["profiles"] + }); + + expect(result).toEqual( + new Map([ + ["posts", ["posts"]], + ["comments", ["comments"]], + ["profiles", ["posts", "users"]] + ]) + ); + }); + }); +}); diff --git a/test/utils/push-to-map-array.spec.ts b/test/utils/push-to-map-array.spec.ts new file mode 100644 index 0000000..a6c6c20 --- /dev/null +++ b/test/utils/push-to-map-array.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { pushToMapArray } from "../../src/utils/push-to-map-array"; + +describe("pushToMapArray", () => { + it("should add value to existing array in map", () => { + const map = new Map>(); + map.set("a", [1, 2]); + + pushToMapArray(map, "a", 3); + + const result = map.get("a"); + expect(result).toEqual([1, 2, 3]); + }); + + it("should create new array if key does not exist", () => { + const map = new Map>(); + + pushToMapArray(map, "b", 4); + + const result = map.get("b"); + expect(result).toEqual([4]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..c077e00 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.spec.ts", "test/**/*.e2e-spec.ts"], + silent: true, + coverage: { + include: ["src/**/*.ts"], + exclude: [ + "src/**/types/**/*.ts", + "src/**/constants/**/*.ts", + "index.ts", + "src/pocketbase-integration.ts" + ] + }, + restoreMocks: true, + globalSetup: "./test/global-setup.ts" + } +});