diff --git a/.agents/skills/triage/comment.md b/.agents/skills/triage/comment.md index 6dc3d5ea1574..9697dc2316b4 100644 --- a/.agents/skills/triage/comment.md +++ b/.agents/skills/triage/comment.md @@ -35,24 +35,38 @@ Generate and return a GitHub comment following the template below. The **Fix** line in the template has three possible forms. Choose the one that matches the triage outcome: -1. **You created a fix:** Use `I was able to fix this issue.` and include the suggested fix link. +1. **You created a fix:** Use `I found a potential fix for this issue.` and include the suggested fix link. Avoid claiming certainty, even if the fix passes tests, frame it as a suggestion that needs human review. 2. **The issue is already fixed on main** (e.g. the user is on an older major version and the bug doesn't reproduce on current main): Use `This issue has already been fixed.` and tell the user how to get the fix (e.g. upgrade). Link the relevant upgrade guide if applicable: [v6](https://docs.astro.build/en/guides/upgrade-to/v6/), [v5](https://docs.astro.build/en/guides/upgrade-to/v5/). -3. **You could not find or create a fix:** Use `I was unable to find a fix for this issue.` and give guidance or a best guess at where the fix might be. +3. **Low-confidence or no fix:** Use `I wasn't able to find a fix, but I identified some areas that may be relevant.` and list the files/code paths that seem related. Frame this as a jumping-off point for a human, not a diagnosis. If a failing test was added, mention it. +4. **No leads at all:** Use `I was unable to determine the cause of this issue.` This should be rare, only use it when you genuinely have nothing useful to point to. ### "Priority" Instructions The **Priority** line communicates the severity of this issue to maintainers. Its goal is to answer the question: **"How bad is it?"** -Select exactly ONE priority label from the `priorityLabels` arg. Use the label descriptions to guide your decision, combined with the triage report's root cause and impact analysis. Render it in bold, with the `- ` prefix removed, like this: `**Priorty P2: Has Workaround.** Then, follow it with 1-2 sentences explaining _why_ you chose that priority. Answer: "who is likely to be affected and under what conditions?". If you are unsure, use your best judgment based on the label descriptions and the triage findings. +Select exactly ONE priority label from the `priorityLabels` arg. Use the label descriptions to guide your decision, combined with the triage report's root cause and impact analysis. Render it in bold, with the `- ` prefix removed, like this: `**Priority P2: Has Workaround.**` Then, follow it with 1-2 sentences explaining _why_ you chose that priority. Answer: "who is likely to be affected and under what conditions?". If you are unsure, use your best judgment based on the label descriptions and the triage findings. + +**Priority calibration — err on the side of lower priority:** + +- **Experimental/unstable features** should almost never be higher than P3. Users of experimental features accept instability. (e.g. a broken option in `experimental.fonts`) +- **Niche adapter/integration combos** (e.g. MDX + Svelte + Cloudflare) are typically P3 or lower unless they affect a core workflow. +- **P4 vs P5** — the key question is breadth: how many typical Astro users would hit this in a standard workflow? (e.g. P4: wrong output for a common routing pattern; P5: `astro build` crashes for most projects) +- **P2: Has Workaround vs P2: Nice to Have** — pick based on whether something behaves unexpectedly (but circumventable) vs. simply a convenience gap (e.g. Has Workaround: unexpected behavior with a way to restructure around it; Nice to Have: cosmetic issue in an error message). If there is no workaround at all, consider P3 or higher instead. +- **When selecting between similar labels**, always refer to their descriptions in `priorityLabels` to make the final call. +- **When in doubt, go lower.** A P3 that gets bumped up by a maintainer is much better than a P5 that causes false alarm. ### Template +The comment must start with an at-a-glance summary, followed by short explanations, then the full report in a collapsible section. Keep the top section scannable, a maintainer should understand the status in under 5 seconds and be able to quickly jump into fixing the issue. + ```markdown -**[I was able to reproduce this issue. / I was unable to reproduce this issue.]** [2-3 sentences describing the root cause, result, and key observations.] +- **Reproduced:** [Yes / No / Skipped — reason] +- **Exploration:** [Yes / No / Partial / Already fixed on main] [If `branchName` is non-null: — [View branch](https://github.com/withastro/astro/compare/{branchName}?expand=1)] +- **Priority:** [See "Priority" Instructions above. Keep to one line explaining why this priority was chosen, who is likely to be affected, and under what conditions (this section should answer the question: "how bad is it?")] -**[See "Fix" Instructions above.]** [1-2 sentences describing the solution, where/when it was already fixed, or guidance on where a fix might be.] [If `branchName` is non-null: [View Suggested Fix](https://github.com/withastro/astro/compare/{branchName}?expand=1)] +[2-3 sentences describing the root cause or key observations, or where/when it was already fixed. Be specific about what's happening and where in the codebase.] -**[See "Priority" Instructions above.]** [1-2 sentences explaining why this priority was chosen, who is likely to be affected, and under what conditions (this section should answer the question: "how bad is it?")] +**[See "Fix" Instructions above.]** [1-2 sentences describing the fix in more detail: what was changed, guidance on where a fix might be, or relevant code areas.]
Full Triage Report @@ -61,7 +75,7 @@ Select exactly ONE priority label from the `priorityLabels` arg. Use the label d
-_This report was made by an LLM. Mistakes happen, check important info._ +_This report was made by an LLM. The analysis may be wrong, and the potential fix might not work, but is intended as a starting point for exploring the issue._ ``` ## Result diff --git a/.agents/skills/triage/diagnose.md b/.agents/skills/triage/diagnose.md index 8bd17de82eae..2a294786cd3d 100644 --- a/.agents/skills/triage/diagnose.md +++ b/.agents/skills/triage/diagnose.md @@ -61,6 +61,8 @@ Iterate until you understand: - What data is being passed - Where the logic diverges from expected behavior +Once done, **revert all instrumentation** before moving on. Use `git checkout -- ` to remove your `console.log` additions from `packages/`. Debug logs must not leak into downstream steps. + ## Step 4: Identify Root Cause Once you understand the issue, document: @@ -75,6 +77,9 @@ Consider: - Is this a regression from a recent change? - Does this affect other similar use cases? - Are there edge cases to consider? +- Never suggest removing a user's dependency (adapters, framework integrations, features like MDX or DB) as a fix, those are things the user needs. The fix must work within the user's existing stack and expected feature-set. + +**Tone calibration:** Describe the root cause factually, not dramatically. Avoid language that overstates impact ("critical flaw", "fundamentally broken", "severe vulnerability") unless the evidence genuinely supports it. A missing null check is a missing null check, not a "critical oversight in the rendering pipeline." The diagnosis should help a maintainer understand what's wrong, guiding them towards a fix, not alarm them. ## Step 5: Write Output diff --git a/.agents/skills/triage/fix.md b/.agents/skills/triage/fix.md index 17bb0805ef74..b6ac206090e6 100644 --- a/.agents/skills/triage/fix.md +++ b/.agents/skills/triage/fix.md @@ -35,7 +35,19 @@ Read `report.md` from the `triageDir` directory to understand: - The suggested approach - Any edge cases to consider -**Skip if prerequisites unmet:** Check `report.md`: If bug not reproduced/skipped OR diagnosis confidence is `low`/`null` OR no root cause found → append "FIX SKIPPED: [reason]" to `report.md` and return `fixed: false`. +**Skip if prerequisites unmet:** Check `report.md`: If bug was not reproduced or was skipped → append "FIX SKIPPED: Not reproduced" to `report.md` and return `fixed: false`. Do NOT attempt a fix based on guesswork when you cannot reproduce or diagnose the issue. + +**Low-confidence path:** If diagnosis confidence is `low` or `null`, or no clear root cause was found → do NOT attempt a code fix. Instead: + +1. Identify the most likely area(s) of the codebase related to the issue (files, functions, code paths). +2. If possible, write a failing test that demonstrates the expected behavior described in the issue. Place it alongside existing tests for that area. +3. If you identified specific code paths, add brief inline comments (prefixed `// TRIAGE:`) near the most relevant lines in `packages/` to help the implementor orient quickly. Keep to 2-3 comments max — these are signposts, not a diagnosis. +4. Append to `report.md`: the areas you identified, why they seem relevant, and any failing test or comments you added. +5. Return `fixed: false`. + +This "breadcrumb" approach is more useful to maintainers than a wrong fix. + +**High-confidence path:** If diagnosis confidence is `medium` or `high` and a clear root cause was identified → proceed with implementing a fix as described in the steps below. **Note:** The repo may be messy from previous steps. Check `git status` and either work from the current state or `git reset --hard` to start clean. @@ -55,6 +67,7 @@ Make changes in `packages/` source files. Follow these principles: - Only change what's necessary to fix the bug - Don't refactor unrelated code - Don't add new features +- **Never "fix" an issue by removing a user's dependency.** Removing an adapter (Cloudflare, Netlify, Vercel, etc.), framework integration (Svelte, React, Vue, etc.), or feature (MDX, DB, etc.) is not a fix, these are things the user needs. The fix must work within the user's existing stack or expected feature set. **Consider edge cases:** diff --git a/.changeset/two-eels-live.md b/.changeset/two-eels-live.md new file mode 100644 index 000000000000..f1bd94acdf99 --- /dev/null +++ b/.changeset/two-eels-live.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes an issue where i18n domains would return 404 when `trailingSlash` is set to `never`. diff --git a/.flue/workflows/issue-triage/WORKFLOW.ts b/.flue/workflows/issue-triage/WORKFLOW.ts index 8502c78626a5..8193ba26f224 100644 --- a/.flue/workflows/issue-triage/WORKFLOW.ts +++ b/.flue/workflows/issue-triage/WORKFLOW.ts @@ -241,19 +241,22 @@ export default async function triage( const triageResult = await runTriagePipeline(flue, issueNumber, issueDetails); let isPushed = false; - // If a successful fix was created, push the fix up to a new branch on GitHub. + // Push the fix branch if there are meaningful changes (fix, failing test, etc.). // The comment we post below will reference that branch, then a maintainer can choose to: // - checkout that branch locally, using the fix as a starting point // - create a PR from that branch entirely in the GH UI // - ignore it completely - if (triageResult.fixed) { + { const diff = await flue.shell('git diff main --stat'); if (diff.stdout.trim()) { const status = await flue.shell('git status --porcelain'); if (status.stdout.trim()) { await flue.shell('git add -A'); + const defaultMessage = triageResult.fixed + ? 'fix(auto-triage): automated fix' + : 'test(auto-triage): failing test and investigation notes'; await flue.shell( - `git commit -m ${JSON.stringify(triageResult.commitMessage ?? 'fix(auto-triage): automated fix')}`, + `git commit -m ${JSON.stringify(triageResult.commitMessage ?? defaultMessage)}`, ); } const pushResult = await flue.shell(`git push -f origin ${branch}`); @@ -274,7 +277,7 @@ export default async function triage( result: v.pipe( v.string(), v.description( - 'Return only the GitHub comment body generated from the template, following the included template directly. This returned comment must start with "**I was able to reproduce this issue.**" or "**I was unable to reproduce this issue.**"', + 'Return only the GitHub comment body generated from the template, following the included template directly. This returned comment must start with the bullet-point summary (- **Reproduced:** ...)', ), ), }); diff --git a/.github/scripts/tsconfig.json b/.github/scripts/tsconfig.json new file mode 100644 index 000000000000..2529f53ec0ef --- /dev/null +++ b/.github/scripts/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "allowJs": true + } +} diff --git a/.github/workflows/check-merge.yml b/.github/workflows/check-merge.yml index ebf67c0a2b6a..42db71d7f64d 100644 --- a/.github/workflows/check-merge.yml +++ b/.github/workflows/check-merge.yml @@ -39,10 +39,23 @@ jobs: with: files: | .changeset/**/*.md - + # Intentionally ran after the changed-files step so the github API is used to identify + # changed files rather than a local git diff, this is more reliable for pull requests + # originating from a forked repository. + - name: Checkout files + id: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: steps.changed-files.outputs.any_changed == 'true' + with: + ref: ${{ github.event.pull_request.head.sha }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + fetch-depth: 1 + persist-credentials: false + sparse-checkout: | + .changeset - name: Check if any changesets contain minor or major changes id: check - if: steps.blocked.outputs.result != 'true' + if: steps.changed-files.outputs.any_changed == 'true' env: ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} run: | diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 234dc416b8f1..3d1d59355f70 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -36,7 +36,7 @@ jobs: - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 24.14.0 + node-version: 24.14.1 cache: "pnpm" - name: Install dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95038d18cf40..632c12e85ba8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,7 +124,7 @@ jobs: - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 24.14.0 + node-version: 24.14.1 cache: "pnpm" - name: Install dependencies @@ -141,7 +141,7 @@ jobs: run: pnpm run publint - name: Type-check test files - run: pnpm -C packages/astro run typecheck:tests + run: pnpm run typecheck:tests test: name: 'Test (${{ matrix.TEST_SUITE.name }}): ${{ matrix.os }} (node@${{ matrix.NODE_VERSION }})' diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml index 4052bdac8776..6ad87c9ce9a1 100644 --- a/.github/workflows/congrats.yml +++ b/.github/workflows/congrats.yml @@ -9,7 +9,7 @@ jobs: congrats: name: congratsbot if: ${{ github.repository_owner == 'withastro' && github.event.head_commit.message != '[ci] format' }} - uses: withastro/automation/.github/workflows/congratsbot.yml@main + uses: withastro/automation/.github/workflows/congratsbot.yml@a5bd0c5748c4d56e687cdd558064f9ee8adfb1f2 # main with: EMOJIS: '🎉,🎊,🧑‍🚀,🥳,🙌,🚀,👏,<:houston_golden:1068575433647456447>,<:astrocoin:894990669515489301>,<:astro_pride:1130501345326157854>' secrets: diff --git a/.github/workflows/continuous_benchmark.yml b/.github/workflows/continuous_benchmark.yml index 6eae291930a4..3e3f2e89a12a 100644 --- a/.github/workflows/continuous_benchmark.yml +++ b/.github/workflows/continuous_benchmark.yml @@ -41,7 +41,7 @@ jobs: - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 24.14.0 + node-version: 24.14.1 cache: "pnpm" - name: Install dependencies @@ -56,7 +56,7 @@ jobs: timeout-minutes: 15 - name: Run the benchmarks - uses: CodSpeedHQ/action@1c8ae4843586d3ba879736b7f6b7b0c990757fab # v4.12.1 + uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4.13.1 timeout-minutes: 30 with: working-directory: ./benchmark diff --git a/.github/workflows/diff-dependencies.yml b/.github/workflows/diff-dependencies.yml index d48c503d2465..8809e4d3c55d 100644 --- a/.github/workflows/diff-dependencies.yml +++ b/.github/workflows/diff-dependencies.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 # allows the diff action to access git history - name: Create Diff - uses: e18e/action-dependency-diff@d995338f3b229fe7b2cd82048df5da930f70c7c3 # v1.4.4 + uses: e18e/action-dependency-diff@5d3c6ac2ad2de2eaca1dc120c5accfd9590764b6 # v1.5.1 with: # We’re using this package primarily to track size changes, not as worried about duplicates duplicate-threshold: 100 diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 9faaa1886608..656ac1b6dbe7 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -9,7 +9,7 @@ on: jobs: prettier: if: github.repository_owner == 'withastro' - uses: withastro/automation/.github/workflows/format.yml@main + uses: withastro/automation/.github/workflows/format.yml@a5bd0c5748c4d56e687cdd558064f9ee8adfb1f2 # main with: command: "format" secrets: inherit diff --git a/.github/workflows/issue-close-cleanup.yml b/.github/workflows/issue-close-cleanup.yml new file mode 100644 index 000000000000..d623fea6f428 --- /dev/null +++ b/.github/workflows/issue-close-cleanup.yml @@ -0,0 +1,20 @@ +name: "Issue: Close Cleanup" + +on: + issues: + types: [closed] + +jobs: + cleanup-branch: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Delete triage fix branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH="flue/fix-${{ github.event.issue.number }}" + # Delete the branch if it exists; ignore errors if it doesn't + gh api "repos/${{ github.repository }}/git/refs/heads/${BRANCH}" \ + -X DELETE 2>/dev/null && echo "Deleted branch ${BRANCH}" || echo "No branch ${BRANCH} to clean up" diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index ca57bdde12da..096b5ceb9e94 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -72,7 +72,7 @@ jobs: - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 24.14.0 + node-version: 24.14.1 cache: pnpm - name: Clone Astro Compiler (for debugging) diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml index 2506fafbde12..3992a0e7d598 100644 --- a/.github/workflows/preview-release.yml +++ b/.github/workflows/preview-release.yml @@ -48,7 +48,7 @@ jobs: - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 24.14.0 + node-version: 24.14.1 cache: "pnpm" - name: Install dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af1d80d65d5e..dd35b0baa3d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 24.14.0 + node-version: 24.14.1 cache: "pnpm" - name: Install dependencies diff --git a/.github/workflows/scripts.yml b/.github/workflows/scripts.yml index 01ad219dd8cb..68ed1dd565e6 100644 --- a/.github/workflows/scripts.yml +++ b/.github/workflows/scripts.yml @@ -38,7 +38,7 @@ jobs: - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 24.14.0 + node-version: 24.14.1 cache: "pnpm" - name: Install dependencies diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 000000000000..26794f96f54b --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,14 @@ +name: Semgrep OSS scan +on: + pull_request: {} + schedule: + - cron: '0 0 * * 6' +jobs: + semgrep: + name: semgrep-oss + runs-on: ubuntu-latest + container: + image: semgrep/semgrep@sha256:500acf49f5e5785aa89af609b983f0427ac8cd08f7e34146277df6cffb002759 # v1.157.0 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - run: semgrep scan --config=auto diff --git a/biome.jsonc b/biome.jsonc index c661d7f0e49e..63a65b9fdd6d 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.2/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", "files": { "includes": [ "**", @@ -46,7 +46,8 @@ // Enforce separate type imports for type-only imports to avoid bundling unneeded code "useImportType": "error", "useExportType": "error", - "useNumberNamespace": "warn" + "useNumberNamespace": "warn", + "noInferrableTypes": "error" }, "suspicious": { // This one is specific to catch `console.log`. The rest of logs are permitted @@ -187,6 +188,26 @@ } } } + }, + { + "includes": ["**/astro/test/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/src/**"], + "message": "The test should not import the source code. Import the code from the dist/ folder instead." + } + ] + } + } + } + } + } } ] } diff --git a/eslint.config.js b/eslint.config.js index 55aa79531465..8686de7d256f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -65,6 +65,7 @@ export default [ '@typescript-eslint/consistent-indexed-object-style': 'off', '@typescript-eslint/consistent-type-definitions': 'off', '@typescript-eslint/dot-notation': 'off', + '@typescript-eslint/no-inferrable-types': 'off', '@typescript-eslint/no-base-to-string': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-floating-promises': 'off', diff --git a/examples/basics/package.json b/examples/basics/package.json index 998a586ddf46..907071afa56f 100644 --- a/examples/basics/package.json +++ b/examples/basics/package.json @@ -13,6 +13,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^6.1.2" + "astro": "^6.1.8" } } diff --git a/examples/blog/README.md b/examples/blog/README.md index 4307d60ba3c9..c5b756145819 100644 --- a/examples/blog/README.md +++ b/examples/blog/README.md @@ -36,6 +36,7 @@ Inside of your Astro project, you'll see the following folders and files: ```text ├── public/ ├── src/ +│   ├── assets/ │   ├── components/ │   ├── content/ │   ├── layouts/ diff --git a/examples/blog/astro.config.mjs b/examples/blog/astro.config.mjs index 0dbd924c3929..ea43603de26f 100644 --- a/examples/blog/astro.config.mjs +++ b/examples/blog/astro.config.mjs @@ -2,10 +2,34 @@ import mdx from '@astrojs/mdx'; import sitemap from '@astrojs/sitemap'; -import { defineConfig } from 'astro/config'; +import { defineConfig, fontProviders } from 'astro/config'; // https://astro.build/config export default defineConfig({ site: 'https://example.com', integrations: [mdx(), sitemap()], + fonts: [ + { + provider: fontProviders.local(), + name: 'Atkinson', + cssVariable: '--font-atkinson', + fallbacks: ['sans-serif'], + options: { + variants: [ + { + src: ['./src/assets/fonts/atkinson-regular.woff'], + weight: 400, + style: 'normal', + display: 'swap', + }, + { + src: ['./src/assets/fonts/atkinson-bold.woff'], + weight: 700, + style: 'normal', + display: 'swap', + }, + ], + }, + }, + ], }); diff --git a/examples/blog/package.json b/examples/blog/package.json index d777f6265bc6..a8fea9d8fb85 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -16,7 +16,7 @@ "@astrojs/mdx": "^5.0.3", "@astrojs/rss": "^4.0.18", "@astrojs/sitemap": "^3.7.2", - "astro": "^6.1.2", + "astro": "^6.1.8", "sharp": "^0.34.3" } } diff --git a/examples/blog/public/fonts/atkinson-bold.woff b/examples/blog/src/assets/fonts/atkinson-bold.woff similarity index 100% rename from examples/blog/public/fonts/atkinson-bold.woff rename to examples/blog/src/assets/fonts/atkinson-bold.woff diff --git a/examples/blog/public/fonts/atkinson-regular.woff b/examples/blog/src/assets/fonts/atkinson-regular.woff similarity index 100% rename from examples/blog/public/fonts/atkinson-regular.woff rename to examples/blog/src/assets/fonts/atkinson-regular.woff diff --git a/examples/blog/src/components/BaseHead.astro b/examples/blog/src/components/BaseHead.astro index 4a4384c4fd22..12fe4fa15712 100644 --- a/examples/blog/src/components/BaseHead.astro +++ b/examples/blog/src/components/BaseHead.astro @@ -5,6 +5,7 @@ import '../styles/global.css'; import type { ImageMetadata } from 'astro'; import FallbackImage from '../assets/blog-placeholder-1.jpg'; import { SITE_TITLE } from '../consts'; +import { Font } from 'astro:assets'; interface Props { title: string; @@ -31,9 +32,7 @@ const { title, description, image = FallbackImage } = Astro.props; /> - - - + diff --git a/examples/blog/src/styles/global.css b/examples/blog/src/styles/global.css index bd6f8ced4fd9..8d0e05ff446e 100644 --- a/examples/blog/src/styles/global.css +++ b/examples/blog/src/styles/global.css @@ -16,22 +16,8 @@ 0 2px 6px rgba(var(--gray), 25%), 0 8px 24px rgba(var(--gray), 33%), 0 16px 32px rgba(var(--gray), 33%); } -@font-face { - font-family: "Atkinson"; - src: url("/fonts/atkinson-regular.woff") format("woff"); - font-weight: 400; - font-style: normal; - font-display: swap; -} -@font-face { - font-family: "Atkinson"; - src: url("/fonts/atkinson-bold.woff") format("woff"); - font-weight: 700; - font-style: normal; - font-display: swap; -} body { - font-family: "Atkinson", sans-serif; + font-family: var(--font-atkinson); margin: 0; padding: 0; text-align: left; diff --git a/examples/component/package.json b/examples/component/package.json index ab320513f56d..e1c48e6524b9 100644 --- a/examples/component/package.json +++ b/examples/component/package.json @@ -18,7 +18,7 @@ ], "scripts": {}, "devDependencies": { - "astro": "^6.1.2" + "astro": "^6.1.8" }, "peerDependencies": { "astro": "^5.0.0 || ^6.0.0" diff --git a/examples/container-with-vitest/package.json b/examples/container-with-vitest/package.json index 1b2688d88ffd..753d919f01f5 100644 --- a/examples/container-with-vitest/package.json +++ b/examples/container-with-vitest/package.json @@ -14,8 +14,8 @@ "test": "vitest run" }, "dependencies": { - "@astrojs/react": "^5.0.2", - "astro": "^6.1.2", + "@astrojs/react": "^5.0.3", + "astro": "^6.1.8", "react": "^18.3.1", "react-dom": "^18.3.1", "vitest": "^4.1.0" diff --git a/examples/framework-alpine/package.json b/examples/framework-alpine/package.json index 930666f3c7e6..eb9e8ea3a349 100644 --- a/examples/framework-alpine/package.json +++ b/examples/framework-alpine/package.json @@ -16,6 +16,6 @@ "@astrojs/alpinejs": "^0.5.0", "@types/alpinejs": "^3.13.11", "alpinejs": "^3.15.8", - "astro": "^6.1.2" + "astro": "^6.1.8" } } diff --git a/examples/framework-multiple/package.json b/examples/framework-multiple/package.json index cd06f32217ff..bb6603311063 100644 --- a/examples/framework-multiple/package.json +++ b/examples/framework-multiple/package.json @@ -13,14 +13,14 @@ "astro": "astro" }, "dependencies": { - "@astrojs/preact": "^5.1.0", - "@astrojs/react": "^5.0.2", + "@astrojs/preact": "^5.1.1", + "@astrojs/react": "^5.0.3", "@astrojs/solid-js": "^6.0.1", - "@astrojs/svelte": "^8.0.4", + "@astrojs/svelte": "^8.0.5", "@astrojs/vue": "^6.0.1", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", - "astro": "^6.1.2", + "astro": "^6.1.8", "preact": "^10.28.4", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/examples/framework-preact/package.json b/examples/framework-preact/package.json index ce4f08ecc17c..2a4301079abd 100644 --- a/examples/framework-preact/package.json +++ b/examples/framework-preact/package.json @@ -13,9 +13,9 @@ "astro": "astro" }, "dependencies": { - "@astrojs/preact": "^5.1.0", + "@astrojs/preact": "^5.1.1", "@preact/signals": "^2.8.1", - "astro": "^6.1.2", + "astro": "^6.1.8", "preact": "^10.28.4" } } diff --git a/examples/framework-react/package.json b/examples/framework-react/package.json index ac51b152c884..6f5b9b86a35d 100644 --- a/examples/framework-react/package.json +++ b/examples/framework-react/package.json @@ -13,10 +13,10 @@ "astro": "astro" }, "dependencies": { - "@astrojs/react": "^5.0.2", + "@astrojs/react": "^5.0.3", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", - "astro": "^6.1.2", + "astro": "^6.1.8", "react": "^18.3.1", "react-dom": "^18.3.1" } diff --git a/examples/framework-solid/package.json b/examples/framework-solid/package.json index 645463f9afa5..5a9546bc706c 100644 --- a/examples/framework-solid/package.json +++ b/examples/framework-solid/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@astrojs/solid-js": "^6.0.1", - "astro": "^6.1.2", + "astro": "^6.1.8", "solid-js": "^1.9.11" } } diff --git a/examples/framework-svelte/package.json b/examples/framework-svelte/package.json index 33e4d0617014..c39df182b1f8 100644 --- a/examples/framework-svelte/package.json +++ b/examples/framework-svelte/package.json @@ -13,8 +13,8 @@ "astro": "astro" }, "dependencies": { - "@astrojs/svelte": "^8.0.4", - "astro": "^6.1.2", + "@astrojs/svelte": "^8.0.5", + "astro": "^6.1.8", "svelte": "^5.53.5" } } diff --git a/examples/framework-vue/package.json b/examples/framework-vue/package.json index 048e90cacb16..8f191e8e6bf2 100644 --- a/examples/framework-vue/package.json +++ b/examples/framework-vue/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@astrojs/vue": "^6.0.1", - "astro": "^6.1.2", + "astro": "^6.1.8", "vue": "^3.5.29" } } diff --git a/examples/hackernews/package.json b/examples/hackernews/package.json index 607ab44ef833..ff006a06999b 100644 --- a/examples/hackernews/package.json +++ b/examples/hackernews/package.json @@ -13,7 +13,7 @@ "astro": "astro" }, "dependencies": { - "@astrojs/node": "^10.0.4", - "astro": "^6.1.2" + "@astrojs/node": "^10.0.5", + "astro": "^6.1.8" } } diff --git a/examples/integration/package.json b/examples/integration/package.json index cb25deee7185..4213dbd61fd7 100644 --- a/examples/integration/package.json +++ b/examples/integration/package.json @@ -18,7 +18,7 @@ ], "scripts": {}, "devDependencies": { - "astro": "^6.1.2" + "astro": "^6.1.8" }, "peerDependencies": { "astro": "^4.0.0" diff --git a/examples/minimal/package.json b/examples/minimal/package.json index d0be1e2325f8..e2f04d513dac 100644 --- a/examples/minimal/package.json +++ b/examples/minimal/package.json @@ -13,6 +13,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^6.1.2" + "astro": "^6.1.8" } } diff --git a/examples/portfolio/package.json b/examples/portfolio/package.json index 0c2e3e808b32..2c002652b07e 100644 --- a/examples/portfolio/package.json +++ b/examples/portfolio/package.json @@ -13,6 +13,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^6.1.2" + "astro": "^6.1.8" } } diff --git a/examples/ssr/package.json b/examples/ssr/package.json index 2bc758bda901..64468234388e 100644 --- a/examples/ssr/package.json +++ b/examples/ssr/package.json @@ -14,9 +14,9 @@ "server": "node dist/server/entry.mjs" }, "dependencies": { - "@astrojs/node": "^10.0.4", - "@astrojs/svelte": "^8.0.4", - "astro": "^6.1.2", + "@astrojs/node": "^10.0.5", + "@astrojs/svelte": "^8.0.5", + "astro": "^6.1.8", "svelte": "^5.53.5" } } diff --git a/examples/starlog/package.json b/examples/starlog/package.json index 2dc25c568649..0741f3fd322b 100644 --- a/examples/starlog/package.json +++ b/examples/starlog/package.json @@ -9,7 +9,7 @@ "astro": "astro" }, "dependencies": { - "astro": "^6.1.2", + "astro": "^6.1.8", "sass": "^1.97.3", "sharp": "^0.34.3" }, diff --git a/examples/toolbar-app/package.json b/examples/toolbar-app/package.json index e2198b271c7e..91e31d37d94d 100644 --- a/examples/toolbar-app/package.json +++ b/examples/toolbar-app/package.json @@ -15,8 +15,8 @@ "./app": "./dist/app.js" }, "devDependencies": { - "@types/node": "^18.17.8", - "astro": "^6.1.2" + "@types/node": "^22.10.6", + "astro": "^6.1.8" }, "engines": { "node": ">=22.12.0" diff --git a/examples/with-markdoc/package.json b/examples/with-markdoc/package.json index de5c74aecbf2..21ab77221ffe 100644 --- a/examples/with-markdoc/package.json +++ b/examples/with-markdoc/package.json @@ -14,6 +14,6 @@ }, "dependencies": { "@astrojs/markdoc": "^1.0.3", - "astro": "^6.1.2" + "astro": "^6.1.8" } } diff --git a/examples/with-mdx/package.json b/examples/with-mdx/package.json index d8846698635c..55473c164b95 100644 --- a/examples/with-mdx/package.json +++ b/examples/with-mdx/package.json @@ -14,8 +14,8 @@ }, "dependencies": { "@astrojs/mdx": "^5.0.3", - "@astrojs/preact": "^5.1.0", - "astro": "^6.1.2", + "@astrojs/preact": "^5.1.1", + "astro": "^6.1.8", "preact": "^10.28.4" } } diff --git a/examples/with-nanostores/package.json b/examples/with-nanostores/package.json index 9e64c71a4e0f..51d4d470daef 100644 --- a/examples/with-nanostores/package.json +++ b/examples/with-nanostores/package.json @@ -13,9 +13,9 @@ "astro": "astro" }, "dependencies": { - "@astrojs/preact": "^5.1.0", + "@astrojs/preact": "^5.1.1", "@nanostores/preact": "^1.0.0", - "astro": "^6.1.2", + "astro": "^6.1.8", "nanostores": "^1.1.1", "preact": "^10.28.4" } diff --git a/examples/with-tailwindcss/package.json b/examples/with-tailwindcss/package.json index aa5b0e9a6fe5..74c775b2f087 100644 --- a/examples/with-tailwindcss/package.json +++ b/examples/with-tailwindcss/package.json @@ -16,7 +16,7 @@ "@astrojs/mdx": "^5.0.3", "@tailwindcss/vite": "^4.2.1", "@types/canvas-confetti": "^1.9.0", - "astro": "^6.1.2", + "astro": "^6.1.8", "canvas-confetti": "^1.9.4", "tailwindcss": "^4.2.1" } diff --git a/examples/with-vitest/package.json b/examples/with-vitest/package.json index 2ac5b27629d7..e44ed3480a7c 100644 --- a/examples/with-vitest/package.json +++ b/examples/with-vitest/package.json @@ -14,7 +14,7 @@ "test": "vitest" }, "dependencies": { - "astro": "^6.1.2", + "astro": "^6.1.8", "vitest": "^4.1.0" } } diff --git a/knip.js b/knip.js index 70702de01bc7..5e28a0378e0a 100644 --- a/knip.js +++ b/knip.js @@ -1,5 +1,5 @@ // @ts-check -const testEntry = 'test/**/*.test.js'; +const testEntry = 'test/**/*.test.{js,ts}'; /** @type {import('knip').KnipConfig} */ export default { @@ -33,7 +33,7 @@ export default { testEntry, 'test/types/**/*', 'e2e/**/*.test.js', - 'test/units/teardown.js', + 'test/units/teardown.ts', // Can't detect this file when using inside a vite plugin 'src/vite-plugin-app/createAstroServerApp.ts', ], diff --git a/package.json b/package.json index 8943e7dee106..c2ae5c16c30b 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test:e2e": "cd packages/astro && pnpm playwright install firefox && pnpm run test:e2e", "test:e2e:match": "cd packages/astro && pnpm playwright install firefox && pnpm run test:e2e:match", "test:e2e:hosts": "turbo run test:hosted", + "typecheck:tests": "pnpm -r typecheck:tests", "benchmark": "astro-benchmark", "lint": "biome lint && knip && eslint . --report-unused-disable-directives-severity=warn --concurrency=auto", "lint:ci": "knip && pnpm run eslint:ci", @@ -62,12 +63,12 @@ }, "devDependencies": { "@astrojs/check": "^0.9.5", - "@biomejs/biome": "2.4.2", + "@biomejs/biome": "2.4.10", "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", "@flue/cli": "^0.0.47", "@flue/client": "^0.0.29", - "@types/node": "^18.19.115", + "@types/node": "^22.10.6", "bgproc": "^0.2.0", "esbuild": "0.25.5", "eslint": "^9.39.3", diff --git a/packages/astro-rss/package.json b/packages/astro-rss/package.json index 715b8b4e8c67..ef76cd050187 100644 --- a/packages/astro-rss/package.json +++ b/packages/astro-rss/package.json @@ -24,7 +24,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "devDependencies": { "@types/xml2js": "^0.4.14", diff --git a/packages/astro-rss/test/pagesGlobToRssItems.test.js b/packages/astro-rss/test/pagesGlobToRssItems.test.js deleted file mode 100644 index 36613c96c89e..000000000000 --- a/packages/astro-rss/test/pagesGlobToRssItems.test.js +++ /dev/null @@ -1,122 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { pagesGlobToRssItems } from '../dist/index.js'; -import { phpFeedItem, web1FeedItem } from './test-utils.js'; - -describe('pagesGlobToRssItems', () => { - it('should generate on valid result', async () => { - const globResult = { - './posts/php.md': () => - new Promise((resolve) => - resolve({ - url: phpFeedItem.link, - frontmatter: { - title: phpFeedItem.title, - pubDate: phpFeedItem.pubDate, - description: phpFeedItem.description, - }, - }), - ), - './posts/nested/web1.md': () => - new Promise((resolve) => - resolve({ - url: web1FeedItem.link, - frontmatter: { - title: web1FeedItem.title, - pubDate: web1FeedItem.pubDate, - description: web1FeedItem.description, - }, - }), - ), - }; - - const items = await pagesGlobToRssItems(globResult); - const expected = [ - { - title: phpFeedItem.title, - link: phpFeedItem.link, - pubDate: new Date(phpFeedItem.pubDate), - description: phpFeedItem.description, - }, - { - title: web1FeedItem.title, - link: web1FeedItem.link, - pubDate: new Date(web1FeedItem.pubDate), - description: web1FeedItem.description, - }, - ]; - - assert.deepEqual( - items.sort((a, b) => a.pubDate - b.pubDate), - expected, - ); - }); - - it('should fail on missing "url"', () => { - const globResult = { - './posts/php.md': () => - new Promise((resolve) => - resolve({ - url: undefined, - frontmatter: { - pubDate: phpFeedItem.pubDate, - description: phpFeedItem.description, - }, - }), - ), - }; - return assert.rejects(pagesGlobToRssItems(globResult)); - }); - - it('should fail on missing "title" key and "description"', () => { - const globResult = { - './posts/php.md': () => - new Promise((resolve) => - resolve({ - url: phpFeedItem.link, - frontmatter: { - title: undefined, - pubDate: phpFeedItem.pubDate, - description: undefined, - }, - }), - ), - }; - return assert.rejects(pagesGlobToRssItems(globResult)); - }); - - it('should not fail on missing "title" key if "description" is present', () => { - const globResult = { - './posts/php.md': () => - new Promise((resolve) => - resolve({ - url: phpFeedItem.link, - frontmatter: { - title: undefined, - pubDate: phpFeedItem.pubDate, - description: phpFeedItem.description, - }, - }), - ), - }; - return assert.doesNotReject(pagesGlobToRssItems(globResult)); - }); - - it('should not fail on missing "description" key if "title" is present', () => { - const globResult = { - './posts/php.md': () => - new Promise((resolve) => - resolve({ - url: phpFeedItem.link, - frontmatter: { - title: phpFeedItem.title, - pubDate: phpFeedItem.pubDate, - description: undefined, - }, - }), - ), - }; - return assert.doesNotReject(pagesGlobToRssItems(globResult)); - }); -}); diff --git a/packages/astro-rss/test/pagesGlobToRssItems.test.ts b/packages/astro-rss/test/pagesGlobToRssItems.test.ts new file mode 100644 index 000000000000..19e897e8648b --- /dev/null +++ b/packages/astro-rss/test/pagesGlobToRssItems.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { pagesGlobToRssItems } from '../dist/index.js'; +import { phpFeedItem, web1FeedItem } from './test-utils.ts'; + +describe('pagesGlobToRssItems', () => { + it('should generate on valid result', async () => { + const globResult = { + './posts/php.md': () => + new Promise((resolve) => + resolve({ + url: phpFeedItem.link, + frontmatter: { + title: phpFeedItem.title, + pubDate: phpFeedItem.pubDate, + description: phpFeedItem.description, + }, + }), + ), + './posts/nested/web1.md': () => + new Promise((resolve) => + resolve({ + url: web1FeedItem.link, + frontmatter: { + title: web1FeedItem.title, + pubDate: web1FeedItem.pubDate, + description: web1FeedItem.description, + }, + }), + ), + }; + + const items = await pagesGlobToRssItems(globResult); + const expected = [ + { + title: phpFeedItem.title, + link: phpFeedItem.link, + pubDate: new Date(phpFeedItem.pubDate), + description: phpFeedItem.description, + }, + { + title: web1FeedItem.title, + link: web1FeedItem.link, + pubDate: new Date(web1FeedItem.pubDate), + description: web1FeedItem.description, + }, + ]; + + assert.deepEqual( + items.sort((a, b) => a.pubDate!.getTime() - b.pubDate!.getTime()), + expected, + ); + }); + + it('should fail on missing "url"', () => { + const globResult = { + './posts/php.md': () => + new Promise((resolve) => + resolve({ + url: undefined, + frontmatter: { + pubDate: phpFeedItem.pubDate, + description: phpFeedItem.description, + }, + }), + ), + }; + return assert.rejects(pagesGlobToRssItems(globResult)); + }); + + it('should fail on missing "title" key and "description"', () => { + const globResult = { + './posts/php.md': () => + new Promise((resolve) => + resolve({ + url: phpFeedItem.link, + frontmatter: { + title: undefined, + pubDate: phpFeedItem.pubDate, + description: undefined, + }, + }), + ), + }; + return assert.rejects(pagesGlobToRssItems(globResult)); + }); + + it('should not fail on missing "title" key if "description" is present', () => { + const globResult = { + './posts/php.md': () => + new Promise((resolve) => + resolve({ + url: phpFeedItem.link, + frontmatter: { + title: undefined, + pubDate: phpFeedItem.pubDate, + description: phpFeedItem.description, + }, + }), + ), + }; + return assert.doesNotReject(pagesGlobToRssItems(globResult)); + }); + + it('should not fail on missing "description" key if "title" is present', () => { + const globResult = { + './posts/php.md': () => + new Promise((resolve) => + resolve({ + url: phpFeedItem.link, + frontmatter: { + title: phpFeedItem.title, + pubDate: phpFeedItem.pubDate, + description: undefined, + }, + }), + ), + }; + return assert.doesNotReject(pagesGlobToRssItems(globResult)); + }); +}); diff --git a/packages/astro-rss/test/rss.test.js b/packages/astro-rss/test/rss.test.js deleted file mode 100644 index a52caf45c361..000000000000 --- a/packages/astro-rss/test/rss.test.js +++ /dev/null @@ -1,301 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as z from 'zod/v4'; -import rss, { getRssString } from '../dist/index.js'; -import { rssSchema } from '../dist/schema.js'; -import { - description, - parseXmlString, - phpFeedItem, - phpFeedItemWithContent, - phpFeedItemWithCustomData, - phpFeedItemWithoutDate, - site, - title, - web1FeedItem, - web1FeedItemWithAllData, - web1FeedItemWithContent, -} from './test-utils.js'; - -// note: I spent 30 minutes looking for a nice node-based snapshot tool -// ...and I gave up. Enjoy big strings! - -// biome-ignore format: keep in one line -const validXmlResult = `<![CDATA[${title}]]>${site}/<![CDATA[${phpFeedItem.title}]]>${site}${phpFeedItem.link}/${site}${phpFeedItem.link}/${new Date(phpFeedItem.pubDate).toUTCString()}<![CDATA[${web1FeedItem.title}]]>${site}${web1FeedItem.link}/${site}${web1FeedItem.link}/${new Date(web1FeedItem.pubDate).toUTCString()}`; -// biome-ignore format: keep in one line -const validXmlWithContentResult = `<![CDATA[${title}]]>${site}/<![CDATA[${phpFeedItemWithContent.title}]]>${site}${phpFeedItemWithContent.link}/${site}${phpFeedItemWithContent.link}/${new Date(phpFeedItemWithContent.pubDate).toUTCString()}<![CDATA[${web1FeedItemWithContent.title}]]>${site}${web1FeedItemWithContent.link}/${site}${web1FeedItemWithContent.link}/${new Date(web1FeedItemWithContent.pubDate).toUTCString()}`; -// biome-ignore format: keep in one line -const validXmlResultWithMissingDate = `<![CDATA[${title}]]>${site}/<![CDATA[${phpFeedItemWithoutDate.title}]]>${site}${phpFeedItemWithoutDate.link}/${site}${phpFeedItemWithoutDate.link}/<![CDATA[${phpFeedItem.title}]]>${site}${phpFeedItem.link}/${site}${phpFeedItem.link}/${new Date(phpFeedItem.pubDate).toUTCString()}`; -// biome-ignore format: keep in one line -const validXmlResultWithAllData = `<![CDATA[${title}]]>${site}/<![CDATA[${phpFeedItem.title}]]>${site}${phpFeedItem.link}/${site}${phpFeedItem.link}/${new Date(phpFeedItem.pubDate).toUTCString()}<![CDATA[${web1FeedItemWithAllData.title}]]>${site}${web1FeedItemWithAllData.link}/${site}${web1FeedItemWithAllData.link}/${new Date(web1FeedItemWithAllData.pubDate).toUTCString()}${web1FeedItemWithAllData.categories[0]}${web1FeedItemWithAllData.categories[1]}${web1FeedItemWithAllData.author}${web1FeedItemWithAllData.commentsUrl}${web1FeedItemWithAllData.source.title}`; -// biome-ignore format: keep in one line -const validXmlWithCustomDataResult = `<![CDATA[${title}]]>${site}/<![CDATA[${phpFeedItemWithCustomData.title}]]>${site}${phpFeedItemWithCustomData.link}/${site}${phpFeedItemWithCustomData.link}/${new Date(phpFeedItemWithCustomData.pubDate).toUTCString()}${phpFeedItemWithCustomData.customData}<![CDATA[${web1FeedItemWithContent.title}]]>${site}${web1FeedItemWithContent.link}/${site}${web1FeedItemWithContent.link}/${new Date(web1FeedItemWithContent.pubDate).toUTCString()}`; -// biome-ignore format: keep in one line -const validXmlWithStylesheet = `<![CDATA[${title}]]>${site}/`; -// biome-ignore format: keep in one line -const validXmlWithXSLStylesheet = `<![CDATA[${title}]]>${site}/`; -// biome-ignore format: keep in one line -const validXmlWithXSLTStylesheet = `<![CDATA[${title}]]>${site}/`; - -function assertXmlDeepEqual(a, b) { - const parsedA = parseXmlString(a); - const parsedB = parseXmlString(b); - - assert.equal(parsedA.err, null); - assert.equal(parsedB.err, null); - assert.deepEqual(parsedA.result, parsedB.result); -} - -describe('rss', () => { - it('should return a response', async () => { - const response = await rss({ - title, - description, - items: [phpFeedItem, web1FeedItem], - site, - }); - - const str = await response.text(); - - // NOTE: Chai used the below parser to perform the tests, but I have omitted it for now. - // parser = new xml2js.Parser({ trim: flag(this, 'deep') }); - - assertXmlDeepEqual(str, validXmlResult); - - const contentType = response.headers.get('Content-Type'); - assert.equal(contentType, 'application/xml'); - }); - - it('should be the same string as getRssString', async () => { - const options = { - title, - description, - items: [phpFeedItem, web1FeedItem], - site, - }; - - const response = await rss(options); - const str1 = await response.text(); - const str2 = await getRssString(options); - - assert.equal(str1, str2); - }); -}); - -describe('getRssString', () => { - it('should generate on valid RSSFeedItem array', async () => { - const str = await getRssString({ - title, - description, - items: [phpFeedItem, web1FeedItem], - site, - }); - - assertXmlDeepEqual(str, validXmlResult); - }); - - it('should generate on valid RSSFeedItem array with HTML content included', async () => { - const str = await getRssString({ - title, - description, - items: [phpFeedItemWithContent, web1FeedItemWithContent], - site, - }); - - assertXmlDeepEqual(str, validXmlWithContentResult); - }); - - it('should generate on valid RSSFeedItem array that is missing date', async () => { - const str = await getRssString({ - title, - description, - items: [phpFeedItemWithoutDate, phpFeedItem], - site, - }); - - assertXmlDeepEqual(str, validXmlResultWithMissingDate); - }); - - it('should generate on valid RSSFeedItem array with all RSS content included', async () => { - const str = await getRssString({ - title, - description, - items: [phpFeedItem, web1FeedItemWithAllData], - site, - }); - - assertXmlDeepEqual(str, validXmlResultWithAllData); - }); - - it('should generate on valid RSSFeedItem array with custom data included', async () => { - const str = await getRssString({ - xmlns: { - dc: 'http://purl.org/dc/elements/1.1/', - }, - title, - description, - items: [phpFeedItemWithCustomData, web1FeedItemWithContent], - site, - }); - - assertXmlDeepEqual(str, validXmlWithCustomDataResult); - }); - - it('should include xml-stylesheet instruction when stylesheet is defined', async () => { - const str = await getRssString({ - title, - description, - items: [], - site, - stylesheet: '/feedstylesheet.css', - }); - - assertXmlDeepEqual(str, validXmlWithStylesheet); - }); - - it('should include xml-stylesheet instruction with xsl type when stylesheet is set to xsl file', async () => { - const str = await getRssString({ - title, - description, - items: [], - site, - stylesheet: '/feedstylesheet.xsl', - }); - - // xml2js doesn't parse processing instructions. Assert the type is present. - assert.equal(str.includes('type="text/xsl"'), true); - assertXmlDeepEqual(str, validXmlWithXSLStylesheet); - }); - - it('should include xml-stylesheet instruction with xslt type when stylesheet is set to xslt file', async () => { - const str = await getRssString({ - title, - description, - items: [], - site, - stylesheet: '/feedstylesheet.xslt', - }); - - // xml2js doesn't parse processing instructions. Assert the type is present. - assert.equal(str.includes('type="text/xsl"'), true); - assertXmlDeepEqual(str, validXmlWithXSLTStylesheet); - }); - - it('should preserve self-closing tags on `customData`', async () => { - const customData = - ''; - const str = await getRssString({ - title, - description, - items: [], - site, - xmlns: { - atom: 'http://www.w3.org/2005/Atom', - }, - customData, - }); - - assert.ok(str.includes(customData)); - }); - - it('should not append trailing slash to URLs with the given option', async () => { - const str = await getRssString({ - title, - description, - items: [phpFeedItem], - site, - trailingSlash: false, - }); - - assert.ok(str.includes('https://example.com<')); - assert.ok(str.includes('https://example.com/php<')); - }); - - it('Deprecated import.meta.glob mapping still works', async () => { - const globResult = { - './posts/php.md': () => - new Promise((resolve) => - resolve({ - url: phpFeedItem.link, - frontmatter: { - title: phpFeedItem.title, - pubDate: phpFeedItem.pubDate, - description: phpFeedItem.description, - }, - }), - ), - './posts/nested/web1.md': () => - new Promise((resolve) => - resolve({ - url: web1FeedItem.link, - frontmatter: { - title: web1FeedItem.title, - pubDate: web1FeedItem.pubDate, - description: web1FeedItem.description, - }, - }), - ), - }; - - const str = await getRssString({ - title, - description, - items: globResult, - site, - }); - - assertXmlDeepEqual(str, validXmlResult); - }); - - it('should fail when an invalid date string is provided', async () => { - const res = rssSchema.safeParse({ - title: phpFeedItem.title, - pubDate: 'invalid date', - description: phpFeedItem.description, - link: phpFeedItem.link, - }); - - assert.equal(res.success, false); - assert.equal(res.error.issues[0].path[0], 'pubDate'); - }); - - it('should be extendable', () => { - let error = null; - try { - rssSchema.extend({ - category: z.string().optional(), - }); - } catch (e) { - error = e.message; - } - assert.equal(error, null); - }); - - it('should not fail when an enclosure has a length of 0', async () => { - let error = null; - try { - await getRssString({ - title, - description, - items: [ - { - title: 'Title', - pubDate: new Date().toISOString(), - description: 'Description', - link: '/link', - enclosure: { - url: '/enclosure', - length: 0, - type: 'audio/mpeg', - }, - }, - ], - site, - }); - } catch (e) { - error = e.message; - } - - assert.equal(error, null); - }); -}); diff --git a/packages/astro-rss/test/rss.test.ts b/packages/astro-rss/test/rss.test.ts new file mode 100644 index 000000000000..402548b78eec --- /dev/null +++ b/packages/astro-rss/test/rss.test.ts @@ -0,0 +1,301 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as z from 'zod/v4'; +import rss, { getRssString } from '../dist/index.js'; +import { rssSchema } from '../dist/schema.js'; +import { + description, + parseXmlString, + phpFeedItem, + phpFeedItemWithContent, + phpFeedItemWithCustomData, + phpFeedItemWithoutDate, + site, + title, + web1FeedItem, + web1FeedItemWithAllData, + web1FeedItemWithContent, +} from './test-utils.ts'; + +// note: I spent 30 minutes looking for a nice node-based snapshot tool +// ...and I gave up. Enjoy big strings! + +// biome-ignore format: keep in one line +const validXmlResult = `<![CDATA[${title}]]>${site}/<![CDATA[${phpFeedItem.title}]]>${site}${phpFeedItem.link}/${site}${phpFeedItem.link}/${new Date(phpFeedItem.pubDate).toUTCString()}<![CDATA[${web1FeedItem.title}]]>${site}${web1FeedItem.link}/${site}${web1FeedItem.link}/${new Date(web1FeedItem.pubDate).toUTCString()}`; +// biome-ignore format: keep in one line +const validXmlWithContentResult = `<![CDATA[${title}]]>${site}/<![CDATA[${phpFeedItemWithContent.title}]]>${site}${phpFeedItemWithContent.link}/${site}${phpFeedItemWithContent.link}/${new Date(phpFeedItemWithContent.pubDate).toUTCString()}<![CDATA[${web1FeedItemWithContent.title}]]>${site}${web1FeedItemWithContent.link}/${site}${web1FeedItemWithContent.link}/${new Date(web1FeedItemWithContent.pubDate).toUTCString()}`; +// biome-ignore format: keep in one line +const validXmlResultWithMissingDate = `<![CDATA[${title}]]>${site}/<![CDATA[${phpFeedItemWithoutDate.title}]]>${site}${phpFeedItemWithoutDate.link}/${site}${phpFeedItemWithoutDate.link}/<![CDATA[${phpFeedItem.title}]]>${site}${phpFeedItem.link}/${site}${phpFeedItem.link}/${new Date(phpFeedItem.pubDate).toUTCString()}`; +// biome-ignore format: keep in one line +const validXmlResultWithAllData = `<![CDATA[${title}]]>${site}/<![CDATA[${phpFeedItem.title}]]>${site}${phpFeedItem.link}/${site}${phpFeedItem.link}/${new Date(phpFeedItem.pubDate).toUTCString()}<![CDATA[${web1FeedItemWithAllData.title}]]>${site}${web1FeedItemWithAllData.link}/${site}${web1FeedItemWithAllData.link}/${new Date(web1FeedItemWithAllData.pubDate).toUTCString()}${web1FeedItemWithAllData.categories[0]}${web1FeedItemWithAllData.categories[1]}${web1FeedItemWithAllData.author}${web1FeedItemWithAllData.commentsUrl}${web1FeedItemWithAllData.source.title}`; +// biome-ignore format: keep in one line +const validXmlWithCustomDataResult = `<![CDATA[${title}]]>${site}/<![CDATA[${phpFeedItemWithCustomData.title}]]>${site}${phpFeedItemWithCustomData.link}/${site}${phpFeedItemWithCustomData.link}/${new Date(phpFeedItemWithCustomData.pubDate).toUTCString()}${phpFeedItemWithCustomData.customData}<![CDATA[${web1FeedItemWithContent.title}]]>${site}${web1FeedItemWithContent.link}/${site}${web1FeedItemWithContent.link}/${new Date(web1FeedItemWithContent.pubDate).toUTCString()}`; +// biome-ignore format: keep in one line +const validXmlWithStylesheet = `<![CDATA[${title}]]>${site}/`; +// biome-ignore format: keep in one line +const validXmlWithXSLStylesheet = `<![CDATA[${title}]]>${site}/`; +// biome-ignore format: keep in one line +const validXmlWithXSLTStylesheet = `<![CDATA[${title}]]>${site}/`; + +function assertXmlDeepEqual(a: string, b: string) { + const parsedA = parseXmlString(a); + const parsedB = parseXmlString(b); + + assert.equal(parsedA.err, null); + assert.equal(parsedB.err, null); + assert.deepEqual(parsedA.result, parsedB.result); +} + +describe('rss', () => { + it('should return a response', async () => { + const response = await rss({ + title, + description, + items: [phpFeedItem, web1FeedItem], + site, + }); + + const str = await response.text(); + + // NOTE: Chai used the below parser to perform the tests, but I have omitted it for now. + // parser = new xml2js.Parser({ trim: flag(this, 'deep') }); + + assertXmlDeepEqual(str, validXmlResult); + + const contentType = response.headers.get('Content-Type'); + assert.equal(contentType, 'application/xml'); + }); + + it('should be the same string as getRssString', async () => { + const options = { + title, + description, + items: [phpFeedItem, web1FeedItem], + site, + }; + + const response = await rss(options); + const str1 = await response.text(); + const str2 = await getRssString(options); + + assert.equal(str1, str2); + }); +}); + +describe('getRssString', () => { + it('should generate on valid RSSFeedItem array', async () => { + const str = await getRssString({ + title, + description, + items: [phpFeedItem, web1FeedItem], + site, + }); + + assertXmlDeepEqual(str, validXmlResult); + }); + + it('should generate on valid RSSFeedItem array with HTML content included', async () => { + const str = await getRssString({ + title, + description, + items: [phpFeedItemWithContent, web1FeedItemWithContent], + site, + }); + + assertXmlDeepEqual(str, validXmlWithContentResult); + }); + + it('should generate on valid RSSFeedItem array that is missing date', async () => { + const str = await getRssString({ + title, + description, + items: [phpFeedItemWithoutDate, phpFeedItem], + site, + }); + + assertXmlDeepEqual(str, validXmlResultWithMissingDate); + }); + + it('should generate on valid RSSFeedItem array with all RSS content included', async () => { + const str = await getRssString({ + title, + description, + items: [phpFeedItem, web1FeedItemWithAllData], + site, + }); + + assertXmlDeepEqual(str, validXmlResultWithAllData); + }); + + it('should generate on valid RSSFeedItem array with custom data included', async () => { + const str = await getRssString({ + xmlns: { + dc: 'http://purl.org/dc/elements/1.1/', + }, + title, + description, + items: [phpFeedItemWithCustomData, web1FeedItemWithContent], + site, + }); + + assertXmlDeepEqual(str, validXmlWithCustomDataResult); + }); + + it('should include xml-stylesheet instruction when stylesheet is defined', async () => { + const str = await getRssString({ + title, + description, + items: [], + site, + stylesheet: '/feedstylesheet.css', + }); + + assertXmlDeepEqual(str, validXmlWithStylesheet); + }); + + it('should include xml-stylesheet instruction with xsl type when stylesheet is set to xsl file', async () => { + const str = await getRssString({ + title, + description, + items: [], + site, + stylesheet: '/feedstylesheet.xsl', + }); + + // xml2js doesn't parse processing instructions. Assert the type is present. + assert.equal(str.includes('type="text/xsl"'), true); + assertXmlDeepEqual(str, validXmlWithXSLStylesheet); + }); + + it('should include xml-stylesheet instruction with xslt type when stylesheet is set to xslt file', async () => { + const str = await getRssString({ + title, + description, + items: [], + site, + stylesheet: '/feedstylesheet.xslt', + }); + + // xml2js doesn't parse processing instructions. Assert the type is present. + assert.equal(str.includes('type="text/xsl"'), true); + assertXmlDeepEqual(str, validXmlWithXSLTStylesheet); + }); + + it('should preserve self-closing tags on `customData`', async () => { + const customData = + ''; + const str = await getRssString({ + title, + description, + items: [], + site, + xmlns: { + atom: 'http://www.w3.org/2005/Atom', + }, + customData, + }); + + assert.ok(str.includes(customData)); + }); + + it('should not append trailing slash to URLs with the given option', async () => { + const str = await getRssString({ + title, + description, + items: [phpFeedItem], + site, + trailingSlash: false, + }); + + assert.ok(str.includes('https://example.com<')); + assert.ok(str.includes('https://example.com/php<')); + }); + + it('Deprecated import.meta.glob mapping still works', async () => { + const globResult = { + './posts/php.md': () => + new Promise((resolve) => + resolve({ + url: phpFeedItem.link, + frontmatter: { + title: phpFeedItem.title, + pubDate: phpFeedItem.pubDate, + description: phpFeedItem.description, + }, + }), + ), + './posts/nested/web1.md': () => + new Promise((resolve) => + resolve({ + url: web1FeedItem.link, + frontmatter: { + title: web1FeedItem.title, + pubDate: web1FeedItem.pubDate, + description: web1FeedItem.description, + }, + }), + ), + }; + + const str = await getRssString({ + title, + description, + items: globResult, + site, + }); + + assertXmlDeepEqual(str, validXmlResult); + }); + + it('should fail when an invalid date string is provided', async () => { + const res = rssSchema.safeParse({ + title: phpFeedItem.title, + pubDate: 'invalid date', + description: phpFeedItem.description, + link: phpFeedItem.link, + }); + + assert.equal(res.success, false); + assert.equal(res.error.issues[0].path[0], 'pubDate'); + }); + + it('should be extendable', () => { + let error = null; + try { + rssSchema.extend({ + category: z.string().optional(), + }); + } catch (e) { + error = (e as Error).message; + } + assert.equal(error, null); + }); + + it('should not fail when an enclosure has a length of 0', async () => { + let error = null; + try { + await getRssString({ + title, + description, + items: [ + { + title: 'Title', + pubDate: new Date(), + description: 'Description', + link: '/link', + enclosure: { + url: '/enclosure', + length: 0, + type: 'audio/mpeg', + }, + }, + ], + site, + }); + } catch (e) { + error = (e as Error).message; + } + + assert.equal(error, null); + }); +}); diff --git a/packages/astro-rss/test/test-utils.js b/packages/astro-rss/test/test-utils.js deleted file mode 100644 index d3ee8ca336c7..000000000000 --- a/packages/astro-rss/test/test-utils.js +++ /dev/null @@ -1,69 +0,0 @@ -import xml2js from 'xml2js'; - -export const title = 'My RSS feed'; -export const description = 'This sure is a nice RSS feed'; -export const site = 'https://example.com'; - -export const phpFeedItemWithoutDate = { - link: '/php', - title: 'Remember PHP?', - description: - 'PHP is a general-purpose scripting language geared toward web development. It was originally created by Danish-Canadian programmer Rasmus Lerdorf in 1994.', -}; -export const phpFeedItem = { - ...phpFeedItemWithoutDate, - pubDate: '1994-05-03', -}; -export const phpFeedItemWithContent = { - ...phpFeedItem, - content: `

${phpFeedItem.title}

${phpFeedItem.description}

`, -}; -export const phpFeedItemWithCustomData = { - ...phpFeedItem, - customData: '', -}; - -export const web1FeedItem = { - // Should support empty string as a URL (possible for homepage route) - link: '', - title: 'Web 1.0', - pubDate: '1997-05-03', - description: - 'Web 1.0 is the term used for the earliest version of the Internet as it emerged from its origins with Defense Advanced Research Projects Agency (DARPA) and became, for the first time, a global network representing the future of digital communications.', -}; -export const web1FeedItemWithContent = { - ...web1FeedItem, - content: `

${web1FeedItem.title}

${web1FeedItem.description}

`, -}; -export const web1FeedItemWithAllData = { - ...web1FeedItem, - categories: ['web1', 'history'], - author: 'test@example.com', - commentsUrl: 'http://example.com/comments', - source: { - url: 'http://example.com/source', - title: 'The Web 1.0 blog', - }, - enclosure: { - url: '/podcast.mp3', - length: 256, - type: 'audio/mpeg', - }, -}; - -const parser = new xml2js.Parser({ trim: true }); - -/** - * - * Utility function to parse an XML string into an object using `xml2js`. - * - * @param {string} xmlString - Stringified XML to parse. - * @return {{ err: Error, result: any }} Represents an option containing the parsed XML string or an Error. - */ -export function parseXmlString(xmlString) { - let res; - parser.parseString(xmlString, (err, result) => { - res = { err, result }; - }); - return res; -} diff --git a/packages/astro-rss/test/test-utils.ts b/packages/astro-rss/test/test-utils.ts new file mode 100644 index 000000000000..4cb276ed1bdf --- /dev/null +++ b/packages/astro-rss/test/test-utils.ts @@ -0,0 +1,69 @@ +import xml2js from 'xml2js'; + +export const title = 'My RSS feed'; +export const description = 'This sure is a nice RSS feed'; +export const site = 'https://example.com'; + +export const phpFeedItemWithoutDate = { + link: '/php', + title: 'Remember PHP?', + description: + 'PHP is a general-purpose scripting language geared toward web development. It was originally created by Danish-Canadian programmer Rasmus Lerdorf in 1994.', +}; +export const phpFeedItem = { + ...phpFeedItemWithoutDate, + pubDate: new Date('1994-05-03'), +}; +export const phpFeedItemWithContent = { + ...phpFeedItem, + content: `

${phpFeedItem.title}

${phpFeedItem.description}

`, +}; +export const phpFeedItemWithCustomData = { + ...phpFeedItem, + customData: '', +}; + +export const web1FeedItem = { + // Should support empty string as a URL (possible for homepage route) + link: '', + title: 'Web 1.0', + pubDate: new Date('1997-05-03'), + description: + 'Web 1.0 is the term used for the earliest version of the Internet as it emerged from its origins with Defense Advanced Research Projects Agency (DARPA) and became, for the first time, a global network representing the future of digital communications.', +}; +export const web1FeedItemWithContent = { + ...web1FeedItem, + content: `

${web1FeedItem.title}

${web1FeedItem.description}

`, +}; +export const web1FeedItemWithAllData = { + ...web1FeedItem, + categories: ['web1', 'history'], + author: 'test@example.com', + commentsUrl: 'http://example.com/comments', + source: { + url: 'http://example.com/source', + title: 'The Web 1.0 blog', + }, + enclosure: { + url: '/podcast.mp3', + length: 256, + type: 'audio/mpeg', + }, +}; + +const parser = new xml2js.Parser({ trim: true }); + +/** + * + * Utility function to parse an XML string into an object using `xml2js`. + * + * @param xmlString - Stringified XML to parse. + * @return Represents an option containing the parsed XML string or an Error. + */ +export function parseXmlString(xmlString: string): { err: Error | null; result: unknown } { + let res!: { err: Error | null; result: unknown }; + parser.parseString(xmlString, (err: Error | null, result: unknown) => { + res = { err, result }; + }); + return res; +} diff --git a/packages/astro-rss/tsconfig.test.json b/packages/astro-rss/tsconfig.test.json new file mode 100644 index 000000000000..7d6bc4428b35 --- /dev/null +++ b/packages/astro-rss/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [{ "path": "../astro/tsconfig.test.json" }] +} diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md index 9106cfd12e50..3dff13a8e42c 100644 --- a/packages/astro/CHANGELOG.md +++ b/packages/astro/CHANGELOG.md @@ -1,5 +1,92 @@ # astro +## 6.1.8 + +### Patch Changes + +- [#16367](https://github.com/withastro/astro/pull/16367) [`a6866a7`](https://github.com/withastro/astro/commit/a6866a7ef086627f8f8237274361d8acc2f85121) Thanks [@ematipico](https://github.com/ematipico)! - Fixes an issue where build output files could contain special characters (`!`, `~`, `{`, `}`) in their names, causing deploy failures on platforms like Netlify. + +- [#16381](https://github.com/withastro/astro/pull/16381) [`217c5b3`](https://github.com/withastro/astro/commit/217c5b3b937f0aee7e59280e8a10cf2bd4237605) Thanks [@ematipico](https://github.com/ematipico)! - Slightly improved the performance of the dev server by caching the internal crawling of the dependencies of a project. + +- [#16348](https://github.com/withastro/astro/pull/16348) [`7d26cd7`](https://github.com/withastro/astro/commit/7d26cd77bc1b33cee81f0e7b408dc2d170be1bdd) Thanks [@ocavue](https://github.com/ocavue)! - Fixes a bug where emitted assets during a client build would contain always fresh, new hashes in their name. Now the build should be more stable. + +- [#16317](https://github.com/withastro/astro/pull/16317) [`d012bfe`](https://github.com/withastro/astro/commit/d012bfeadb5b33f9ab1175191d59357d629c327e) Thanks [@das-peter](https://github.com/das-peter)! - Fixes a bug where `allowedDomains` weren't correctly propagated when using the development server. + +- [#16379](https://github.com/withastro/astro/pull/16379) [`5a84551`](https://github.com/withastro/astro/commit/5a845514114ae21ca9820e98b56cce33c0cf579b) Thanks [@martrapp](https://github.com/martrapp)! - Improves Vue scoped style handling in DEV mode during client router navigation. + +- [#16317](https://github.com/withastro/astro/pull/16317) [`d012bfe`](https://github.com/withastro/astro/commit/d012bfeadb5b33f9ab1175191d59357d629c327e) Thanks [@das-peter](https://github.com/das-peter)! - Adds tests to verify settings are properly propagated when using the development server. + +- [#16282](https://github.com/withastro/astro/pull/16282) [`5b0fdaa`](https://github.com/withastro/astro/commit/5b0fdaa8ba3dc17f4b93d9847c3255150b0aeab2) Thanks [@jmurty](https://github.com/jmurty)! - Fixes build errors on platforms with skew protection enabled (e.g. Vercel, Netlify) for inter-chunk Javascript using dynamic imports + +- Updated dependencies [[`e0b240e`](https://github.com/withastro/astro/commit/e0b240edea4db632138def3a9003b4b12e12f765)]: + - @astrojs/telemetry@3.3.1 + +## 6.1.7 + +### Patch Changes + +- [#16027](https://github.com/withastro/astro/pull/16027) [`c62516b`](https://github.com/withastro/astro/commit/c62516bbbf8fdf95d38293440d28221c048c41f0) Thanks [@fkatsuhiro](https://github.com/fkatsuhiro)! - Fixes a bug where remote image dimensions were not validated during static builds on Netlify. + +- [#16311](https://github.com/withastro/astro/pull/16311) [`94048f2`](https://github.com/withastro/astro/commit/94048f27c30f47ae0e01f90231e0496ed80595f7) Thanks [@Arecsu](https://github.com/Arecsu)! - Fixes `--port` flag being ignored after a Vite-triggered server restart (e.g. when a `.env` file changes) + +- [#16316](https://github.com/withastro/astro/pull/16316) [`0fcd04c`](https://github.com/withastro/astro/commit/0fcd04cc985002b56c9e2d36bcb68da0d3f08d5f) Thanks [@ematipico](https://github.com/ematipico)! - Fixes the `/_image` endpoint accepting an arbitrary `f=svg` query parameter and serving non-SVG content as `image/svg+xml`. The endpoint now validates that the source is actually SVG before honoring `f=svg`, matching the same guard already enforced on the `` component path. + +## 6.1.6 + +### Patch Changes + +- [#16202](https://github.com/withastro/astro/pull/16202) [`b5c2fba`](https://github.com/withastro/astro/commit/b5c2fba8bf2bc315db94e525f12f7661dd357822) Thanks [@matthewp](https://github.com/matthewp)! - Fixes Actions failing with `ActionsWithoutServerOutputError` when using `output: 'static'` with an adapter + +- [#16303](https://github.com/withastro/astro/pull/16303) [`b06eabf`](https://github.com/withastro/astro/commit/b06eabf01afda713066feb803bbc4c89af634aaf) Thanks [@matthewp](https://github.com/matthewp)! - Improves handling of special characters in inline `` variants (case-insensitive, + * whitespace, or self-closing forms) or ` + + + diff --git a/packages/astro/test/fixtures/set-html/src/pages/fetch.astro b/packages/astro/test/fixtures/astro-directives/src/pages/set-html-fetch.astro similarity index 100% rename from packages/astro/test/fixtures/set-html/src/pages/fetch.astro rename to packages/astro/test/fixtures/astro-directives/src/pages/set-html-fetch.astro diff --git a/packages/astro/test/fixtures/set-html/src/pages/index.astro b/packages/astro/test/fixtures/astro-directives/src/pages/set-html-types.astro similarity index 100% rename from packages/astro/test/fixtures/set-html/src/pages/index.astro rename to packages/astro/test/fixtures/astro-directives/src/pages/set-html-types.astro diff --git a/packages/astro/test/fixtures/astro-fallback/src/components/Client.jsx b/packages/astro/test/fixtures/astro-expr/src/components/Client.jsx similarity index 100% rename from packages/astro/test/fixtures/astro-fallback/src/components/Client.jsx rename to packages/astro/test/fixtures/astro-expr/src/components/Client.jsx diff --git a/packages/astro/test/fixtures/astro-slot-with-client/src/components/Slotted.astro b/packages/astro/test/fixtures/astro-expr/src/components/Slotted.astro similarity index 100% rename from packages/astro/test/fixtures/astro-slot-with-client/src/components/Slotted.astro rename to packages/astro/test/fixtures/astro-expr/src/components/Slotted.astro diff --git a/packages/astro/test/fixtures/astro-slot-with-client/src/components/Thing.jsx b/packages/astro/test/fixtures/astro-expr/src/components/Thing.jsx similarity index 100% rename from packages/astro/test/fixtures/astro-slot-with-client/src/components/Thing.jsx rename to packages/astro/test/fixtures/astro-expr/src/components/Thing.jsx diff --git a/packages/astro/test/fixtures/astro-fallback/src/pages/index.astro b/packages/astro/test/fixtures/astro-expr/src/pages/fallback.astro similarity index 100% rename from packages/astro/test/fixtures/astro-fallback/src/pages/index.astro rename to packages/astro/test/fixtures/astro-expr/src/pages/fallback.astro diff --git a/packages/astro/test/fixtures/astro-slot-with-client/src/pages/index.astro b/packages/astro/test/fixtures/astro-expr/src/pages/slot-with-client.astro similarity index 100% rename from packages/astro/test/fixtures/astro-slot-with-client/src/pages/index.astro rename to packages/astro/test/fixtures/astro-expr/src/pages/slot-with-client.astro diff --git a/packages/astro/test/fixtures/astro-external-files/package.json b/packages/astro/test/fixtures/astro-external-files/package.json deleted file mode 100644 index 5f0a99bcfebe..000000000000 --- a/packages/astro/test/fixtures/astro-external-files/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/astro-external-files", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/astro-fallback/astro.config.mjs b/packages/astro/test/fixtures/astro-fallback/astro.config.mjs deleted file mode 100644 index a8afbd82f250..000000000000 --- a/packages/astro/test/fixtures/astro-fallback/astro.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import preact from '@astrojs/preact'; -import { defineConfig } from 'astro/config'; - -// https://astro.build/config -export default defineConfig({ - integrations: [preact()], -}); diff --git a/packages/astro/test/fixtures/astro-fallback/package.json b/packages/astro/test/fixtures/astro-fallback/package.json deleted file mode 100644 index c60672a50d9b..000000000000 --- a/packages/astro/test/fixtures/astro-fallback/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@test/astro-fallback", - "version": "0.0.0", - "private": true, - "dependencies": { - "@astrojs/preact": "workspace:*", - "astro": "workspace:*", - "preact": "^10.29.0" - } -} diff --git a/packages/astro/test/fixtures/astro-generator/package.json b/packages/astro/test/fixtures/astro-generator/package.json deleted file mode 100644 index 5fe696624cba..000000000000 --- a/packages/astro/test/fixtures/astro-generator/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/astro-generator", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/astro-external-files/public/external-file.js b/packages/astro/test/fixtures/astro-public/public/external-file.js similarity index 100% rename from packages/astro/test/fixtures/astro-external-files/public/external-file.js rename to packages/astro/test/fixtures/astro-public/public/external-file.js diff --git a/packages/astro/test/fixtures/astro-external-files/src/pages/index.astro b/packages/astro/test/fixtures/astro-public/src/pages/external-files.astro similarity index 100% rename from packages/astro/test/fixtures/astro-external-files/src/pages/index.astro rename to packages/astro/test/fixtures/astro-public/src/pages/external-files.astro diff --git a/packages/astro/test/fixtures/astro-sitemap-rss/package.json b/packages/astro/test/fixtures/astro-sitemap-rss/package.json deleted file mode 100644 index 22461bfcfaf1..000000000000 --- a/packages/astro/test/fixtures/astro-sitemap-rss/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/astro-sitemap-rss", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/404.astro b/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/404.astro deleted file mode 100644 index 23df09841650..000000000000 --- a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/404.astro +++ /dev/null @@ -1,10 +0,0 @@ ---- ---- - - - 404 - - - 404 - - diff --git a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episode/fazers.md b/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episode/fazers.md deleted file mode 100644 index 9efbf1fa224a..000000000000 --- a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episode/fazers.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Fazers -artist: King Geedorah -type: music -duration: 197 -pubDate: '2003-07-03 00:00:00' -description: Rhapsody ranked Take Me to Your Leader 17th on its list “Hip-Hop’s Best Albums of the Decade” -explicit: true ---- - -# Fazers - -Rhapsody ranked Take Me to Your Leader 17th on its list “Hip-Hop’s Best Albums of the Decade” diff --git a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episode/rap-snitch-knishes.md b/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episode/rap-snitch-knishes.md deleted file mode 100644 index e7ade24b4e28..000000000000 --- a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episode/rap-snitch-knishes.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Rap Snitch Knishes (feat. Mr. Fantastik) -artist: MF Doom -type: music -duration: 172 -pubDate: '2004-11-16 00:00:00' -description: Complex named this song the “22nd funniest rap song of all time.” -explicit: true ---- - -# Rap Snitch Knishes (feat. Mr. Fantastik) - -Complex named this song the “22nd funniest rap song of all time.” diff --git a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episode/rhymes-like-dimes.md b/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episode/rhymes-like-dimes.md deleted file mode 100644 index ba73c28d84cb..000000000000 --- a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episode/rhymes-like-dimes.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Rhymes Like Dimes (feat. Cucumber Slice) -artist: MF Doom -type: music -duration: 259 -pubDate: '1999-10-19 00:00:00' -description: | - Operation: Doomsday has been heralded as an underground classic that established MF Doom's rank within the underground hip-hop scene during the early to mid-2000s. -explicit: true ---- - -# Rhymes Like Dimes (feat. Cucumber Slice) - -Operation: Doomsday has been heralded as an underground classic that established MF Doom's rank within the underground hip-hop scene during the early to mid-2000s. diff --git a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episodes/[...page].astro b/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episodes/[...page].astro deleted file mode 100644 index 93366b73e378..000000000000 --- a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episodes/[...page].astro +++ /dev/null @@ -1,58 +0,0 @@ ---- -export async function getStaticPaths({paginate, rss}) { - const episodes = Object.values(import.meta.glob('../episode/*.md', { eager: true })).sort((a, b) => new Date(b.frontmatter.pubDate) - new Date(a.frontmatter.pubDate)); - rss({ - title: 'MF Doomcast', - description: 'The podcast about the things you find on a picnic, or at a picnic table', - xmlns: { - itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd', - content: 'http://purl.org/rss/1.0/modules/content/', - }, - customData: `en-us` + - `MF Doom`, - items: episodes.map((episode) => ({ - title: episode.frontmatter.title, - link: episode.url, - description: episode.frontmatter.description, - pubDate: episode.frontmatter.pubDate + 'Z', - customData: `${episode.frontmatter.type}` + - `${episode.frontmatter.duration}` + - `${episode.frontmatter.explicit || false}`, - })), - dest: '/custom/feed.xml', - }); - rss({ - title: 'MF Doomcast', - description: 'The podcast about the things you find on a picnic, or at a picnic table', - xmlns: { - itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd', - content: 'http://purl.org/rss/1.0/modules/content/', - }, - customData: `en-us` + - `MF Doom`, - items: episodes.map((episode) => ({ - title: episode.frontmatter.title, - link: `https://example.com${episode.url}/`, - description: episode.frontmatter.description, - pubDate: episode.frontmatter.pubDate + 'Z', - customData: `${episode.frontmatter.type}` + - `${episode.frontmatter.duration}` + - `${episode.frontmatter.explicit || false}`, - })), - dest: '/custom/feed-pregenerated-urls.xml', - }); - return paginate(episodes); -} - -const { page } = Astro.props; ---- - - - - Podcast Episodes - - - - {page.data.map((ep) => (
  • {ep.frontmatter.title}
  • ))} - - diff --git a/packages/astro/test/fixtures/astro-slot-with-client/astro.config.mjs b/packages/astro/test/fixtures/astro-slot-with-client/astro.config.mjs deleted file mode 100644 index d3664dcde950..000000000000 --- a/packages/astro/test/fixtures/astro-slot-with-client/astro.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import preact from '@astrojs/preact'; -import { defineConfig } from 'astro/config'; - -export default defineConfig({ - integrations: [ - preact() - ] -}); diff --git a/packages/astro/test/fixtures/astro-slot-with-client/package.json b/packages/astro/test/fixtures/astro-slot-with-client/package.json deleted file mode 100644 index 8738dc97d4de..000000000000 --- a/packages/astro/test/fixtures/astro-slot-with-client/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@test/astro-slot-with-client", - "private": true, - "dependencies": { - "@astrojs/preact": "workspace:*", - "astro": "workspace:*", - "preact": "^10.29.0" - } -} diff --git a/packages/astro/test/fixtures/unused-slot/src/components/Card.astro b/packages/astro/test/fixtures/astro-slots/src/components/Card.astro similarity index 100% rename from packages/astro/test/fixtures/unused-slot/src/components/Card.astro rename to packages/astro/test/fixtures/astro-slots/src/components/Card.astro diff --git a/packages/astro/test/fixtures/unused-slot/src/pages/index.astro b/packages/astro/test/fixtures/astro-slots/src/pages/unused-slot.astro similarity index 100% rename from packages/astro/test/fixtures/unused-slot/src/pages/index.astro rename to packages/astro/test/fixtures/astro-slots/src/pages/unused-slot.astro diff --git a/packages/astro/test/fixtures/config-host/package.json b/packages/astro/test/fixtures/config-host/package.json deleted file mode 100644 index a7dbd5f8018e..000000000000 --- a/packages/astro/test/fixtures/config-host/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/config-host", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/config-path/config/my-config.mjs b/packages/astro/test/fixtures/config-path/config/my-config.mjs deleted file mode 100644 index eb66c74caeb7..000000000000 --- a/packages/astro/test/fixtures/config-path/config/my-config.mjs +++ /dev/null @@ -1,6 +0,0 @@ -export default { - server: { - host: true, - port: 8080, - }, -} diff --git a/packages/astro/test/fixtures/config-path/package.json b/packages/astro/test/fixtures/config-path/package.json deleted file mode 100644 index 0adfca36b4bd..000000000000 --- a/packages/astro/test/fixtures/config-path/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/config-path", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/css-order-transparent/astro.config.mjs b/packages/astro/test/fixtures/content-collection-picture-render/astro.config.mjs similarity index 100% rename from packages/astro/test/fixtures/css-order-transparent/astro.config.mjs rename to packages/astro/test/fixtures/content-collection-picture-render/astro.config.mjs diff --git a/packages/astro/test/fixtures/content-collection-picture-render/package.json b/packages/astro/test/fixtures/content-collection-picture-render/package.json new file mode 100644 index 000000000000..391b7cba3413 --- /dev/null +++ b/packages/astro/test/fixtures/content-collection-picture-render/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/content-collection-picture-render", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/content-collection-picture-render/src/assets/test-image.png b/packages/astro/test/fixtures/content-collection-picture-render/src/assets/test-image.png new file mode 100644 index 000000000000..0f2de3749df2 Binary files /dev/null and b/packages/astro/test/fixtures/content-collection-picture-render/src/assets/test-image.png differ diff --git a/packages/astro/test/fixtures/content-collection-picture-render/src/content.config.ts b/packages/astro/test/fixtures/content-collection-picture-render/src/content.config.ts new file mode 100644 index 000000000000..8be8cdaa9485 --- /dev/null +++ b/packages/astro/test/fixtures/content-collection-picture-render/src/content.config.ts @@ -0,0 +1,16 @@ +import { defineCollection } from 'astro:content'; +import { z } from 'astro/zod'; +import { glob } from 'astro/loaders'; + +const blog = defineCollection({ + loader: glob({ pattern: '**/*.md', base: './src/content/blog' }), + schema: ({ image }) => + z.object({ + title: z.string(), + cover: image(), + }), +}); + +export const collections = { + blog, +}; diff --git a/packages/astro/test/fixtures/content-collection-picture-render/src/content/blog/post-1.md b/packages/astro/test/fixtures/content-collection-picture-render/src/content/blog/post-1.md new file mode 100644 index 000000000000..79a8b06e438b --- /dev/null +++ b/packages/astro/test/fixtures/content-collection-picture-render/src/content/blog/post-1.md @@ -0,0 +1,8 @@ +--- +title: Post One +cover: ../../assets/test-image.png +--- + +Hello world! Here is an image: + +![test image](../../assets/test-image.png) diff --git a/packages/astro/test/fixtures/content-collection-picture-render/src/pages/blog/[...slug].astro b/packages/astro/test/fixtures/content-collection-picture-render/src/pages/blog/[...slug].astro new file mode 100644 index 000000000000..3ace133f1141 --- /dev/null +++ b/packages/astro/test/fixtures/content-collection-picture-render/src/pages/blog/[...slug].astro @@ -0,0 +1,26 @@ +--- +import { getCollection, render } from 'astro:content'; + +export async function getStaticPaths() { + const posts = await getCollection('blog'); + return posts.map((post) => ({ + params: { slug: post.id }, + props: { post }, + })); +} + +const { post } = Astro.props; +const { Content } = await render(post); +--- + + + {post.data.title} + + +

    {post.data.title}

    + cover +
    + +
    + + diff --git a/packages/astro/test/fixtures/content-collection-picture-render/src/pages/index.astro b/packages/astro/test/fixtures/content-collection-picture-render/src/pages/index.astro new file mode 100644 index 000000000000..facd1fd4cb55 --- /dev/null +++ b/packages/astro/test/fixtures/content-collection-picture-render/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +import { Picture } from 'astro:assets'; +import testImage from '../assets/test-image.png'; +--- + + + Picture Page + + + + + diff --git a/packages/astro/test/fixtures/content-collections-cache-invalidation/.gitignore b/packages/astro/test/fixtures/content-collections-cache-invalidation/.gitignore deleted file mode 100644 index 3fec32c84275..000000000000 --- a/packages/astro/test/fixtures/content-collections-cache-invalidation/.gitignore +++ /dev/null @@ -1 +0,0 @@ -tmp/ diff --git a/packages/astro/test/fixtures/content-collections-cache-invalidation/astro.config.mjs b/packages/astro/test/fixtures/content-collections-cache-invalidation/astro.config.mjs deleted file mode 100644 index a74151f32bd2..000000000000 --- a/packages/astro/test/fixtures/content-collections-cache-invalidation/astro.config.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from 'astro/config'; - -// https://astro.build/config -export default defineConfig({ - base: '/docs', - compressHTML: false, - vite: { - build: { - assetsInlineLimit: 0, - } - } -}); diff --git a/packages/astro/test/fixtures/content-collections-cache-invalidation/cache/lockfile-mismatch/content/manifest.json b/packages/astro/test/fixtures/content-collections-cache-invalidation/cache/lockfile-mismatch/content/manifest.json deleted file mode 100644 index 6b5f19749834..000000000000 --- a/packages/astro/test/fixtures/content-collections-cache-invalidation/cache/lockfile-mismatch/content/manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"entries":[[{"collection":"blog","type":"content","entry":"/src/content/blog/one.md"},"No8AlxYwy8HK3dH9W3Mj/6SeHMI="]],"serverEntries":[],"clientEntries":[],"lockfiles":"2jmj7l5rSw0yVb/vlWAYkK/YBwk=","configs":"h80ch7FwzpG2BXKQM39ZqFpU3dg="} \ No newline at end of file diff --git a/packages/astro/test/fixtures/content-collections-cache-invalidation/cache/version-mismatch/content/manifest.json b/packages/astro/test/fixtures/content-collections-cache-invalidation/cache/version-mismatch/content/manifest.json deleted file mode 100644 index 20a905210dfa..000000000000 --- a/packages/astro/test/fixtures/content-collections-cache-invalidation/cache/version-mismatch/content/manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"version":1111111,"entries":[[{"collection":"blog","type":"content","entry":"/src/content/blog/one.md"},"No8AlxYwy8HK3dH9W3Mj/6SeHMI="]],"serverEntries":[],"clientEntries":[],"lockfiles":"2jmj7l5rSw0yVb/vlWAYkK/YBwk=","configs":"h80ch7FwzpG2BXKQM39ZqFpU3dg="} diff --git a/packages/astro/test/fixtures/content-collections-cache-invalidation/package.json b/packages/astro/test/fixtures/content-collections-cache-invalidation/package.json deleted file mode 100644 index 865550ef33b8..000000000000 --- a/packages/astro/test/fixtures/content-collections-cache-invalidation/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/content-collections-cache-invalidation", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/content-collections-cache-invalidation/src/content.config.ts b/packages/astro/test/fixtures/content-collections-cache-invalidation/src/content.config.ts deleted file mode 100644 index 97afe1fb86c3..000000000000 --- a/packages/astro/test/fixtures/content-collections-cache-invalidation/src/content.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineCollection } from 'astro:content'; -import { z } from 'astro/zod'; -import { glob } from 'astro/loaders'; - -const blog = defineCollection({ - loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), - schema: z.object({ - title: z.string() - }) -}); - -export const collections = { blog }; diff --git a/packages/astro/test/fixtures/content-collections-cache-invalidation/src/content/blog/one.md b/packages/astro/test/fixtures/content-collections-cache-invalidation/src/content/blog/one.md deleted file mode 100644 index fec6f5277ecf..000000000000 --- a/packages/astro/test/fixtures/content-collections-cache-invalidation/src/content/blog/one.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: One ---- - -Hello world diff --git a/packages/astro/test/fixtures/content-collections-cache-invalidation/src/pages/index.astro b/packages/astro/test/fixtures/content-collections-cache-invalidation/src/pages/index.astro deleted file mode 100644 index e06d49b853b1..000000000000 --- a/packages/astro/test/fixtures/content-collections-cache-invalidation/src/pages/index.astro +++ /dev/null @@ -1,10 +0,0 @@ ---- ---- - - - Testing - - -

    Testing

    - - diff --git a/packages/astro/test/fixtures/content-collections-same-contents/astro.config.mjs b/packages/astro/test/fixtures/content-collections-same-contents/astro.config.mjs deleted file mode 100644 index 417b7c5e9cce..000000000000 --- a/packages/astro/test/fixtures/content-collections-same-contents/astro.config.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import {defineConfig} from 'astro/config'; - -// https://astro.build/config -export default defineConfig({ - base: '/docs', - vite: { - build: { - assetsInlineLimit: 0 - } - } -}); diff --git a/packages/astro/test/fixtures/content-collections-same-contents/package.json b/packages/astro/test/fixtures/content-collections-same-contents/package.json deleted file mode 100644 index ef61d36fa83c..000000000000 --- a/packages/astro/test/fixtures/content-collections-same-contents/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/content-collections-same-contents", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/content-collections-same-contents/src/content.config.ts b/packages/astro/test/fixtures/content-collections-same-contents/src/content.config.ts deleted file mode 100644 index fa8c74e5dff0..000000000000 --- a/packages/astro/test/fixtures/content-collections-same-contents/src/content.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineCollection } from 'astro:content'; -import { z } from 'astro/zod'; -import { glob } from 'astro/loaders'; - - -const docs = defineCollection({ - loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/docs' }), - schema: z.object({ - title: z.string(), - }), -}); - -export const collections = { - docs, -} diff --git a/packages/astro/test/fixtures/content-collections-same-contents/src/content/docs/one.md b/packages/astro/test/fixtures/content-collections-same-contents/src/content/docs/one.md deleted file mode 100644 index 58c118ceba76..000000000000 --- a/packages/astro/test/fixtures/content-collections-same-contents/src/content/docs/one.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: One ---- - -# Title - -stuff - diff --git a/packages/astro/test/fixtures/content-collections-same-contents/src/content/docs/two.md b/packages/astro/test/fixtures/content-collections-same-contents/src/content/docs/two.md deleted file mode 100644 index 64662cb49a61..000000000000 --- a/packages/astro/test/fixtures/content-collections-same-contents/src/content/docs/two.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: One ---- - -# Title - -stuff diff --git a/packages/astro/test/fixtures/content-collections-same-contents/src/pages/docs.astro b/packages/astro/test/fixtures/content-collections-same-contents/src/pages/docs.astro deleted file mode 100644 index a57364f8633c..000000000000 --- a/packages/astro/test/fixtures/content-collections-same-contents/src/pages/docs.astro +++ /dev/null @@ -1,17 +0,0 @@ ---- -import { getEntry, render } from 'astro:content'; -const entry = await getEntry('docs', 'one'); -const { Content } = await render(entry); ---- - - - - - It's content time! - - -
    - -
    - - diff --git a/packages/astro/test/fixtures/content-frontmatter/package.json b/packages/astro/test/fixtures/content-frontmatter/package.json new file mode 100644 index 000000000000..5c28762dea4c --- /dev/null +++ b/packages/astro/test/fixtures/content-frontmatter/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/content-frontmatter", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/content-frontmatter/src/content.config.ts b/packages/astro/test/fixtures/content-frontmatter/src/content.config.ts new file mode 100644 index 000000000000..1adf93154ace --- /dev/null +++ b/packages/astro/test/fixtures/content-frontmatter/src/content.config.ts @@ -0,0 +1,14 @@ +import { defineCollection } from 'astro:content'; +import { z } from 'astro/zod'; +import { glob } from 'astro/loaders'; + +const posts = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/posts' }), + schema: z.object({ + title: z.string(), + }), +}); + +export const collections = { + posts +}; diff --git a/packages/astro/test/fixtures/content-frontmatter/src/content/posts/blog.md b/packages/astro/test/fixtures/content-frontmatter/src/content/posts/blog.md new file mode 100644 index 000000000000..30df4cc62af5 --- /dev/null +++ b/packages/astro/test/fixtures/content-frontmatter/src/content/posts/blog.md @@ -0,0 +1,3 @@ +--- +title: One +--- diff --git a/packages/astro/test/fixtures/content-frontmatter/src/pages/index.astro b/packages/astro/test/fixtures/content-frontmatter/src/pages/index.astro new file mode 100644 index 000000000000..ddd890d35621 --- /dev/null +++ b/packages/astro/test/fixtures/content-frontmatter/src/pages/index.astro @@ -0,0 +1,8 @@ +--- +--- + + Test + +

    Test

    + + diff --git a/packages/astro/test/fixtures/content-static-paths-integration/package.json b/packages/astro/test/fixtures/content-static-paths-integration/package.json index 92f141a6f87d..6e15b0eeb2e2 100644 --- a/packages/astro/test/fixtures/content-static-paths-integration/package.json +++ b/packages/astro/test/fixtures/content-static-paths-integration/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { - "astro": "workspace:*", - "@astrojs/mdx": "workspace:*" + "@astrojs/mdx": "workspace:*", + "astro": "workspace:*" } } diff --git a/packages/astro/test/fixtures/content-static-paths-integration/src/content.config.ts b/packages/astro/test/fixtures/content-static-paths-integration/src/content.config.ts index a880cc1053fd..6df4d99e5c60 100644 --- a/packages/astro/test/fixtures/content-static-paths-integration/src/content.config.ts +++ b/packages/astro/test/fixtures/content-static-paths-integration/src/content.config.ts @@ -13,4 +13,4 @@ const blog = defineCollection({ }), }); -export const collections = { blog }; +export const collections = { blog }; \ No newline at end of file diff --git a/packages/astro/test/fixtures/core-image-base/package.json b/packages/astro/test/fixtures/core-image-base/package.json deleted file mode 100644 index a5c4cd6f34bd..000000000000 --- a/packages/astro/test/fixtures/core-image-base/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@test/core-image-base", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - }, - "exports": { - "./service": "./src/service.ts" - } -} diff --git a/packages/astro/test/fixtures/core-image-base/src/assets/penguin1.jpg b/packages/astro/test/fixtures/core-image-base/src/assets/penguin1.jpg deleted file mode 100644 index 1a8986ac5092..000000000000 Binary files a/packages/astro/test/fixtures/core-image-base/src/assets/penguin1.jpg and /dev/null differ diff --git a/packages/astro/test/fixtures/core-image-base/src/assets/penguin2.jpg b/packages/astro/test/fixtures/core-image-base/src/assets/penguin2.jpg deleted file mode 100644 index e859ac3c992f..000000000000 Binary files a/packages/astro/test/fixtures/core-image-base/src/assets/penguin2.jpg and /dev/null differ diff --git a/packages/astro/test/fixtures/core-image-base/src/content.config.ts b/packages/astro/test/fixtures/core-image-base/src/content.config.ts deleted file mode 100644 index 466ced827049..000000000000 --- a/packages/astro/test/fixtures/core-image-base/src/content.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineCollection } from 'astro:content'; -import { z } from 'astro/zod'; -import { glob } from "astro/loaders"; - -const blogCollection = defineCollection({ - loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), - schema: ({image}) => z.object({ - title: z.string(), - image: image(), - cover: z.object({ - image: image() - }) - }), -}); - -export const collections = { - blog: blogCollection -}; diff --git a/packages/astro/test/fixtures/core-image-base/src/content/blog/one.md b/packages/astro/test/fixtures/core-image-base/src/content/blog/one.md deleted file mode 100644 index d449290ef6d6..000000000000 --- a/packages/astro/test/fixtures/core-image-base/src/content/blog/one.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: One -image: ../../assets/penguin2.jpg -cover: - image: ../../assets/penguin1.jpg ---- - -# A post - -text here diff --git a/packages/astro/test/fixtures/core-image-base/src/pages/alias.astro b/packages/astro/test/fixtures/core-image-base/src/pages/alias.astro deleted file mode 100644 index 10990dbe3e9b..000000000000 --- a/packages/astro/test/fixtures/core-image-base/src/pages/alias.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import image from "~/assets/penguin1.jpg"; ---- - -A penguin! diff --git a/packages/astro/test/fixtures/core-image-base/src/pages/aliasMarkdown.md b/packages/astro/test/fixtures/core-image-base/src/pages/aliasMarkdown.md deleted file mode 100644 index 048a79bdd5cf..000000000000 --- a/packages/astro/test/fixtures/core-image-base/src/pages/aliasMarkdown.md +++ /dev/null @@ -1,3 +0,0 @@ -![A penguin](~/assets/penguin1.jpg) - -A penguin diff --git a/packages/astro/test/fixtures/core-image-base/src/pages/blog/[...slug].astro b/packages/astro/test/fixtures/core-image-base/src/pages/blog/[...slug].astro deleted file mode 100644 index 45dfb8ab9946..000000000000 --- a/packages/astro/test/fixtures/core-image-base/src/pages/blog/[...slug].astro +++ /dev/null @@ -1,32 +0,0 @@ ---- -import { getImage } from 'astro:assets'; -import { getCollection, render } from 'astro:content'; - -export async function getStaticPaths() { - const blogEntries = await getCollection('blog'); - return blogEntries.map(entry => ({ - params: { slug: entry.id }, props: { entry }, - })); -} - -const { entry } = Astro.props; -const { Content } = await render(entry); ---- - - - Testing - - -

    Testing

    - -
    - -
    - -
    - -
    - - - - diff --git a/packages/astro/test/fixtures/core-image-base/src/pages/format.astro b/packages/astro/test/fixtures/core-image-base/src/pages/format.astro deleted file mode 100644 index d2b165d892b5..000000000000 --- a/packages/astro/test/fixtures/core-image-base/src/pages/format.astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -import { Image } from 'astro:assets'; -import myImage from "../assets/penguin1.jpg"; ---- - - - - - -
    - a penguin -
    - -
    - a penguin -
    - - diff --git a/packages/astro/test/fixtures/core-image-base/src/pages/get-image.astro b/packages/astro/test/fixtures/core-image-base/src/pages/get-image.astro deleted file mode 100644 index d83b1fd8ee78..000000000000 --- a/packages/astro/test/fixtures/core-image-base/src/pages/get-image.astro +++ /dev/null @@ -1,8 +0,0 @@ ---- -import { getImage } from "astro:assets"; -import image from "../assets/penguin2.jpg"; - -const myImage = await getImage({ src: image, width: 207, height: 243, alt: 'a penguin' }); ---- - - diff --git a/packages/astro/test/fixtures/core-image-base/src/pages/index.astro b/packages/astro/test/fixtures/core-image-base/src/pages/index.astro deleted file mode 100644 index d7f9b05519a5..000000000000 --- a/packages/astro/test/fixtures/core-image-base/src/pages/index.astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -import { Image } from 'astro:assets'; -import myImage from "../assets/penguin1.jpg"; ---- - - - - - -
    - a penguin -
    - -
    - fred -
    - - diff --git a/packages/astro/test/fixtures/core-image-base/src/pages/post.md b/packages/astro/test/fixtures/core-image-base/src/pages/post.md deleted file mode 100644 index 98da01ce41b2..000000000000 --- a/packages/astro/test/fixtures/core-image-base/src/pages/post.md +++ /dev/null @@ -1,3 +0,0 @@ -![My article cover](../assets/penguin1.jpg) - -Image worked diff --git a/packages/astro/test/fixtures/core-image-base/src/pages/quality.astro b/packages/astro/test/fixtures/core-image-base/src/pages/quality.astro deleted file mode 100644 index 706bb6766934..000000000000 --- a/packages/astro/test/fixtures/core-image-base/src/pages/quality.astro +++ /dev/null @@ -1,22 +0,0 @@ ---- -import { Image } from 'astro:assets'; -import myImage from "../assets/penguin1.jpg"; ---- - - - - - -
    - a penguin -
    - -
    - a penguin -
    - -
    - a penguin -
    - - diff --git a/packages/astro/test/fixtures/core-image-base/tsconfig.json b/packages/astro/test/fixtures/core-image-base/tsconfig.json deleted file mode 100644 index 1854f60e49c2..000000000000 --- a/packages/astro/test/fixtures/core-image-base/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "astro/tsconfigs/strict", - "compilerOptions": { - "paths": { - "~/assets/*": [ - "src/assets/*" - ] - }, - }, - "include": [".astro/types.d.ts", "**/*"], - "exclude": ["dist"] -} diff --git a/packages/astro/test/fixtures/core-image-fs-config-outside-imported/penguin-imported.jpg b/packages/astro/test/fixtures/core-image-fs-config-outside-imported/penguin-imported.jpg deleted file mode 100644 index e859ac3c992f..000000000000 Binary files a/packages/astro/test/fixtures/core-image-fs-config-outside-imported/penguin-imported.jpg and /dev/null differ diff --git a/packages/astro/test/fixtures/core-image-base/src/pages/direct.astro b/packages/astro/test/fixtures/core-image-ssg/src/pages/direct.astro similarity index 100% rename from packages/astro/test/fixtures/core-image-base/src/pages/direct.astro rename to packages/astro/test/fixtures/core-image-ssg/src/pages/direct.astro diff --git a/packages/astro/test/fixtures/core-image-svg-optimized/astro.config.mjs b/packages/astro/test/fixtures/core-image-svg-optimized/astro.config.mjs deleted file mode 100644 index c26e4cbdc1e3..000000000000 --- a/packages/astro/test/fixtures/core-image-svg-optimized/astro.config.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'astro/config'; - -export default defineConfig({ - experimental: { - svgo: { - plugins: [ - 'preset-default', - { - name: 'removeViewBox', - active: false, - }, - ], - }, - }, -}); diff --git a/packages/astro/test/fixtures/core-image-svg-optimized/package.json b/packages/astro/test/fixtures/core-image-svg-optimized/package.json deleted file mode 100644 index 4557b402af29..000000000000 --- a/packages/astro/test/fixtures/core-image-svg-optimized/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@test/core-image-svg-optimized", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - }, - "scripts": { - "dev": "astro dev" - } -} diff --git a/packages/astro/test/fixtures/core-image-svg-optimized/tsconfig.json b/packages/astro/test/fixtures/core-image-svg-optimized/tsconfig.json deleted file mode 100644 index 51747eeb3102..000000000000 --- a/packages/astro/test/fixtures/core-image-svg-optimized/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "astro/tsconfigs/strict", - "compilerOptions": { - "paths": { - "~/assets/*": [ - "src/assets/*" - ] - }, - } -} diff --git a/packages/astro/test/fixtures/core-image-svg-optimized/src/assets/unoptimized.svg b/packages/astro/test/fixtures/core-image-svg/src/assets/unoptimized.svg similarity index 100% rename from packages/astro/test/fixtures/core-image-svg-optimized/src/assets/unoptimized.svg rename to packages/astro/test/fixtures/core-image-svg/src/assets/unoptimized.svg diff --git a/packages/astro/test/fixtures/core-image-svg-optimized/src/pages/optimized.astro b/packages/astro/test/fixtures/core-image-svg/src/pages/optimized.astro similarity index 100% rename from packages/astro/test/fixtures/core-image-svg-optimized/src/pages/optimized.astro rename to packages/astro/test/fixtures/core-image-svg/src/pages/optimized.astro diff --git a/packages/astro/test/fixtures/core-image/src/pages/outsideProject.astro b/packages/astro/test/fixtures/core-image/src/pages/outsideProject.astro index 268b44741302..2ca5803bcfa6 100644 --- a/packages/astro/test/fixtures/core-image/src/pages/outsideProject.astro +++ b/packages/astro/test/fixtures/core-image/src/pages/outsideProject.astro @@ -1,6 +1,6 @@ --- import { Image } from "astro:assets"; -import imageOutsideProject from "../../../core-image-base/src/assets/penguin1.jpg"; +import imageOutsideProject from "../../../core-image-ssg/src/assets/penguin1.jpg"; --- outside project diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-2/astro.config.mjs b/packages/astro/test/fixtures/css-inline-stylesheets-2/astro.config.mjs deleted file mode 100644 index afdd192283fa..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-2/astro.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -import { defineConfig } from 'astro/config'; - -export default defineConfig({ - -}); diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-2/package.json b/packages/astro/test/fixtures/css-inline-stylesheets-2/package.json deleted file mode 100644 index 05196f0565b4..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-2/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/css-inline-stylesheets-2", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/components/Button.astro b/packages/astro/test/fixtures/css-inline-stylesheets-2/src/components/Button.astro deleted file mode 100644 index 3f25cbd3e3ae..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/components/Button.astro +++ /dev/null @@ -1,86 +0,0 @@ ---- -const { class: className = '', style, href } = Astro.props; -const { variant = 'primary' } = Astro.props; ---- - - - - - - - - \ No newline at end of file diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/content.config.ts b/packages/astro/test/fixtures/css-inline-stylesheets-2/src/content.config.ts deleted file mode 100644 index 36d8e1e78b1b..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/content.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineCollection } from "astro:content"; -import { glob } from "astro/loaders"; - -const en = defineCollection({ - loader: glob({ - base: './src/content/en/', - pattern: '*.md', - }) -}); - -export const collections = { - en -}; diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/content/en/endeavour.md b/packages/astro/test/fixtures/css-inline-stylesheets-2/src/content/en/endeavour.md deleted file mode 100644 index 428698f3a820..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/content/en/endeavour.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Endeavour -description: 'Learn about the Endeavour NASA space shuttle.' -publishedDate: 'Sun Jul 11 2021 00:00:00 GMT-0400 (Eastern Daylight Time)' -tags: [space, 90s] ---- - -**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Endeavour) - -Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly. - -The United States Congress approved the construction of Endeavour in 1987 to replace the Space Shuttle Challenger, which was destroyed in 1986. - -NASA chose, on cost grounds, to build much of Endeavour from spare parts rather than refitting the Space Shuttle Enterprise, and used structural spares built during the construction of Discovery and Atlantis in its assembly. \ No newline at end of file diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/imported.css b/packages/astro/test/fixtures/css-inline-stylesheets-2/src/imported.css deleted file mode 100644 index 3959523ff16e..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/imported.css +++ /dev/null @@ -1,15 +0,0 @@ -.bg-skyblue { - background: skyblue; -} - -.bg-lightcoral { - background: lightcoral; -} - -.red { - color: darkred; -} - -.blue { - color: royalblue; -} diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/layouts/Layout.astro b/packages/astro/test/fixtures/css-inline-stylesheets-2/src/layouts/Layout.astro deleted file mode 100644 index 0a26655189f5..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/layouts/Layout.astro +++ /dev/null @@ -1,35 +0,0 @@ ---- -import Button from '../components/Button.astro'; -import '../imported.css'; - -interface Props { - title: string; -} - -const { title } = Astro.props; ---- - - - - - - - - - {title} - - - - - - - diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/pages/endeavour.md b/packages/astro/test/fixtures/css-inline-stylesheets-2/src/pages/endeavour.md deleted file mode 100644 index 670cc693a0eb..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/pages/endeavour.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: Endeavour -description: 'Learn about the Endeavour NASA space shuttle.' -publishedDate: 'Sun Jul 11 2021 00:00:00 GMT-0400 (Eastern Daylight Time)' -tags: [space, 90s] -layout: ../layouts/Layout.astro ---- - -**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Endeavour) - -Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly. - -The United States Congress approved the construction of Endeavour in 1987 to replace the Space Shuttle Challenger, which was destroyed in 1986. - -NASA chose, on cost grounds, to build much of Endeavour from spare parts rather than refitting the Space Shuttle Enterprise, and used structural spares built during the construction of Discovery and Atlantis in its assembly. diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/pages/index.astro b/packages/astro/test/fixtures/css-inline-stylesheets-2/src/pages/index.astro deleted file mode 100644 index 276094579883..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-2/src/pages/index.astro +++ /dev/null @@ -1,20 +0,0 @@ ---- -import { getEntry, render } from 'astro:content'; -import Button from '../components/Button.astro'; -import Layout from '../layouts/Layout.astro'; - -const entry = await getEntry('en', 'endeavour'); -const { Content } = await render(entry); ---- - - -
    -

    Welcome to Astro

    - - -
    -
    diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-3/astro.config.mjs b/packages/astro/test/fixtures/css-inline-stylesheets-3/astro.config.mjs deleted file mode 100644 index afdd192283fa..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-3/astro.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -import { defineConfig } from 'astro/config'; - -export default defineConfig({ - -}); diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-3/package.json b/packages/astro/test/fixtures/css-inline-stylesheets-3/package.json deleted file mode 100644 index 00e58c587604..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-3/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/css-inline-stylesheets-3", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-3/src/components/Button.astro b/packages/astro/test/fixtures/css-inline-stylesheets-3/src/components/Button.astro deleted file mode 100644 index 3f25cbd3e3ae..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-3/src/components/Button.astro +++ /dev/null @@ -1,86 +0,0 @@ ---- -const { class: className = '', style, href } = Astro.props; -const { variant = 'primary' } = Astro.props; ---- - - - - - - - - \ No newline at end of file diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-3/src/content/en/endeavour.md b/packages/astro/test/fixtures/css-inline-stylesheets-3/src/content/en/endeavour.md deleted file mode 100644 index 51d6e8c42178..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-3/src/content/en/endeavour.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Endeavour -description: 'Learn about the Endeavour NASA space shuttle.' -publishedDate: 'Sun Jul 11 2021 00:00:00 GMT-0400 (Eastern Daylight Time)' -tags: [space, 90s] ---- - -**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Endeavour) - -Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly. - -The United States Congress approved the construction of Endeavour in 1987 to replace the Space Shuttle Challenger, which was destroyed in 1986. - -NASA chose, on cost grounds, to build much of Endeavour from spare parts rather than refitting the Space Shuttle Enterprise, and used structural spares built during the construction of Discovery and Atlantis in its assembly. diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-3/src/imported.css b/packages/astro/test/fixtures/css-inline-stylesheets-3/src/imported.css deleted file mode 100644 index 3959523ff16e..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-3/src/imported.css +++ /dev/null @@ -1,15 +0,0 @@ -.bg-skyblue { - background: skyblue; -} - -.bg-lightcoral { - background: lightcoral; -} - -.red { - color: darkred; -} - -.blue { - color: royalblue; -} diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-3/src/layouts/Layout.astro b/packages/astro/test/fixtures/css-inline-stylesheets-3/src/layouts/Layout.astro deleted file mode 100644 index 0a26655189f5..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-3/src/layouts/Layout.astro +++ /dev/null @@ -1,35 +0,0 @@ ---- -import Button from '../components/Button.astro'; -import '../imported.css'; - -interface Props { - title: string; -} - -const { title } = Astro.props; ---- - - - - - - - - - {title} - - - - - - - diff --git a/packages/astro/test/fixtures/css-inline-stylesheets-3/src/pages/index.astro b/packages/astro/test/fixtures/css-inline-stylesheets-3/src/pages/index.astro deleted file mode 100644 index bc96c02453f3..000000000000 --- a/packages/astro/test/fixtures/css-inline-stylesheets-3/src/pages/index.astro +++ /dev/null @@ -1,17 +0,0 @@ ---- -import { getEntry, render } from 'astro:content'; -import Button from '../components/Button.astro'; - -const entry = await getEntry('en', 'endeavour'); -const { Content } = await render(entry); ---- - -
    -

    Welcome to Astro

    - - -
    diff --git a/packages/astro/test/fixtures/css-order-transparent/src/components/Item.astro b/packages/astro/test/fixtures/css-order-layout/src/components/Item.astro similarity index 100% rename from packages/astro/test/fixtures/css-order-transparent/src/components/Item.astro rename to packages/astro/test/fixtures/css-order-layout/src/components/Item.astro diff --git a/packages/astro/test/fixtures/css-order-transparent/src/pages/index.astro b/packages/astro/test/fixtures/css-order-layout/src/pages/transparent.astro similarity index 100% rename from packages/astro/test/fixtures/css-order-transparent/src/pages/index.astro rename to packages/astro/test/fixtures/css-order-layout/src/pages/transparent.astro diff --git a/packages/astro/test/fixtures/css-order-transparent/package.json b/packages/astro/test/fixtures/css-order-transparent/package.json deleted file mode 100644 index 9edc472a0d29..000000000000 --- a/packages/astro/test/fixtures/css-order-transparent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@test/css-order-transparent", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/custom-500-middleware/astro.config.mjs b/packages/astro/test/fixtures/custom-500-middleware/astro.config.mjs deleted file mode 100644 index 882e6515a67e..000000000000 --- a/packages/astro/test/fixtures/custom-500-middleware/astro.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import { defineConfig } from 'astro/config'; - -// https://astro.build/config -export default defineConfig({}); diff --git a/packages/astro/test/fixtures/custom-500-middleware/package.json b/packages/astro/test/fixtures/custom-500-middleware/package.json deleted file mode 100644 index f2da581504ce..000000000000 --- a/packages/astro/test/fixtures/custom-500-middleware/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/custom-500-middleware", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/custom-500-middleware/src/middleware.js b/packages/astro/test/fixtures/custom-500-middleware/src/middleware.js deleted file mode 100644 index 57a00ebc302d..000000000000 --- a/packages/astro/test/fixtures/custom-500-middleware/src/middleware.js +++ /dev/null @@ -1,4 +0,0 @@ -export function onRequest(_context, next) { - throw 'an error' - return next() -} \ No newline at end of file diff --git a/packages/astro/test/fixtures/custom-500-middleware/src/pages/500.astro b/packages/astro/test/fixtures/custom-500-middleware/src/pages/500.astro deleted file mode 100644 index c25ff6ccdd4d..000000000000 --- a/packages/astro/test/fixtures/custom-500-middleware/src/pages/500.astro +++ /dev/null @@ -1,17 +0,0 @@ ---- -interface Props { - error: unknown -} - -const { error } = Astro.props ---- - - - - Server error - Custom 500 - - -

    Server error

    -

    {error}

    - - diff --git a/packages/astro/test/fixtures/custom-500-middleware/src/pages/index.astro b/packages/astro/test/fixtures/custom-500-middleware/src/pages/index.astro deleted file mode 100644 index fd9b2b4c4e0a..000000000000 --- a/packages/astro/test/fixtures/custom-500-middleware/src/pages/index.astro +++ /dev/null @@ -1,11 +0,0 @@ ---- ---- - - - - Custom 500 - - -

    Home

    - - diff --git a/packages/astro/test/fixtures/dev-container/package.json b/packages/astro/test/fixtures/dev-container/package.json new file mode 100644 index 000000000000..d885101169a0 --- /dev/null +++ b/packages/astro/test/fixtures/dev-container/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/dev-container", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/dev-container/public/test.txt b/packages/astro/test/fixtures/dev-container/public/test.txt new file mode 100644 index 000000000000..8318c86b357b --- /dev/null +++ b/packages/astro/test/fixtures/dev-container/public/test.txt @@ -0,0 +1 @@ +Test \ No newline at end of file diff --git a/packages/astro/test/fixtures/dev-container/src/components/404.astro b/packages/astro/test/fixtures/dev-container/src/components/404.astro new file mode 100644 index 000000000000..5b971b2701e3 --- /dev/null +++ b/packages/astro/test/fixtures/dev-container/src/components/404.astro @@ -0,0 +1 @@ +

    Custom 404

    diff --git a/packages/astro/test/fixtures/dev-container/src/components/test.astro b/packages/astro/test/fixtures/dev-container/src/components/test.astro new file mode 100644 index 000000000000..db591822509e --- /dev/null +++ b/packages/astro/test/fixtures/dev-container/src/components/test.astro @@ -0,0 +1 @@ +

    {Astro.params.slug}

    diff --git a/packages/astro/test/fixtures/dev-container/src/pages/index.astro b/packages/astro/test/fixtures/dev-container/src/pages/index.astro new file mode 100644 index 000000000000..39939601c599 --- /dev/null +++ b/packages/astro/test/fixtures/dev-container/src/pages/index.astro @@ -0,0 +1,9 @@ +--- +const name = 'Testing'; +--- + + {name} + +

    {name}

    + + diff --git a/packages/astro/test/fixtures/dev-container/src/pages/page.astro b/packages/astro/test/fixtures/dev-container/src/pages/page.astro new file mode 100644 index 000000000000..a2417e3ed97b --- /dev/null +++ b/packages/astro/test/fixtures/dev-container/src/pages/page.astro @@ -0,0 +1 @@ +

    Regular page

    diff --git a/packages/astro/test/fixtures/dev-container/src/pages/test-[slug].astro b/packages/astro/test/fixtures/dev-container/src/pages/test-[slug].astro new file mode 100644 index 000000000000..db591822509e --- /dev/null +++ b/packages/astro/test/fixtures/dev-container/src/pages/test-[slug].astro @@ -0,0 +1 @@ +

    {Astro.params.slug}

    diff --git a/packages/astro/test/fixtures/dev-error-pages/package.json b/packages/astro/test/fixtures/dev-error-pages/package.json new file mode 100644 index 000000000000..273f2ede8caf --- /dev/null +++ b/packages/astro/test/fixtures/dev-error-pages/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/dev-error-pages", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/dev-error-pages/src/pages/404.astro b/packages/astro/test/fixtures/dev-error-pages/src/pages/404.astro new file mode 100644 index 000000000000..5b971b2701e3 --- /dev/null +++ b/packages/astro/test/fixtures/dev-error-pages/src/pages/404.astro @@ -0,0 +1 @@ +

    Custom 404

    diff --git a/packages/astro/test/fixtures/dev-error-pages/src/pages/500.astro b/packages/astro/test/fixtures/dev-error-pages/src/pages/500.astro new file mode 100644 index 000000000000..17b8f9c06000 --- /dev/null +++ b/packages/astro/test/fixtures/dev-error-pages/src/pages/500.astro @@ -0,0 +1 @@ +

    Server Error

    diff --git a/packages/astro/test/fixtures/dev-error-pages/src/pages/index.astro b/packages/astro/test/fixtures/dev-error-pages/src/pages/index.astro new file mode 100644 index 000000000000..f95bef307333 --- /dev/null +++ b/packages/astro/test/fixtures/dev-error-pages/src/pages/index.astro @@ -0,0 +1 @@ +

    Home

    diff --git a/packages/astro/test/fixtures/dev-error-pages/src/pages/throwing.astro b/packages/astro/test/fixtures/dev-error-pages/src/pages/throwing.astro new file mode 100644 index 000000000000..b4f6926b94d1 --- /dev/null +++ b/packages/astro/test/fixtures/dev-error-pages/src/pages/throwing.astro @@ -0,0 +1,3 @@ +--- +throw new Error('boom'); +--- diff --git a/packages/astro/test/fixtures/dev-render/package.json b/packages/astro/test/fixtures/dev-render/package.json new file mode 100644 index 000000000000..9678b855db49 --- /dev/null +++ b/packages/astro/test/fixtures/dev-render/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/dev-render", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/dev-render/src/components/BothFlipped.astro b/packages/astro/test/fixtures/dev-render/src/components/BothFlipped.astro new file mode 100644 index 000000000000..9ed3a8c747fd --- /dev/null +++ b/packages/astro/test/fixtures/dev-render/src/components/BothFlipped.astro @@ -0,0 +1 @@ +
    diff --git a/packages/astro/test/fixtures/dev-render/src/components/BothLiteral.astro b/packages/astro/test/fixtures/dev-render/src/components/BothLiteral.astro
    new file mode 100644
    index 000000000000..b97399835822
    --- /dev/null
    +++ b/packages/astro/test/fixtures/dev-render/src/components/BothLiteral.astro
    @@ -0,0 +1 @@
    +
    diff --git a/packages/astro/test/fixtures/dev-render/src/components/BothSpread.astro b/packages/astro/test/fixtures/dev-render/src/components/BothSpread.astro
    new file mode 100644
    index 000000000000..576752aa32be
    --- /dev/null
    +++ b/packages/astro/test/fixtures/dev-render/src/components/BothSpread.astro
    @@ -0,0 +1 @@
    +
    diff --git a/packages/astro/test/fixtures/dev-render/src/components/Class.astro b/packages/astro/test/fixtures/dev-render/src/components/Class.astro
    new file mode 100644
    index 000000000000..b15ee292b7c5
    --- /dev/null
    +++ b/packages/astro/test/fixtures/dev-render/src/components/Class.astro
    @@ -0,0 +1 @@
    +
    diff --git a/packages/astro/test/fixtures/dev-render/src/components/ClassList.astro b/packages/astro/test/fixtures/dev-render/src/components/ClassList.astro
    new file mode 100644
    index 000000000000..3dfe498c62bd
    --- /dev/null
    +++ b/packages/astro/test/fixtures/dev-render/src/components/ClassList.astro
    @@ -0,0 +1 @@
    +
    diff --git a/packages/astro/test/fixtures/dev-render/src/components/NullComponent.astro b/packages/astro/test/fixtures/dev-render/src/components/NullComponent.astro
    new file mode 100644
    index 000000000000..cd7aef969c2f
    --- /dev/null
    +++ b/packages/astro/test/fixtures/dev-render/src/components/NullComponent.astro
    @@ -0,0 +1,3 @@
    +---
    +return null;
    +---
    diff --git a/packages/astro/test/fixtures/dev-render/src/pages/chunk.astro b/packages/astro/test/fixtures/dev-render/src/pages/chunk.astro
    new file mode 100644
    index 000000000000..95dc7218f601
    --- /dev/null
    +++ b/packages/astro/test/fixtures/dev-render/src/pages/chunk.astro
    @@ -0,0 +1,4 @@
    +---
    +const value = { type: 'foobar' }
    +---
    +
    {value}
    diff --git a/packages/astro/test/fixtures/dev-render/src/pages/class-merge.astro b/packages/astro/test/fixtures/dev-render/src/pages/class-merge.astro new file mode 100644 index 000000000000..4b0c8163bbc7 --- /dev/null +++ b/packages/astro/test/fixtures/dev-render/src/pages/class-merge.astro @@ -0,0 +1,12 @@ +--- +import Class from '../components/Class.astro'; +import ClassList from '../components/ClassList.astro'; +import BothLiteral from '../components/BothLiteral.astro'; +import BothFlipped from '../components/BothFlipped.astro'; +import BothSpread from '../components/BothSpread.astro'; +--- + + + + + diff --git a/packages/astro/test/fixtures/dev-render/src/pages/custom-elements.astro b/packages/astro/test/fixtures/dev-render/src/pages/custom-elements.astro new file mode 100644 index 000000000000..45349c210a99 --- /dev/null +++ b/packages/astro/test/fixtures/dev-render/src/pages/custom-elements.astro @@ -0,0 +1,11 @@ +--- +const selectedColor = "blue"; +const autoplay = 2000; +--- + + Custom Element Attributes Test + + + Test with autoplay prop working + + diff --git a/packages/astro/test/fixtures/dev-render/src/pages/index.astro b/packages/astro/test/fixtures/dev-render/src/pages/index.astro new file mode 100644 index 000000000000..ee59d2543bbc --- /dev/null +++ b/packages/astro/test/fixtures/dev-render/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +const name = 'Testing'; +const TagA = 'p style=color:red;' +const TagB = 'p>' +--- + + {name} + + + + + diff --git a/packages/astro/test/fixtures/dev-render/src/pages/null-component.astro b/packages/astro/test/fixtures/dev-render/src/pages/null-component.astro new file mode 100644 index 000000000000..649a9923306d --- /dev/null +++ b/packages/astro/test/fixtures/dev-render/src/pages/null-component.astro @@ -0,0 +1,4 @@ +--- +import NullComponent from '../components/NullComponent.astro'; +--- + diff --git a/packages/astro/test/fixtures/dev-render/src/pages/sub/index.astro b/packages/astro/test/fixtures/dev-render/src/pages/sub/index.astro new file mode 100644 index 000000000000..df6df48bcfe7 --- /dev/null +++ b/packages/astro/test/fixtures/dev-render/src/pages/sub/index.astro @@ -0,0 +1 @@ +

    testing

    diff --git a/packages/astro/test/fixtures/dev-request-url/package.json b/packages/astro/test/fixtures/dev-request-url/package.json new file mode 100644 index 000000000000..338477e7c336 --- /dev/null +++ b/packages/astro/test/fixtures/dev-request-url/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/dev-request-url", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/dev-request-url/src/pages/prerendered.astro b/packages/astro/test/fixtures/dev-request-url/src/pages/prerendered.astro new file mode 100644 index 000000000000..83162bb315ab --- /dev/null +++ b/packages/astro/test/fixtures/dev-request-url/src/pages/prerendered.astro @@ -0,0 +1,4 @@ +--- +export const prerender = true; +--- +{Astro.request.url} diff --git a/packages/astro/test/fixtures/dev-request-url/src/pages/url.astro b/packages/astro/test/fixtures/dev-request-url/src/pages/url.astro new file mode 100644 index 000000000000..42cf81cc5466 --- /dev/null +++ b/packages/astro/test/fixtures/dev-request-url/src/pages/url.astro @@ -0,0 +1 @@ +{Astro.request.url} diff --git a/packages/astro/test/fixtures/endpoint-routing/package.json b/packages/astro/test/fixtures/endpoint-routing/package.json new file mode 100644 index 000000000000..c57fea8248b0 --- /dev/null +++ b/packages/astro/test/fixtures/endpoint-routing/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/endpoint-routing", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/endpoint-routing/src/pages/headers.ts b/packages/astro/test/fixtures/endpoint-routing/src/pages/headers.ts new file mode 100644 index 000000000000..fd00968d710b --- /dev/null +++ b/packages/astro/test/fixtures/endpoint-routing/src/pages/headers.ts @@ -0,0 +1 @@ +export const GET = () => { return new Response('content', { status: 201, headers: { Test: 'value' } }) } diff --git a/packages/astro/test/fixtures/endpoint-routing/src/pages/incorrect.ts b/packages/astro/test/fixtures/endpoint-routing/src/pages/incorrect.ts new file mode 100644 index 000000000000..76426e3e778d --- /dev/null +++ b/packages/astro/test/fixtures/endpoint-routing/src/pages/incorrect.ts @@ -0,0 +1 @@ +export const GET = _ => {} diff --git a/packages/astro/test/fixtures/endpoint-routing/src/pages/internal-error.ts b/packages/astro/test/fixtures/endpoint-routing/src/pages/internal-error.ts new file mode 100644 index 000000000000..79004e2e5714 --- /dev/null +++ b/packages/astro/test/fixtures/endpoint-routing/src/pages/internal-error.ts @@ -0,0 +1 @@ +export const GET = ({ url }) => new Response('something went wrong', { headers: { "Content-Type": "text/plain" }, status: 500 }) diff --git a/packages/astro/test/fixtures/endpoint-routing/src/pages/multi-headers.js b/packages/astro/test/fixtures/endpoint-routing/src/pages/multi-headers.js new file mode 100644 index 000000000000..9d5ca26cd1ec --- /dev/null +++ b/packages/astro/test/fixtures/endpoint-routing/src/pages/multi-headers.js @@ -0,0 +1,10 @@ +export const GET = () => { + const headers = new Headers(); + headers.append('x-single', 'single'); + headers.append('x-triple', 'one'); + headers.append('x-triple', 'two'); + headers.append('x-triple', 'three'); + headers.append('Set-cookie', 'hello'); + headers.append('Set-Cookie', 'world'); + return new Response(null, { headers }); +} diff --git a/packages/astro/test/fixtures/endpoint-routing/src/pages/not-found.ts b/packages/astro/test/fixtures/endpoint-routing/src/pages/not-found.ts new file mode 100644 index 000000000000..a51ed8df0b37 --- /dev/null +++ b/packages/astro/test/fixtures/endpoint-routing/src/pages/not-found.ts @@ -0,0 +1 @@ +export const GET = ({ url }) => new Response('empty', { headers: { "Content-Type": "text/plain" }, status: 404 }) diff --git a/packages/astro/test/fixtures/endpoint-routing/src/pages/response-redirect.ts b/packages/astro/test/fixtures/endpoint-routing/src/pages/response-redirect.ts new file mode 100644 index 000000000000..d62ee9b82850 --- /dev/null +++ b/packages/astro/test/fixtures/endpoint-routing/src/pages/response-redirect.ts @@ -0,0 +1 @@ +export const GET = ({ url }) => Response.redirect("https://example.com/destination", 307) diff --git a/packages/astro/test/fixtures/endpoint-routing/src/pages/response.ts b/packages/astro/test/fixtures/endpoint-routing/src/pages/response.ts new file mode 100644 index 000000000000..d7c8841e2199 --- /dev/null +++ b/packages/astro/test/fixtures/endpoint-routing/src/pages/response.ts @@ -0,0 +1 @@ +export const GET = ({ url }) => new Response(null, { headers: { Location: "https://example.com/destination" }, status: 307 }) diff --git a/packages/astro/test/fixtures/endpoint-routing/src/pages/setCookies.js b/packages/astro/test/fixtures/endpoint-routing/src/pages/setCookies.js new file mode 100644 index 000000000000..b004885ed90a --- /dev/null +++ b/packages/astro/test/fixtures/endpoint-routing/src/pages/setCookies.js @@ -0,0 +1,8 @@ +export const GET = context => { + const headers = new Headers(); + context.cookies.set('key1', 'value1'); + context.cookies.set('key2', 'value2'); + headers.append('set-cookie', 'key3=value3'); + headers.append('set-cookie', 'key4=value4'); + return new Response(null, { headers }); +} diff --git a/packages/astro/test/fixtures/endpoint-routing/src/pages/streaming.js b/packages/astro/test/fixtures/endpoint-routing/src/pages/streaming.js new file mode 100644 index 000000000000..dce57070848e --- /dev/null +++ b/packages/astro/test/fixtures/endpoint-routing/src/pages/streaming.js @@ -0,0 +1,22 @@ +export const GET = ({ locals }) => { + let sentChunks = 0; + + const readableStream = new ReadableStream({ + async pull(controller) { + if (sentChunks === 3) return controller.close(); + else sentChunks++; + + await new Promise(resolve => setTimeout(resolve, 1000)); + controller.enqueue(new TextEncoder().encode('hello')); + }, + cancel() { + locals.cancelledByTheServer = true; + } + }); + + return new Response(readableStream, { + headers: { + "Content-Type": "text/event-stream" + } + }) +} diff --git a/packages/astro/test/fixtures/feature-support-message-suppresion/astro.config.mjs b/packages/astro/test/fixtures/feature-support-message-suppresion/astro.config.mjs deleted file mode 100644 index a28113763aff..000000000000 --- a/packages/astro/test/fixtures/feature-support-message-suppresion/astro.config.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import { defineConfig } from 'astro/config'; -export default defineConfig({ - integrations: [ - { - name: 'astro-test-feature-support-message-suppression', - hooks: { - 'astro:config:done': ({ setAdapter }) => { - setAdapter({ - name: 'astro-test-feature-support-message-suppression', - supportedAstroFeatures: { - staticOutput: "stable", - hybridOutput: "stable", - serverOutput: { - support: "experimental", - message: "This should be logged.", - suppress: "default", - }, - sharpImageService: { - support: 'limited', - message: 'This shouldn\'t be logged.', - suppress: "all", - }, - } - }) - }, - }, - }, - ], -}); diff --git a/packages/astro/test/fixtures/feature-support-message-suppresion/package.json b/packages/astro/test/fixtures/feature-support-message-suppresion/package.json deleted file mode 100644 index f34355172460..000000000000 --- a/packages/astro/test/fixtures/feature-support-message-suppresion/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@test/feature-support-message-suppresion", - "type": "module", - "version": "0.0.1", - "private": true, - "scripts": { - "dev": "astro dev", - "build": "astro build", - "preview": "astro preview", - "astro": "astro" - }, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/feature-support-message-suppresion/src/pages/index.astro b/packages/astro/test/fixtures/feature-support-message-suppresion/src/pages/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/head-injection/package.json b/packages/astro/test/fixtures/head-injection/package.json deleted file mode 100644 index 82455011aecd..000000000000 --- a/packages/astro/test/fixtures/head-injection/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/head-injection", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/head-injection/src/components/Layout.astro b/packages/astro/test/fixtures/head-injection/src/components/Layout.astro deleted file mode 100644 index 225a16a12ced..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/components/Layout.astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -const title = 'My Title'; ---- - - - - - - - - - - - diff --git a/packages/astro/test/fixtures/head-injection/src/components/RegularSlot.astro b/packages/astro/test/fixtures/head-injection/src/components/RegularSlot.astro deleted file mode 100644 index cec06fe2f361..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/components/RegularSlot.astro +++ /dev/null @@ -1,8 +0,0 @@ - -
    - -
    diff --git a/packages/astro/test/fixtures/head-injection/src/components/SlotRenderComponent.astro b/packages/astro/test/fixtures/head-injection/src/components/SlotRenderComponent.astro deleted file mode 100644 index d8756fff54d7..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/components/SlotRenderComponent.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -const html = await Astro.slots.render('slot-name'); ---- -
    - -
    - - diff --git a/packages/astro/test/fixtures/head-injection/src/components/SlotRenderLayout.astro b/packages/astro/test/fixtures/head-injection/src/components/SlotRenderLayout.astro deleted file mode 100644 index efef491a0649..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/components/SlotRenderLayout.astro +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/packages/astro/test/fixtures/head-injection/src/components/SlotsRender.astro b/packages/astro/test/fixtures/head-injection/src/components/SlotsRender.astro deleted file mode 100644 index 6ca7d20637fb..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/components/SlotsRender.astro +++ /dev/null @@ -1,25 +0,0 @@ ---- -interface Props { - title: string; - subtitle: string; - content?: string; -} -const { - title, - subtitle = await Astro.slots.render("subtitle"), - content = await Astro.slots.render("content"), -} = Astro.props; ---- - - -
    -
    - {title &&

    {title}

    } - {subtitle &&

    } - {content &&

    } -
    -
    diff --git a/packages/astro/test/fixtures/head-injection/src/components/UsesSlotRender.astro b/packages/astro/test/fixtures/head-injection/src/components/UsesSlotRender.astro deleted file mode 100644 index 35d127cd5ef2..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/components/UsesSlotRender.astro +++ /dev/null @@ -1,7 +0,0 @@ ---- -import SlotRenderComponent from "./SlotRenderComponent.astro"; ---- - - -

    Paragraph.

    -
    diff --git a/packages/astro/test/fixtures/head-injection/src/components/with-slot-render2/inner.astro b/packages/astro/test/fixtures/head-injection/src/components/with-slot-render2/inner.astro deleted file mode 100644 index 9af3df31d216..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/components/with-slot-render2/inner.astro +++ /dev/null @@ -1,10 +0,0 @@ ---- ---- - -

    View link tag position

    - - diff --git a/packages/astro/test/fixtures/head-injection/src/components/with-slot-render2/slots-render-outer.astro b/packages/astro/test/fixtures/head-injection/src/components/with-slot-render2/slots-render-outer.astro deleted file mode 100644 index 391b360cf6ca..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/components/with-slot-render2/slots-render-outer.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -const content = await Astro.slots.render('default') ---- - - diff --git a/packages/astro/test/fixtures/head-injection/src/pages/index.md b/packages/astro/test/fixtures/head-injection/src/pages/index.md deleted file mode 100644 index f32c4c3d67c8..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/pages/index.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -layout: ../components/Layout.astro ---- - -# Heading - -And content here. diff --git a/packages/astro/test/fixtures/head-injection/src/pages/with-render-slot-in-head-buffer.astro b/packages/astro/test/fixtures/head-injection/src/pages/with-render-slot-in-head-buffer.astro deleted file mode 100644 index 5cd5c6261b5b..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/pages/with-render-slot-in-head-buffer.astro +++ /dev/null @@ -1,7 +0,0 @@ ---- -import Layout from "../components/Layout.astro"; -import UsesSlotRender from "../components/UsesSlotRender.astro" ---- - - - diff --git a/packages/astro/test/fixtures/head-injection/src/pages/with-slot-in-render-slot.astro b/packages/astro/test/fixtures/head-injection/src/pages/with-slot-in-render-slot.astro deleted file mode 100644 index e3c2975e27ed..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/pages/with-slot-in-render-slot.astro +++ /dev/null @@ -1,24 +0,0 @@ ---- -import Layout from '../components/Layout.astro'; -import SlotsRender from '../components/SlotsRender.astro'; ---- - - - - -

    - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. -

    - -

    - Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. -

    -
    -
    -
    diff --git a/packages/astro/test/fixtures/head-injection/src/pages/with-slot-in-slot.astro b/packages/astro/test/fixtures/head-injection/src/pages/with-slot-in-slot.astro deleted file mode 100644 index 1bd33e57783d..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/pages/with-slot-in-slot.astro +++ /dev/null @@ -1,11 +0,0 @@ ---- -import RegularSlot from "../components/RegularSlot.astro" -import Layout from "../components/SlotRenderLayout.astro"; ---- - - - -

    Paragraph.

    -
    -
    -
    diff --git a/packages/astro/test/fixtures/head-injection/src/pages/with-slot-render.astro b/packages/astro/test/fixtures/head-injection/src/pages/with-slot-render.astro deleted file mode 100644 index b9cbfae9617f..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/pages/with-slot-render.astro +++ /dev/null @@ -1,9 +0,0 @@ ---- -import Component from "../components/SlotRenderComponent.astro" -import Layout from "../components/SlotRenderLayout.astro"; ---- - - -

    Paragraph.

    -
    -
    diff --git a/packages/astro/test/fixtures/head-injection/src/pages/with-slot-render2.astro b/packages/astro/test/fixtures/head-injection/src/pages/with-slot-render2.astro deleted file mode 100644 index 316416a0c660..000000000000 --- a/packages/astro/test/fixtures/head-injection/src/pages/with-slot-render2.astro +++ /dev/null @@ -1,19 +0,0 @@ ---- -import Inner from '../components/with-slot-render2/inner.astro' -import SlotsRenderOuter from '../components/with-slot-render2/slots-render-outer.astro' ---- - - - - - - - - Astro - - - - - - - diff --git a/packages/astro/test/fixtures/hmr-css/package.json b/packages/astro/test/fixtures/hmr-css/package.json deleted file mode 100644 index 36bf56c915b3..000000000000 --- a/packages/astro/test/fixtures/hmr-css/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/hmr-css", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/astro.config.mjs b/packages/astro/test/fixtures/i18n-css-leak-basic/astro.config.mjs new file mode 100644 index 000000000000..9a4d452f1628 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-css-leak-basic/astro.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + build: { + inlineStylesheets: 'never', + }, + i18n: { + locales: ['en'], + defaultLocale: 'en', + routing: { + redirectToDefaultLocale: false, + }, + }, +}); diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/package.json b/packages/astro/test/fixtures/i18n-css-leak-basic/package.json new file mode 100644 index 000000000000..1efbbaff7eba --- /dev/null +++ b/packages/astro/test/fixtures/i18n-css-leak-basic/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/i18n-css-leak-basic", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/components/Header.astro b/packages/astro/test/fixtures/i18n-css-leak-basic/src/components/Header.astro new file mode 100644 index 000000000000..202a09e3f04c --- /dev/null +++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/components/Header.astro @@ -0,0 +1,9 @@ +--- +import { getRelativeLocaleUrl } from 'astro:i18n'; + +const docsHref = getRelativeLocaleUrl('en', 'docs'); +--- + +
    + Docs +
    diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/DocsLayout.astro b/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/DocsLayout.astro new file mode 100644 index 000000000000..13a014e3f214 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/DocsLayout.astro @@ -0,0 +1,12 @@ +--- +import '../styles/docs.css'; +--- + + + + Docs + + + + + diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/SiteLayout.astro b/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/SiteLayout.astro new file mode 100644 index 000000000000..c264ec08e0c9 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/SiteLayout.astro @@ -0,0 +1,14 @@ +--- +import Header from '../components/Header.astro'; +import '../styles/site.css'; +--- + + + + Site + + +
    + + + diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/docs/index.astro b/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/docs/index.astro new file mode 100644 index 000000000000..997686a93b13 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/docs/index.astro @@ -0,0 +1,7 @@ +--- +import DocsLayout from '../../layouts/DocsLayout.astro'; +--- + + +

    Docs

    +
    diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/index.astro b/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/index.astro new file mode 100644 index 000000000000..d8703482bd48 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/index.astro @@ -0,0 +1,7 @@ +--- +import SiteLayout from '../layouts/SiteLayout.astro'; +--- + + +

    Home

    +
    diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/docs.css b/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/docs.css new file mode 100644 index 000000000000..d6295bd006d6 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/docs.css @@ -0,0 +1,7 @@ +body { + background: black; +} + +h1 { + color: red; +} diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/site.css b/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/site.css new file mode 100644 index 000000000000..5b6976fbff55 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/site.css @@ -0,0 +1,3 @@ +body { + background: white; +} diff --git a/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs deleted file mode 100644 index ee4909209d5a..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig} from "astro/config"; - -export default defineConfig({ - base: "new-site", - i18n: { - defaultLocale: 'en', - locales: [ - 'en', 'pt', 'it', { - path: "spanish", - codes: ["es", "es-ar"] - } - ], - routing: { - prefixDefaultLocale: true - } - } -}) diff --git a/packages/astro/test/fixtures/i18n-routing-base/package.json b/packages/astro/test/fixtures/i18n-routing-base/package.json deleted file mode 100644 index f17923112778..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-base/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/i18n-routing-base", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/blog/[id].astro deleted file mode 100644 index 97b41230d6e9..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/blog/[id].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [ - {params: {id: '1'}, props: { content: "Hello world" }}, - {params: {id: '2'}, props: { content: "Eat Something" }}, - {params: {id: '3'}, props: { content: "How are you?" }}, - ]; -} -const { content } = Astro.props; ---- - - - Astro - - -{content} - - diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/start.astro deleted file mode 100644 index d9f61aa025c1..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-base/src/pages/en/start.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Hello - - diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/index.astro deleted file mode 100644 index 05faf7b0bcce..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-base/src/pages/index.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - - Hello - - diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/blog/[id].astro deleted file mode 100644 index e37f83a30243..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/blog/[id].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [ - {params: {id: '1'}, props: { content: "Hola mundo" }}, - {params: {id: '2'}, props: { content: "Eat Something" }}, - {params: {id: '3'}, props: { content: "How are you?" }}, - ]; -} -const { content } = Astro.props; ---- - - - Astro - - -{content} - - diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/start.astro deleted file mode 100644 index 15a63a7b87f5..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-base/src/pages/pt/start.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Hola - - diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro deleted file mode 100644 index f560f94f5ade..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [ - {params: {id: '1'}, props: { content: "Lo siento" }}, - {params: {id: '2'}, props: { content: "Eat Something" }}, - {params: {id: '3'}, props: { content: "How are you?" }}, - ]; -} -const { content } = Astro.props; ---- - - - Astro - - -{content} - - diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro deleted file mode 100644 index d67e9de3f085..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -const currentLocale = Astro.currentLocale; ---- - - - Astro - - -Espanol -Current Locale: {currentLocale ? currentLocale : "none"} - - diff --git a/packages/astro/test/fixtures/i18n-routing-dynamic/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-dynamic/astro.config.mjs deleted file mode 100644 index d283edf6fb17..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-dynamic/astro.config.mjs +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "astro/config"; - -// https://astro.build/config -export default defineConfig({ - i18n: { - defaultLocale: "ru", - locales: ["ru", "en"], - routing: { - prefixDefaultLocale: true, - }, - }, -}); - diff --git a/packages/astro/test/fixtures/i18n-routing-dynamic/package.json b/packages/astro/test/fixtures/i18n-routing-dynamic/package.json deleted file mode 100644 index 074b37b7dc16..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-dynamic/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/i18n-routing-dynamic", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/i18n-routing-dynamic/src/pages/[language].astro b/packages/astro/test/fixtures/i18n-routing-dynamic/src/pages/[language].astro deleted file mode 100644 index 0721acd19df0..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-dynamic/src/pages/[language].astro +++ /dev/null @@ -1,11 +0,0 @@ ---- -export async function getStaticPaths() { - return [{ params: { language: "ru" } }, { params: { language: "en" } }]; -} - -const { currentLocale } = Astro; ---- - -
    - {currentLocale} -
    diff --git a/packages/astro/test/fixtures/i18n-routing-dynamic/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-dynamic/src/pages/index.astro deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/astro.config.mjs deleted file mode 100644 index 56bf5373c329..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/astro.config.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig} from "astro/config"; - -import node from "@astrojs/node" - -export default defineConfig({ - base: "/", - output: "static", - i18n: { - locales: ["en", "es"], - defaultLocale: "en", - fallback: { - es: "en", - }, - routing: { - fallbackType: "rewrite", - prefixDefaultLocale: false, - }, - }, - adapter: node({mode: 'standalone'}) -}); diff --git a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/package.json b/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/package.json deleted file mode 100644 index 6b84905c1079..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@test/i18n-routing-fallback-rewrite-hybrid", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*", - "@astrojs/node": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/[slug].astro b/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/[slug].astro deleted file mode 100644 index b72b6f08d31c..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/[slug].astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -export const prerender = true - -export async function getStaticPaths() { - return [ - { params: { slug: 'slug-1' } }, - { params: { slug: 'slug-2' } }, - ]; -} -const { slug } = Astro.params; ---- -{slug} - {Astro.currentLocale} - diff --git a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/about.astro b/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/about.astro deleted file mode 100644 index f3512808e588..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/about.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -export const prerender = false ---- - -about - {Astro.currentLocale} \ No newline at end of file diff --git a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/es/index.astro b/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/es/index.astro deleted file mode 100644 index 32f93188e026..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/es/index.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -export const prerender = true ---- - -ES index \ No newline at end of file diff --git a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/index.astro deleted file mode 100644 index 67fb0413d09b..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid/src/pages/index.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -export const prerender = true ---- - -locale - {Astro.currentLocale} \ No newline at end of file diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/astro.config.mjs deleted file mode 100644 index 8006c260f80c..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/astro.config.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig} from "astro/config"; - -export default defineConfig({ - i18n: { - defaultLocale: 'en', - locales: [ - 'en', 'pt', 'it', { - path: "spanish", - codes: ["es", "es-ar"] - } - ], - routing: "manual", - fallback: { - it: 'en' - } - } -}) diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/package.json b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/package.json deleted file mode 100644 index 8230d254b88d..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/i18n-manual-with-default-middleware", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/middleware.js b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/middleware.js deleted file mode 100644 index 8d24302d017e..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/middleware.js +++ /dev/null @@ -1,24 +0,0 @@ -import { middleware } from 'astro:i18n'; -import { defineMiddleware, sequence } from 'astro:middleware'; - -const customLogic = defineMiddleware(async (context, next) => { - const url = new URL(context.request.url); - if (url.pathname.startsWith('/about')) { - return new Response('ABOUT ME', { - status: 200, - }); - } - - const response = await next(); - - return response; -}); - -export const onRequest = sequence( - customLogic, - middleware({ - prefixDefaultLocale: true, - redirectToDefaultLocale: true, - fallbackType: "rewrite" - }) -); diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/about.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/about.astro deleted file mode 100644 index b5cb264b5f34..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/about.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - - - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/blog.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/blog.astro deleted file mode 100644 index f40d52dad178..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/blog.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Blog should not render - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/en/blog/[id].astro deleted file mode 100644 index 97b41230d6e9..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/en/blog/[id].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [ - {params: {id: '1'}, props: { content: "Hello world" }}, - {params: {id: '2'}, props: { content: "Eat Something" }}, - {params: {id: '3'}, props: { content: "How are you?" }}, - ]; -} -const { content } = Astro.props; ---- - - - Astro - - -{content} - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/en/start.astro deleted file mode 100644 index 9c7c9b12d659..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/en/start.astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -import {getRelativeLocaleUrl} from "astro:i18n"; - -const customUrl = getRelativeLocaleUrl("en", "/blog/title") ---- - - - Astro - - -Hello

    {customUrl}

    - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/index.astro deleted file mode 100644 index 05faf7b0bcce..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/index.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - - Hello - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/pt/blog/[id].astro deleted file mode 100644 index e37f83a30243..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/pt/blog/[id].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [ - {params: {id: '1'}, props: { content: "Hola mundo" }}, - {params: {id: '2'}, props: { content: "Eat Something" }}, - {params: {id: '3'}, props: { content: "How are you?" }}, - ]; -} -const { content } = Astro.props; ---- - - - Astro - - -{content} - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/pt/start.astro deleted file mode 100644 index 9a37428ca626..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/pt/start.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -const currentLocale = Astro.currentLocale; ---- - - - Astro - - -Hola -Current Locale: {currentLocale ? currentLocale : "none"} - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/spanish/index.astro b/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/spanish/index.astro deleted file mode 100644 index a36031be6ec0..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware/src/pages/spanish/index.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -const currentLocale = Astro.currentLocale; - ---- - - - - Astro - - -Hola. -Current Locale: {currentLocale ? currentLocale : "none"} - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-manual/astro.config.mjs deleted file mode 100644 index 0638988f063b..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/astro.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig} from "astro/config"; - -export default defineConfig({ - i18n: { - defaultLocale: 'en', - locales: [ - 'en', 'pt', 'it', { - path: "spanish", - codes: ["es", "es-ar"] - } - ], - routing: "manual" - } -}) diff --git a/packages/astro/test/fixtures/i18n-routing-manual/package.json b/packages/astro/test/fixtures/i18n-routing-manual/package.json deleted file mode 100644 index b79591a69645..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/i18n-routing-manual", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/middleware.js b/packages/astro/test/fixtures/i18n-routing-manual/src/middleware.js deleted file mode 100644 index fc926e8bfc99..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/src/middleware.js +++ /dev/null @@ -1,20 +0,0 @@ -import { redirectToDefaultLocale, requestHasLocale } from 'astro:i18n'; -import { defineMiddleware } from 'astro:middleware'; - -const allowList = new Set(['/help', '/help/']); - -export const onRequest = defineMiddleware(async (context, next) => { - if (allowList.has(context.url.pathname)) { - return await next(); - } - if (requestHasLocale(context)) { - return await next(); - } - - if (context.url.pathname === '/' || context.url.pathname === '/redirect-me') { - return redirectToDefaultLocale(context); - } - return new Response(null, { - status: 404, - }); -}); diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/404.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/404.astro deleted file mode 100644 index b0a1d22960d6..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/404.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -export const prerender = true ---- - - - - Astro - - -404 - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/blog/[id].astro deleted file mode 100644 index 97b41230d6e9..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/blog/[id].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [ - {params: {id: '1'}, props: { content: "Hello world" }}, - {params: {id: '2'}, props: { content: "Eat Something" }}, - {params: {id: '3'}, props: { content: "How are you?" }}, - ]; -} -const { content } = Astro.props; ---- - - - Astro - - -{content} - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/blog/index.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/blog/index.astro deleted file mode 100644 index edb95dc8da71..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/blog/index.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Blog start - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/index.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/index.astro deleted file mode 100644 index d9f61aa025c1..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/index.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Hello - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/start.astro deleted file mode 100644 index d9f61aa025c1..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/en/start.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Hello - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/help.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/help.astro deleted file mode 100644 index f0c02bccf29e..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/help.astro +++ /dev/null @@ -1,11 +0,0 @@ ---- - ---- - - - Astro - - - Outside route - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/index.astro deleted file mode 100644 index d9f61aa025c1..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/index.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Hello - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/pt/blog/[id].astro deleted file mode 100644 index e37f83a30243..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/pt/blog/[id].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [ - {params: {id: '1'}, props: { content: "Hola mundo" }}, - {params: {id: '2'}, props: { content: "Eat Something" }}, - {params: {id: '3'}, props: { content: "How are you?" }}, - ]; -} -const { content } = Astro.props; ---- - - - Astro - - -{content} - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/pt/start.astro deleted file mode 100644 index 8e6455be4d76..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/pt/start.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -const currentLocale = Astro.currentLocale; ---- - - - Astro - - -Oi -Current Locale: {currentLocale ? currentLocale : "none"} - - diff --git a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/spanish/index.astro b/packages/astro/test/fixtures/i18n-routing-manual/src/pages/spanish/index.astro deleted file mode 100644 index a36031be6ec0..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-manual/src/pages/spanish/index.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -const currentLocale = Astro.currentLocale; - ---- - - - - Astro - - -Hola. -Current Locale: {currentLocale ? currentLocale : "none"} - - diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/astro.config.mjs deleted file mode 100644 index 20e815540d6b..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/astro.config.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig} from "astro/config"; - -export default defineConfig({ - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'pt', - 'it', - { - path: "zh-Hant", - codes: ["zh-HK", "zh-TW"] - } - - ] - } -}) diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/package.json b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/package.json deleted file mode 100644 index 6cb31aafbd8c..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/i18n-routing-preferred-language", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/blog/[id].astro deleted file mode 100644 index 97b41230d6e9..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/blog/[id].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [ - {params: {id: '1'}, props: { content: "Hello world" }}, - {params: {id: '2'}, props: { content: "Eat Something" }}, - {params: {id: '3'}, props: { content: "How are you?" }}, - ]; -} -const { content } = Astro.props; ---- - - - Astro - - -{content} - - diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/end.astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/end.astro deleted file mode 100644 index 9f33d8aa0bd3..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/end.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -End - - diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/start.astro deleted file mode 100644 index d9f61aa025c1..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/en/start.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Hello - - diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/index.astro deleted file mode 100644 index 05faf7b0bcce..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/index.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - - Hello - - diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/preferred-locale.astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/preferred-locale.astro deleted file mode 100644 index 1fb998c60b7e..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/preferred-locale.astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -const locale = Astro.preferredLocale; -const localeList = Astro.preferredLocaleList; ---- - - - - Astro - - - Locale: {locale ? locale : "none"} - - diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/blog/[id].astro deleted file mode 100644 index e37f83a30243..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/blog/[id].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [ - {params: {id: '1'}, props: { content: "Hola mundo" }}, - {params: {id: '2'}, props: { content: "Eat Something" }}, - {params: {id: '3'}, props: { content: "How are you?" }}, - ]; -} -const { content } = Astro.props; ---- - - - Astro - - -{content} - - diff --git a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/start.astro deleted file mode 100644 index 15a63a7b87f5..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-redirect-preferred-language/src/pages/pt/start.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Hola - - diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-subdomain/astro.config.mjs deleted file mode 100644 index 900676bbdbb5..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-subdomain/astro.config.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { defineConfig} from "astro/config"; - -export default defineConfig({ - output: "server", - trailingSlash: "never", - i18n: { - defaultLocale: 'en', - locales: [ - 'en', 'pt', 'it' - ], - domains: { - pt: "https://example.pt", - it: "http://it.example.com" - }, - routing: { - prefixDefaultLocale: true, - redirectToDefaultLocale: false - } - }, - site: "https://example.com", - security: { - allowedDomains: [ - { hostname: 'example.pt' }, - { hostname: 'it.example.com' }, - { hostname: 'example.com' } - ] - } -}) diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/package.json b/packages/astro/test/fixtures/i18n-routing-subdomain/package.json deleted file mode 100644 index 931425fa6206..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-subdomain/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/i18n-routing-subdomain", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/blog/[id].astro deleted file mode 100644 index 97b41230d6e9..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/blog/[id].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [ - {params: {id: '1'}, props: { content: "Hello world" }}, - {params: {id: '2'}, props: { content: "Eat Something" }}, - {params: {id: '3'}, props: { content: "How are you?" }}, - ]; -} -const { content } = Astro.props; ---- - - - Astro - - -{content} - - diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/index.astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/index.astro deleted file mode 100644 index 3e50ac6bf3cb..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/index.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Hello - - diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/start.astro deleted file mode 100644 index 990baecd9a8c..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/en/start.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Start - - diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/index.astro deleted file mode 100644 index c6186a7b7622..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/index.astro +++ /dev/null @@ -1,19 +0,0 @@ ---- -import { getAbsoluteLocaleUrl, getLocaleByPath, getPathByLocale, getRelativeLocaleUrl } from "astro:i18n"; - -let absoluteLocaleUrl_pt = getAbsoluteLocaleUrl("pt", "about"); -let absoluteLocaleUrl_it = getAbsoluteLocaleUrl("it"); - ---- - - - - Astro - - - Virtual module doesn't break - - Absolute URL pt: {absoluteLocaleUrl_pt} - Absolute URL it: {absoluteLocaleUrl_it} - - diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/blog/[id].astro deleted file mode 100644 index e37f83a30243..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/blog/[id].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export function getStaticPaths() { - return [ - {params: {id: '1'}, props: { content: "Hola mundo" }}, - {params: {id: '2'}, props: { content: "Eat Something" }}, - {params: {id: '3'}, props: { content: "How are you?" }}, - ]; -} -const { content } = Astro.props; ---- - - - Astro - - -{content} - - diff --git a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/start.astro deleted file mode 100644 index 5a4a84c2cf0c..000000000000 --- a/packages/astro/test/fixtures/i18n-routing-subdomain/src/pages/pt/start.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -Oi essa e start - - diff --git a/packages/astro/test/fixtures/i18n-server-island/astro.config.mjs b/packages/astro/test/fixtures/i18n-server-island/astro.config.mjs deleted file mode 100644 index cc445b3962c8..000000000000 --- a/packages/astro/test/fixtures/i18n-server-island/astro.config.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from "astro/config"; - -export default defineConfig({ - i18n: { - defaultLocale: 'en', - locales: [ - 'en', 'pt', 'it', { - path: "spanish", - codes: ["es", "es-ar"] - } - ], - routing: { - prefixDefaultLocale: true - } - }, - base: "/new-site" -}) diff --git a/packages/astro/test/fixtures/i18n-server-island/package.json b/packages/astro/test/fixtures/i18n-server-island/package.json deleted file mode 100644 index e360d49d34c9..000000000000 --- a/packages/astro/test/fixtures/i18n-server-island/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/i18n-server-island", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/i18n-server-island/src/components/Island.astro b/packages/astro/test/fixtures/i18n-server-island/src/components/Island.astro deleted file mode 100644 index 305caf85a9e1..000000000000 --- a/packages/astro/test/fixtures/i18n-server-island/src/components/Island.astro +++ /dev/null @@ -1 +0,0 @@ -

    I am a server island

    diff --git a/packages/astro/test/fixtures/i18n-server-island/src/pages/en/island.astro b/packages/astro/test/fixtures/i18n-server-island/src/pages/en/island.astro deleted file mode 100644 index b7bbf509bf7e..000000000000 --- a/packages/astro/test/fixtures/i18n-server-island/src/pages/en/island.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import Island from "../../components/Island.astro" ---- - - diff --git a/packages/astro/test/fixtures/i18n-server-island/src/pages/index.astro b/packages/astro/test/fixtures/i18n-server-island/src/pages/index.astro deleted file mode 100644 index 51507e040d25..000000000000 --- a/packages/astro/test/fixtures/i18n-server-island/src/pages/index.astro +++ /dev/null @@ -1,8 +0,0 @@ - - - Astro - - -I am index - - diff --git a/packages/astro/test/fixtures/middleware-sequence-request-clone/astro.config.mjs b/packages/astro/test/fixtures/middleware-sequence-request-clone/astro.config.mjs deleted file mode 100644 index 0c1b887d0eec..000000000000 --- a/packages/astro/test/fixtures/middleware-sequence-request-clone/astro.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'astro/config'; - -// https://astro.build/config -export default defineConfig({ - vite: { - plugins: [], - } -}); diff --git a/packages/astro/test/fixtures/middleware-sequence-request-clone/package.json b/packages/astro/test/fixtures/middleware-sequence-request-clone/package.json deleted file mode 100644 index f9aacd4d7207..000000000000 --- a/packages/astro/test/fixtures/middleware-sequence-request-clone/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/middleware-sequence-request-clone", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/middleware-sequence-request-clone/src/middleware.js b/packages/astro/test/fixtures/middleware-sequence-request-clone/src/middleware.js deleted file mode 100644 index c12367dc40f0..000000000000 --- a/packages/astro/test/fixtures/middleware-sequence-request-clone/src/middleware.js +++ /dev/null @@ -1,17 +0,0 @@ -import { sequence, defineMiddleware } from 'astro/middleware'; - -const middleware1 = defineMiddleware((_, next) => next('/')); - -const middleware2 = defineMiddleware(async ({ request, cookies }, next) => { - cookies.set('cookie1', 'Cookie from middleware 1'); - console.log(await request.clone().text()); - return next(); -}); - -const middleware3 = defineMiddleware(async ({ request, cookies }, next) => { - cookies.set('cookie2', 'Cookie from middleware 2'); - await request.clone(); - return next(); -}); - -export const onRequest = sequence(middleware1, middleware2, middleware3); diff --git a/packages/astro/test/fixtures/middleware-sequence-request-clone/src/pages/index.astro b/packages/astro/test/fixtures/middleware-sequence-request-clone/src/pages/index.astro deleted file mode 100644 index 8dbcc65c4bdb..000000000000 --- a/packages/astro/test/fixtures/middleware-sequence-request-clone/src/pages/index.astro +++ /dev/null @@ -1 +0,0 @@ -

    Hello Sequence and Request Clone

    \ No newline at end of file diff --git a/packages/astro/test/fixtures/middleware-ssg/astro.config.mjs b/packages/astro/test/fixtures/middleware-ssg/astro.config.mjs deleted file mode 100644 index a8ce50d378e6..000000000000 --- a/packages/astro/test/fixtures/middleware-ssg/astro.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -import { defineConfig } from 'astro/config'; - -export default defineConfig({ - output: "static", -}); diff --git a/packages/astro/test/fixtures/middleware-ssg/package.json b/packages/astro/test/fixtures/middleware-ssg/package.json deleted file mode 100644 index 2ac44245434c..000000000000 --- a/packages/astro/test/fixtures/middleware-ssg/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/middleware-ssg", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/middleware-ssg/src/middleware.js b/packages/astro/test/fixtures/middleware-ssg/src/middleware.js deleted file mode 100644 index 265a4329eb34..000000000000 --- a/packages/astro/test/fixtures/middleware-ssg/src/middleware.js +++ /dev/null @@ -1,12 +0,0 @@ -import { defineMiddleware, sequence } from 'astro:middleware'; - -const first = defineMiddleware(async (context, next) => { - if (context.request.url.includes('/second')) { - context.locals.name = 'second'; - } else { - context.locals.name = 'bar'; - } - return await next(); -}); - -export const onRequest = sequence(first); diff --git a/packages/astro/test/fixtures/middleware-ssg/src/pages/index.astro b/packages/astro/test/fixtures/middleware-ssg/src/pages/index.astro deleted file mode 100644 index 395a4d695cfa..000000000000 --- a/packages/astro/test/fixtures/middleware-ssg/src/pages/index.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -const data = Astro.locals; ---- - - - - Testing - - - - Index -

    {data?.name}

    - - diff --git a/packages/astro/test/fixtures/middleware-ssg/src/pages/second.astro b/packages/astro/test/fixtures/middleware-ssg/src/pages/second.astro deleted file mode 100644 index c6edf9cd75a0..000000000000 --- a/packages/astro/test/fixtures/middleware-ssg/src/pages/second.astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -const data = Astro.locals; ---- - - - - Testing - - - -

    {data?.name}

    - - diff --git a/packages/astro/test/fixtures/middleware-virtual/astro.config.mjs b/packages/astro/test/fixtures/middleware-virtual/astro.config.mjs deleted file mode 100644 index bc095ecddb69..000000000000 --- a/packages/astro/test/fixtures/middleware-virtual/astro.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import { defineConfig } from "astro/config"; - -export default defineConfig({}) diff --git a/packages/astro/test/fixtures/middleware-virtual/package.json b/packages/astro/test/fixtures/middleware-virtual/package.json deleted file mode 100644 index 7cfbeb721047..000000000000 --- a/packages/astro/test/fixtures/middleware-virtual/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/middleware-virtual", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/middleware-virtual/src/middleware.js b/packages/astro/test/fixtures/middleware-virtual/src/middleware.js deleted file mode 100644 index 55004a00cfdb..000000000000 --- a/packages/astro/test/fixtures/middleware-virtual/src/middleware.js +++ /dev/null @@ -1,6 +0,0 @@ -import { defineMiddleware } from 'astro:middleware'; - -export const onRequest = defineMiddleware(async (context, next) => { - console.log('[MIDDLEWARE] in ' + context.url.toString()); - return next(); -}); diff --git a/packages/astro/test/fixtures/middleware-virtual/src/pages/index.astro b/packages/astro/test/fixtures/middleware-virtual/src/pages/index.astro deleted file mode 100644 index 9bd31f5fde27..000000000000 --- a/packages/astro/test/fixtures/middleware-virtual/src/pages/index.astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -const data = Astro.locals; ---- - - - - Index - - - -Index - - diff --git a/packages/astro/test/fixtures/redirects/src/pages/late.astro b/packages/astro/test/fixtures/redirects/src/pages/late.astro index 62d35411927e..d6854dba0fd2 100644 --- a/packages/astro/test/fixtures/redirects/src/pages/late.astro +++ b/packages/astro/test/fixtures/redirects/src/pages/late.astro @@ -1,6 +1,6 @@ --- import Redirect from '../components/redirect.astro'; -const staticMode = import.meta.env.STATIC_MODE; +const staticMode = !!import.meta.env.STATIC_MODE; --- diff --git a/packages/astro/test/fixtures/set-html/package.json b/packages/astro/test/fixtures/set-html/package.json deleted file mode 100644 index c40fcd8acf41..000000000000 --- a/packages/astro/test/fixtures/set-html/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/set-html", - "version": "1.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/set-html/src/pages/children.astro b/packages/astro/test/fixtures/set-html/src/pages/children.astro deleted file mode 100644 index 3ef5c5b0b944..000000000000 --- a/packages/astro/test/fixtures/set-html/src/pages/children.astro +++ /dev/null @@ -1,22 +0,0 @@ ---- -import Slot from '../../components/Slot.astro'; ---- - - - -

    Bug: Astro.slots.render() with arguments does not work with <Fragment> slot

    -

    Comment out working example and uncomment non working examples

    -
    - - - - Test - - - - - - - diff --git a/packages/astro/test/fixtures/ssr-env/astro.config.mjs b/packages/astro/test/fixtures/ssr-env/astro.config.mjs deleted file mode 100644 index 42f10a57298d..000000000000 --- a/packages/astro/test/fixtures/ssr-env/astro.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -import preact from '@astrojs/preact'; - -export default { - integrations: [preact()] -} diff --git a/packages/astro/test/fixtures/ssr-env/package.json b/packages/astro/test/fixtures/ssr-env/package.json deleted file mode 100644 index ab377aadccc2..000000000000 --- a/packages/astro/test/fixtures/ssr-env/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@test/ssr-env", - "version": "0.0.0", - "private": true, - "dependencies": { - "@astrojs/preact": "workspace:*", - "astro": "workspace:*", - "preact": "^10.29.0" - } -} diff --git a/packages/astro/test/fixtures/ssr-markdown/package.json b/packages/astro/test/fixtures/ssr-markdown/package.json deleted file mode 100644 index 4c76381919b1..000000000000 --- a/packages/astro/test/fixtures/ssr-markdown/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/ssr-markdown", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/ssr-markdown/src/layouts/Base.astro b/packages/astro/test/fixtures/ssr-prerender/src/layouts/Base.astro similarity index 100% rename from packages/astro/test/fixtures/ssr-markdown/src/layouts/Base.astro rename to packages/astro/test/fixtures/ssr-prerender/src/layouts/Base.astro diff --git a/packages/astro/test/fixtures/ssr-markdown/src/pages/post.md b/packages/astro/test/fixtures/ssr-prerender/src/pages/post.md similarity index 100% rename from packages/astro/test/fixtures/ssr-markdown/src/pages/post.md rename to packages/astro/test/fixtures/ssr-prerender/src/pages/post.md diff --git a/packages/astro/test/fixtures/ssr-script/src/pages/dynamic.astro b/packages/astro/test/fixtures/ssr-script/src/pages/dynamic.astro new file mode 100644 index 000000000000..4a98fcb693a9 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-script/src/pages/dynamic.astro @@ -0,0 +1,11 @@ + + + Dynamic import + + +

    Dynamic import

    + + + diff --git a/packages/astro/test/fixtures/ssr-script/src/scripts/confetti.js b/packages/astro/test/fixtures/ssr-script/src/scripts/confetti.js new file mode 100644 index 000000000000..467452d00507 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-script/src/scripts/confetti.js @@ -0,0 +1,3 @@ +export function celebrate() { + console.log('confetti!'); +} diff --git a/packages/astro/test/fixtures/ssr-env/src/components/Env.jsx b/packages/astro/test/fixtures/ssr-scripts/src/components/Env.jsx similarity index 100% rename from packages/astro/test/fixtures/ssr-env/src/components/Env.jsx rename to packages/astro/test/fixtures/ssr-scripts/src/components/Env.jsx diff --git a/packages/astro/test/fixtures/ssr-env/src/pages/ssr.astro b/packages/astro/test/fixtures/ssr-scripts/src/pages/ssr.astro similarity index 100% rename from packages/astro/test/fixtures/ssr-env/src/pages/ssr.astro rename to packages/astro/test/fixtures/ssr-scripts/src/pages/ssr.astro diff --git a/packages/astro/test/fixtures/ssr-split-manifest/astro.config.mjs b/packages/astro/test/fixtures/ssr-split-manifest/astro.config.mjs deleted file mode 100644 index e5e10bd9b7be..000000000000 --- a/packages/astro/test/fixtures/ssr-split-manifest/astro.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'astro/config'; -export default defineConfig({ - output: "server", - redirects: { - "/redirect": "/" - } -}) diff --git a/packages/astro/test/fixtures/ssr-split-manifest/package.json b/packages/astro/test/fixtures/ssr-split-manifest/package.json deleted file mode 100644 index b980cc8a7b2e..000000000000 --- a/packages/astro/test/fixtures/ssr-split-manifest/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/ssr-split-manifest", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/[...post].astro b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/[...post].astro deleted file mode 100644 index 8bac75eb9404..000000000000 --- a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/[...post].astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -export async function getStaticPaths() { - return [ - { - params: { page: 1 }, - }, - { - params: { page: 2 }, - }, - { - params: { page: 3 } - } - ] -}; ---- - - - \ No newline at end of file diff --git a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/index.astro b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/index.astro deleted file mode 100644 index d8f84c6632a7..000000000000 --- a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/index.astro +++ /dev/null @@ -1,17 +0,0 @@ ---- -import { manifest } from 'astro:ssr-manifest'; ---- - - - Testing - - - -

    Testing index

    -
    - - diff --git a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/lorem.md b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/lorem.md deleted file mode 100644 index 8a38d58c1963..000000000000 --- a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/lorem.md +++ /dev/null @@ -1 +0,0 @@ -# Title \ No newline at end of file diff --git a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/prerender.astro b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/prerender.astro deleted file mode 100644 index 2eec6dbf13c9..000000000000 --- a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/prerender.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -export const prerender = true ---- - - - - Pre render me - - - - - diff --git a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/zod.astro b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/zod.astro deleted file mode 100644 index 06d949d47f6c..000000000000 --- a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/zod.astro +++ /dev/null @@ -1,17 +0,0 @@ ---- -import { manifest } from 'astro:ssr-manifest'; ---- - - - Testing - - - -

    Testing

    -
    - - \ No newline at end of file diff --git a/packages/astro/test/fixtures/ssr-trailing-slash/astro.config.mjs b/packages/astro/test/fixtures/ssr-trailing-slash/astro.config.mjs deleted file mode 100644 index 131182c04360..000000000000 --- a/packages/astro/test/fixtures/ssr-trailing-slash/astro.config.mjs +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'astro/config'; -import node from '@astrojs/node'; - -export default defineConfig({ - base: '/mybase', - trailingSlash: 'never', - output: 'server', - adapter: node({ mode: 'standalone' }) -}); \ No newline at end of file diff --git a/packages/astro/test/fixtures/ssr-trailing-slash/package.json b/packages/astro/test/fixtures/ssr-trailing-slash/package.json deleted file mode 100644 index cc0eab11d9f9..000000000000 --- a/packages/astro/test/fixtures/ssr-trailing-slash/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "test-ssr-trailing-slash", - "type": "module", - "scripts": { - "dev": "astro dev", - "build": "astro build", - "start": "node dist/server/entry.mjs" - }, - "dependencies": { - "astro": "workspace:*", - "@astrojs/node": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/ssr-trailing-slash/src/pages/index.astro b/packages/astro/test/fixtures/ssr-trailing-slash/src/pages/index.astro deleted file mode 100644 index 389edb215e82..000000000000 --- a/packages/astro/test/fixtures/ssr-trailing-slash/src/pages/index.astro +++ /dev/null @@ -1,10 +0,0 @@ ---- -export const prerender = false; -const pathname = Astro.url.pathname; ---- - - -

    Test: {pathname}

    - {pathname} - - \ No newline at end of file diff --git a/packages/astro/test/fixtures/streaming/src/pages/fragment-streaming.astro b/packages/astro/test/fixtures/streaming/src/pages/fragment-streaming.astro new file mode 100644 index 000000000000..506616e7b94d --- /dev/null +++ b/packages/astro/test/fixtures/streaming/src/pages/fragment-streaming.astro @@ -0,0 +1,28 @@ +--- +import { wait } from '../wait'; + +export const prerender = false; + +// This promise resolves after a delay — the sync sibling should stream before it +const promise = wait(50).then(() => 'resolved'); +--- + +Fragment Streaming + + + +

    I should appear before the promise resolves

    + {promise.then(() =>

    I appear after the promise resolves

    )} +
    + + +

    Bare sync sibling (always worked)

    + {promise.then(() =>

    Bare async sibling

    )} + + diff --git a/packages/astro/test/fixtures/unused-slot/package.json b/packages/astro/test/fixtures/unused-slot/package.json deleted file mode 100644 index b30e1f40e39d..000000000000 --- a/packages/astro/test/fixtures/unused-slot/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/unused-slot", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/with-endpoint-routes/package.json b/packages/astro/test/fixtures/with-endpoint-routes/package.json deleted file mode 100644 index fe4f12c99376..000000000000 --- a/packages/astro/test/fixtures/with-endpoint-routes/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/with-endpoint-routes", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/with-endpoint-routes/src/astro.png b/packages/astro/test/fixtures/with-endpoint-routes/src/astro.png deleted file mode 100644 index 36889e8f77b0..000000000000 Binary files a/packages/astro/test/fixtures/with-endpoint-routes/src/astro.png and /dev/null differ diff --git a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/404.astro b/packages/astro/test/fixtures/with-endpoint-routes/src/pages/404.astro deleted file mode 100644 index b9261c13d24a..000000000000 --- a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/404.astro +++ /dev/null @@ -1 +0,0 @@ -

    Text from pages/404.astro

    diff --git a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/[slug].json.ts b/packages/astro/test/fixtures/with-endpoint-routes/src/pages/[slug].json.ts deleted file mode 100644 index 3c1408300f25..000000000000 --- a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/[slug].json.ts +++ /dev/null @@ -1,13 +0,0 @@ -export async function getStaticPaths() { - return [ - { params: { slug: 'thing1' } }, - { params: { slug: 'thing2' } } - ]; -} - -export async function GET({ params }) { - return Response.json({ - slug: params.slug, - title: '[slug]' - }); -} diff --git a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/data/[slug].json.ts b/packages/astro/test/fixtures/with-endpoint-routes/src/pages/data/[slug].json.ts deleted file mode 100644 index 26cd9b065c34..000000000000 --- a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/data/[slug].json.ts +++ /dev/null @@ -1,13 +0,0 @@ -export async function getStaticPaths() { - return [ - { params: { slug: 'thing3' } }, - { params: { slug: 'thing4' } } - ]; -} - -export async function GET({ params }) { - return Response.json({ - slug: params.slug, - title: 'data [slug]' - }); -} diff --git a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/home.json.ts b/packages/astro/test/fixtures/with-endpoint-routes/src/pages/home.json.ts deleted file mode 100644 index 2bee50a8e328..000000000000 --- a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/home.json.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function GET() { - return Response.json({ title: 'home' }); -} diff --git a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/images/[image].svg.ts b/packages/astro/test/fixtures/with-endpoint-routes/src/pages/images/[image].svg.ts deleted file mode 100644 index e80063105532..000000000000 --- a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/images/[image].svg.ts +++ /dev/null @@ -1,16 +0,0 @@ -export async function getStaticPaths() { - return [{ params: { image: "1" } }, { params: { image: "2" } }]; -} - -export async function GET({ params }) { - return new Response( - ` - ${params.image} -`, - { - headers: { - 'content-type': 'image/svg+xml', - }, - } - ); -} diff --git a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/images/hex.ts b/packages/astro/test/fixtures/with-endpoint-routes/src/pages/images/hex.ts deleted file mode 100644 index c258c3091c2d..000000000000 --- a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/images/hex.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { readFile } from 'node:fs/promises'; - -export async function GET() { - const buffer = await readFile(new URL('../../astro.png', import.meta.url)); - return new Response(buffer.buffer); -} diff --git a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/images/static.svg.ts b/packages/astro/test/fixtures/with-endpoint-routes/src/pages/images/static.svg.ts deleted file mode 100644 index 423f258f8c17..000000000000 --- a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/images/static.svg.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function GET() { - return new Response( - ` - Static SVG -`, - { - headers: { - 'content-type': 'image/svg+xml', - }, - } - ); -} diff --git a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/invalid-redirect.json.ts b/packages/astro/test/fixtures/with-endpoint-routes/src/pages/invalid-redirect.json.ts deleted file mode 100644 index 9e8e40580ea8..000000000000 --- a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/invalid-redirect.json.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const GET = () => { - return new Response( - undefined, - { - status: 301, - headers: { - Location: 'https://example.com', - } - } - ); -}; diff --git a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/not-ok.ts b/packages/astro/test/fixtures/with-endpoint-routes/src/pages/not-ok.ts deleted file mode 100644 index c1a8aff913cc..000000000000 --- a/packages/astro/test/fixtures/with-endpoint-routes/src/pages/not-ok.ts +++ /dev/null @@ -1,5 +0,0 @@ -export async function GET() { - return new Response("Text from pages/not-ok.ts", { - status: 404, - }); -} diff --git a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/.gitignore b/packages/astro/test/fixtures/with-subpath-no-trailing-slash/.gitignore deleted file mode 100644 index 8a085be49804..000000000000 --- a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/dist-* diff --git a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/astro.config.mjs b/packages/astro/test/fixtures/with-subpath-no-trailing-slash/astro.config.mjs deleted file mode 100644 index 227d24574d05..000000000000 --- a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/astro.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ - -export default { - site: 'http://example.com', -} diff --git a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/package.json b/packages/astro/test/fixtures/with-subpath-no-trailing-slash/package.json deleted file mode 100644 index 199b3c1528d4..000000000000 --- a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/with-subpath-no-trailing-slash", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/src/pages/[id].astro b/packages/astro/test/fixtures/with-subpath-no-trailing-slash/src/pages/[id].astro deleted file mode 100644 index b5dbc43074c7..000000000000 --- a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/src/pages/[id].astro +++ /dev/null @@ -1,6 +0,0 @@ ---- -export function getStaticPaths() { - return [{ params: { id: '1' } }]; -} ---- -

    Post #1

    diff --git a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/src/pages/another.astro b/packages/astro/test/fixtures/with-subpath-no-trailing-slash/src/pages/another.astro deleted file mode 100644 index d0563f41456c..000000000000 --- a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/src/pages/another.astro +++ /dev/null @@ -1 +0,0 @@ -
    another page
    \ No newline at end of file diff --git a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/src/pages/index.astro b/packages/astro/test/fixtures/with-subpath-no-trailing-slash/src/pages/index.astro deleted file mode 100644 index 42e6a5177169..000000000000 --- a/packages/astro/test/fixtures/with-subpath-no-trailing-slash/src/pages/index.astro +++ /dev/null @@ -1 +0,0 @@ -
    testing
    \ No newline at end of file diff --git a/packages/astro/test/fixtures/without-site-config/package.json b/packages/astro/test/fixtures/without-site-config/package.json deleted file mode 100644 index 473b7a34bccb..000000000000 --- a/packages/astro/test/fixtures/without-site-config/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/without-site-config", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/without-site-config/src/pages/[id].astro b/packages/astro/test/fixtures/without-site-config/src/pages/[id].astro deleted file mode 100644 index b5dbc43074c7..000000000000 --- a/packages/astro/test/fixtures/without-site-config/src/pages/[id].astro +++ /dev/null @@ -1,6 +0,0 @@ ---- -export function getStaticPaths() { - return [{ params: { id: '1' } }]; -} ---- -

    Post #1

    diff --git a/packages/astro/test/fixtures/without-site-config/src/pages/another.astro b/packages/astro/test/fixtures/without-site-config/src/pages/another.astro deleted file mode 100644 index d0563f41456c..000000000000 --- a/packages/astro/test/fixtures/without-site-config/src/pages/another.astro +++ /dev/null @@ -1 +0,0 @@ -
    another page
    \ No newline at end of file diff --git a/packages/astro/test/fixtures/without-site-config/src/pages/base-index.astro b/packages/astro/test/fixtures/without-site-config/src/pages/base-index.astro deleted file mode 100644 index 76e198f3da49..000000000000 --- a/packages/astro/test/fixtures/without-site-config/src/pages/base-index.astro +++ /dev/null @@ -1 +0,0 @@ -
    testing
    diff --git a/packages/astro/test/fixtures/without-site-config/src/pages/html-ext/[slug].astro b/packages/astro/test/fixtures/without-site-config/src/pages/html-ext/[slug].astro deleted file mode 100644 index 599fd0f26e9b..000000000000 --- a/packages/astro/test/fixtures/without-site-config/src/pages/html-ext/[slug].astro +++ /dev/null @@ -1,6 +0,0 @@ ---- -export function getStaticPaths() { - return [{ params: { slug: '1' } }]; -} ---- -

    none: {Astro.params.slug}

    diff --git a/packages/astro/test/fixtures/without-site-config/src/pages/html-ext/[slug].html.astro b/packages/astro/test/fixtures/without-site-config/src/pages/html-ext/[slug].html.astro deleted file mode 100644 index 79ab2d434b38..000000000000 --- a/packages/astro/test/fixtures/without-site-config/src/pages/html-ext/[slug].html.astro +++ /dev/null @@ -1,6 +0,0 @@ ---- -export function getStaticPaths() { - return [{ params: { slug: '1' } }]; -} ---- -

    html: {Astro.params.slug}

    diff --git a/packages/astro/test/fixtures/without-site-config/src/pages/index.astro b/packages/astro/test/fixtures/without-site-config/src/pages/index.astro deleted file mode 100644 index 42e6a5177169..000000000000 --- a/packages/astro/test/fixtures/without-site-config/src/pages/index.astro +++ /dev/null @@ -1 +0,0 @@ -
    testing
    \ No newline at end of file diff --git a/packages/astro/test/fixtures/without-site-config/src/pages/redirect.astro b/packages/astro/test/fixtures/without-site-config/src/pages/redirect.astro deleted file mode 100644 index 4b640e5b5df8..000000000000 --- a/packages/astro/test/fixtures/without-site-config/src/pages/redirect.astro +++ /dev/null @@ -1,4 +0,0 @@ ---- -const anotherURL = new URL('./another/', Astro.url); -return Response.redirect(anotherURL.toString()); ---- diff --git a/packages/astro/test/fixtures/without-site-config/src/pages/te st.astro b/packages/astro/test/fixtures/without-site-config/src/pages/te st.astro deleted file mode 100644 index 42e6a5177169..000000000000 --- a/packages/astro/test/fixtures/without-site-config/src/pages/te st.astro +++ /dev/null @@ -1 +0,0 @@ -
    testing
    \ No newline at end of file diff --git "a/packages/astro/test/fixtures/without-site-config/src/pages/\343\203\206\343\202\271\343\203\210.astro" "b/packages/astro/test/fixtures/without-site-config/src/pages/\343\203\206\343\202\271\343\203\210.astro" deleted file mode 100644 index 42e6a5177169..000000000000 --- "a/packages/astro/test/fixtures/without-site-config/src/pages/\343\203\206\343\202\271\343\203\210.astro" +++ /dev/null @@ -1 +0,0 @@ -
    testing
    \ No newline at end of file diff --git a/packages/astro/test/fonts.test.js b/packages/astro/test/fonts.test.js deleted file mode 100644 index 0f1598ad0f53..000000000000 --- a/packages/astro/test/fonts.test.js +++ /dev/null @@ -1,306 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { existsSync } from 'node:fs'; -import { readdir } from 'node:fs/promises'; -import { after, before, describe, it } from 'node:test'; -import { fontProviders } from 'astro/config'; -import * as cheerio from 'cheerio'; -import testAdapter from './test-adapter.js'; -import { loadFixture } from './test-utils.js'; - -describe('astro fonts', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - describe('dev', () => { - /** @type {import('./test-utils.js').DevServer} */ - let devServer; - - describe('shared', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/fonts/', - fonts: [ - { - name: 'Poppins', - cssVariable: '--font-test', - provider: fontProviders.fontsource(), - weights: [400, 500], - }, - ], - }); - await fixture.clean(); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer?.stop(); - }); - - it('Includes styles', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal(html.includes('`; - const result = await compileWithBase(source, '/my-base/'); - - // CSS should be in result.css array - assert.ok(result.css.length > 0); - const css = result.css[0].code; - - // URL should be rewritten to include base - assert.match(css, /url\(['"]?\/my-base\/fonts\/font\.woff2['"]?\)/); - }); - - it('should rewrite unquoted URLs', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base/'); - const css = result.css[0].code; - - assert.match(css, /url\(\/my-base\/images\/bg\.png\)/); - }); - - it('should rewrite double-quoted URLs', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base/'); - const css = result.css[0].code; - - // URL should be rewritten (quotes may be preserved or removed by CSS processor) - assert.ok(css.includes('/my-base/images/bg.png')); - }); - - it('should rewrite single-quoted URLs', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base/'); - const css = result.css[0].code; - - assert.ok(css.includes('/my-base/images/bg.png')); - }); - - it('should handle base path without trailing slash', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base'); - const css = result.css[0].code; - - assert.match(css, /url\(\/my-base\/images\/bg\.png\)/); - }); - - it('should handle nested base paths', async () => { - const source = ``; - const result = await compileWithBase(source, '/path/to/app/'); - const css = result.css[0].code; - - assert.match(css, /url\(\/path\/to\/app\/images\/bg\.png\)/); - }); - - it('should handle multiple URLs in one declaration', async () => { - const source = ``; - const result = await compileWithBase(source, '/base/'); - const css = result.css[0].code; - - assert.ok(css.includes('/base/bg.png')); - assert.ok(css.includes('/base/fg.png')); - }); - }); - - describe('URLs that should NOT be rewritten', () => { - it('should not rewrite relative URLs starting with ./', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base/'); - const css = result.css[0].code; - - assert.match(css, /url\(\.\/local\.png\)/); - assert.doesNotMatch(css, /\/my-base/); - }); - - it('should not rewrite relative URLs starting with ../', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base/'); - const css = result.css[0].code; - - assert.match(css, /url\(\.\.\/parent\.png\)/); - assert.doesNotMatch(css, /\/my-base/); - }); - - it('should not rewrite external https:// URLs', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base/'); - const css = result.css[0].code; - - assert.match(css, /url\(https:\/\/example\.com\/image\.png\)/); - assert.doesNotMatch(css, /\/my-base/); - }); - - it('should not rewrite external http:// URLs', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base/'); - const css = result.css[0].code; - - assert.match(css, /url\(http:\/\/example\.com\/image\.png\)/); - assert.doesNotMatch(css, /\/my-base/); - }); - - it('should not rewrite data URIs', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base/'); - const css = result.css[0].code; - - assert.match(css, /url\(data:image\/svg\+xml/); - assert.doesNotMatch(css, /\/my-base/); - }); - - it('should not rewrite protocol-relative URLs', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base/'); - const css = result.css[0].code; - - assert.match(css, /url\(\/\/cdn\.example\.com\/image\.png\)/); - // Should not have /my-base// (double slash would be wrong) - assert.doesNotMatch(css, /\/my-base\/\//); - }); - - // Note: @import statements are processed by Vite's CSS plugin separately - // and will attempt to resolve the imported file. Our rewriteCssUrls function - // correctly skips @import URLs via negative lookbehind in the regex. - }); - - describe('Edge cases', () => { - it('should handle base="/" as no-op', async () => { - const source = ``; - const result = await compileWithBase(source, '/'); - const css = result.css[0].code; - - // Should NOT add extra slash - assert.match(css, /url\(\/images\/bg\.png\)/); - assert.doesNotMatch(css, /url\(\/\/images/); - }); - - it('should be idempotent (not double-apply base)', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base/'); - const css = result.css[0].code; - - // Should not become /my-base/my-base/images/bg.png - assert.match(css, /url\(\/my-base\/images\/bg\.png\)/); - assert.doesNotMatch(css, /\/my-base\/my-base/); - }); - - it('should handle URLs with whitespace', async () => { - const source = ``; - const result = await compileWithBase(source, '/my-base/'); - const css = result.css[0].code; - - assert.ok(css.includes('/my-base/images/bg.png')); - }); - - it('should handle empty base as no-op', async () => { - const source = ``; - const result = await compileWithBase(source, ''); - const css = result.css[0].code; - - assert.match(css, /url\(\/images\/bg\.png\)/); - }); - }); - - describe('Complex CSS scenarios', () => { - it('should handle @font-face with format()', async () => { - const source = ` -`; - const result = await compileWithBase(source, '/app/'); - const css = result.css[0].code; - - assert.ok(css.includes('/app/fonts/test.woff2')); - assert.ok(css.includes('/app/fonts/test.woff')); - }); - - it('should handle background shorthand with multiple values', async () => { - const source = ` -`; - const result = await compileWithBase(source, '/base/'); - const css = result.css[0].code; - - assert.ok(css.includes('/base/bg.png')); - }); - - it('should handle image-set()', async () => { - const source = ` -`; - const result = await compileWithBase(source, '/base/'); - const css = result.css[0].code; - - // Note: image-set() also contains url() which should be rewritten - assert.ok(css.includes('/base/img-1x.png') || /\/base\/img-1x\.png/.exec(css)); - assert.ok(css.includes('/base/img-2x.png') || /\/base\/img-2x\.png/.exec(css)); - }); - - it('should handle mask-image property', async () => { - const source = ``; - const result = await compileWithBase(source, '/app/'); - const css = result.css[0].code; - - assert.ok(css.includes('/app/mask.svg')); - }); - - it('should handle list-style-image property', async () => { - const source = ``; - const result = await compileWithBase(source, '/app/'); - const css = result.css[0].code; - - assert.ok(css.includes('/app/bullet.png')); - }); - - it('should handle cursor property', async () => { - const source = ``; - const result = await compileWithBase(source, '/app/'); - const css = result.css[0].code; - - assert.ok(css.includes('/app/cursor.png')); - }); - - it('should handle mixed quoted and unquoted URLs', async () => { - const source = ` -`; - const result = await compileWithBase(source, '/base/'); - const css = result.css[0].code; - - assert.ok(css.includes('/base/bg1.png')); - assert.ok(css.includes('/base/bg2.png')); - assert.ok(css.includes('/base/bg3.png')); - }); - }); - - describe('Sass/Less/Stylus preprocessing', () => { - it('should rewrite URLs in Sass', async () => { - const source = ` -`; - const result = await compileWithBase(source, '/base/'); - const css = result.css[0].code; - - // After Sass compilation, the URL should be rewritten - assert.ok(css.includes('/base/images/bg.png')); - }); - }); -}); diff --git a/packages/astro/test/units/compile/css-base-path.test.ts b/packages/astro/test/units/compile/css-base-path.test.ts new file mode 100644 index 000000000000..5cb41c56d66f --- /dev/null +++ b/packages/astro/test/units/compile/css-base-path.test.ts @@ -0,0 +1,311 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { resolveConfig } from 'vite'; +import { compileAstro } from '../../../dist/vite-plugin-astro/compile.js'; +import type { AstroConfig } from '../../../dist/types/public/config.js'; +import type { CompileProps } from '../../../dist/core/compile/compile.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; +import { nodeLogDestination } from '../../../dist/core/logger/node.js'; + +const logger = new AstroLogger({ destination: nodeLogDestination, level: 'silent' }); + +/** Compile Astro source with a given base path. */ +async function compileWithBase(source: string, base = '/') { + const viteConfig = await resolveConfig({ configFile: false }, 'serve'); + const props: CompileProps = { + astroConfig: { + root: pathToFileURL('/'), + base, + experimental: {}, + build: { format: 'directory' }, + trailingSlash: 'ignore', + } as AstroConfig, + viteConfig, + toolbarEnabled: false, + filename: '/src/pages/index.astro', + source, + }; + return compileAstro({ + compileProps: props as any, + astroFileToCompileMetadata: new Map(), + logger, + }); +} + +describe('CSS Base Path Rewriting', () => { + describe('Absolute URL rewriting', () => { + it('should rewrite absolute URLs with base path', async () => { + const source = ` +`; + const result = await compileWithBase(source, '/my-base/'); + + // CSS should be in result.css array + assert.ok(result.css.length > 0); + const css = result.css[0].code; + + // URL should be rewritten to include base + assert.match(css, /url\(['"]?\/my-base\/fonts\/font\.woff2['"]?\)/); + }); + + it('should rewrite unquoted URLs', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base/'); + const css = result.css[0].code; + + assert.match(css, /url\(\/my-base\/images\/bg\.png\)/); + }); + + it('should rewrite double-quoted URLs', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base/'); + const css = result.css[0].code; + + // URL should be rewritten (quotes may be preserved or removed by CSS processor) + assert.ok(css.includes('/my-base/images/bg.png')); + }); + + it('should rewrite single-quoted URLs', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base/'); + const css = result.css[0].code; + + assert.ok(css.includes('/my-base/images/bg.png')); + }); + + it('should handle base path without trailing slash', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base'); + const css = result.css[0].code; + + assert.match(css, /url\(\/my-base\/images\/bg\.png\)/); + }); + + it('should handle nested base paths', async () => { + const source = ``; + const result = await compileWithBase(source, '/path/to/app/'); + const css = result.css[0].code; + + assert.match(css, /url\(\/path\/to\/app\/images\/bg\.png\)/); + }); + + it('should handle multiple URLs in one declaration', async () => { + const source = ``; + const result = await compileWithBase(source, '/base/'); + const css = result.css[0].code; + + assert.ok(css.includes('/base/bg.png')); + assert.ok(css.includes('/base/fg.png')); + }); + }); + + describe('URLs that should NOT be rewritten', () => { + it('should not rewrite relative URLs starting with ./', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base/'); + const css = result.css[0].code; + + assert.match(css, /url\(\.\/local\.png\)/); + assert.doesNotMatch(css, /\/my-base/); + }); + + it('should not rewrite relative URLs starting with ../', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base/'); + const css = result.css[0].code; + + assert.match(css, /url\(\.\.\/parent\.png\)/); + assert.doesNotMatch(css, /\/my-base/); + }); + + it('should not rewrite external https:// URLs', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base/'); + const css = result.css[0].code; + + assert.match(css, /url\(https:\/\/example\.com\/image\.png\)/); + assert.doesNotMatch(css, /\/my-base/); + }); + + it('should not rewrite external http:// URLs', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base/'); + const css = result.css[0].code; + + assert.match(css, /url\(http:\/\/example\.com\/image\.png\)/); + assert.doesNotMatch(css, /\/my-base/); + }); + + it('should not rewrite data URIs', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base/'); + const css = result.css[0].code; + + assert.match(css, /url\(data:image\/svg\+xml/); + assert.doesNotMatch(css, /\/my-base/); + }); + + it('should not rewrite protocol-relative URLs', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base/'); + const css = result.css[0].code; + + assert.match(css, /url\(\/\/cdn\.example\.com\/image\.png\)/); + // Should not have /my-base// (double slash would be wrong) + assert.doesNotMatch(css, /\/my-base\/\//); + }); + + // Note: @import statements are processed by Vite's CSS plugin separately + // and will attempt to resolve the imported file. Our rewriteCssUrls function + // correctly skips @import URLs via negative lookbehind in the regex. + }); + + describe('Edge cases', () => { + it('should handle base="/" as no-op', async () => { + const source = ``; + const result = await compileWithBase(source, '/'); + const css = result.css[0].code; + + // Should NOT add extra slash + assert.match(css, /url\(\/images\/bg\.png\)/); + assert.doesNotMatch(css, /url\(\/\/images/); + }); + + it('should be idempotent (not double-apply base)', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base/'); + const css = result.css[0].code; + + // Should not become /my-base/my-base/images/bg.png + assert.match(css, /url\(\/my-base\/images\/bg\.png\)/); + assert.doesNotMatch(css, /\/my-base\/my-base/); + }); + + it('should handle URLs with whitespace', async () => { + const source = ``; + const result = await compileWithBase(source, '/my-base/'); + const css = result.css[0].code; + + assert.ok(css.includes('/my-base/images/bg.png')); + }); + + it('should handle empty base as no-op', async () => { + const source = ``; + const result = await compileWithBase(source, ''); + const css = result.css[0].code; + + assert.match(css, /url\(\/images\/bg\.png\)/); + }); + }); + + describe('Complex CSS scenarios', () => { + it('should handle @font-face with format()', async () => { + const source = ` +`; + const result = await compileWithBase(source, '/app/'); + const css = result.css[0].code; + + assert.ok(css.includes('/app/fonts/test.woff2')); + assert.ok(css.includes('/app/fonts/test.woff')); + }); + + it('should handle background shorthand with multiple values', async () => { + const source = ` +`; + const result = await compileWithBase(source, '/base/'); + const css = result.css[0].code; + + assert.ok(css.includes('/base/bg.png')); + }); + + it('should handle image-set()', async () => { + const source = ` +`; + const result = await compileWithBase(source, '/base/'); + const css = result.css[0].code; + + // Note: image-set() also contains url() which should be rewritten + assert.ok(css.includes('/base/img-1x.png') || /\/base\/img-1x\.png/.exec(css)); + assert.ok(css.includes('/base/img-2x.png') || /\/base\/img-2x\.png/.exec(css)); + }); + + it('should handle mask-image property', async () => { + const source = ``; + const result = await compileWithBase(source, '/app/'); + const css = result.css[0].code; + + assert.ok(css.includes('/app/mask.svg')); + }); + + it('should handle list-style-image property', async () => { + const source = ``; + const result = await compileWithBase(source, '/app/'); + const css = result.css[0].code; + + assert.ok(css.includes('/app/bullet.png')); + }); + + it('should handle cursor property', async () => { + const source = ``; + const result = await compileWithBase(source, '/app/'); + const css = result.css[0].code; + + assert.ok(css.includes('/app/cursor.png')); + }); + + it('should handle mixed quoted and unquoted URLs', async () => { + const source = ` +`; + const result = await compileWithBase(source, '/base/'); + const css = result.css[0].code; + + assert.ok(css.includes('/base/bg1.png')); + assert.ok(css.includes('/base/bg2.png')); + assert.ok(css.includes('/base/bg3.png')); + }); + }); + + describe('Sass/Less/Stylus preprocessing', () => { + it('should rewrite URLs in Sass', async () => { + const source = ` +`; + const result = await compileWithBase(source, '/base/'); + const css = result.css[0].code; + + // After Sass compilation, the URL should be rewritten + assert.ok(css.includes('/base/images/bg.png')); + }); + }); +}); diff --git a/packages/astro/test/units/compile/invalid-css.test.js b/packages/astro/test/units/compile/invalid-css.test.js deleted file mode 100644 index 73d52e5ec8c1..000000000000 --- a/packages/astro/test/units/compile/invalid-css.test.js +++ /dev/null @@ -1,43 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { pathToFileURL } from 'node:url'; -import { resolveConfig } from 'vite'; -import { compile } from '../../../dist/core/compile/index.js'; -import { AggregateError } from '../../../dist/core/errors/index.js'; - -describe('astro/src/core/compile', () => { - describe('Invalid CSS', () => { - it('throws an aggregate error with the errors', async () => { - let error; - try { - await compile({ - astroConfig: { - root: pathToFileURL('/'), - experimental: {}, - }, - viteConfig: await resolveConfig({ configFile: false }, 'serve'), - filename: '/src/pages/index.astro', - source: ` - --- - --- - - - `, - }); - } catch (err) { - error = err; - } - - assert.equal(error instanceof AggregateError, true); - assert.equal(error.errors[0].message.includes('expected ")"'), true); - }); - }); -}); diff --git a/packages/astro/test/units/compile/invalid-css.test.ts b/packages/astro/test/units/compile/invalid-css.test.ts new file mode 100644 index 000000000000..9c3e0d043381 --- /dev/null +++ b/packages/astro/test/units/compile/invalid-css.test.ts @@ -0,0 +1,45 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { resolveConfig } from 'vite'; +import { compile } from '../../../dist/core/compile/index.js'; +import { AggregateError } from '../../../dist/core/errors/index.js'; +import type { AstroConfig } from '../../../dist/types/public/config.js'; + +describe('astro/src/core/compile', () => { + describe('Invalid CSS', () => { + it('throws an aggregate error with the errors', async () => { + let error; + try { + await compile({ + astroConfig: { + root: pathToFileURL('/'), + experimental: {}, + } as AstroConfig, + viteConfig: await resolveConfig({ configFile: false }, 'serve'), + toolbarEnabled: false, + filename: '/src/pages/index.astro', + source: ` + --- + --- + + + `, + }); + } catch (err) { + error = err; + } + + assert.equal(error instanceof AggregateError, true); + assert.equal((error as AggregateError).errors[0].message.includes('expected ")"'), true); + }); + }); +}); diff --git a/packages/astro/test/units/compile/rust-compiler.test.js b/packages/astro/test/units/compile/rust-compiler.test.js deleted file mode 100644 index aaa5bbe6fe68..000000000000 --- a/packages/astro/test/units/compile/rust-compiler.test.js +++ /dev/null @@ -1,145 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { pathToFileURL } from 'node:url'; -import { resolveConfig } from 'vite'; -import { compile } from '../../../dist/core/compile/compile-rs.js'; - -/** - * @param {string} source - * @param {object} [configOverrides] - */ -async function compileWithRust(source, configOverrides = {}) { - const viteConfig = await resolveConfig({ configFile: false }, 'serve'); - return compile({ - astroConfig: { - root: pathToFileURL('/'), - base: '/', - experimental: { rustCompiler: true }, - compressHTML: false, - scopedStyleStrategy: 'attribute', - devToolbar: { enabled: false }, - site: undefined, - ...configOverrides, - }, - viteConfig, - toolbarEnabled: false, - filename: '/src/components/index.astro', - source, - }); -} - -describe('experimental.rustCompiler - core compile', () => { - it('compiles a basic Astro component', async () => { - const result = await compileWithRust('

    Hello World

    '); - assert.ok(result.code); - }); - - it('compiles a component with frontmatter', async () => { - const result = await compileWithRust(`\ ---- -const greeting = 'Hello'; ---- -

    {greeting}

    `); - assert.ok(result.code); - }); - - it('returns a source map', async () => { - const result = await compileWithRust('

    Hello

    '); - assert.ok(result.map); - }); - - it('returns a scope string', async () => { - const result = await compileWithRust('

    Hello

    '); - assert.equal(typeof result.scope, 'string'); - }); - - it('returns populated css array for styled components', async () => { - const result = await compileWithRust(`\ - -

    Hello

    `); - assert.ok(Array.isArray(result.css)); - assert.equal(result.css.length, 1); - assert.ok(result.css[0].code); - }); - - it('returns empty css array for unstyled components', async () => { - const result = await compileWithRust('

    Hello

    '); - assert.ok(Array.isArray(result.css)); - assert.equal(result.css.length, 0); - }); - - it('returns populated scripts array for components with scripts', async () => { - const result = await compileWithRust(`\ - -

    Hello

    `); - assert.ok(Array.isArray(result.scripts)); - assert.equal(result.scripts.length, 1); - }); - - it('returns empty scripts array for components without scripts', async () => { - const result = await compileWithRust('

    Hello

    '); - assert.ok(Array.isArray(result.scripts)); - assert.equal(result.scripts.length, 0); - }); - - it('detects head content', async () => { - const result = await compileWithRust(`\ - - My Page - -

    Hello

    `); - assert.equal(result.containsHead, true); - }); - - it('reports containsHead as false when no head element present', async () => { - const result = await compileWithRust('

    Hello

    '); - assert.equal(result.containsHead, false); - }); - - it('marks global styles with isGlobal', async () => { - const result = await compileWithRust(`\ - -

    Global

    `); - assert.equal(result.css.length, 1); - assert.equal(result.css[0].isGlobal, true); - }); - - it('marks scoped styles with isGlobal false', async () => { - const result = await compileWithRust(`\ - -

    Scoped

    `); - assert.equal(result.css.length, 1); - assert.equal(result.css[0].isGlobal, false); - }); - - it('returns one css entry per style block', async () => { - const result = await compileWithRust(`\ - - -

    Hello

    -

    World

    `); - assert.equal(result.css.length, 2); - }); - - it('throws a CompilerError on unclosed tags', async () => { - await assert.rejects( - () => compileWithRust('

    Unclosed tag'), - (err) => { - assert.ok(err.message || err.name); - assert.ok(err.message.includes('Unexpected token')); - return true; - }, - ); - }); - - it('handles empty component without throwing', async () => { - const result = await compileWithRust(''); - assert.ok(result.code !== undefined); - }); -}); diff --git a/packages/astro/test/units/compile/rust-compiler.test.ts b/packages/astro/test/units/compile/rust-compiler.test.ts new file mode 100644 index 000000000000..0c53e68d7823 --- /dev/null +++ b/packages/astro/test/units/compile/rust-compiler.test.ts @@ -0,0 +1,143 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { resolveConfig } from 'vite'; +import { compile } from '../../../dist/core/compile/compile-rs.js'; +import type { AstroConfig } from '../../../dist/types/public/config.js'; + +async function compileWithRust(source: string, configOverrides: Partial = {}) { + const viteConfig = await resolveConfig({ configFile: false }, 'serve'); + return compile({ + astroConfig: { + root: pathToFileURL('/'), + base: '/', + experimental: { rustCompiler: true }, + compressHTML: false, + scopedStyleStrategy: 'attribute', + devToolbar: { enabled: false }, + site: undefined, + ...configOverrides, + } as AstroConfig, + viteConfig, + toolbarEnabled: false, + filename: '/src/components/index.astro', + source, + }); +} + +describe('experimental.rustCompiler - core compile', () => { + it('compiles a basic Astro component', async () => { + const result = await compileWithRust('

    Hello World

    '); + assert.ok(result.code); + }); + + it('compiles a component with frontmatter', async () => { + const result = await compileWithRust(`\ +--- +const greeting = 'Hello'; +--- +

    {greeting}

    `); + assert.ok(result.code); + }); + + it('returns a source map', async () => { + const result = await compileWithRust('

    Hello

    '); + assert.ok(result.map); + }); + + it('returns a scope string', async () => { + const result = await compileWithRust('

    Hello

    '); + assert.equal(typeof result.scope, 'string'); + }); + + it('returns populated css array for styled components', async () => { + const result = await compileWithRust(`\ + +

    Hello

    `); + assert.ok(Array.isArray(result.css)); + assert.equal(result.css.length, 1); + assert.ok(result.css[0].code); + }); + + it('returns empty css array for unstyled components', async () => { + const result = await compileWithRust('

    Hello

    '); + assert.ok(Array.isArray(result.css)); + assert.equal(result.css.length, 0); + }); + + it('returns populated scripts array for components with scripts', async () => { + const result = await compileWithRust(`\ + +

    Hello

    `); + assert.ok(Array.isArray(result.scripts)); + assert.equal(result.scripts.length, 1); + }); + + it('returns empty scripts array for components without scripts', async () => { + const result = await compileWithRust('

    Hello

    '); + assert.ok(Array.isArray(result.scripts)); + assert.equal(result.scripts.length, 0); + }); + + it('detects head content', async () => { + const result = await compileWithRust(`\ + + My Page + +

    Hello

    `); + assert.equal(result.containsHead, true); + }); + + it('reports containsHead as false when no head element present', async () => { + const result = await compileWithRust('

    Hello

    '); + assert.equal(result.containsHead, false); + }); + + it('marks global styles with isGlobal', async () => { + const result = await compileWithRust(`\ + +

    Global

    `); + assert.equal(result.css.length, 1); + assert.equal(result.css[0].isGlobal, true); + }); + + it('marks scoped styles with isGlobal false', async () => { + const result = await compileWithRust(`\ + +

    Scoped

    `); + assert.equal(result.css.length, 1); + assert.equal(result.css[0].isGlobal, false); + }); + + it('returns one css entry per style block', async () => { + const result = await compileWithRust(`\ + + +

    Hello

    +

    World

    `); + assert.equal(result.css.length, 2); + }); + + it('throws a CompilerError on unclosed tags', async () => { + await assert.rejects( + () => compileWithRust('

    Unclosed tag'), + (err: unknown) => { + const e = err as { message?: string; name?: string }; + assert.ok(e.message || e.name); + assert.ok(e.message?.includes('Unexpected token')); + return true; + }, + ); + }); + + it('handles empty component without throwing', async () => { + const result = await compileWithRust(''); + assert.ok(result.code !== undefined); + }); +}); diff --git a/packages/astro/test/units/config/config-merge.test.js b/packages/astro/test/units/config/config-merge.test.js deleted file mode 100644 index e269b454a0c8..000000000000 --- a/packages/astro/test/units/config/config-merge.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { mergeConfig } from '../../../dist/core/config/index.js'; - -describe('mergeConfig', () => { - it('keeps server.allowedHosts as boolean', () => { - const defaults = { - server: { - allowedHosts: [], - }, - }; - const overrides = { - server: { - allowedHosts: true, - }, - }; - const merged = mergeConfig(defaults, overrides); - assert.equal(merged.server.allowedHosts, true); - }); -}); diff --git a/packages/astro/test/units/config/config-merge.test.ts b/packages/astro/test/units/config/config-merge.test.ts new file mode 100644 index 000000000000..59eba321d2da --- /dev/null +++ b/packages/astro/test/units/config/config-merge.test.ts @@ -0,0 +1,22 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { mergeConfig } from '../../../dist/core/config/index.js'; + +describe('mergeConfig', () => { + it('keeps server.allowedHosts as boolean', () => { + const defaults = { + server: { + // Typed as string[] to match AstroConfig's allowedHosts field + allowedHosts: [] as string[], + }, + }; + // allowedHosts can also be true (allow all) — cast to satisfy DeepPartial + const overrides = { + server: { + allowedHosts: true as boolean | string[], + }, + }; + const merged = mergeConfig(defaults, overrides as typeof defaults); + assert.equal(merged.server.allowedHosts, true); + }); +}); diff --git a/packages/astro/test/units/config/config-resolve.test.js b/packages/astro/test/units/config/config-resolve.test.ts similarity index 100% rename from packages/astro/test/units/config/config-resolve.test.js rename to packages/astro/test/units/config/config-resolve.test.ts diff --git a/packages/astro/test/units/config/config-server.test.js b/packages/astro/test/units/config/config-server.test.js deleted file mode 100644 index 6f621007c14a..000000000000 --- a/packages/astro/test/units/config/config-server.test.js +++ /dev/null @@ -1,73 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { flagsToAstroInlineConfig } from '../../../dist/cli/flags.js'; -import { resolveConfig } from '../../../dist/core/config/index.js'; - -const cwd = fileURLToPath(new URL('../../fixtures/config-host/', import.meta.url)); - -describe('config.server', () => { - function resolveConfigWithFlags(flags) { - return resolveConfig( - flagsToAstroInlineConfig({ - root: cwd, - ...flags, - }), - 'dev', - ); - } - - describe('host', () => { - it('can be specified via --host flag', async () => { - const projectRootURL = new URL('../../fixtures/astro-basic/', import.meta.url); - const { astroConfig } = await resolveConfigWithFlags({ - root: fileURLToPath(projectRootURL), - host: true, - }); - - assert.equal(astroConfig.server.host, true); - }); - }); - - describe('config', () => { - describe('relative path', () => { - it('can be passed via relative --config', async () => { - const projectRootURL = new URL('../../fixtures/astro-basic/', import.meta.url); - const configFileURL = 'my-config.mjs'; - const { astroConfig } = await resolveConfigWithFlags({ - root: fileURLToPath(projectRootURL), - config: configFileURL, - }); - assert.equal(astroConfig.server.port, 8080); - }); - }); - - describe('relative path with leading ./', () => { - it('can be passed via relative --config', async () => { - const projectRootURL = new URL('../../fixtures/astro-basic/', import.meta.url); - const configFileURL = './my-config.mjs'; - const { astroConfig } = await resolveConfigWithFlags({ - root: fileURLToPath(projectRootURL), - config: configFileURL, - }); - assert.equal(astroConfig.server.port, 8080); - }); - }); - - describe('incorrect path', () => { - it('fails and exits when config does not exist', async () => { - const projectRootURL = new URL('../../fixtures/astro-basic/', import.meta.url); - const configFileURL = './does-not-exist.mjs'; - try { - await resolveConfigWithFlags({ - root: fileURLToPath(projectRootURL), - config: configFileURL, - }); - assert.equal(false, true, 'this should not have resolved'); - } catch (err) { - assert.equal(err.message.includes('Unable to resolve'), true); - } - }); - }); - }); -}); diff --git a/packages/astro/test/units/config/config-server.test.ts b/packages/astro/test/units/config/config-server.test.ts new file mode 100644 index 000000000000..ab9c0d6b83c6 --- /dev/null +++ b/packages/astro/test/units/config/config-server.test.ts @@ -0,0 +1,65 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { flagsToAstroInlineConfig, type Flags } from '../../../dist/cli/flags.js'; +import { resolveConfig } from '../../../dist/core/config/index.js'; + +describe('config.server', () => { + function resolveConfigWithFlags(flags: Partial) { + return resolveConfig(flagsToAstroInlineConfig(flags as Flags), 'dev'); + } + + describe('host', () => { + it('can be specified via --host flag', async () => { + const projectRootURL = new URL('../../fixtures/astro-basic/', import.meta.url); + const { astroConfig } = await resolveConfigWithFlags({ + root: fileURLToPath(projectRootURL), + host: true, + }); + + assert.equal(astroConfig.server.host, true); + }); + }); + + describe('config', () => { + describe('relative path', () => { + it('can be passed via relative --config', async () => { + const projectRootURL = new URL('../../fixtures/astro-basic/', import.meta.url); + const configFileURL = 'my-config.mjs'; + const { astroConfig } = await resolveConfigWithFlags({ + root: fileURLToPath(projectRootURL), + config: configFileURL, + }); + assert.equal(astroConfig.server.port, 8080); + }); + }); + + describe('relative path with leading ./', () => { + it('can be passed via relative --config', async () => { + const projectRootURL = new URL('../../fixtures/astro-basic/', import.meta.url); + const configFileURL = './my-config.mjs'; + const { astroConfig } = await resolveConfigWithFlags({ + root: fileURLToPath(projectRootURL), + config: configFileURL, + }); + assert.equal(astroConfig.server.port, 8080); + }); + }); + + describe('incorrect path', () => { + it('fails and exits when config does not exist', async () => { + const projectRootURL = new URL('../../fixtures/astro-basic/', import.meta.url); + const configFileURL = './does-not-exist.mjs'; + try { + await resolveConfigWithFlags({ + root: fileURLToPath(projectRootURL), + config: configFileURL, + }); + assert.equal(false, true, 'this should not have resolved'); + } catch (err: unknown) { + assert.equal((err as Error).message.includes('Unable to resolve'), true); + } + }); + }); + }); +}); diff --git a/packages/astro/test/units/config/config-tsconfig.test.js b/packages/astro/test/units/config/config-tsconfig.test.js deleted file mode 100644 index 94e9438982fd..000000000000 --- a/packages/astro/test/units/config/config-tsconfig.test.js +++ /dev/null @@ -1,69 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import * as path from 'node:path'; -import { describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { toJson } from 'tsconfck'; -import { loadTSConfig, updateTSConfigForFramework } from '../../../dist/core/config/index.js'; - -const cwd = fileURLToPath(new URL('../../fixtures/tsconfig-handling/', import.meta.url)); - -describe('TSConfig handling', () => { - describe('tsconfig / jsconfig loading', () => { - it('can load tsconfig.json', async () => { - const config = await loadTSConfig(cwd); - - assert.equal(config !== undefined, true); - }); - - it('can resolve tsconfig.json up directories', async () => { - const config = await loadTSConfig(cwd); - - assert.equal(config !== undefined, true); - assert.equal(config.tsconfigFile, path.join(cwd, 'tsconfig.json')); - assert.deepEqual(config.tsconfig.files, ['im-a-test']); - }); - - it('can fall back to jsconfig.json if tsconfig.json does not exist', async () => { - const config = await loadTSConfig(path.join(cwd, 'jsconfig')); - - assert.equal(config !== undefined, true); - assert.equal(config.tsconfigFile, path.join(cwd, 'jsconfig', 'jsconfig.json')); - assert.deepEqual(config.tsconfig.files, ['im-a-test-js']); - }); - - it('properly return errors when not resolving', async () => { - const invalidConfig = await loadTSConfig(path.join(cwd, 'invalid')); - const missingConfig = await loadTSConfig(path.join(cwd, 'missing')); - - assert.equal(invalidConfig, 'invalid-config'); - assert.equal(missingConfig, 'missing-config'); - }); - - it('does not change baseUrl in raw config', async () => { - const loadedConfig = await loadTSConfig(path.join(cwd, 'baseUrl')); - const rawConfig = await readFile(path.join(cwd, 'baseUrl', 'tsconfig.json'), 'utf-8') - .then(toJson) - .then((content) => JSON.parse(content)); - - assert.deepEqual(loadedConfig.rawConfig, rawConfig); - }); - }); - - describe('tsconfig / jsconfig updates', () => { - it('can update a tsconfig with a framework config', async () => { - const config = await loadTSConfig(cwd); - const updatedConfig = updateTSConfigForFramework(config.tsconfig, 'react'); - - assert.notEqual(config.tsconfig, 'react-jsx'); - assert.equal(updatedConfig.compilerOptions.jsx, 'react-jsx'); - }); - - it('produce no changes on invalid frameworks', async () => { - const config = await loadTSConfig(cwd); - const updatedConfig = updateTSConfigForFramework(config.tsconfig, 'doesnt-exist'); - - assert.deepEqual(config.tsconfig, updatedConfig); - }); - }); -}); diff --git a/packages/astro/test/units/config/config-tsconfig.test.ts b/packages/astro/test/units/config/config-tsconfig.test.ts new file mode 100644 index 000000000000..e85e51ef7a38 --- /dev/null +++ b/packages/astro/test/units/config/config-tsconfig.test.ts @@ -0,0 +1,81 @@ +import * as assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { toJson } from 'tsconfck'; +import { loadTSConfig, updateTSConfigForFramework } from '../../../dist/core/config/index.js'; +import type { frameworkWithTSSettings } from '../../../dist/core/config/tsconfig.js'; + +const cwd = fileURLToPath(new URL('../../fixtures/tsconfig-handling/', import.meta.url)); + +/** Assert that loadTSConfig returned a valid result (not an error string). */ +function assertValidConfig( + config: Awaited>, +): asserts config is Exclude { + assert.ok(typeof config !== 'string', `Expected a valid config but got error: ${config}`); +} + +describe('TSConfig handling', () => { + describe('tsconfig / jsconfig loading', () => { + it('can load tsconfig.json', async () => { + const config = await loadTSConfig(cwd); + assert.equal(config !== undefined, true); + }); + + it('can resolve tsconfig.json up directories', async () => { + const config = await loadTSConfig(cwd); + assertValidConfig(config); + assert.equal(config.tsconfigFile, path.join(cwd, 'tsconfig.json')); + assert.deepEqual(config.tsconfig.files, ['im-a-test']); + }); + + it('can fall back to jsconfig.json if tsconfig.json does not exist', async () => { + const config = await loadTSConfig(path.join(cwd, 'jsconfig')); + assertValidConfig(config); + assert.equal(config.tsconfigFile, path.join(cwd, 'jsconfig', 'jsconfig.json')); + assert.deepEqual(config.tsconfig.files, ['im-a-test-js']); + }); + + it('properly return errors when not resolving', async () => { + const invalidConfig = await loadTSConfig(path.join(cwd, 'invalid')); + const missingConfig = await loadTSConfig(path.join(cwd, 'missing')); + + assert.equal(invalidConfig, 'invalid-config'); + assert.equal(missingConfig, 'missing-config'); + }); + + it('does not change baseUrl in raw config', async () => { + const loadedConfig = await loadTSConfig(path.join(cwd, 'baseUrl')); + assertValidConfig(loadedConfig); + const rawConfig = await readFile(path.join(cwd, 'baseUrl', 'tsconfig.json'), 'utf-8') + .then(toJson) + .then((content) => JSON.parse(content)); + + assert.deepEqual(loadedConfig.rawConfig, rawConfig); + }); + }); + + describe('tsconfig / jsconfig updates', () => { + it('can update a tsconfig with a framework config', async () => { + const config = await loadTSConfig(cwd); + assertValidConfig(config); + const updatedConfig = updateTSConfigForFramework(config.tsconfig, 'react'); + + assert.notEqual(config.tsconfig, 'react-jsx'); + assert.equal(updatedConfig.compilerOptions?.jsx, 'react-jsx'); + }); + + it('produce no changes on invalid frameworks', async () => { + const config = await loadTSConfig(cwd); + assertValidConfig(config); + // 'doesnt-exist' is not a valid frameworkWithTSSettings — cast to test fallback behaviour + const updatedConfig = updateTSConfigForFramework( + config.tsconfig, + 'doesnt-exist' as frameworkWithTSSettings, + ); + + assert.deepEqual(config.tsconfig, updatedConfig); + }); + }); +}); diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js deleted file mode 100644 index 8938c14e166d..000000000000 --- a/packages/astro/test/units/config/config-validate.test.js +++ /dev/null @@ -1,630 +0,0 @@ -// @ts-check -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { stripVTControlCharacters } from 'node:util'; -import * as z from 'zod/v4'; -import { fontProviders } from '../../../dist/assets/fonts/providers/index.js'; -import { LocalFontProvider } from '../../../dist/assets/fonts/providers/local.js'; -import { validateConfig as _validateConfig } from '../../../dist/core/config/validate.js'; -import { formatConfigErrorMessage } from '../../../dist/core/messages/runtime.js'; -import { envField } from '../../../dist/env/config.js'; - -/** - * - * @param {any} userConfig - */ -async function validateConfig(userConfig) { - return _validateConfig(userConfig, process.cwd(), ''); -} - -describe('Config Validation', () => { - it('empty user config is valid', async () => { - assert.doesNotThrow(() => validateConfig({}).catch((err) => err)); - }); - - it('Zod errors are returned when invalid config is used', async () => { - const configError = await validateConfig({ site: 42 }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - }); - - it('A validation error can be formatted correctly', async () => { - const configError = await validateConfig({ site: 42 }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - const formattedError = stripVTControlCharacters(formatConfigErrorMessage(configError)); - assert.equal( - formattedError, - `[config] Astro found issue(s) with your configuration: - -! site: Expected type "string", received "number"`, - ); - }); - - it('Multiple validation errors can be formatted correctly', async () => { - const veryBadConfig = { - integrations: [42], - build: { format: 'invalid' }, - }; - const configError = await validateConfig(veryBadConfig).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - const formattedError = stripVTControlCharacters(formatConfigErrorMessage(configError)); - assert.equal( - formattedError, - `[config] Astro found issue(s) with your configuration: - -! integrations.0: Expected type "object", received "number" - -! build.format: Did not match union. - > Expected type "file" | "directory" | "preserve" - > Received "invalid"`, - ); - }); - - it('ignores falsey "integration" values', async () => { - const result = await validateConfig({ integrations: [0, false, null, undefined] }); - assert.deepEqual(result.integrations, []); - }); - it('normalizes "integration" values', async () => { - const result = await validateConfig({ integrations: [{ name: '@astrojs/a' }] }); - assert.deepEqual(result.integrations, [{ name: '@astrojs/a', hooks: {} }]); - }); - it('flattens array "integration" values', async () => { - const result = await validateConfig({ - integrations: [{ name: '@astrojs/a' }, [{ name: '@astrojs/b' }, { name: '@astrojs/c' }]], - }); - assert.deepEqual(result.integrations, [ - { name: '@astrojs/a', hooks: {} }, - { name: '@astrojs/b', hooks: {} }, - { name: '@astrojs/c', hooks: {} }, - ]); - }); - it('ignores null or falsy "integration" values', async () => { - const configError = await validateConfig({ - integrations: [null, undefined, false, '', ``], - }).catch((err) => err); - assert.equal(configError instanceof Error, false); - }); - it('Error when outDir is placed within publicDir', async () => { - const configError = await validateConfig({ outDir: './public/dist' }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - 'The value of `outDir` must not point to a path within the folder set as `publicDir`, this will cause an infinite loop', - ); - }); - - it('errors with helpful message when output is "hybrid"', async () => { - const configError = await validateConfig({ output: 'hybrid' }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.ok( - configError.issues[0].message.includes('removed'), - 'Error message should explain that "hybrid" has been removed', - ); - }); - - describe('i18n', async () => { - it('defaultLocale is not in locales', async () => { - const configError = await validateConfig({ - i18n: { - defaultLocale: 'en', - locales: ['es'], - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - 'The default locale `en` is not present in the `i18n.locales` array.', - ); - }); - - it('errors if codes are empty', async () => { - const configError = await validateConfig({ - i18n: { - defaultLocale: 'uk', - locales: [ - 'es', - { - path: 'something', - codes: [], - }, - ], - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - `**i18n.locales.1**: Did not match union. -> Expected type \`string | { codes.0: string }\` -> Received \`{ "path": "something", "codes": [] }\``, - ); - }); - - it('errors if the default locale is not in path', async () => { - const configError = await validateConfig({ - i18n: { - defaultLocale: 'uk', - locales: [ - 'es', - { - path: 'something', - codes: ['en-UK'], - }, - ], - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - 'The default locale `uk` is not present in the `i18n.locales` array.', - ); - }); - - it('errors if a fallback value does not exist', async () => { - const configError = await validateConfig({ - i18n: { - defaultLocale: 'en', - locales: ['es', 'en'], - fallback: { - es: 'it', - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - "The locale `it` value in the `i18n.fallback` record doesn't exist in the `i18n.locales` array.", - ); - }); - - it('errors if a fallback key does not exist', async () => { - const configError = await validateConfig({ - i18n: { - defaultLocale: 'en', - locales: ['es', 'en'], - fallback: { - it: 'en', - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - "The locale `it` key in the `i18n.fallback` record doesn't exist in the `i18n.locales` array.", - ); - }); - - it('errors if a fallback key contains the default locale', async () => { - const configError = await validateConfig({ - i18n: { - defaultLocale: 'en', - locales: ['es', 'en'], - fallback: { - en: 'es', - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - "You can't use the default locale as a key. The default locale can only be used as value.", - ); - }); - - it( - 'errors if `i18n.prefixDefaultLocale` is `false` and `i18n.redirectToDefaultLocale` is `true`', - { todo: 'Enable in Astro 6.0', skip: 'Removed validation' }, - async () => { - const configError = await validateConfig({ - i18n: { - defaultLocale: 'en', - locales: ['es', 'en'], - routing: { - prefixDefaultLocale: false, - redirectToDefaultLocale: true, - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - 'The option `i18n.routing.redirectToDefaultLocale` can be used only when `i18n.routing.prefixDefaultLocale` is set to `true`; otherwise, redirects might cause infinite loops. Remove the option `i18n.routing.redirectToDefaultLocale`, or change its value to `false`.', - ); - }, - ); - - it('errors if a domains key does not exist', async () => { - const configError = await validateConfig({ - output: 'server', - site: 'https://www.example.com', - i18n: { - defaultLocale: 'en', - locales: ['es', 'en'], - domains: { - lorem: 'https://example.com', - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - "The locale `lorem` key in the `i18n.domains` record doesn't exist in the `i18n.locales` array.", - ); - }); - - it('errors if a domains value is not an URL', async () => { - const configError = await validateConfig({ - output: 'server', - site: 'https://www.example.com', - i18n: { - defaultLocale: 'en', - locales: ['es', 'en'], - domains: { - en: 'www.example.com', - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - "The domain value must be a valid URL, and it has to start with 'https' or 'http'.", - ); - }); - - it('errors if a domains value is not an URL with incorrect protocol', async () => { - const configError = await validateConfig({ - output: 'server', - site: 'https://www.example.com', - i18n: { - defaultLocale: 'en', - locales: ['es', 'en'], - domains: { - en: 'tcp://www.example.com', - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - "The domain value must be a valid URL, and it has to start with 'https' or 'http'.", - ); - }); - - it('errors if a domain is a URL with a pathname that is not the home', async () => { - const configError = await validateConfig({ - output: 'server', - site: 'https://www.example.com', - i18n: { - defaultLocale: 'en', - locales: ['es', 'en'], - domains: { - en: 'https://www.example.com/blog/page/', - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - "The URL `https://www.example.com/blog/page/` must contain only the origin. A subsequent pathname isn't allowed here. Remove `/blog/page/`.", - ); - }); - - it('errors if domains is enabled but site is not provided', async () => { - const configError = await validateConfig({ - output: 'server', - i18n: { - defaultLocale: 'en', - locales: ['es', 'en'], - domains: { - en: 'https://www.example.com/', - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - "The option `site` isn't set. When using the 'domains' strategy for `i18n`, `site` is required to create absolute URLs for locales that aren't mapped to a domain.", - ); - }); - - it('errors if domains is enabled but the `output` is not "server"', async () => { - const configError = await validateConfig({ - output: 'static', - i18n: { - defaultLocale: 'en', - locales: ['es', 'en'], - domains: { - en: 'https://www.example.com/', - }, - }, - site: 'https://foo.org', - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message, - 'Domain support is only available when `output` is `"server"`.', - ); - }); - }); - - describe('env', () => { - it('Should allow not providing a schema', () => { - assert.doesNotThrow(() => - validateConfig({ - env: { - schema: undefined, - }, - }), - ); - }); - - it('Should allow schema variables with numbers', () => { - assert.doesNotThrow(() => - validateConfig({ - env: { - schema: { - ABC123: envField.string({ access: 'public', context: 'server' }), - }, - }, - }), - ); - }); - - it('Should not allow schema variables starting with a number', async () => { - const configError = await validateConfig({ - env: { - schema: { - '123ABC': envField.string({ access: 'public', context: 'server' }), - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal(configError.issues[0].message, 'Invalid key in record'); - }); - - it('Should provide a useful error for access/context invalid combinations', async () => { - const configError = await validateConfig({ - env: { - schema: { - // @ts-expect-error we test an invalid combination - BAR: envField.string({ access: 'secret', context: 'client' }), - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message.includes( - '**Invalid combination** of "access" and "context" options', - ), - true, - ); - }); - }); - - describe('fonts', () => { - it('Should allow empty fonts', () => { - assert.doesNotThrow(() => - validateConfig({ - fonts: [], - }), - ); - }); - - it('Should error on invalid css variable', async () => { - let configError = await validateConfig({ - fonts: [ - { - name: 'Roboto', - cssVariable: 'test', - provider: { name: 'foo', resolveFont: () => undefined }, - }, - ], - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message.includes( - 'contains invalid characters for CSS variable generation', - ), - true, - ); - - configError = await validateConfig({ - fonts: [ - { - name: 'Roboto', - cssVariable: '-test', - provider: { name: 'foo', resolveFont: () => undefined }, - }, - ], - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message.includes( - 'contains invalid characters for CSS variable generation', - ), - true, - ); - - configError = await validateConfig({ - fonts: [ - { - name: 'Roboto', - cssVariable: '--test ', - provider: { name: 'foo', resolveFont: () => undefined }, - }, - ], - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message.includes( - 'contains invalid characters for CSS variable generation', - ), - true, - ); - - configError = await validateConfig({ - fonts: [ - { - name: 'Roboto', - cssVariable: '--test:x', - provider: { name: 'foo', resolveFont: () => undefined }, - }, - ], - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - assert.equal( - configError.issues[0].message.includes( - 'contains invalid characters for CSS variable generation', - ), - true, - ); - - assert.doesNotThrow(() => - validateConfig({ - fonts: [ - { - name: 'Roboto', - cssVariable: '--test', - provider: { name: 'foo', resolveFont: () => undefined }, - }, - ], - }), - ); - }); - - it('Should allow empty font fallbacks', () => { - assert.doesNotThrow(() => - validateConfig({ - fonts: [ - { - provider: fontProviders.google(), - name: 'Roboto', - fallbacks: [], - cssVariable: '--font-roboto', - }, - ], - }), - ); - }); - - it('Should allow family options', () => { - assert.doesNotThrow(() => - validateConfig({ - fonts: [ - { - provider: fontProviders.google(), - name: 'Roboto', - cssVariable: '--font-roboto', - options: {}, - }, - ], - }), - ); - }); - - it('should preserve FontProvider as a class instance', async () => { - const config = await validateConfig({ - fonts: [ - { - provider: fontProviders.local(), - name: 'Roboto', - cssVariable: '--font-roboto', - options: {}, - }, - ], - }); - assert.equal(config.fonts?.[0].provider instanceof LocalFontProvider, true); - }); - }); - - describe('session', () => { - it('should allow session config without a driver', async () => { - // Adapters like cloudflare, netlify, and node provide default drivers - // so users should be able to configure session options without specifying a driver - const result = await validateConfig({ - session: { - ttl: 60 * 60, // 1 hour - }, - }); - assert.equal(result.session.ttl, 60 * 60); - assert.equal(result.session.driver, undefined); - }); - }); - - describe('csp', () => { - it('should throw an error if incorrect scriptHashes are passed', async () => { - let configError = await validateConfig({ - security: { - csp: { - scriptDirective: { - hashes: ['fancy-1234567890'], - }, - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - }); - - it('should throw an error if incorrect styleHashes are passed', async () => { - let configError = await validateConfig({ - security: { - csp: { - styleDirective: { - hashes: ['fancy-1234567890'], - }, - }, - }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - }); - - it('should not throw an error for correct hashes', async () => { - assert.doesNotThrow(() => { - validateConfig({ - security: { - csp: { - styleDirective: { - hashes: ['sha256-1234567890'], - }, - }, - }, - }); - }); - }); - - it('should not throw an error when the directives are correct', () => { - assert.doesNotThrow(() => - validateConfig({ - security: { - csp: { - directives: ["image-src 'self'"], - }, - }, - }).catch((err) => err), - ); - }); - }); - - describe('devToolbar', () => { - it('should allow valid placement values', async () => { - for (const placement of ['bottom-left', 'bottom-center', 'bottom-right']) { - const result = await validateConfig({ - devToolbar: { placement }, - }); - assert.equal(result.devToolbar.placement, placement); - } - }); - - it('should allow omitting placement (optional)', async () => { - const result = await validateConfig({ - devToolbar: { enabled: true }, - }); - assert.equal(result.devToolbar.placement, undefined); - }); - - it('should reject invalid placement values', async () => { - const configError = await validateConfig({ - devToolbar: { placement: 'top-left' }, - }).catch((err) => err); - assert.equal(configError instanceof z.ZodError, true); - }); - }); -}); diff --git a/packages/astro/test/units/config/config-validate.test.ts b/packages/astro/test/units/config/config-validate.test.ts new file mode 100644 index 000000000000..d6229bf01e0f --- /dev/null +++ b/packages/astro/test/units/config/config-validate.test.ts @@ -0,0 +1,624 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { stripVTControlCharacters } from 'node:util'; +import * as z from 'zod/v4'; +import { fontProviders } from '../../../dist/assets/fonts/providers/index.js'; +import { LocalFontProvider } from '../../../dist/assets/fonts/providers/local.js'; +import { validateConfig as _validateConfig } from '../../../dist/core/config/validate.js'; +import { formatConfigErrorMessage } from '../../../dist/core/messages/runtime.js'; +import { envField } from '../../../dist/env/config.js'; + +async function validateConfig(userConfig: Record) { + return _validateConfig(userConfig, process.cwd(), ''); +} + +describe('Config Validation', () => { + it('empty user config is valid', async () => { + assert.doesNotThrow(() => validateConfig({}).catch((err) => err)); + }); + + it('Zod errors are returned when invalid config is used', async () => { + const configError = await validateConfig({ site: 42 }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + }); + + it('A validation error can be formatted correctly', async () => { + const configError = await validateConfig({ site: 42 }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + const formattedError = stripVTControlCharacters(formatConfigErrorMessage(configError)); + assert.equal( + formattedError, + `[config] Astro found issue(s) with your configuration: + +! site: Expected type "string", received "number"`, + ); + }); + + it('Multiple validation errors can be formatted correctly', async () => { + const veryBadConfig = { + integrations: [42], + build: { format: 'invalid' }, + }; + const configError = await validateConfig(veryBadConfig).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + const formattedError = stripVTControlCharacters(formatConfigErrorMessage(configError)); + assert.equal( + formattedError, + `[config] Astro found issue(s) with your configuration: + +! integrations.0: Expected type "object", received "number" + +! build.format: Did not match union. + > Expected type "file" | "directory" | "preserve" + > Received "invalid"`, + ); + }); + + it('ignores falsey "integration" values', async () => { + const result = await validateConfig({ integrations: [0, false, null, undefined] }); + assert.deepEqual(result.integrations, []); + }); + it('normalizes "integration" values', async () => { + const result = await validateConfig({ integrations: [{ name: '@astrojs/a' }] }); + assert.deepEqual(result.integrations, [{ name: '@astrojs/a', hooks: {} }]); + }); + it('flattens array "integration" values', async () => { + const result = await validateConfig({ + integrations: [{ name: '@astrojs/a' }, [{ name: '@astrojs/b' }, { name: '@astrojs/c' }]], + }); + assert.deepEqual(result.integrations, [ + { name: '@astrojs/a', hooks: {} }, + { name: '@astrojs/b', hooks: {} }, + { name: '@astrojs/c', hooks: {} }, + ]); + }); + it('ignores null or falsy "integration" values', async () => { + const configError = await validateConfig({ + integrations: [null, undefined, false, '', ``], + }).catch((err) => err); + assert.equal(configError instanceof Error, false); + }); + it('Error when outDir is placed within publicDir', async () => { + const configError = await validateConfig({ outDir: './public/dist' }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + 'The value of `outDir` must not point to a path within the folder set as `publicDir`, this will cause an infinite loop', + ); + }); + + it('errors with helpful message when output is "hybrid"', async () => { + const configError = await validateConfig({ output: 'hybrid' }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.ok( + configError.issues[0].message.includes('removed'), + 'Error message should explain that "hybrid" has been removed', + ); + }); + + describe('i18n', async () => { + it('defaultLocale is not in locales', async () => { + const configError = await validateConfig({ + i18n: { + defaultLocale: 'en', + locales: ['es'], + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + 'The default locale `en` is not present in the `i18n.locales` array.', + ); + }); + + it('errors if codes are empty', async () => { + const configError = await validateConfig({ + i18n: { + defaultLocale: 'uk', + locales: [ + 'es', + { + path: 'something', + codes: [], + }, + ], + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + `**i18n.locales.1**: Did not match union. +> Expected type \`string | { codes.0: string }\` +> Received \`{ "path": "something", "codes": [] }\``, + ); + }); + + it('errors if the default locale is not in path', async () => { + const configError = await validateConfig({ + i18n: { + defaultLocale: 'uk', + locales: [ + 'es', + { + path: 'something', + codes: ['en-UK'], + }, + ], + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + 'The default locale `uk` is not present in the `i18n.locales` array.', + ); + }); + + it('errors if a fallback value does not exist', async () => { + const configError = await validateConfig({ + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + fallback: { + es: 'it', + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + "The locale `it` value in the `i18n.fallback` record doesn't exist in the `i18n.locales` array.", + ); + }); + + it('errors if a fallback key does not exist', async () => { + const configError = await validateConfig({ + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + fallback: { + it: 'en', + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + "The locale `it` key in the `i18n.fallback` record doesn't exist in the `i18n.locales` array.", + ); + }); + + it('errors if a fallback key contains the default locale', async () => { + const configError = await validateConfig({ + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + fallback: { + en: 'es', + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + "You can't use the default locale as a key. The default locale can only be used as value.", + ); + }); + + it('errors if `i18n.prefixDefaultLocale` is `false` and `i18n.redirectToDefaultLocale` is `true`', { + todo: 'Enable in Astro 6.0', + skip: 'Removed validation', + }, async () => { + const configError = await validateConfig({ + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + routing: { + prefixDefaultLocale: false, + redirectToDefaultLocale: true, + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + 'The option `i18n.routing.redirectToDefaultLocale` can be used only when `i18n.routing.prefixDefaultLocale` is set to `true`; otherwise, redirects might cause infinite loops. Remove the option `i18n.routing.redirectToDefaultLocale`, or change its value to `false`.', + ); + }); + + it('errors if a domains key does not exist', async () => { + const configError = await validateConfig({ + output: 'server', + site: 'https://www.example.com', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + lorem: 'https://example.com', + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + "The locale `lorem` key in the `i18n.domains` record doesn't exist in the `i18n.locales` array.", + ); + }); + + it('errors if a domains value is not an URL', async () => { + const configError = await validateConfig({ + output: 'server', + site: 'https://www.example.com', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + en: 'www.example.com', + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + "The domain value must be a valid URL, and it has to start with 'https' or 'http'.", + ); + }); + + it('errors if a domains value is not an URL with incorrect protocol', async () => { + const configError = await validateConfig({ + output: 'server', + site: 'https://www.example.com', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + en: 'tcp://www.example.com', + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + "The domain value must be a valid URL, and it has to start with 'https' or 'http'.", + ); + }); + + it('errors if a domain is a URL with a pathname that is not the home', async () => { + const configError = await validateConfig({ + output: 'server', + site: 'https://www.example.com', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + en: 'https://www.example.com/blog/page/', + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + "The URL `https://www.example.com/blog/page/` must contain only the origin. A subsequent pathname isn't allowed here. Remove `/blog/page/`.", + ); + }); + + it('errors if domains is enabled but site is not provided', async () => { + const configError = await validateConfig({ + output: 'server', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + en: 'https://www.example.com/', + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + "The option `site` isn't set. When using the 'domains' strategy for `i18n`, `site` is required to create absolute URLs for locales that aren't mapped to a domain.", + ); + }); + + it('errors if domains is enabled but the `output` is not "server"', async () => { + const configError = await validateConfig({ + output: 'static', + i18n: { + defaultLocale: 'en', + locales: ['es', 'en'], + domains: { + en: 'https://www.example.com/', + }, + }, + site: 'https://foo.org', + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message, + 'Domain support is only available when `output` is `"server"`.', + ); + }); + }); + + describe('env', () => { + it('Should allow not providing a schema', () => { + assert.doesNotThrow(() => + validateConfig({ + env: { + schema: undefined, + }, + }), + ); + }); + + it('Should allow schema variables with numbers', () => { + assert.doesNotThrow(() => + validateConfig({ + env: { + schema: { + ABC123: envField.string({ access: 'public', context: 'server' }), + }, + }, + }), + ); + }); + + it('Should not allow schema variables starting with a number', async () => { + const configError = await validateConfig({ + env: { + schema: { + '123ABC': envField.string({ access: 'public', context: 'server' }), + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal(configError.issues[0].message, 'Invalid key in record'); + }); + + it('Should provide a useful error for access/context invalid combinations', async () => { + const configError = await validateConfig({ + env: { + schema: { + // @ts-expect-error we test an invalid combination + BAR: envField.string({ access: 'secret', context: 'client' }), + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message.includes( + '**Invalid combination** of "access" and "context" options', + ), + true, + ); + }); + }); + + describe('fonts', () => { + it('Should allow empty fonts', () => { + assert.doesNotThrow(() => + validateConfig({ + fonts: [], + }), + ); + }); + + it('Should error on invalid css variable', async () => { + let configError = await validateConfig({ + fonts: [ + { + name: 'Roboto', + cssVariable: 'test', + provider: { name: 'foo', resolveFont: () => undefined }, + }, + ], + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message.includes( + 'contains invalid characters for CSS variable generation', + ), + true, + ); + + configError = await validateConfig({ + fonts: [ + { + name: 'Roboto', + cssVariable: '-test', + provider: { name: 'foo', resolveFont: () => undefined }, + }, + ], + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message.includes( + 'contains invalid characters for CSS variable generation', + ), + true, + ); + + configError = await validateConfig({ + fonts: [ + { + name: 'Roboto', + cssVariable: '--test ', + provider: { name: 'foo', resolveFont: () => undefined }, + }, + ], + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message.includes( + 'contains invalid characters for CSS variable generation', + ), + true, + ); + + configError = await validateConfig({ + fonts: [ + { + name: 'Roboto', + cssVariable: '--test:x', + provider: { name: 'foo', resolveFont: () => undefined }, + }, + ], + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.equal( + configError.issues[0].message.includes( + 'contains invalid characters for CSS variable generation', + ), + true, + ); + + assert.doesNotThrow(() => + validateConfig({ + fonts: [ + { + name: 'Roboto', + cssVariable: '--test', + provider: { name: 'foo', resolveFont: () => undefined }, + }, + ], + }), + ); + }); + + it('Should allow empty font fallbacks', () => { + assert.doesNotThrow(() => + validateConfig({ + fonts: [ + { + provider: fontProviders.google(), + name: 'Roboto', + fallbacks: [], + cssVariable: '--font-roboto', + }, + ], + }), + ); + }); + + it('Should allow family options', () => { + assert.doesNotThrow(() => + validateConfig({ + fonts: [ + { + provider: fontProviders.google(), + name: 'Roboto', + cssVariable: '--font-roboto', + options: {}, + }, + ], + }), + ); + }); + + it('should preserve FontProvider as a class instance', async () => { + const config = await validateConfig({ + fonts: [ + { + provider: fontProviders.local(), + name: 'Roboto', + cssVariable: '--font-roboto', + options: {}, + }, + ], + }); + assert.equal(config.fonts?.[0].provider instanceof LocalFontProvider, true); + }); + }); + + describe('session', () => { + it('should allow session config without a driver', async () => { + // Adapters like cloudflare, netlify, and node provide default drivers + // so users should be able to configure session options without specifying a driver + const result = await validateConfig({ + session: { + ttl: 60 * 60, // 1 hour + }, + }); + assert.equal(result.session?.ttl, 60 * 60); + assert.equal(result.session?.driver, undefined); + }); + }); + + describe('csp', () => { + it('should throw an error if incorrect scriptHashes are passed', async () => { + let configError = await validateConfig({ + security: { + csp: { + scriptDirective: { + hashes: ['fancy-1234567890'], + }, + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + }); + + it('should throw an error if incorrect styleHashes are passed', async () => { + let configError = await validateConfig({ + security: { + csp: { + styleDirective: { + hashes: ['fancy-1234567890'], + }, + }, + }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + }); + + it('should not throw an error for correct hashes', async () => { + assert.doesNotThrow(() => { + validateConfig({ + security: { + csp: { + styleDirective: { + hashes: ['sha256-1234567890'], + }, + }, + }, + }); + }); + }); + + it('should not throw an error when the directives are correct', () => { + assert.doesNotThrow(() => + validateConfig({ + security: { + csp: { + directives: ["image-src 'self'"], + }, + }, + }).catch((err) => err), + ); + }); + }); + + describe('devToolbar', () => { + it('should allow valid placement values', async () => { + for (const placement of ['bottom-left', 'bottom-center', 'bottom-right']) { + const result = await validateConfig({ + devToolbar: { placement }, + }); + assert.equal(result.devToolbar.placement, placement); + } + }); + + it('should allow omitting placement (optional)', async () => { + const result = await validateConfig({ + devToolbar: { enabled: true }, + }); + assert.equal(result.devToolbar.placement, undefined); + }); + + it('should reject invalid placement values', async () => { + const configError = await validateConfig({ + devToolbar: { placement: 'top-left' }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + }); + }); +}); diff --git a/packages/astro/test/units/config/format.test.js b/packages/astro/test/units/config/format.test.js deleted file mode 100644 index 66938a03a5b1..000000000000 --- a/packages/astro/test/units/config/format.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { createFixture, runInContainer } from '../test-utils.js'; - -describe('Astro config formats', () => { - it('An mjs config can import TypeScript modules', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': ``, - '/src/stuff.ts': `export default 'works';`, - '/astro.config.mjs': `\ - import stuff from './src/stuff.ts'; - export default {} - `, - }); - - await runInContainer({ inlineConfig: { root: fixture.path } }, () => { - assert.equal( - true, - true, - 'We were able to get into the container which means the config loaded.', - ); - }); - }); -}); diff --git a/packages/astro/test/units/config/refined-validators.test.ts b/packages/astro/test/units/config/refined-validators.test.ts new file mode 100644 index 000000000000..f2a37e65d82d --- /dev/null +++ b/packages/astro/test/units/config/refined-validators.test.ts @@ -0,0 +1,444 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { AstroConfig } from '../../../dist/types/public/config.js'; +import { + validateAssetsPrefix, + validateFontsCssVariables, + validateI18nDefaultLocale, + validateI18nDomains, + validateI18nFallback, + validateI18nRedirectToDefaultLocale, + validateOutDirNotInPublicDir, + validateRemotePatterns, +} from '../../../dist/core/config/schemas/refined-validators.js'; + +/** Cast partial test data to a strict Pick type via `unknown`. */ +const build = (v: unknown) => ({ build: v }) as Pick; +const i18n = (v: unknown) => v as NonNullable; +const domains = (v: unknown) => v as Pick; +const font = (v: unknown) => v as NonNullable[number]; + +// #region validateAssetsPrefix +describe('validateAssetsPrefix', () => { + it('returns no issues for a string prefix', () => { + const issues = validateAssetsPrefix(build({ assetsPrefix: 'https://cdn.example.com' })); + assert.equal(issues.length, 0); + }); + + it('returns no issues when assetsPrefix is undefined', () => { + const issues = validateAssetsPrefix(build({})); + assert.equal(issues.length, 0); + }); + + it('returns no issues for an object with fallback', () => { + const issues = validateAssetsPrefix( + build({ assetsPrefix: { css: 'https://css.cdn.com', fallback: 'https://cdn.com' } }), + ); + assert.equal(issues.length, 0); + }); + + it('returns an issue for an object without fallback', () => { + const issues = validateAssetsPrefix(build({ assetsPrefix: { css: 'https://css.cdn.com' } })); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /fallback/i); + assert.deepEqual(issues[0].path, ['build', 'assetsPrefix']); + }); +}); +// #endregion + +// #region validateRemotePatterns +describe('validateRemotePatterns', () => { + it('returns no issues for empty array', () => { + const issues = validateRemotePatterns([]); + assert.equal(issues.length, 0); + }); + + it('returns no issues for valid hostname wildcard at start', () => { + const issues = validateRemotePatterns([{ hostname: '*.example.com' }]); + assert.equal(issues.length, 0); + }); + + it('returns no issues for double-star hostname wildcard at start', () => { + const issues = validateRemotePatterns([{ hostname: '**.example.com' }]); + assert.equal(issues.length, 0); + }); + + it('returns an issue for wildcard in the middle of hostname', () => { + const issues = validateRemotePatterns([{ hostname: 'cdn.*.example.com' }]); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /beginning of the hostname/); + assert.deepEqual(issues[0].path, ['image', 'remotePatterns', 0, 'hostname']); + }); + + it('returns an issue for wildcard at the end of hostname', () => { + const issues = validateRemotePatterns([{ hostname: 'example.*' }]); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /beginning of the hostname/); + }); + + it('returns no issues for valid pathname wildcard at end', () => { + const issues = validateRemotePatterns([{ pathname: '/images/*' }]); + assert.equal(issues.length, 0); + }); + + it('returns no issues for double-star pathname wildcard at end', () => { + const issues = validateRemotePatterns([{ pathname: '/images/**' }]); + assert.equal(issues.length, 0); + }); + + it('returns an issue for wildcard at the start of pathname', () => { + const issues = validateRemotePatterns([{ pathname: '/*/images' }]); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /end of a pathname/); + assert.deepEqual(issues[0].path, ['image', 'remotePatterns', 0, 'pathname']); + }); + + it('returns issues for multiple invalid patterns', () => { + const issues = validateRemotePatterns([ + { hostname: 'cdn.*.example.com' }, + { hostname: '*.valid.com' }, + { pathname: '/*/bad' }, + ]); + assert.equal(issues.length, 2); + }); + + it('returns no issues for patterns without wildcards', () => { + const issues = validateRemotePatterns([{ hostname: 'example.com', pathname: '/images' }]); + assert.equal(issues.length, 0); + }); +}); +// #endregion + +// #region validateI18nRedirectToDefaultLocale +describe('validateI18nRedirectToDefaultLocale', () => { + it('returns no issues when i18n is undefined', () => { + const issues = validateI18nRedirectToDefaultLocale(undefined); + assert.equal(issues.length, 0); + }); + + it('returns no issues when prefixDefaultLocale is true and redirectToDefaultLocale is true', () => { + const issues = validateI18nRedirectToDefaultLocale( + i18n({ + routing: { + prefixDefaultLocale: true, + redirectToDefaultLocale: true, + fallbackType: 'redirect', + }, + }), + ); + assert.equal(issues.length, 0); + }); + + it('returns no issues when prefixDefaultLocale is false and redirectToDefaultLocale is false', () => { + const issues = validateI18nRedirectToDefaultLocale( + i18n({ + routing: { + prefixDefaultLocale: false, + redirectToDefaultLocale: false, + fallbackType: 'redirect', + }, + }), + ); + assert.equal(issues.length, 0); + }); + + it('returns an issue when prefixDefaultLocale is false and redirectToDefaultLocale is true', () => { + const issues = validateI18nRedirectToDefaultLocale( + i18n({ + routing: { + prefixDefaultLocale: false, + redirectToDefaultLocale: true, + fallbackType: 'redirect', + }, + }), + ); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /redirectToDefaultLocale/); + assert.match(issues[0].message, /prefixDefaultLocale/); + assert.deepEqual(issues[0].path, ['i18n', 'routing', 'redirectToDefaultLocale']); + }); + + it('returns no issues when routing is manual', () => { + const issues = validateI18nRedirectToDefaultLocale(i18n({ routing: 'manual' })); + assert.equal(issues.length, 0); + }); +}); +// #endregion + +// #region validateOutDirNotInPublicDir +describe('validateOutDirNotInPublicDir', () => { + it('returns no issues when outDir is outside publicDir', () => { + const issues = validateOutDirNotInPublicDir( + new URL('file:///project/dist/'), + new URL('file:///project/public/'), + ); + assert.equal(issues.length, 0); + }); + + it('returns an issue when outDir equals publicDir', () => { + const issues = validateOutDirNotInPublicDir( + new URL('file:///project/public/'), + new URL('file:///project/public/'), + ); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /outDir/); + assert.match(issues[0].message, /publicDir/); + assert.deepEqual(issues[0].path, ['outDir']); + }); + + it('returns an issue when outDir is inside publicDir', () => { + const issues = validateOutDirNotInPublicDir( + new URL('file:///project/public/dist/'), + new URL('file:///project/public/'), + ); + assert.equal(issues.length, 1); + }); +}); +// #endregion + +// #region validateI18nDefaultLocale +describe('validateI18nDefaultLocale', () => { + it('returns no issues when defaultLocale is in locales', () => { + const issues = validateI18nDefaultLocale({ + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + }); + assert.equal(issues.length, 0); + }); + + it('returns an issue when defaultLocale is not in locales', () => { + const issues = validateI18nDefaultLocale({ + defaultLocale: 'es', + locales: ['en', 'fr', 'de'], + }); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /es/); + assert.match(issues[0].message, /not present/); + assert.deepEqual(issues[0].path, ['i18n', 'locales']); + }); + + it('handles object locales (uses path property)', () => { + const issues = validateI18nDefaultLocale({ + defaultLocale: 'english', + locales: [{ path: 'english', codes: ['en'] }, 'fr'], + }); + assert.equal(issues.length, 0); + }); + + it('returns an issue when defaultLocale is missing from object locales', () => { + const issues = validateI18nDefaultLocale({ + defaultLocale: 'en', + locales: [{ path: 'english', codes: ['en'] }, 'fr'], + }); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /en/); + }); +}); +// #endregion + +// #region validateI18nFallback +describe('validateI18nFallback', () => { + it('returns no issues when fallback is undefined', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr'], + }); + assert.equal(issues.length, 0); + }); + + it('returns no issues for valid fallback entries', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr', 'de'], + fallback: { fr: 'en', de: 'en' }, + }); + assert.equal(issues.length, 0); + }); + + it('returns an issue when fallback key is not in locales', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr'], + fallback: { es: 'en' }, + }); + assert.ok(issues.some((i) => i.message.includes('es') && i.message.includes('key'))); + }); + + it('returns an issue when fallback value is not in locales', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr'], + fallback: { fr: 'de' }, + }); + assert.ok(issues.some((i) => i.message.includes('de') && i.message.includes('value'))); + }); + + it('returns an issue when default locale is used as a fallback key', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr'], + fallback: { en: 'fr' }, + }); + assert.ok(issues.some((i) => i.message.includes('default locale'))); + }); + + it('returns multiple issues for multiple invalid entries', () => { + const issues = validateI18nFallback({ + defaultLocale: 'en', + locales: ['en', 'fr'], + fallback: { es: 'de', en: 'fr' }, + }); + // es not in locales (key issue), de not in locales (value issue), en is default locale + assert.ok(issues.length >= 3); + }); +}); +// #endregion + +// #region validateI18nDomains +describe('validateI18nDomains', () => { + it('returns no issues when i18n is undefined', () => { + const issues = validateI18nDomains(domains({ i18n: undefined })); + assert.equal(issues.length, 0); + }); + + it('returns no issues when domains is undefined', () => { + const issues = validateI18nDomains(domains({ i18n: { locales: ['en'], defaultLocale: 'en' } })); + assert.equal(issues.length, 0); + }); + + it('returns an issue when site is not set', () => { + const issues = validateI18nDomains( + domains({ + site: undefined, + output: 'server', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { fr: 'https://fr.example.com' }, + }, + }), + ); + assert.ok(issues.some((i) => i.message.includes('site'))); + }); + + it('returns an issue when output is not server', () => { + const issues = validateI18nDomains( + domains({ + site: 'https://example.com', + output: 'static', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { fr: 'https://fr.example.com' }, + }, + }), + ); + assert.ok(issues.some((i) => i.message.includes('output') && i.message.includes('server'))); + }); + + it('returns an issue when domain locale key is not in locales', () => { + const issues = validateI18nDomains( + domains({ + site: 'https://example.com', + output: 'server', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { de: 'https://de.example.com' }, + }, + }), + ); + assert.ok(issues.some((i) => i.message.includes('de'))); + }); + + it('returns an issue when domain value is not a URL', () => { + const issues = validateI18nDomains( + domains({ + site: 'https://example.com', + output: 'server', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { fr: 'not-a-url' }, + }, + }), + ); + assert.ok(issues.some((i) => i.message.includes('http'))); + }); + + it('returns an issue when domain URL has a pathname', () => { + const issues = validateI18nDomains( + domains({ + site: 'https://example.com', + output: 'server', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { fr: 'https://fr.example.com/blog' }, + }, + }), + ); + assert.ok(issues.some((i) => i.message.includes('/blog'))); + }); + + it('returns no issues for valid domain configuration', () => { + const issues = validateI18nDomains( + domains({ + site: 'https://example.com', + output: 'server', + i18n: { + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: { fr: 'https://fr.example.com' }, + }, + }), + ); + assert.equal(issues.length, 0); + }); +}); +// #endregion + +// #region validateFontsCssVariables +describe('validateFontsCssVariables', () => { + it('returns no issues for valid CSS variable names', () => { + const issues = validateFontsCssVariables([ + font({ cssVariable: '--font-body' }), + font({ cssVariable: '--heading-font' }), + ]); + assert.equal(issues.length, 0); + }); + + it('returns an issue when cssVariable does not start with --', () => { + const issues = validateFontsCssVariables([font({ cssVariable: 'font-body' })]); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /cssVariable/); + assert.deepEqual(issues[0].path, ['fonts', 0, 'cssVariable']); + }); + + it('returns an issue when cssVariable contains a space', () => { + const issues = validateFontsCssVariables([font({ cssVariable: '--font body' })]); + assert.equal(issues.length, 1); + }); + + it('returns an issue when cssVariable contains a colon', () => { + const issues = validateFontsCssVariables([font({ cssVariable: '--font:body' })]); + assert.equal(issues.length, 1); + }); + + it('returns issues for multiple invalid entries', () => { + const issues = validateFontsCssVariables([ + font({ cssVariable: '--valid' }), + font({ cssVariable: 'no-prefix' }), + font({ cssVariable: '--has space' }), + ]); + assert.equal(issues.length, 2); + assert.deepEqual(issues[0].path, ['fonts', 1, 'cssVariable']); + assert.deepEqual(issues[1].path, ['fonts', 2, 'cssVariable']); + }); + + it('returns no issues for empty array', () => { + const issues = validateFontsCssVariables([]); + assert.equal(issues.length, 0); + }); +}); +// #endregion diff --git a/packages/astro/test/units/content-collections/frontmatter.test.js b/packages/astro/test/units/content-collections/frontmatter.test.js deleted file mode 100644 index 5db1ede09532..000000000000 --- a/packages/astro/test/units/content-collections/frontmatter.test.js +++ /dev/null @@ -1,73 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { attachContentServerListeners } from '../../../dist/content/index.js'; -import { createFixture, runInContainer } from '../test-utils.js'; - -describe('frontmatter', () => { - async function createContentFixture() { - return await createFixture({ - '/src/content/posts/blog.md': `\ - --- - title: One - --- - `, - '/src/content.config.ts': `\ - import { defineCollection } from 'astro:content'; - import { z } from 'astro/zod'; - import { glob } from 'astro/loaders'; - - const posts = defineCollection({ - loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/posts' }), - schema: z.string() - }); - - export const collections = { - posts - }; - `, - '/src/pages/index.astro': `\ - --- - --- - - Test - -

    Test

    - - - `, - }); - } - - it('errors in content/ does not crash server', async () => { - const fixture = await createContentFixture(); - - await runInContainer({ inlineConfig: { root: fixture.path } }, async (container) => { - await attachContentServerListeners(container); - - await fixture.writeFile( - '/src/content/posts/blog.md', - ` - --- - title: One - title: two - --- - `, - ); - await new Promise((resolve) => setTimeout(resolve, 100)); - // Note, if we got here, it didn't crash - }); - }); - - it('increases watcher max listeners to avoid startup warnings', async () => { - const fixture = await createContentFixture(); - - await runInContainer({ inlineConfig: { root: fixture.path } }, async (container) => { - const watcher = container.viteServer.watcher; - watcher.setMaxListeners(10); - - await attachContentServerListeners(container); - - assert.equal(watcher.getMaxListeners(), 50); - }); - }); -}); diff --git a/packages/astro/test/units/content-collections/get-entry-info.test.js b/packages/astro/test/units/content-collections/get-entry-info.test.ts similarity index 100% rename from packages/astro/test/units/content-collections/get-entry-info.test.js rename to packages/astro/test/units/content-collections/get-entry-info.test.ts diff --git a/packages/astro/test/units/content-collections/get-entry-type.test.js b/packages/astro/test/units/content-collections/get-entry-type.test.ts similarity index 100% rename from packages/astro/test/units/content-collections/get-entry-type.test.js rename to packages/astro/test/units/content-collections/get-entry-type.test.ts diff --git a/packages/astro/test/units/content-collections/image-references.test.js b/packages/astro/test/units/content-collections/image-references.test.js deleted file mode 100644 index 84595e0cee58..000000000000 --- a/packages/astro/test/units/content-collections/image-references.test.js +++ /dev/null @@ -1,94 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { updateImageReferencesInData } from '../../../dist/content/runtime.js'; -import { imageSrcToImportId } from '../../../dist/assets/utils/resolveImports.js'; - -const IMAGE_PREFIX = '__ASTRO_IMAGE_'; -const FILE_NAME = 'src/content/blog/post.md'; - -function makeImageMap(src, meta) { - const id = imageSrcToImportId(src, FILE_NAME); - return new Map([[id, meta]]); -} - -const heroMeta = { - src: '/_astro/hero.abc123.png', - width: 800, - height: 600, - format: 'png', -}; - -describe('updateImageReferencesInData', () => { - it('replaces a top-level image placeholder with resolved ImageMetadata', () => { - const data = { image: `${IMAGE_PREFIX}./hero.png` }; - const map = makeImageMap('./hero.png', heroMeta); - const result = updateImageReferencesInData(data, FILE_NAME, map); - assert.deepEqual(result.image, heroMeta); - }); - - it('resolves an image nested inside an object', () => { - const data = { cover: { src: `${IMAGE_PREFIX}./hero.png`, alt: 'Hero' } }; - const map = makeImageMap('./hero.png', heroMeta); - const result = updateImageReferencesInData(data, FILE_NAME, map); - assert.deepEqual(result.cover.src, heroMeta); - }); - - it('resolves images nested inside an array', () => { - const data = { - gallery: [`${IMAGE_PREFIX}./hero.png`, `${IMAGE_PREFIX}./hero.png`], - }; - const map = makeImageMap('./hero.png', heroMeta); - const result = updateImageReferencesInData(data, FILE_NAME, map); - assert.deepEqual(result.gallery[0], heroMeta); - assert.deepEqual(result.gallery[1], heroMeta); - }); - - it('falls back to the raw src string when the id is not in the map', () => { - const data = { image: `${IMAGE_PREFIX}./missing.png` }; - const result = updateImageReferencesInData(data, FILE_NAME, new Map()); - assert.equal(result.image, './missing.png'); - }); - - it('leaves non-image strings unchanged', () => { - const data = { title: 'Hello', slug: 'hello-world' }; - const result = updateImageReferencesInData(data, FILE_NAME, new Map()); - assert.equal(result.title, 'Hello'); - assert.equal(result.slug, 'hello-world'); - }); - - it('handles an empty imageAssetMap gracefully', () => { - const data = { image: `${IMAGE_PREFIX}./hero.png` }; - const result = updateImageReferencesInData(data, FILE_NAME, new Map()); - assert.equal(result.image, './hero.png'); - }); - - it('handles undefined imageAssetMap — falls back to raw src', () => { - const data = { image: `${IMAGE_PREFIX}./hero.png` }; - const result = updateImageReferencesInData(data, FILE_NAME, undefined); - assert.equal(result.image, './hero.png'); - }); - - it('handles data with no image fields', () => { - const data = { title: 'My Post', tags: ['a', 'b'], count: 3 }; - const result = updateImageReferencesInData(data, FILE_NAME, new Map()); - assert.deepEqual(result, data); - }); - - it('resolves multiple different images in the same entry', () => { - const thumbMeta = { src: '/_astro/thumb.xyz.png', width: 100, height: 100, format: 'png' }; - const heroId = imageSrcToImportId('./hero.png', FILE_NAME); - const thumbId = imageSrcToImportId('./thumb.png', FILE_NAME); - const map = new Map([ - [heroId, heroMeta], - [thumbId, thumbMeta], - ]); - const data = { - hero: `${IMAGE_PREFIX}./hero.png`, - thumb: `${IMAGE_PREFIX}./thumb.png`, - }; - const result = updateImageReferencesInData(data, FILE_NAME, map); - assert.deepEqual(result.hero, heroMeta); - assert.deepEqual(result.thumb, thumbMeta); - }); -}); diff --git a/packages/astro/test/units/content-collections/image-references.test.ts b/packages/astro/test/units/content-collections/image-references.test.ts new file mode 100644 index 000000000000..f72112ede204 --- /dev/null +++ b/packages/astro/test/units/content-collections/image-references.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { updateImageReferencesInData } from '../../../dist/content/runtime.js'; +import { imageSrcToImportId } from '../../../dist/assets/utils/resolveImports.js'; +import type { ImageMetadata } from '../../../dist/assets/types.js'; + +const IMAGE_PREFIX = '__ASTRO_IMAGE_'; +const FILE_NAME = 'src/content/blog/post.md'; + +function makeImageMap(src: string, meta: ImageMetadata): Map { + const id = imageSrcToImportId(src, FILE_NAME); + assert.ok(id, `imageSrcToImportId returned undefined for src="${src}"`); + return new Map([[id, meta]]); +} + +const heroMeta: ImageMetadata = { + src: '/_astro/hero.abc123.png', + width: 800, + height: 600, + format: 'png', +}; + +describe('updateImageReferencesInData', () => { + it('replaces a top-level image placeholder with resolved ImageMetadata', () => { + const data = { image: `${IMAGE_PREFIX}./hero.png` }; + const map = makeImageMap('./hero.png', heroMeta); + const result = updateImageReferencesInData(data, FILE_NAME, map); + assert.deepEqual(result.image, heroMeta); + }); + + it('resolves an image nested inside an object', () => { + const data = { cover: { src: `${IMAGE_PREFIX}./hero.png`, alt: 'Hero' } }; + const map = makeImageMap('./hero.png', heroMeta); + const result = updateImageReferencesInData(data, FILE_NAME, map); + assert.deepEqual(result.cover.src, heroMeta); + }); + + it('resolves images nested inside an array', () => { + const data = { + gallery: [`${IMAGE_PREFIX}./hero.png`, `${IMAGE_PREFIX}./hero.png`], + }; + const map = makeImageMap('./hero.png', heroMeta); + const result = updateImageReferencesInData(data, FILE_NAME, map); + assert.deepEqual(result.gallery[0], heroMeta); + assert.deepEqual(result.gallery[1], heroMeta); + }); + + it('falls back to the raw src string when the id is not in the map', () => { + const data = { image: `${IMAGE_PREFIX}./missing.png` }; + const result = updateImageReferencesInData(data, FILE_NAME, new Map()); + assert.equal(result.image, './missing.png'); + }); + + it('leaves non-image strings unchanged', () => { + const data = { title: 'Hello', slug: 'hello-world' }; + const result = updateImageReferencesInData(data, FILE_NAME, new Map()); + assert.equal(result.title, 'Hello'); + assert.equal(result.slug, 'hello-world'); + }); + + it('handles an empty imageAssetMap gracefully', () => { + const data = { image: `${IMAGE_PREFIX}./hero.png` }; + const result = updateImageReferencesInData(data, FILE_NAME, new Map()); + assert.equal(result.image, './hero.png'); + }); + + it('handles undefined imageAssetMap — falls back to raw src', () => { + const data = { image: `${IMAGE_PREFIX}./hero.png` }; + const result = updateImageReferencesInData(data, FILE_NAME, undefined); + assert.equal(result.image, './hero.png'); + }); + + it('handles data with no image fields', () => { + const data = { title: 'My Post', tags: ['a', 'b'], count: 3 }; + const result = updateImageReferencesInData(data, FILE_NAME, new Map()); + assert.deepEqual(result, data); + }); + + it('resolves multiple different images in the same entry', () => { + const thumbMeta: ImageMetadata = { + src: '/_astro/thumb.xyz.png', + width: 100, + height: 100, + format: 'png', + }; + const heroId = imageSrcToImportId('./hero.png', FILE_NAME); + const thumbId = imageSrcToImportId('./thumb.png', FILE_NAME); + assert.ok(heroId); + assert.ok(thumbId); + const map = new Map([ + [heroId, heroMeta], + [thumbId, thumbMeta], + ]); + const data = { + hero: `${IMAGE_PREFIX}./hero.png`, + thumb: `${IMAGE_PREFIX}./thumb.png`, + }; + const result = updateImageReferencesInData(data, FILE_NAME, map); + assert.deepEqual(result.hero, heroMeta); + assert.deepEqual(result.thumb, thumbMeta); + }); +}); diff --git a/packages/astro/test/units/content-collections/mutable-data-store.test.js b/packages/astro/test/units/content-collections/mutable-data-store.test.js deleted file mode 100644 index 692a74852600..000000000000 --- a/packages/astro/test/units/content-collections/mutable-data-store.test.js +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, before, after } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { promises as fs } from 'node:fs'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import * as devalue from 'devalue'; -import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; - -describe('MutableDataStore', () => { - let tmpDir; - - before(async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'astro-test-')); - }); - - after(async () => { - try { - await rm(tmpDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - }); - - it('reproduces race condition: concurrent writeToDisk() calls lose data', async () => { - const filePath = pathToFileURL(path.join(tmpDir, 'data-store.json')); - const store = await MutableDataStore.fromFile(filePath); - - store.set('c', 'key1', { id: 'key1', data: {} }); - const p1 = store.writeToDisk(); - - store.set('c', 'key2', { id: 'key2', data: {} }); - const p2 = store.writeToDisk(); - - await Promise.all([p1, p2]); - - const raw = await fs.readFile(filePath, 'utf-8'); - const collections = devalue.parse(raw); - const collection = collections.get('c'); - - assert.ok(collection.has('key1'), 'key1 should be present in the written file'); - assert.ok( - collection.has('key2'), - 'key2 should be present in the written file (this will FAIL before the fix)', - ); - }); -}); diff --git a/packages/astro/test/units/content-collections/mutable-data-store.test.ts b/packages/astro/test/units/content-collections/mutable-data-store.test.ts new file mode 100644 index 000000000000..a15b2590aa3d --- /dev/null +++ b/packages/astro/test/units/content-collections/mutable-data-store.test.ts @@ -0,0 +1,153 @@ +import { describe, it, before, after } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import * as devalue from 'devalue'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { imageSrcToImportId } from '../../../dist/assets/utils/resolveImports.js'; + +describe('MutableDataStore', () => { + let tmpDir: string; + + before(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'astro-test-')); + }); + + after(async () => { + try { + await rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('removes stale image asset import after entry image path is updated (issue #16097)', async () => { + const assetsFilePath = path.join(tmpDir, 'content-assets.mjs'); + const entryFilePath = 'src/content/categories/example.json'; + const store = new MutableDataStore(); + const scoped = store.scopedStore('categories'); + + scoped.set({ + id: 'example', + data: {}, + filePath: entryFilePath, + assetImports: ['./images/seed.webp'], + }); + + scoped.set({ + id: 'example', + data: {}, + filePath: entryFilePath, + assetImports: ['./images/non-existing.jpg'], + }); + + scoped.set({ + id: 'example', + data: {}, + filePath: entryFilePath, + assetImports: ['./images/seed.webp'], + }); + + await store.writeAssetImports(assetsFilePath); + + const content = await fs.readFile(assetsFilePath, 'utf-8'); + + const validId = imageSrcToImportId('./images/seed.webp', entryFilePath); + const staleId = imageSrcToImportId('./images/non-existing.jpg', entryFilePath); + + assert.ok(!!validId); + assert.ok( + content.includes(validId), + `content-assets.mjs should reference the valid image import "${validId}"`, + ); + assert.ok( + !content.includes('non-existing.jpg'), + `content-assets.mjs must NOT reference the stale invalid import "${staleId}" after the path is restored`, + ); + }); + + it('removes asset imports when an entry is deleted', async () => { + const assetsFilePath = path.join(tmpDir, 'content-assets-delete.mjs'); + const entryFilePath = 'src/content/categories/deleted.json'; + const store = new MutableDataStore(); + const scoped = store.scopedStore('categories'); + + scoped.set({ + id: 'deleted-entry', + data: {}, + filePath: entryFilePath, + assetImports: ['./images/to-be-removed.webp'], + }); + + await store.writeAssetImports(assetsFilePath); + const contentBefore = await fs.readFile(assetsFilePath, 'utf-8'); + assert.ok( + contentBefore.includes('to-be-removed.webp'), + 'should contain the image before deletion', + ); + + scoped.delete('deleted-entry'); + await store.writeAssetImports(assetsFilePath); + await store.waitUntilSaveComplete(); + + const contentAfter = await fs.readFile(assetsFilePath, 'utf-8'); + assert.ok( + !contentAfter.includes('to-be-removed.webp'), + 'should NOT contain the image after the entry is deleted', + ); + }); + + it('removes asset imports when a collection is cleared', async () => { + const assetsFilePath = path.join(tmpDir, 'content-assets-clear.mjs'); + const entryFilePath = 'src/content/blog/post.json'; + const store = new MutableDataStore(); + const scoped = store.scopedStore('blog'); + + scoped.set({ + id: 'post-1', + data: {}, + filePath: entryFilePath, + assetImports: ['./images/cover.webp'], + }); + + await store.writeAssetImports(assetsFilePath); + const contentBefore = await fs.readFile(assetsFilePath, 'utf-8'); + assert.ok(contentBefore.includes('cover.webp'), 'should contain the image before clear'); + + scoped.clear(); + await store.writeAssetImports(assetsFilePath); + await store.waitUntilSaveComplete(); + + const contentAfter = await fs.readFile(assetsFilePath, 'utf-8'); + assert.ok( + !contentAfter.includes('cover.webp'), + 'should NOT contain the image after the collection is cleared', + ); + }); + + it('reproduces race condition: concurrent writeToDisk() calls lose data', async () => { + const filePath = pathToFileURL(path.join(tmpDir, 'data-store.json')); + const store = await MutableDataStore.fromFile(filePath); + + store.set('c', 'key1', { id: 'key1', data: {} }); + const p1 = store.writeToDisk(); + + store.set('c', 'key2', { id: 'key2', data: {} }); + const p2 = store.writeToDisk(); + + await Promise.all([p1, p2]); + + const raw = await fs.readFile(filePath, 'utf-8'); + const collections = devalue.parse(raw); + const collection = collections.get('c'); + + assert.ok(collection.has('key1'), 'key1 should be present in the written file'); + assert.ok( + collection.has('key2'), + 'key2 should be present in the written file (this will FAIL before the fix)', + ); + }); +}); diff --git a/packages/astro/test/units/content-layer/core-loader.test.js b/packages/astro/test/units/content-layer/core-loader.test.js deleted file mode 100644 index 9c953484b555..000000000000 --- a/packages/astro/test/units/content-layer/core-loader.test.js +++ /dev/null @@ -1,290 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { describe, it, before } from 'node:test'; -import { z } from 'zod'; -import { defineCollection } from '../../../dist/content/config.js'; -import { ContentLayer } from '../../../dist/content/content-layer.js'; -import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; -import { Logger } from '../../../dist/core/logger/core.js'; - -import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; - -describe('Core Content Layer loader', () => { - let logger; - const root = createTempDir(); - - before(() => { - logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - }); - - it('returns collection from a simple loader', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - - // Create a simple loader - const simpleLoader = () => [ - { id: 'siamese', breed: 'Siamese' }, - { id: 'tabby', breed: 'Tabby' }, - ]; - - // Define collections - const collections = { - cats: defineCollection({ - loader: simpleLoader, - schema: z.object({ - id: z.string(), - breed: z.string(), - }), - }), - }; - - // Create ContentLayer with test config - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - // Sync content - await contentLayer.sync(); - - const entries = store.values('cats'); - assert.equal(entries.length, 2); - assert.equal(entries[0].id, 'siamese'); - assert.equal(entries[1].id, 'tabby'); - }); - - it('returns collection from a simple loader that uses an object', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - - const objectLoader = () => ({ - capybara: { - name: 'Capybara', - scientificName: 'Hydrochoerus hydrochaeris', - lifespan: 10, - weight: 50000, - diet: ['grass', 'aquatic plants', 'bark', 'fruits'], - nocturnal: false, - }, - hamster: { - name: 'Golden Hamster', - scientificName: 'Mesocricetus auratus', - lifespan: 2, - weight: 120, - diet: ['seeds', 'nuts', 'insects'], - nocturnal: true, - }, - }); - - // Define collections - const collections = { - rodents: defineCollection({ - loader: objectLoader, - schema: z.object({ - name: z.string(), - scientificName: z.string(), - lifespan: z.number().int().positive(), - weight: z.number().positive(), - diet: z.array(z.string()), - nocturnal: z.boolean(), - }), - }), - }; - - // Create ContentLayer with test config - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - // Sync content - await contentLayer.sync(); - - const entries = store.values('rodents'); - assert.equal(entries.length, 2); - - const capybara = entries.find((e) => e.id === 'capybara'); - assert.ok(capybara); - assert.equal(capybara.data.name, 'Capybara'); - assert.equal(capybara.data.weight, 50000); - }); - - it('can render markdown in loaders', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - - const markdownContent = ` -# heading 1 -hello -## heading 2 -![image](./image.png) -![image 2](https://example.com/image.png) -`; - - // Create a loader that renders markdown - const markdownRenderingLoader = { - name: 'markdown-rendering-loader', - load: async (context) => { - const result = await context.renderMarkdown(markdownContent, { - fileURL: new URL('test.md', root), - }); - - const data = { - lastValue: 1, - lastUpdated: new Date(), - // Store rendered content in data for this test - renderedHtml: result.html, - headingsCount: result.metadata.headings.length, - }; - - const parsed = await context.parseData({ - id: 'value', - data, - }); - - await context.store.set({ - id: 'value', - data: parsed, - }); - }, - }; - - // Define collections - const collections = { - increment: defineCollection({ - loader: markdownRenderingLoader, - schema: z.object({ - lastValue: z.number(), - lastUpdated: z.date(), - renderedHtml: z.string(), - headingsCount: z.number(), - }), - }), - }; - - // Create ContentLayer with test config - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - // Sync content - await contentLayer.sync(); - - const entry = store.get('increment', 'value'); - assert.ok(entry); - assert.ok(entry.data.renderedHtml); - assert.ok(entry.data.renderedHtml.includes('

    heading 1

    ')); - assert.ok(entry.data.renderedHtml.includes('

    heading 2

    ')); - assert.equal(entry.data.headingsCount, 2); - }); - - it('stores Date objects', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const now = new Date(); - - // Create a loader that returns Date objects - const dateLoader = { - name: 'date-loader', - load: async (context) => { - await context.store.set({ - id: 'test-date', - data: { - created: now, - title: 'Test', - }, - }); - }, - }; - - // Define collections - const collections = { - dates: defineCollection({ - loader: dateLoader, - schema: z.object({ - created: z.date(), - title: z.string(), - }), - }), - }; - - // Create ContentLayer with test config - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - // Sync content - await contentLayer.sync(); - - const entry = store.get('dates', 'test-date'); - assert.ok(entry); - assert.ok(entry.data.created instanceof Date); - assert.equal(entry.data.created.toISOString(), now.toISOString()); - }); - - it('allows "slug" as a field', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - - // Create a loader that uses slug field - const slugLoader = { - name: 'slug-loader', - load: async (context) => { - const data = { - lastValue: 1, - lastUpdated: new Date(), - slug: 'slimy', - }; - - const parsed = await context.parseData({ - id: 'value', - data, - }); - - await context.store.set({ - id: 'value', - data: parsed, - }); - }, - }; - - // Define collections - const collections = { - increment: defineCollection({ - loader: slugLoader, - schema: z.object({ - lastValue: z.number(), - lastUpdated: z.date(), - slug: z.string().optional(), - }), - }), - }; - - // Create ContentLayer with test config - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - // Sync content - await contentLayer.sync(); - - const entry = store.get('increment', 'value'); - assert.ok(entry); - assert.equal(entry.data.slug, 'slimy'); - }); -}); diff --git a/packages/astro/test/units/content-layer/core-loader.test.ts b/packages/astro/test/units/content-layer/core-loader.test.ts new file mode 100644 index 000000000000..2ec54e1c55fa --- /dev/null +++ b/packages/astro/test/units/content-layer/core-loader.test.ts @@ -0,0 +1,290 @@ +import { strict as assert } from 'node:assert'; +import { describe, it, before } from 'node:test'; +import { z } from 'zod'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; + +import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.ts'; + +describe('Core Content Layer loader', () => { + let logger: any; + const root = createTempDir(); + + before(() => { + logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + }); + + it('returns collection from a simple loader', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + + // Create a simple loader + const simpleLoader = () => [ + { id: 'siamese', breed: 'Siamese' }, + { id: 'tabby', breed: 'Tabby' }, + ]; + + // Define collections + const collections = { + cats: defineCollection({ + loader: simpleLoader, + schema: z.object({ + id: z.string(), + breed: z.string(), + }), + }), + }; + + // Create ContentLayer with test config + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + const entries = store.values('cats'); + assert.equal(entries.length, 2); + assert.equal(entries[0].id, 'siamese'); + assert.equal(entries[1].id, 'tabby'); + }); + + it('returns collection from a simple loader that uses an object', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + + const objectLoader = () => ({ + capybara: { + name: 'Capybara', + scientificName: 'Hydrochoerus hydrochaeris', + lifespan: 10, + weight: 50000, + diet: ['grass', 'aquatic plants', 'bark', 'fruits'], + nocturnal: false, + }, + hamster: { + name: 'Golden Hamster', + scientificName: 'Mesocricetus auratus', + lifespan: 2, + weight: 120, + diet: ['seeds', 'nuts', 'insects'], + nocturnal: true, + }, + }); + + // Define collections + const collections = { + rodents: defineCollection({ + loader: objectLoader, + schema: z.object({ + name: z.string(), + scientificName: z.string(), + lifespan: z.number().int().positive(), + weight: z.number().positive(), + diet: z.array(z.string()), + nocturnal: z.boolean(), + }), + }), + }; + + // Create ContentLayer with test config + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + const entries = store.values('rodents'); + assert.equal(entries.length, 2); + + const capybara = entries.find((e) => e.id === 'capybara'); + assert.ok(capybara); + assert.equal(capybara.data.name, 'Capybara'); + assert.equal(capybara.data.weight, 50000); + }); + + it('can render markdown in loaders', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + + const markdownContent = ` +# heading 1 +hello +## heading 2 +![image](./image.png) +![image 2](https://example.com/image.png) +`; + + // Create a loader that renders markdown + const markdownRenderingLoader = { + name: 'markdown-rendering-loader', + load: async (context: any) => { + const result = await context.renderMarkdown(markdownContent, { + fileURL: new URL('test.md', root), + }); + + const data = { + lastValue: 1, + lastUpdated: new Date(), + // Store rendered content in data for this test + renderedHtml: result.html, + headingsCount: result.metadata.headings.length, + }; + + const parsed = await context.parseData({ + id: 'value', + data, + }); + + await context.store.set({ + id: 'value', + data: parsed, + }); + }, + }; + + // Define collections + const collections = { + increment: defineCollection({ + loader: markdownRenderingLoader, + schema: z.object({ + lastValue: z.number(), + lastUpdated: z.date(), + renderedHtml: z.string(), + headingsCount: z.number(), + }), + }), + }; + + // Create ContentLayer with test config + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + const entry: any = store.get('increment', 'value'); + assert.ok(entry); + assert.ok(entry.data.renderedHtml); + assert.ok(entry.data.renderedHtml.includes('

    heading 1

    ')); + assert.ok(entry.data.renderedHtml.includes('

    heading 2

    ')); + assert.equal(entry.data.headingsCount, 2); + }); + + it('stores Date objects', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const now = new Date(); + + // Create a loader that returns Date objects + const dateLoader = { + name: 'date-loader', + load: async (context: any) => { + await context.store.set({ + id: 'test-date', + data: { + created: now, + title: 'Test', + }, + }); + }, + }; + + // Define collections + const collections = { + dates: defineCollection({ + loader: dateLoader, + schema: z.object({ + created: z.date(), + title: z.string(), + }), + }), + }; + + // Create ContentLayer with test config + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + const entry: any = store.get('dates', 'test-date'); + assert.ok(entry); + assert.ok(entry.data.created instanceof Date); + assert.equal(entry.data.created.toISOString(), now.toISOString()); + }); + + it('allows "slug" as a field', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + + // Create a loader that uses slug field + const slugLoader = { + name: 'slug-loader', + load: async (context: any) => { + const data = { + lastValue: 1, + lastUpdated: new Date(), + slug: 'slimy', + }; + + const parsed = await context.parseData({ + id: 'value', + data, + }); + + await context.store.set({ + id: 'value', + data: parsed, + }); + }, + }; + + // Define collections + const collections = { + increment: defineCollection({ + loader: slugLoader, + schema: z.object({ + lastValue: z.number(), + lastUpdated: z.date(), + slug: z.string().optional(), + }), + }), + }; + + // Create ContentLayer with test config + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + const entry: any = store.get('increment', 'value'); + assert.ok(entry); + assert.equal(entry.data.slug, 'slimy'); + }); +}); diff --git a/packages/astro/test/units/content-layer/data-transforms.test.js b/packages/astro/test/units/content-layer/data-transforms.test.js deleted file mode 100644 index c4b68f818046..000000000000 --- a/packages/astro/test/units/content-layer/data-transforms.test.js +++ /dev/null @@ -1,517 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { z } from 'zod'; -import { defineCollection } from '../../../dist/content/config.js'; -import { createReference } from '../../../dist/content/runtime.js'; -import { ContentLayer } from '../../../dist/content/content-layer.js'; -import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; -import { Logger } from '../../../dist/core/logger/core.js'; -import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; - -describe('Content Layer - Data Transforms', () => { - const root = createTempDir(); - const reference = createReference(); - - it('transforms reference strings to reference objects', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - // Create a loader that returns data with reference strings - const dogsLoader = { - name: 'dogs-loader', - load: async (context) => { - const data = { - id: 'beagle', - name: 'Beagle Dog', - favoriteCat: 'tabby', - }; - - const parsed = await context.parseData({ - id: 'beagle', - data, - }); - - await context.store.set({ - id: 'beagle', - data: parsed, - }); - }, - }; - - const collections = { - dogs: defineCollection({ - loader: dogsLoader, - schema: z.object({ - id: z.string(), - name: z.string(), - favoriteCat: reference('cats'), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const result = store.get('dogs', 'beagle'); - assert.ok(result); - assert.equal(result.data.id, 'beagle'); - assert.equal(result.data.name, 'Beagle Dog'); - assert.deepEqual(result.data.favoriteCat, { collection: 'cats', id: 'tabby' }); - }); - - it('transforms dates correctly', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const eventsLoader = { - name: 'events-loader', - load: async (context) => { - const data = { - id: 'event1', - title: 'Launch Event', - publishedDate: '2024-07-20', - eventTime: '2024-07-20T10:00:00Z', - }; - - const parsed = await context.parseData({ - id: 'event1', - data, - }); - - await context.store.set({ - id: 'event1', - data: parsed, - }); - }, - }; - - const collections = { - events: defineCollection({ - loader: eventsLoader, - schema: z.object({ - id: z.string(), - title: z.string(), - publishedDate: z.coerce.date(), - eventTime: z.coerce.date(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const result = store.get('events', 'event1'); - assert.ok(result); - assert.ok(result.data.publishedDate instanceof Date); - assert.ok(result.data.eventTime instanceof Date); - assert.equal(result.data.publishedDate.toISOString().split('T')[0], '2024-07-20'); - assert.equal(result.data.eventTime.toISOString(), '2024-07-20T10:00:00.000Z'); - }); - - it('applies schema defaults', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const productsLoader = { - name: 'products-loader', - load: async (context) => { - const data = { - id: 'product1', - name: 'Basic Product', - // Missing inStock, category, and tags - should use defaults - }; - - const parsed = await context.parseData({ - id: 'product1', - data, - }); - - await context.store.set({ - id: 'product1', - data: parsed, - }); - }, - }; - - const collections = { - products: defineCollection({ - loader: productsLoader, - schema: z.object({ - id: z.string(), - name: z.string(), - inStock: z.boolean().default(false), - category: z.string().default('uncategorized'), - tags: z.array(z.string()).default([]), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const result = store.get('products', 'product1'); - assert.ok(result); - assert.equal(result.data.inStock, false); - assert.equal(result.data.category, 'uncategorized'); - assert.deepEqual(result.data.tags, []); - }); - - it('handles array of references', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const teamsLoader = { - name: 'teams-loader', - load: async (context) => { - const data = { - id: 'team1', - name: 'Rocket Team', - members: ['john', 'jane', 'bob'], - }; - - const parsed = await context.parseData({ - id: 'team1', - data, - }); - - await context.store.set({ - id: 'team1', - data: parsed, - }); - }, - }; - - const collections = { - teams: defineCollection({ - loader: teamsLoader, - schema: z.object({ - id: z.string(), - name: z.string(), - members: z.array(reference('people')), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const result = store.get('teams', 'team1'); - assert.ok(result); - assert.equal(result.data.members.length, 3); - assert.deepEqual(result.data.members[0], { collection: 'people', id: 'john' }); - assert.deepEqual(result.data.members[1], { collection: 'people', id: 'jane' }); - assert.deepEqual(result.data.members[2], { collection: 'people', id: 'bob' }); - }); - - it('validates and rejects invalid data', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const itemsLoader = { - name: 'items-loader', - load: async (context) => { - const data = { - id: 'invalid', - name: 'Test Item', - count: 'not-a-number', // Should be number - email: 'not-an-email', // Should be valid email - }; - - try { - const parsed = await context.parseData({ - id: 'invalid', - data, - }); - - await context.store.set({ - id: 'invalid', - data: parsed, - }); - } catch (error) { - // Store error info for testing - await context.store.set({ - id: 'error', - data: { - hasError: true, - errorMessage: error.message, - }, - }); - } - }, - }; - - const collections = { - items: defineCollection({ - loader: itemsLoader, - schema: z.object({ - id: z.string(), - name: z.string(), - count: z.number(), - email: z.string().email(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // The invalid entry should not be stored - const invalidEntry = store.get('items', 'invalid'); - assert.equal(invalidEntry, undefined); - - // Check if error was captured - const errorEntry = store.get('items', 'error'); - assert.ok(errorEntry); - assert.equal(errorEntry.data.hasError, true); - assert.ok(errorEntry.data.errorMessage.includes('data does not match collection schema')); - }); - - it('handles nested schemas with mixed transforms', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const articlesLoader = { - name: 'articles-loader', - load: async (context) => { - const data = { - id: 'complex', - metadata: { - created: '2024-01-01', - updated: '2024-01-15T10:30:00Z', - author: 'john-doe', - }, - settings: { - isPublished: true, - // Missing priority - should use default - }, - }; - - const parsed = await context.parseData({ - id: 'complex', - data, - }); - - await context.store.set({ - id: 'complex', - data: parsed, - }); - }, - }; - - const collections = { - articles: defineCollection({ - loader: articlesLoader, - schema: z.object({ - id: z.string(), - metadata: z.object({ - created: z.coerce.date(), - updated: z.coerce.date(), - author: reference('authors'), - tags: z.array(z.string()).default([]), - }), - settings: z.object({ - isPublished: z.boolean().default(false), - priority: z.number().default(0), - }), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const result = store.get('articles', 'complex'); - assert.ok(result); - assert.ok(result.data.metadata.created instanceof Date); - assert.ok(result.data.metadata.updated instanceof Date); - assert.deepEqual(result.data.metadata.author, { collection: 'authors', id: 'john-doe' }); - assert.deepEqual(result.data.metadata.tags, []); // default empty array - assert.equal(result.data.settings.isPublished, true); - assert.equal(result.data.settings.priority, 0); // default value - }); - - it('handles optional fields correctly', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const minimalProductLoader = { - name: 'minimal-product-loader', - load: async (context) => { - const data = { - id: 'minimal', - name: 'Minimal Product', - // All optional fields omitted - }; - - const parsed = await context.parseData({ - id: 'minimal', - data, - }); - - await context.store.set({ - id: 'minimal', - data: parsed, - }); - }, - }; - - const collections = { - products: defineCollection({ - loader: minimalProductLoader, - schema: z.object({ - id: z.string(), - name: z.string(), - description: z.string().optional(), - price: z.number().optional(), - relatedProduct: reference('products').optional(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const result = store.get('products', 'minimal'); - assert.ok(result); - assert.equal(result.data.description, undefined); - assert.equal(result.data.price, undefined); - assert.equal(result.data.relatedProduct, undefined); - }); - - it('transforms reference with default value', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const itemsLoader = { - name: 'items-loader', - load: async (context) => { - // Load two items - one with category, one without - const items = [ - { - id: 'item1', - name: 'Item with category', - category: 'electronics', - }, - { - id: 'item2', - name: 'Item without category', - // No category specified - should use default - }, - ]; - - for (const item of items) { - const parsed = await context.parseData({ - id: item.id, - data: item, - }); - - await context.store.set({ - id: item.id, - data: parsed, - }); - } - }, - }; - - const collections = { - items: defineCollection({ - loader: itemsLoader, - schema: z.object({ - id: z.string(), - name: z.string(), - category: reference('categories').default('general'), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const result1 = store.get('items', 'item1'); - assert.deepEqual(result1.data.category, { collection: 'categories', id: 'electronics' }); - - const result2 = store.get('items', 'item2'); - // The default is applied as a string, not transformed to a reference object - assert.equal(result2.data.category, 'general'); - }); -}); diff --git a/packages/astro/test/units/content-layer/data-transforms.test.ts b/packages/astro/test/units/content-layer/data-transforms.test.ts new file mode 100644 index 000000000000..2b1e8392b51a --- /dev/null +++ b/packages/astro/test/units/content-layer/data-transforms.test.ts @@ -0,0 +1,517 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { z } from 'zod'; +import { defineCollection } from '../../../dist/content/config.js'; +import { createReference } from '../../../dist/content/runtime.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; +import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.ts'; + +describe('Content Layer - Data Transforms', () => { + const root = createTempDir(); + const reference = createReference(); + + it('transforms reference strings to reference objects', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + // Create a loader that returns data with reference strings + const dogsLoader = { + name: 'dogs-loader', + load: async (context: any) => { + const data = { + id: 'beagle', + name: 'Beagle Dog', + favoriteCat: 'tabby', + }; + + const parsed = await context.parseData({ + id: 'beagle', + data, + }); + + await context.store.set({ + id: 'beagle', + data: parsed, + }); + }, + }; + + const collections = { + dogs: defineCollection({ + loader: dogsLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + favoriteCat: reference('cats'), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result: any = store.get('dogs', 'beagle'); + assert.ok(result); + assert.equal(result.data.id, 'beagle'); + assert.equal(result.data.name, 'Beagle Dog'); + assert.deepEqual(result.data.favoriteCat, { collection: 'cats', id: 'tabby' }); + }); + + it('transforms dates correctly', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const eventsLoader = { + name: 'events-loader', + load: async (context: any) => { + const data = { + id: 'event1', + title: 'Launch Event', + publishedDate: '2024-07-20', + eventTime: '2024-07-20T10:00:00Z', + }; + + const parsed = await context.parseData({ + id: 'event1', + data, + }); + + await context.store.set({ + id: 'event1', + data: parsed, + }); + }, + }; + + const collections = { + events: defineCollection({ + loader: eventsLoader, + schema: z.object({ + id: z.string(), + title: z.string(), + publishedDate: z.coerce.date(), + eventTime: z.coerce.date(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result: any = store.get('events', 'event1'); + assert.ok(result); + assert.ok(result.data.publishedDate instanceof Date); + assert.ok(result.data.eventTime instanceof Date); + assert.equal(result.data.publishedDate.toISOString().split('T')[0], '2024-07-20'); + assert.equal(result.data.eventTime.toISOString(), '2024-07-20T10:00:00.000Z'); + }); + + it('applies schema defaults', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const productsLoader = { + name: 'products-loader', + load: async (context: any) => { + const data = { + id: 'product1', + name: 'Basic Product', + // Missing inStock, category, and tags - should use defaults + }; + + const parsed = await context.parseData({ + id: 'product1', + data, + }); + + await context.store.set({ + id: 'product1', + data: parsed, + }); + }, + }; + + const collections = { + products: defineCollection({ + loader: productsLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + inStock: z.boolean().default(false), + category: z.string().default('uncategorized'), + tags: z.array(z.string()).default([]), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result: any = store.get('products', 'product1'); + assert.ok(result); + assert.equal(result.data.inStock, false); + assert.equal(result.data.category, 'uncategorized'); + assert.deepEqual(result.data.tags, []); + }); + + it('handles array of references', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const teamsLoader = { + name: 'teams-loader', + load: async (context: any) => { + const data = { + id: 'team1', + name: 'Rocket Team', + members: ['john', 'jane', 'bob'], + }; + + const parsed = await context.parseData({ + id: 'team1', + data, + }); + + await context.store.set({ + id: 'team1', + data: parsed, + }); + }, + }; + + const collections = { + teams: defineCollection({ + loader: teamsLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + members: z.array(reference('people')), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result: any = store.get('teams', 'team1'); + assert.ok(result); + assert.equal(result.data.members.length, 3); + assert.deepEqual(result.data.members[0], { collection: 'people', id: 'john' }); + assert.deepEqual(result.data.members[1], { collection: 'people', id: 'jane' }); + assert.deepEqual(result.data.members[2], { collection: 'people', id: 'bob' }); + }); + + it('validates and rejects invalid data', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const itemsLoader = { + name: 'items-loader', + load: async (context: any) => { + const data = { + id: 'invalid', + name: 'Test Item', + count: 'not-a-number', // Should be number + email: 'not-an-email', // Should be valid email + }; + + try { + const parsed = await context.parseData({ + id: 'invalid', + data, + }); + + await context.store.set({ + id: 'invalid', + data: parsed, + }); + } catch (error: any) { + // Store error info for testing + await context.store.set({ + id: 'error', + data: { + hasError: true, + errorMessage: error.message, + }, + }); + } + }, + }; + + const collections = { + items: defineCollection({ + loader: itemsLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + count: z.number(), + email: z.string().email(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // The invalid entry should not be stored + const invalidEntry: any = store.get('items', 'invalid'); + assert.equal(invalidEntry, undefined); + + // Check if error was captured + const errorEntry: any = store.get('items', 'error'); + assert.ok(errorEntry); + assert.equal(errorEntry.data.hasError, true); + assert.ok(errorEntry.data.errorMessage.includes('data does not match collection schema')); + }); + + it('handles nested schemas with mixed transforms', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const articlesLoader = { + name: 'articles-loader', + load: async (context: any) => { + const data = { + id: 'complex', + metadata: { + created: '2024-01-01', + updated: '2024-01-15T10:30:00Z', + author: 'john-doe', + }, + settings: { + isPublished: true, + // Missing priority - should use default + }, + }; + + const parsed = await context.parseData({ + id: 'complex', + data, + }); + + await context.store.set({ + id: 'complex', + data: parsed, + }); + }, + }; + + const collections = { + articles: defineCollection({ + loader: articlesLoader, + schema: z.object({ + id: z.string(), + metadata: z.object({ + created: z.coerce.date(), + updated: z.coerce.date(), + author: reference('authors'), + tags: z.array(z.string()).default([]), + }), + settings: z.object({ + isPublished: z.boolean().default(false), + priority: z.number().default(0), + }), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result: any = store.get('articles', 'complex'); + assert.ok(result); + assert.ok(result.data.metadata.created instanceof Date); + assert.ok(result.data.metadata.updated instanceof Date); + assert.deepEqual(result.data.metadata.author, { collection: 'authors', id: 'john-doe' }); + assert.deepEqual(result.data.metadata.tags, []); // default empty array + assert.equal(result.data.settings.isPublished, true); + assert.equal(result.data.settings.priority, 0); // default value + }); + + it('handles optional fields correctly', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const minimalProductLoader = { + name: 'minimal-product-loader', + load: async (context: any) => { + const data = { + id: 'minimal', + name: 'Minimal Product', + // All optional fields omitted + }; + + const parsed = await context.parseData({ + id: 'minimal', + data, + }); + + await context.store.set({ + id: 'minimal', + data: parsed, + }); + }, + }; + + const collections = { + products: defineCollection({ + loader: minimalProductLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + price: z.number().optional(), + relatedProduct: reference('products').optional(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result: any = store.get('products', 'minimal'); + assert.ok(result); + assert.equal(result.data.description, undefined); + assert.equal(result.data.price, undefined); + assert.equal(result.data.relatedProduct, undefined); + }); + + it('transforms reference with default value', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const itemsLoader = { + name: 'items-loader', + load: async (context: any) => { + // Load two items - one with category, one without + const items = [ + { + id: 'item1', + name: 'Item with category', + category: 'electronics', + }, + { + id: 'item2', + name: 'Item without category', + // No category specified - should use default + }, + ]; + + for (const item of items) { + const parsed = await context.parseData({ + id: item.id, + data: item, + }); + + await context.store.set({ + id: item.id, + data: parsed, + }); + } + }, + }; + + const collections = { + items: defineCollection({ + loader: itemsLoader, + schema: z.object({ + id: z.string(), + name: z.string(), + category: (reference as any)('categories').default('general'), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const result1: any = store.get('items', 'item1'); + assert.deepEqual(result1.data.category, { collection: 'categories', id: 'electronics' }); + + const result2: any = store.get('items', 'item2'); + // The default is applied as a string, not transformed to a reference object + assert.equal(result2.data.category, 'general'); + }); +}); diff --git a/packages/astro/test/units/content-layer/file-loader.test.js b/packages/astro/test/units/content-layer/file-loader.test.js deleted file mode 100644 index 5f1add6e6a1d..000000000000 --- a/packages/astro/test/units/content-layer/file-loader.test.js +++ /dev/null @@ -1,293 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { z } from 'zod'; -import { file } from '../../../dist/content/loaders/file.js'; -import { defineCollection } from '../../../dist/content/config.js'; -import { ContentLayer } from '../../../dist/content/content-layer.js'; -import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; -import { Logger } from '../../../dist/core/logger/core.js'; -import { createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; - -describe('File Loader', () => { - const root = new URL('../../fixtures/content-layer/', import.meta.url); - - it('loads entries from JSON file', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - dogs: defineCollection({ - loader: file('src/data/dogs.json'), - schema: z.object({ - breed: z.string(), - id: z.string(), - size: z.string(), - origin: z.string(), - lifespan: z.string(), - temperament: z.array(z.string()), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check that entries were loaded - const entries = store.values('dogs'); - assert.equal(entries.length, 25); - - // Check a specific entry - const beagle = entries.find((e) => e.id === 'beagle'); - assert.ok(beagle); - assert.equal(beagle.data.breed, 'Beagle'); - assert.deepEqual(beagle.data.temperament, ['Friendly', 'Curious', 'Merry']); - assert.equal(beagle.filePath, 'src/data/dogs.json'); - }); - - it('loads entries from YAML file', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - fish: defineCollection({ - loader: file('src/data/fish.yaml'), - schema: z.object({ - name: z.string(), - breed: z.string(), - age: z.number(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entries = store.values('fish'); - assert.equal(entries.length, 10); - - const nemo = entries.find((e) => e.id === 'nemo'); - assert.ok(nemo); - assert.equal(nemo.data.name, 'Nemo'); - assert.equal(nemo.data.breed, 'Clownfish'); - assert.equal(nemo.data.age, 3); - }); - - it('loads entries from TOML file', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - songs: defineCollection({ - loader: file('src/data/songs.toml'), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entries = store.values('songs'); - assert.equal(entries.length, 8); - - // Songs have 'name' and 'artists' fields - const crown = entries.find((e) => e.id === 'crown'); - assert.ok(crown); - assert.equal(crown.data.name, 'Crown'); - }); - - it('loads entries from CSV file with custom parser', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - plants: defineCollection({ - loader: file('src/data/plants.csv', { - parser: (text) => { - const [headers, ...rows] = text.trim().split('\n'); - return rows.map((row) => - Object.fromEntries(headers.split(',').map((h, i) => [h, row.split(',')[i]])), - ); - }, - }), - schema: z.object({ - id: z.string(), - common_name: z.string(), - scientific_name: z.string(), - color: z.string(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entries = store.values('plants'); - assert.equal(entries.length, 10); - - const rose = entries.find((e) => e.id === 'rose'); - assert.ok(rose); - assert.equal(rose.data.common_name, 'Rose'); - assert.equal(rose.data.scientific_name, 'Rosa'); - assert.equal(rose.data.color, 'Red'); - }); - - it('loads nested JSON with custom parser', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - birds: defineCollection({ - loader: file('src/data/birds.json', { - parser: (text) => JSON.parse(text).birds, - }), - schema: z.object({ - id: z.string(), - name: z.string(), - breed: z.string(), - age: z.number(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entries = store.values('birds'); - assert.equal(entries.length, 5); - - const bluejay = entries.find((e) => e.id === 'bluejay'); - assert.ok(bluejay); - assert.equal(bluejay.data.name, 'Blue Jay'); - assert.equal(bluejay.data.breed, 'Cyanocitta cristata'); - assert.equal(bluejay.data.age, 3); - }); - - it('uses async parser', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - birdsAsync: defineCollection({ - loader: file('src/data/birds.json', { - parser: async (text) => { - // Simulate async work - await new Promise((resolve) => setTimeout(resolve, 10)); - return JSON.parse(text).birds; - }, - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entries = store.values('birdsAsync'); - assert.equal(entries.length, 5); - }); - - it('warns on duplicate IDs', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - - // Create a custom logger to capture warnings - const warnings = []; - const logger = new Logger({ - dest: { - write: (msg) => { - if (msg.level === 'warn') { - warnings.push(msg.message); - } - return true; - }, - }, - level: 'info', - }); - - const collections = { - dogsWithDupes: defineCollection({ - loader: file('src/data/dogs.json', { - parser: () => [ - { id: 'beagle', breed: 'Beagle 1' }, - { id: 'beagle', breed: 'Beagle 2' }, - ], - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check that a warning was logged - assert.ok(warnings.some((w) => w.includes('Duplicate id "beagle"'))); - - // Check that the last entry won - const entries = store.values('dogsWithDupes'); - assert.equal(entries.length, 1); - assert.equal(entries[0].data.breed, 'Beagle 2'); - }); -}); diff --git a/packages/astro/test/units/content-layer/file-loader.test.ts b/packages/astro/test/units/content-layer/file-loader.test.ts new file mode 100644 index 000000000000..d1d13df21cfb --- /dev/null +++ b/packages/astro/test/units/content-layer/file-loader.test.ts @@ -0,0 +1,293 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { z } from 'zod'; +import { file } from '../../../dist/content/loaders/file.js'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; +import { createTestConfigObserver, createMinimalSettings } from './test-helpers.ts'; + +describe('File Loader', () => { + const root = new URL('../../fixtures/content-layer/', import.meta.url); + + it('loads entries from JSON file', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + dogs: defineCollection({ + loader: file('src/data/dogs.json'), + schema: z.object({ + breed: z.string(), + id: z.string(), + size: z.string(), + origin: z.string(), + lifespan: z.string(), + temperament: z.array(z.string()), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check that entries were loaded + const entries = store.values('dogs'); + assert.equal(entries.length, 25); + + // Check a specific entry + const beagle = entries.find((e) => e.id === 'beagle'); + assert.ok(beagle); + assert.equal(beagle.data.breed, 'Beagle'); + assert.deepEqual(beagle.data.temperament, ['Friendly', 'Curious', 'Merry']); + assert.equal(beagle.filePath, 'src/data/dogs.json'); + }); + + it('loads entries from YAML file', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + fish: defineCollection({ + loader: file('src/data/fish.yaml'), + schema: z.object({ + name: z.string(), + breed: z.string(), + age: z.number(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('fish'); + assert.equal(entries.length, 10); + + const nemo = entries.find((e) => e.id === 'nemo'); + assert.ok(nemo); + assert.equal(nemo.data.name, 'Nemo'); + assert.equal(nemo.data.breed, 'Clownfish'); + assert.equal(nemo.data.age, 3); + }); + + it('loads entries from TOML file', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + songs: defineCollection({ + loader: file('src/data/songs.toml'), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('songs'); + assert.equal(entries.length, 8); + + // Songs have 'name' and 'artists' fields + const crown = entries.find((e) => e.id === 'crown'); + assert.ok(crown); + assert.equal(crown.data.name, 'Crown'); + }); + + it('loads entries from CSV file with custom parser', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + plants: defineCollection({ + loader: file('src/data/plants.csv', { + parser: (text) => { + const [headers, ...rows] = text.trim().split('\n'); + return rows.map((row) => + Object.fromEntries(headers.split(',').map((h, i) => [h, row.split(',')[i]])), + ); + }, + }), + schema: z.object({ + id: z.string(), + common_name: z.string(), + scientific_name: z.string(), + color: z.string(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('plants'); + assert.equal(entries.length, 10); + + const rose = entries.find((e) => e.id === 'rose'); + assert.ok(rose); + assert.equal(rose.data.common_name, 'Rose'); + assert.equal(rose.data.scientific_name, 'Rosa'); + assert.equal(rose.data.color, 'Red'); + }); + + it('loads nested JSON with custom parser', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + birds: defineCollection({ + loader: file('src/data/birds.json', { + parser: (text) => JSON.parse(text).birds, + }), + schema: z.object({ + id: z.string(), + name: z.string(), + breed: z.string(), + age: z.number(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('birds'); + assert.equal(entries.length, 5); + + const bluejay = entries.find((e) => e.id === 'bluejay'); + assert.ok(bluejay); + assert.equal(bluejay.data.name, 'Blue Jay'); + assert.equal(bluejay.data.breed, 'Cyanocitta cristata'); + assert.equal(bluejay.data.age, 3); + }); + + it('uses async parser', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + birdsAsync: defineCollection({ + loader: file('src/data/birds.json', { + parser: async (text) => { + // Simulate async work + await new Promise((resolve) => setTimeout(resolve, 10)); + return JSON.parse(text).birds; + }, + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('birdsAsync'); + assert.equal(entries.length, 5); + }); + + it('warns on duplicate IDs', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + + // Create a custom logger to capture warnings + const warnings: string[] = []; + const logger = new AstroLogger({ + destination: { + write: (msg: any) => { + if (msg.level === 'warn') { + warnings.push(msg.message); + } + return true; + }, + }, + level: 'info', + }); + + const collections = { + dogsWithDupes: defineCollection({ + loader: file('src/data/dogs.json', { + parser: () => [ + { id: 'beagle', breed: 'Beagle 1' }, + { id: 'beagle', breed: 'Beagle 2' }, + ], + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check that a warning was logged + assert.ok(warnings.some((w) => w.includes('Duplicate id "beagle"'))); + + // Check that the last entry won + const entries = store.values('dogsWithDupes'); + assert.equal(entries.length, 1); + assert.equal(entries[0].data.breed, 'Beagle 2'); + }); +}); diff --git a/packages/astro/test/units/content-layer/glob-loader.test.js b/packages/astro/test/units/content-layer/glob-loader.test.js deleted file mode 100644 index 4fc4892ab3a4..000000000000 --- a/packages/astro/test/units/content-layer/glob-loader.test.js +++ /dev/null @@ -1,351 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { glob } from '../../../dist/content/loaders/glob.js'; -import { defineCollection } from '../../../dist/content/config.js'; -import { ContentLayer } from '../../../dist/content/content-layer.js'; -import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; -import { Logger } from '../../../dist/core/logger/core.js'; -import { - createTestConfigObserver, - createMinimalSettings, - createMarkdownEntryType, -} from './test-helpers.js'; - -describe('Glob Loader', () => { - const root = new URL('../../fixtures/content-layer/', import.meta.url); - - it('loads markdown files with glob pattern', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root, { - contentEntryTypes: [createMarkdownEntryType()], - }); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - spacecraft: defineCollection({ - loader: glob({ pattern: '*.md', base: 'src/content/space' }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entries = store.values('spacecraft'); - assert.ok(entries.length > 0); - - // Check that columbia exists - const columbia = entries.find((e) => e.id === 'columbia'); - assert.ok(columbia); - assert.ok(columbia.body); - assert.ok(columbia.body.includes('Space Shuttle Columbia')); - assert.equal(columbia.filePath.replace(/\\/g, '/'), 'src/content/space/columbia.md'); - }); - - it('handles negative matches in glob pattern', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root, { - contentEntryTypes: [createMarkdownEntryType()], - }); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - probes: defineCollection({ - loader: glob({ pattern: ['*.md', '!voyager-*'], base: 'src/data/space-probes' }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entries = store.values('probes'); - assert.equal(entries.length, 6); - - // Verify voyager probes are excluded - assert.ok(entries.every((e) => !e.id.startsWith('voyager'))); - - // Check that other probes exist - const cassini = entries.find((e) => e.id === 'cassini'); - assert.ok(cassini); - }); - - it('retains body by default', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root, { - contentEntryTypes: [createMarkdownEntryType()], - }); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - spacecraftWithBody: defineCollection({ - loader: glob({ pattern: '*.md', base: 'src/content/space' }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entries = store.values('spacecraftWithBody'); - assert.ok(entries.length > 0); - - const entry = entries[0]; - assert.ok(entry.body); - assert.ok(entry.body.length > 0); - }); - - it('clears body when retainBody is false', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root, { - contentEntryTypes: [createMarkdownEntryType()], - }); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - spacecraftNoBody: defineCollection({ - loader: glob({ pattern: '*.md', base: 'src/content/space', retainBody: false }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entries = store.values('spacecraftNoBody'); - assert.ok(entries.length > 0); - - const entry = entries[0]; - assert.equal(entry.body, undefined); - }); - - it('loads YAML files with glob pattern', async () => { - const store = new MutableDataStore(); - - // Create custom YAML data entry type - const yamlEntryType = { - extensions: ['.yaml', '.yml'], - getEntryInfo: ({ contents }) => { - // Simple YAML parser - const lines = contents.trim().split('\n'); - const data = {}; - lines.forEach((line) => { - const colonIndex = line.indexOf(':'); - if (colonIndex > -1) { - const key = line.substring(0, colonIndex).trim(); - const value = line - .substring(colonIndex + 1) - .trim() - .replace(/["']/g, ''); - data[key] = value; - } - }); - return { data, body: '', slug: '' }; - }, - }; - - const settings = createMinimalSettings(root, { - config: { - root, - srcDir: new URL('./src/', root), - }, - dataEntryTypes: [yamlEntryType], - }); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - numbersYaml: defineCollection({ - loader: glob({ pattern: 'src/data/glob-yaml/*', base: '.' }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entries = store.values('numbersYaml'); - assert.equal(entries.length, 3); - - const ids = entries.map((e) => e.id).sort(); - // The glob loader includes the path in the ID - assert.deepEqual(ids, [ - 'src/data/glob-yaml/one', - 'src/data/glob-yaml/three', - 'src/data/glob-yaml/two', - ]); - }); - - it('loads TOML files with glob pattern', async () => { - const store = new MutableDataStore(); - - // Create custom TOML data entry type - const tomlEntryType = { - extensions: ['.toml'], - getEntryInfo: ({ contents }) => { - // Simple TOML parser for key-value pairs - const lines = contents.trim().split('\n'); - const data = {}; - lines.forEach((line) => { - const equalIndex = line.indexOf('='); - if (equalIndex > -1) { - const key = line.substring(0, equalIndex).trim(); - const value = line - .substring(equalIndex + 1) - .trim() - .replace(/["']/g, ''); - data[key] = value; - } - }); - return { data, body: '', slug: '' }; - }, - }; - - const settings = createMinimalSettings(root, { - config: { - root, - srcDir: new URL('./src/', root), - }, - dataEntryTypes: [tomlEntryType], - }); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const collections = { - numbersToml: defineCollection({ - loader: glob({ pattern: 'src/data/glob-toml/*', base: '.' }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entries = store.values('numbersToml'); - assert.equal(entries.length, 3); - - const ids = entries.map((e) => e.id).sort(); - // The glob loader includes the path in the ID - assert.deepEqual(ids, [ - 'src/data/glob-toml/one', - 'src/data/glob-toml/three', - 'src/data/glob-toml/two', - ]); - }); - - it('warns about missing directory', async () => { - const store = new MutableDataStore(); - const warnings = []; - const logger = new Logger({ - dest: { - write: (msg) => { - if (msg.level === 'warn') { - warnings.push(msg.message); - } - return true; - }, - }, - level: 'info', - }); - - const settings = createMinimalSettings(root); - - const collections = { - notADirectory: defineCollection({ - loader: glob({ pattern: '*', base: 'src/nonexistent' }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - assert.ok(warnings.some((w) => w.includes('does not exist'))); - }); - - it('warns about no matching files', async () => { - const store = new MutableDataStore(); - const warnings = []; - const logger = new Logger({ - dest: { - write: (msg) => { - if (msg.level === 'warn') { - warnings.push(msg.message); - } - return true; - }, - }, - level: 'info', - }); - - const settings = createMinimalSettings(root); - - const collections = { - nothingMatches: defineCollection({ - loader: glob({ pattern: 'nothingmatches/*', base: 'src/data' }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - assert.ok(warnings.some((w) => w.includes('No files found matching'))); - }); -}); diff --git a/packages/astro/test/units/content-layer/glob-loader.test.ts b/packages/astro/test/units/content-layer/glob-loader.test.ts new file mode 100644 index 000000000000..a3114781c4a0 --- /dev/null +++ b/packages/astro/test/units/content-layer/glob-loader.test.ts @@ -0,0 +1,351 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { glob } from '../../../dist/content/loaders/glob.js'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; +import { + createTestConfigObserver, + createMinimalSettings, + createMarkdownEntryType, +} from './test-helpers.ts'; + +describe('Glob Loader', () => { + const root = new URL('../../fixtures/content-layer/', import.meta.url); + + it('loads markdown files with glob pattern', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root, { + contentEntryTypes: [createMarkdownEntryType()], + }); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + spacecraft: defineCollection({ + loader: glob({ pattern: '*.md', base: 'src/content/space' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('spacecraft'); + assert.ok(entries.length > 0); + + // Check that columbia exists + const columbia = entries.find((e) => e.id === 'columbia'); + assert.ok(columbia); + assert.ok(columbia.body); + assert.ok(columbia.body.includes('Space Shuttle Columbia')); + assert.equal(columbia.filePath!.replace(/\\/g, '/'), 'src/content/space/columbia.md'); + }); + + it('handles negative matches in glob pattern', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root, { + contentEntryTypes: [createMarkdownEntryType()], + }); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + probes: defineCollection({ + loader: glob({ pattern: ['*.md', '!voyager-*'], base: 'src/data/space-probes' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('probes'); + assert.equal(entries.length, 6); + + // Verify voyager probes are excluded + assert.ok(entries.every((e) => !e.id.startsWith('voyager'))); + + // Check that other probes exist + const cassini = entries.find((e) => e.id === 'cassini'); + assert.ok(cassini); + }); + + it('retains body by default', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root, { + contentEntryTypes: [createMarkdownEntryType()], + }); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + spacecraftWithBody: defineCollection({ + loader: glob({ pattern: '*.md', base: 'src/content/space' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('spacecraftWithBody'); + assert.ok(entries.length > 0); + + const entry = entries[0]; + assert.ok(entry.body); + assert.ok(entry.body.length > 0); + }); + + it('clears body when retainBody is false', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root, { + contentEntryTypes: [createMarkdownEntryType()], + }); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + spacecraftNoBody: defineCollection({ + loader: glob({ pattern: '*.md', base: 'src/content/space', retainBody: false }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('spacecraftNoBody'); + assert.ok(entries.length > 0); + + const entry = entries[0]; + assert.equal(entry.body, undefined); + }); + + it('loads YAML files with glob pattern', async () => { + const store = new MutableDataStore(); + + // Create custom YAML data entry type + const yamlEntryType = { + extensions: ['.yaml', '.yml'], + getEntryInfo: ({ contents }: any) => { + // Simple YAML parser + const lines = contents.trim().split('\n'); + const data: Record = {}; + lines.forEach((line: string) => { + const colonIndex = line.indexOf(':'); + if (colonIndex > -1) { + const key = line.substring(0, colonIndex).trim(); + const value = line + .substring(colonIndex + 1) + .trim() + .replace(/["']/g, ''); + data[key] = value; + } + }); + return { data, body: '', slug: '' }; + }, + }; + + const settings = createMinimalSettings(root, { + config: { + root, + srcDir: new URL('./src/', root), + }, + dataEntryTypes: [yamlEntryType], + }); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + numbersYaml: defineCollection({ + loader: glob({ pattern: 'src/data/glob-yaml/*', base: '.' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('numbersYaml'); + assert.equal(entries.length, 3); + + const ids = entries.map((e) => e.id).sort(); + // The glob loader includes the path in the ID + assert.deepEqual(ids, [ + 'src/data/glob-yaml/one', + 'src/data/glob-yaml/three', + 'src/data/glob-yaml/two', + ]); + }); + + it('loads TOML files with glob pattern', async () => { + const store = new MutableDataStore(); + + // Create custom TOML data entry type + const tomlEntryType = { + extensions: ['.toml'], + getEntryInfo: ({ contents }: any) => { + // Simple TOML parser for key-value pairs + const lines = contents.trim().split('\n'); + const data: Record = {}; + lines.forEach((line: string) => { + const equalIndex = line.indexOf('='); + if (equalIndex > -1) { + const key = line.substring(0, equalIndex).trim(); + const value = line + .substring(equalIndex + 1) + .trim() + .replace(/["']/g, ''); + data[key] = value; + } + }); + return { data, body: '', slug: '' }; + }, + }; + + const settings = createMinimalSettings(root, { + config: { + root, + srcDir: new URL('./src/', root), + }, + dataEntryTypes: [tomlEntryType], + }); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + numbersToml: defineCollection({ + loader: glob({ pattern: 'src/data/glob-toml/*', base: '.' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entries = store.values('numbersToml'); + assert.equal(entries.length, 3); + + const ids = entries.map((e) => e.id).sort(); + // The glob loader includes the path in the ID + assert.deepEqual(ids, [ + 'src/data/glob-toml/one', + 'src/data/glob-toml/three', + 'src/data/glob-toml/two', + ]); + }); + + it('warns about missing directory', async () => { + const store = new MutableDataStore(); + const warnings: string[] = []; + const logger = new AstroLogger({ + destination: { + write: (msg: any) => { + if (msg.level === 'warn') { + warnings.push(msg.message); + } + return true; + }, + }, + level: 'info', + }); + + const settings = createMinimalSettings(root); + + const collections = { + notADirectory: defineCollection({ + loader: glob({ pattern: '*', base: 'src/nonexistent' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + assert.ok(warnings.some((w) => w.includes('does not exist'))); + }); + + it('warns about no matching files', async () => { + const store = new MutableDataStore(); + const warnings: string[] = []; + const logger = new AstroLogger({ + destination: { + write: (msg: any) => { + if (msg.level === 'warn') { + warnings.push(msg.message); + } + return true; + }, + }, + level: 'info', + }); + + const settings = createMinimalSettings(root); + + const collections = { + nothingMatches: defineCollection({ + loader: glob({ pattern: 'nothingmatches/*', base: 'src/data' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + assert.ok(warnings.some((w) => w.includes('No files found matching'))); + }); +}); diff --git a/packages/astro/test/units/content-layer/live-loaders.test.js b/packages/astro/test/units/content-layer/live-loaders.test.js deleted file mode 100644 index 3413cca5cfc8..000000000000 --- a/packages/astro/test/units/content-layer/live-loaders.test.js +++ /dev/null @@ -1,669 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { z } from 'zod'; -import { defineCollection } from '../../../dist/content/config.js'; -import { ContentLayer } from '../../../dist/content/content-layer.js'; -import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; -import { Logger } from '../../../dist/core/logger/core.js'; -import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; - -describe('Content Layer - Live Loaders', () => { - const root = createTempDir(); - - it('loads initial data through sync', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - // Define test data - const entries = { - 123: { - id: '123', - data: { title: 'Page 123', age: 10 }, - rendered: { html: '

    Page 123

    This is rendered content.

    ' }, - }, - 456: { - id: '456', - data: { title: 'Page 456', age: 20 }, - }, - 789: { - id: '789', - data: { title: 'Page 789', age: 30 }, - }, - }; - - // Create a live loader - const testLoader = { - name: 'test-loader', - load: async (context) => { - // Sync loader that loads initial data - for (const entry of Object.values(entries)) { - const parsed = await context.parseData({ - id: entry.id, - data: entry.data, - }); - - await context.store.set({ - id: entry.id, - data: parsed, - rendered: entry.rendered, - }); - } - }, - }; - - const collections = { - liveStuff: defineCollection({ - loader: testLoader, - schema: z.object({ - title: z.string(), - age: z.number(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Verify initial data was loaded - const allEntries = store.values('liveStuff'); - assert.equal(allEntries.length, 3); - - // Check individual entries - const entry1 = store.get('liveStuff', '123'); - assert.ok(entry1); - assert.equal(entry1.data.title, 'Page 123'); - assert.equal(entry1.data.age, 10); - assert.ok(entry1.rendered); - assert.equal(entry1.rendered.html, '

    Page 123

    This is rendered content.

    '); - - const entry2 = store.get('liveStuff', '456'); - assert.ok(entry2); - assert.equal(entry2.data.title, 'Page 456'); - assert.equal(entry2.data.age, 20); - assert.ok(!entry2.rendered); // No rendered content for this entry - - const entry3 = store.get('liveStuff', '789'); - assert.ok(entry3); - assert.equal(entry3.data.title, 'Page 789'); - assert.equal(entry3.data.age, 30); - }); - - it('simulates live loader with loadEntry functionality', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - // Mock data source - const dataSource = { - 123: { id: '123', data: { title: 'Page 123', age: 10 } }, - 456: { id: '456', data: { title: 'Page 456', age: 20 } }, - }; - - // Loader that simulates live loading behavior - const liveSimulationLoader = { - name: 'live-simulation-loader', - load: async (context) => { - // Initial load - only load entry 123 - const entry = dataSource['123']; - const parsed = await context.parseData({ - id: entry.id, - data: entry.data, - }); - - await context.store.set({ - id: entry.id, - data: parsed, - }); - - // Store metadata about what would be available for live loading - await context.store.set({ - id: '_meta', - data: { - availableIds: Object.keys(dataSource), - supportsLiveLoading: true, - }, - }); - }, - }; - - const collections = { - liveSimulation: defineCollection({ - loader: liveSimulationLoader, - schema: z.object({ - title: z.string(), - age: z.number(), - availableIds: z.array(z.string()).optional(), - supportsLiveLoading: z.boolean().optional(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check initial state - const entry123 = store.get('liveSimulation', '123'); - assert.ok(entry123); - assert.equal(entry123.data.title, 'Page 123'); - - // Entry 456 would not be loaded initially - const entry456 = store.get('liveSimulation', '456'); - assert.ok(!entry456); - - // Check metadata - const meta = store.get('liveSimulation', '_meta'); - assert.ok(meta); - assert.deepEqual(meta.data.availableIds, ['123', '456']); - assert.equal(meta.data.supportsLiveLoading, true); - }); - - it('demonstrates dynamic data transformation', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - // Loader that transforms data based on context - const transformLoader = { - name: 'transform-loader', - load: async (context) => { - const entries = [ - { id: '1', data: { title: 'Entry 1', value: 10, category: 'A' } }, - { id: '2', data: { title: 'Entry 2', value: 20, category: 'B' } }, - { id: '3', data: { title: 'Entry 3', value: 30, category: 'A' } }, - ]; - - for (const entry of entries) { - // Apply transformations - const transformedData = { - ...entry.data, - // Add computed fields - doubled: entry.data.value * 2, - categoryLabel: `Category ${entry.data.category}`, - timestamp: new Date('2025-01-01T00:00:00.000Z'), - }; - - const parsed = await context.parseData({ - id: entry.id, - data: transformedData, - }); - - await context.store.set({ - id: entry.id, - data: parsed, - }); - } - }, - }; - - const collections = { - transformed: defineCollection({ - loader: transformLoader, - schema: z.object({ - title: z.string(), - value: z.number(), - category: z.string(), - doubled: z.number(), - categoryLabel: z.string(), - timestamp: z.date(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Verify transformations - const entry1 = store.get('transformed', '1'); - assert.ok(entry1); - assert.equal(entry1.data.doubled, 20); - assert.equal(entry1.data.categoryLabel, 'Category A'); - - const entry2 = store.get('transformed', '2'); - assert.ok(entry2); - assert.equal(entry2.data.doubled, 40); - assert.equal(entry2.data.categoryLabel, 'Category B'); - }); - - it('handles loader errors gracefully', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - // Loader that simulates error conditions - const errorProneLoader = { - name: 'error-prone-loader', - load: async (context) => { - // Add some valid entries - await context.store.set({ - id: 'valid-1', - data: { title: 'Valid Entry 1', status: 'ok' }, - }); - - // Try to parse invalid data - this should be caught by schema validation - try { - const parsed = await context.parseData({ - id: 'invalid-1', - data: { title: 123, status: 'invalid' }, // title should be string - }); - await context.store.set({ - id: 'invalid-1', - data: parsed, - }); - } catch (error) { - // Store error information - await context.store.set({ - id: 'error-log', - data: { - title: 'Error Log', - status: 'error', - errorMessage: error.message, - }, - }); - } - }, - }; - - const collections = { - errorProne: defineCollection({ - loader: errorProneLoader, - schema: z.object({ - title: z.string(), - status: z.string(), - errorMessage: z.string().optional(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check valid entry - const validEntry = store.get('errorProne', 'valid-1'); - assert.ok(validEntry); - assert.equal(validEntry.data.title, 'Valid Entry 1'); - assert.equal(validEntry.data.status, 'ok'); - - // Check that invalid entry was not stored - const invalidEntry = store.get('errorProne', 'invalid-1'); - assert.ok(!invalidEntry); - - // Check error log - const errorLog = store.get('errorProne', 'error-log'); - assert.ok(errorLog); - assert.ok(errorLog.data.errorMessage); - }); - - it('supports complex rendered content', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const renderedContentLoader = { - name: 'rendered-content-loader', - load: async (context) => { - const articles = [ - { - id: 'article-1', - data: { - title: 'First Article', - author: 'John Doe', - publishDate: new Date('2025-01-15'), - }, - content: - '# First Article\n\nThis is the **first** article with [links](https://example.com).', - }, - { - id: 'article-2', - data: { - title: 'Second Article', - author: 'Jane Smith', - publishDate: new Date('2025-01-20'), - }, - content: - '## Second Article\n\nThis article has:\n- Lists\n- Code blocks\n\n```js\nconsole.log("Hello");\n```', - }, - ]; - - for (const article of articles) { - // Simulate rendering process - const rendered = await context.renderMarkdown(article.content, { - fileURL: new URL(`${article.id}.md`, root), - }); - - const parsed = await context.parseData({ - id: article.id, - data: article.data, - }); - - await context.store.set({ - id: article.id, - data: parsed, - body: article.content, - rendered: { - html: rendered.html, - metadata: { - ...rendered.metadata, - wordCount: article.content.split(/\s+/).length, - readingTime: Math.ceil(article.content.split(/\s+/).length / 200), - }, - }, - }); - } - }, - }; - - const collections = { - articles: defineCollection({ - loader: renderedContentLoader, - schema: z.object({ - title: z.string(), - author: z.string(), - publishDate: z.date(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check first article - const article1 = store.get('articles', 'article-1'); - assert.ok(article1); - assert.equal(article1.data.title, 'First Article'); - assert.ok(article1.body); - assert.ok(article1.rendered); - assert.ok(article1.rendered.html); - assert.ok(article1.rendered.html.includes('First Article')); - assert.ok(article1.rendered.metadata); - assert.ok(article1.rendered.metadata.wordCount > 0); - - // Check second article - const article2 = store.get('articles', 'article-2'); - assert.ok(article2); - assert.ok(article2.rendered); - // Check for code block rendering - assert.ok(article2.rendered.html.includes(' { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const cacheAwareLoader = { - name: 'cache-aware-loader', - load: async (context) => { - const now = new Date(); - const entries = [ - { - id: 'static-content', - data: { - title: 'Static Page', - type: 'static', - content: 'This content rarely changes', - }, - cache: { - lastModified: new Date('2024-01-01'), - maxAge: 86400 * 30, // 30 days - tags: ['static', 'page'], - }, - }, - { - id: 'dynamic-content', - data: { - title: 'Dynamic Dashboard', - type: 'dynamic', - content: 'This updates frequently', - }, - cache: { - lastModified: now, - maxAge: 300, // 5 minutes - tags: ['dynamic', 'dashboard', 'realtime'], - }, - }, - { - id: 'user-content', - data: { - title: 'User Profile', - type: 'personalized', - content: 'User-specific content', - }, - cache: { - lastModified: now, - maxAge: 0, // No caching - tags: ['user', 'personalized', 'no-cache'], - }, - }, - ]; - - for (const entry of entries) { - const parsed = await context.parseData({ - id: entry.id, - data: { - ...entry.data, - cacheInfo: { - maxAge: entry.cache.maxAge, - tags: entry.cache.tags, - lastModified: entry.cache.lastModified.toISOString(), - }, - }, - }); - - await context.store.set({ - id: entry.id, - data: parsed, - }); - } - }, - }; - - const collections = { - cached: defineCollection({ - loader: cacheAwareLoader, - schema: z.object({ - title: z.string(), - type: z.string(), - content: z.string(), - cacheInfo: z.object({ - maxAge: z.number(), - tags: z.array(z.string()), - lastModified: z.string(), - }), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Verify static content caching - const staticContent = store.get('cached', 'static-content'); - assert.ok(staticContent); - assert.equal(staticContent.data.cacheInfo.maxAge, 86400 * 30); - assert.ok(staticContent.data.cacheInfo.tags.includes('static')); - - // Verify dynamic content caching - const dynamicContent = store.get('cached', 'dynamic-content'); - assert.ok(dynamicContent); - assert.equal(dynamicContent.data.cacheInfo.maxAge, 300); - assert.ok(dynamicContent.data.cacheInfo.tags.includes('realtime')); - - // Verify personalized content caching - const userContent = store.get('cached', 'user-content'); - assert.ok(userContent); - assert.equal(userContent.data.cacheInfo.maxAge, 0); - assert.ok(userContent.data.cacheInfo.tags.includes('no-cache')); - }); - - it('validates schema during data loading', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const validationLoader = { - name: 'validation-loader', - load: async (context) => { - const testData = [ - // Valid entries - { id: 'valid-1', data: { name: 'Alice', age: 30, email: 'alice@example.com' } }, - { id: 'valid-2', data: { name: 'Bob', age: 25, email: 'bob@example.com' } }, - // Invalid entries (these will fail schema validation) - { id: 'invalid-age', data: { name: 'Charlie', age: -5, email: 'charlie@example.com' } }, - { id: 'invalid-email', data: { name: 'David', age: 35, email: 'not-an-email' } }, - { id: 'missing-field', data: { name: 'Eve', age: 28 } }, // missing email - ]; - - let successCount = 0; - let errorCount = 0; - - for (const item of testData) { - try { - const parsed = await context.parseData({ - id: item.id, - data: item.data, - }); - - await context.store.set({ - id: item.id, - data: parsed, - }); - successCount++; - } catch (_error) { - errorCount++; - // Optionally store validation errors - if (item.id.startsWith('invalid')) { - await context.store.set({ - id: `${item.id}-error`, - data: { - name: `Error for ${item.data.name}`, - age: 0, - email: 'error@example.com', - validationError: true, - }, - }); - } - } - } - - // Store summary - await context.store.set({ - id: '_validation_summary', - data: { - name: 'Validation Summary', - age: 0, - email: 'summary@example.com', - successCount, - errorCount, - }, - }); - }, - }; - - const collections = { - validated: defineCollection({ - loader: validationLoader, - schema: z.object({ - name: z.string().min(1), - age: z.number().positive(), - email: z.string().email(), - validationError: z.boolean().optional(), - successCount: z.number().optional(), - errorCount: z.number().optional(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check valid entries - const valid1 = store.get('validated', 'valid-1'); - assert.ok(valid1); - assert.equal(valid1.data.name, 'Alice'); - assert.equal(valid1.data.age, 30); - - const valid2 = store.get('validated', 'valid-2'); - assert.ok(valid2); - assert.equal(valid2.data.name, 'Bob'); - - // Check that invalid entries were not stored - const invalidAge = store.get('validated', 'invalid-age'); - assert.ok(!invalidAge); - - const invalidEmail = store.get('validated', 'invalid-email'); - assert.ok(!invalidEmail); - - const missingField = store.get('validated', 'missing-field'); - assert.ok(!missingField); - - // Check summary - const summary = store.get('validated', '_validation_summary'); - assert.ok(summary); - assert.equal(summary.data.successCount, 2); // Only valid-1 and valid-2 - assert.equal(summary.data.errorCount, 3); // Three invalid entries - }); -}); diff --git a/packages/astro/test/units/content-layer/live-loaders.test.ts b/packages/astro/test/units/content-layer/live-loaders.test.ts new file mode 100644 index 000000000000..ca1c6498134c --- /dev/null +++ b/packages/astro/test/units/content-layer/live-loaders.test.ts @@ -0,0 +1,669 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { z } from 'zod'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; +import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.ts'; + +describe('Content Layer - Live Loaders', () => { + const root = createTempDir(); + + it('loads initial data through sync', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + // Define test data + const entries = { + 123: { + id: '123', + data: { title: 'Page 123', age: 10 }, + rendered: { html: '

    Page 123

    This is rendered content.

    ' }, + }, + 456: { + id: '456', + data: { title: 'Page 456', age: 20 }, + }, + 789: { + id: '789', + data: { title: 'Page 789', age: 30 }, + }, + }; + + // Create a live loader + const testLoader = { + name: 'test-loader', + load: async (context: any) => { + // Sync loader that loads initial data + for (const entry of Object.values(entries)) { + const parsed = await context.parseData({ + id: entry.id, + data: entry.data, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + rendered: (entry as any).rendered, + }); + } + }, + }; + + const collections = { + liveStuff: defineCollection({ + loader: testLoader, + schema: z.object({ + title: z.string(), + age: z.number(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify initial data was loaded + const allEntries = store.values('liveStuff'); + assert.equal(allEntries.length, 3); + + // Check individual entries + const entry1: any = store.get('liveStuff', '123'); + assert.ok(entry1); + assert.equal(entry1.data.title, 'Page 123'); + assert.equal(entry1.data.age, 10); + assert.ok(entry1.rendered); + assert.equal(entry1.rendered.html, '

    Page 123

    This is rendered content.

    '); + + const entry2: any = store.get('liveStuff', '456'); + assert.ok(entry2); + assert.equal(entry2.data.title, 'Page 456'); + assert.equal(entry2.data.age, 20); + assert.ok(!entry2.rendered); // No rendered content for this entry + + const entry3: any = store.get('liveStuff', '789'); + assert.ok(entry3); + assert.equal(entry3.data.title, 'Page 789'); + assert.equal(entry3.data.age, 30); + }); + + it('simulates live loader with loadEntry functionality', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + // Mock data source + const dataSource = { + 123: { id: '123', data: { title: 'Page 123', age: 10 } }, + 456: { id: '456', data: { title: 'Page 456', age: 20 } }, + }; + + // Loader that simulates live loading behavior + const liveSimulationLoader = { + name: 'live-simulation-loader', + load: async (context: any) => { + // Initial load - only load entry 123 + const entry = dataSource['123']; + const parsed = await context.parseData({ + id: entry.id, + data: entry.data, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + + // Store metadata about what would be available for live loading + await context.store.set({ + id: '_meta', + data: { + availableIds: Object.keys(dataSource), + supportsLiveLoading: true, + }, + }); + }, + }; + + const collections = { + liveSimulation: defineCollection({ + loader: liveSimulationLoader, + schema: z.object({ + title: z.string(), + age: z.number(), + availableIds: z.array(z.string()).optional(), + supportsLiveLoading: z.boolean().optional(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check initial state + const entry123: any = store.get('liveSimulation', '123'); + assert.ok(entry123); + assert.equal(entry123.data.title, 'Page 123'); + + // Entry 456 would not be loaded initially + const entry456: any = store.get('liveSimulation', '456'); + assert.ok(!entry456); + + // Check metadata + const meta: any = store.get('liveSimulation', '_meta'); + assert.ok(meta); + assert.deepEqual(meta.data.availableIds, ['123', '456']); + assert.equal(meta.data.supportsLiveLoading, true); + }); + + it('demonstrates dynamic data transformation', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + // Loader that transforms data based on context + const transformLoader = { + name: 'transform-loader', + load: async (context: any) => { + const entries = [ + { id: '1', data: { title: 'Entry 1', value: 10, category: 'A' } }, + { id: '2', data: { title: 'Entry 2', value: 20, category: 'B' } }, + { id: '3', data: { title: 'Entry 3', value: 30, category: 'A' } }, + ]; + + for (const entry of entries) { + // Apply transformations + const transformedData = { + ...entry.data, + // Add computed fields + doubled: entry.data.value * 2, + categoryLabel: `Category ${entry.data.category}`, + timestamp: new Date('2025-01-01T00:00:00.000Z'), + }; + + const parsed = await context.parseData({ + id: entry.id, + data: transformedData, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + const collections = { + transformed: defineCollection({ + loader: transformLoader, + schema: z.object({ + title: z.string(), + value: z.number(), + category: z.string(), + doubled: z.number(), + categoryLabel: z.string(), + timestamp: z.date(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify transformations + const entry1: any = store.get('transformed', '1'); + assert.ok(entry1); + assert.equal(entry1.data.doubled, 20); + assert.equal(entry1.data.categoryLabel, 'Category A'); + + const entry2: any = store.get('transformed', '2'); + assert.ok(entry2); + assert.equal(entry2.data.doubled, 40); + assert.equal(entry2.data.categoryLabel, 'Category B'); + }); + + it('handles loader errors gracefully', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + // Loader that simulates error conditions + const errorProneLoader = { + name: 'error-prone-loader', + load: async (context: any) => { + // Add some valid entries + await context.store.set({ + id: 'valid-1', + data: { title: 'Valid Entry 1', status: 'ok' }, + }); + + // Try to parse invalid data - this should be caught by schema validation + try { + const parsed = await context.parseData({ + id: 'invalid-1', + data: { title: 123, status: 'invalid' }, // title should be string + }); + await context.store.set({ + id: 'invalid-1', + data: parsed, + }); + } catch (error: any) { + // Store error information + await context.store.set({ + id: 'error-log', + data: { + title: 'Error Log', + status: 'error', + errorMessage: error.message, + }, + }); + } + }, + }; + + const collections = { + errorProne: defineCollection({ + loader: errorProneLoader, + schema: z.object({ + title: z.string(), + status: z.string(), + errorMessage: z.string().optional(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check valid entry + const validEntry: any = store.get('errorProne', 'valid-1'); + assert.ok(validEntry); + assert.equal(validEntry.data.title, 'Valid Entry 1'); + assert.equal(validEntry.data.status, 'ok'); + + // Check that invalid entry was not stored + const invalidEntry: any = store.get('errorProne', 'invalid-1'); + assert.ok(!invalidEntry); + + // Check error log + const errorLog: any = store.get('errorProne', 'error-log'); + assert.ok(errorLog); + assert.ok(errorLog.data.errorMessage); + }); + + it('supports complex rendered content', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const renderedContentLoader = { + name: 'rendered-content-loader', + load: async (context: any) => { + const articles = [ + { + id: 'article-1', + data: { + title: 'First Article', + author: 'John Doe', + publishDate: new Date('2025-01-15'), + }, + content: + '# First Article\n\nThis is the **first** article with [links](https://example.com).', + }, + { + id: 'article-2', + data: { + title: 'Second Article', + author: 'Jane Smith', + publishDate: new Date('2025-01-20'), + }, + content: + '## Second Article\n\nThis article has:\n- Lists\n- Code blocks\n\n```js\nconsole.log("Hello");\n```', + }, + ]; + + for (const article of articles) { + // Simulate rendering process + const rendered = await context.renderMarkdown(article.content, { + fileURL: new URL(`${article.id}.md`, root), + }); + + const parsed = await context.parseData({ + id: article.id, + data: article.data, + }); + + await context.store.set({ + id: article.id, + data: parsed, + body: article.content, + rendered: { + html: rendered.html, + metadata: { + ...rendered.metadata, + wordCount: article.content.split(/\s+/).length, + readingTime: Math.ceil(article.content.split(/\s+/).length / 200), + }, + }, + }); + } + }, + }; + + const collections = { + articles: defineCollection({ + loader: renderedContentLoader, + schema: z.object({ + title: z.string(), + author: z.string(), + publishDate: z.date(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check first article + const article1: any = store.get('articles', 'article-1'); + assert.ok(article1); + assert.equal(article1.data.title, 'First Article'); + assert.ok(article1.body); + assert.ok(article1.rendered); + assert.ok(article1.rendered.html); + assert.ok(article1.rendered.html.includes('First Article')); + assert.ok(article1.rendered.metadata); + assert.ok(article1.rendered.metadata.wordCount > 0); + + // Check second article + const article2: any = store.get('articles', 'article-2'); + assert.ok(article2); + assert.ok(article2.rendered); + // Check for code block rendering + assert.ok(article2.rendered.html.includes(' { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const cacheAwareLoader = { + name: 'cache-aware-loader', + load: async (context: any) => { + const now = new Date(); + const entries = [ + { + id: 'static-content', + data: { + title: 'Static Page', + type: 'static', + content: 'This content rarely changes', + }, + cache: { + lastModified: new Date('2024-01-01'), + maxAge: 86400 * 30, // 30 days + tags: ['static', 'page'], + }, + }, + { + id: 'dynamic-content', + data: { + title: 'Dynamic Dashboard', + type: 'dynamic', + content: 'This updates frequently', + }, + cache: { + lastModified: now, + maxAge: 300, // 5 minutes + tags: ['dynamic', 'dashboard', 'realtime'], + }, + }, + { + id: 'user-content', + data: { + title: 'User Profile', + type: 'personalized', + content: 'User-specific content', + }, + cache: { + lastModified: now, + maxAge: 0, // No caching + tags: ['user', 'personalized', 'no-cache'], + }, + }, + ]; + + for (const entry of entries) { + const parsed = await context.parseData({ + id: entry.id, + data: { + ...entry.data, + cacheInfo: { + maxAge: entry.cache.maxAge, + tags: entry.cache.tags, + lastModified: entry.cache.lastModified.toISOString(), + }, + }, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + const collections = { + cached: defineCollection({ + loader: cacheAwareLoader, + schema: z.object({ + title: z.string(), + type: z.string(), + content: z.string(), + cacheInfo: z.object({ + maxAge: z.number(), + tags: z.array(z.string()), + lastModified: z.string(), + }), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify static content caching + const staticContent: any = store.get('cached', 'static-content'); + assert.ok(staticContent); + assert.equal(staticContent.data.cacheInfo.maxAge, 86400 * 30); + assert.ok(staticContent.data.cacheInfo.tags.includes('static')); + + // Verify dynamic content caching + const dynamicContent: any = store.get('cached', 'dynamic-content'); + assert.ok(dynamicContent); + assert.equal(dynamicContent.data.cacheInfo.maxAge, 300); + assert.ok(dynamicContent.data.cacheInfo.tags.includes('realtime')); + + // Verify personalized content caching + const userContent: any = store.get('cached', 'user-content'); + assert.ok(userContent); + assert.equal(userContent.data.cacheInfo.maxAge, 0); + assert.ok(userContent.data.cacheInfo.tags.includes('no-cache')); + }); + + it('validates schema during data loading', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const validationLoader = { + name: 'validation-loader', + load: async (context: any) => { + const testData = [ + // Valid entries + { id: 'valid-1', data: { name: 'Alice', age: 30, email: 'alice@example.com' } }, + { id: 'valid-2', data: { name: 'Bob', age: 25, email: 'bob@example.com' } }, + // Invalid entries (these will fail schema validation) + { id: 'invalid-age', data: { name: 'Charlie', age: -5, email: 'charlie@example.com' } }, + { id: 'invalid-email', data: { name: 'David', age: 35, email: 'not-an-email' } }, + { id: 'missing-field', data: { name: 'Eve', age: 28 } }, // missing email + ]; + + let successCount = 0; + let errorCount = 0; + + for (const item of testData) { + try { + const parsed = await context.parseData({ + id: item.id, + data: item.data, + }); + + await context.store.set({ + id: item.id, + data: parsed, + }); + successCount++; + } catch (_error: any) { + errorCount++; + // Optionally store validation errors + if (item.id.startsWith('invalid')) { + await context.store.set({ + id: `${item.id}-error`, + data: { + name: `Error for ${item.data.name}`, + age: 0, + email: 'error@example.com', + validationError: true, + }, + }); + } + } + } + + // Store summary + await context.store.set({ + id: '_validation_summary', + data: { + name: 'Validation Summary', + age: 0, + email: 'summary@example.com', + successCount, + errorCount, + }, + }); + }, + }; + + const collections = { + validated: defineCollection({ + loader: validationLoader, + schema: z.object({ + name: z.string().min(1), + age: z.number().positive(), + email: z.string().email(), + validationError: z.boolean().optional(), + successCount: z.number().optional(), + errorCount: z.number().optional(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check valid entries + const valid1: any = store.get('validated', 'valid-1'); + assert.ok(valid1); + assert.equal(valid1.data.name, 'Alice'); + assert.equal(valid1.data.age, 30); + + const valid2: any = store.get('validated', 'valid-2'); + assert.ok(valid2); + assert.equal(valid2.data.name, 'Bob'); + + // Check that invalid entries were not stored + const invalidAge: any = store.get('validated', 'invalid-age'); + assert.ok(!invalidAge); + + const invalidEmail: any = store.get('validated', 'invalid-email'); + assert.ok(!invalidEmail); + + const missingField: any = store.get('validated', 'missing-field'); + assert.ok(!missingField); + + // Check summary + const summary: any = store.get('validated', '_validation_summary'); + assert.ok(summary); + assert.equal(summary.data.successCount, 2); // Only valid-1 and valid-2 + assert.equal(summary.data.errorCount, 3); // Three invalid entries + }); +}); diff --git a/packages/astro/test/units/content-layer/loader-warnings.test.js b/packages/astro/test/units/content-layer/loader-warnings.test.js deleted file mode 100644 index 9408486619cd..000000000000 --- a/packages/astro/test/units/content-layer/loader-warnings.test.js +++ /dev/null @@ -1,576 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { z } from 'zod'; -import { defineCollection } from '../../../dist/content/config.js'; -import { ContentLayer } from '../../../dist/content/content-layer.js'; -import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; -import { Logger } from '../../../dist/core/logger/core.js'; -import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; -import { Writable } from 'node:stream'; -import fs from 'node:fs/promises'; - -describe('Content Layer - Loader Warnings', () => { - it('warns about missing data in loaders', async () => { - const root = createTempDir(); - const store = new MutableDataStore(); - const logs = []; - - const logger = new Logger({ - level: 'warn', - dest: new Writable({ - objectMode: true, - write(event, _, callback) { - logs.push(event); - callback(); - }, - }), - }); - - // Loader that simulates various warning scenarios - const warningLoader = { - name: 'warning-loader', - load: async (context) => { - // Warn about missing directory - context.logger.warn('Directory "src/content/non-existent-dir" does not exist'); - - // Add some valid entries - await context.store.set({ - id: 'valid-1', - data: { title: 'Valid Entry', status: 'ok' }, - }); - - // Try to add duplicate ID - this should be handled by the store - await context.store.set({ - id: 'duplicate-id', - data: { title: 'First Entry', value: 1 }, - }); - - // Second attempt with same ID (store will overwrite) - await context.store.set({ - id: 'duplicate-id', - data: { title: 'Second Entry', value: 2 }, - }); - - // Log warning about duplicate - context.logger.warn('Duplicate id "duplicate-id" found in collection'); - }, - }; - - const collections = { - warnings: defineCollection({ - loader: warningLoader, - schema: z.object({ - title: z.string(), - status: z.string().optional(), - value: z.number().optional(), - }), - }), - }; - - const settings = createMinimalSettings(root); - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check for warning logs - const missingDirWarning = logs.find( - (log) => log.level === 'warn' && log.message.includes('does not exist'), - ); - assert.ok(missingDirWarning, 'Should warn about missing directory'); - - const duplicateWarning = logs.find( - (log) => log.level === 'warn' && log.message.includes('Duplicate id'), - ); - assert.ok(duplicateWarning, 'Should warn about duplicate ID'); - - // Verify entries - const validEntry = store.get('warnings', 'valid-1'); - assert.ok(validEntry); - assert.equal(validEntry.data.title, 'Valid Entry'); - - // Duplicate ID should have the second entry's data (overwritten) - const duplicateEntry = store.get('warnings', 'duplicate-id'); - assert.ok(duplicateEntry); - assert.equal(duplicateEntry.data.title, 'Second Entry'); - assert.equal(duplicateEntry.data.value, 2); - }); - - it('warns about no files found in pattern matching', async () => { - const root = createTempDir(); - const store = new MutableDataStore(); - const logs = []; - - const logger = new Logger({ - level: 'warn', - dest: new Writable({ - objectMode: true, - write(event, _, callback) { - logs.push(event); - callback(); - }, - }), - }); - - // Create an empty directory - const emptyDir = new URL('./src/content/empty/', root); - await fs.mkdir(emptyDir, { recursive: true }); - - // Loader that simulates glob pattern with no matches - const emptyPatternLoader = { - name: 'empty-pattern-loader', - load: async (context) => { - // Simulate checking for files and finding none - const pattern = '*.mdx'; - const base = 'src/content/empty'; - - context.logger.warn(`No files found matching pattern "${pattern}" in "${base}"`); - - // Store metadata about the empty result - await context.store.set({ - id: '_meta', - data: { - pattern, - base, - filesFound: 0, - message: 'No matching files', - }, - }); - }, - }; - - const collections = { - emptyPattern: defineCollection({ - loader: emptyPatternLoader, - schema: z.object({ - pattern: z.string().optional(), - base: z.string().optional(), - filesFound: z.number().optional(), - message: z.string().optional(), - }), - }), - }; - - const settings = createMinimalSettings(root); - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check for warning - const noFilesWarning = logs.find( - (log) => log.level === 'warn' && log.message.includes('No files found matching'), - ); - assert.ok(noFilesWarning, 'Should warn about no files found'); - - // Check metadata - const meta = store.get('emptyPattern', '_meta'); - assert.ok(meta); - assert.equal(meta.data.filesFound, 0); - }); - - it('handles validation errors gracefully', async () => { - const root = createTempDir(); - const store = new MutableDataStore(); - const logs = []; - - const logger = new Logger({ - level: 'error', - dest: new Writable({ - objectMode: true, - write(event, _, callback) { - logs.push(event); - callback(); - }, - }), - }); - - // Loader that produces validation errors - const validationErrorLoader = { - name: 'validation-error-loader', - load: async (context) => { - const testData = [ - { id: 'item1', name: 'Valid Item', count: 5 }, - { id: 'item2', count: 10 }, // Missing required 'name' - { name: 'No ID Item', count: 15 }, // Would fail if 'id' is required by schema - { id: 'item3', name: 'Invalid Count', count: 'not-a-number' }, // Wrong type - ]; - - let successCount = 0; - let errorCount = 0; - - for (const item of testData) { - try { - const parsed = await context.parseData({ - id: item.id || 'generated-' + Date.now(), - data: item, - }); - - await context.store.set({ - id: item.id || 'generated-' + Date.now(), - data: parsed, - }); - successCount++; - } catch (error) { - errorCount++; - context.logger.error(`Validation failed for ${item.id || 'unknown'}: ${error.message}`); - } - } - - // Store summary - await context.store.set({ - id: '_summary', - data: { - name: 'Validation Summary', - count: 0, - validationStats: { - success: successCount, - errors: errorCount, - }, - }, - }); - }, - }; - - const collections = { - validated: defineCollection({ - loader: validationErrorLoader, - schema: z.object({ - name: z.string(), - count: z.number(), - validationStats: z - .object({ - success: z.number(), - errors: z.number(), - }) - .optional(), - }), - }), - }; - - const settings = createMinimalSettings(root); - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check for validation error logs - const validationErrors = logs.filter( - (log) => log.level === 'error' && log.message.includes('Validation failed'), - ); - assert.ok(validationErrors.length > 0, 'Should log validation errors'); - - // Check valid entry - const validEntry = store.get('validated', 'item1'); - assert.ok(validEntry); - assert.equal(validEntry.data.name, 'Valid Item'); - assert.equal(validEntry.data.count, 5); - - // Check summary - const summary = store.get('validated', '_summary'); - assert.ok(summary); - assert.ok(summary.data.validationStats.errors > 0); - }); - - it('handles malformed data gracefully', async () => { - const root = createTempDir(); - const store = new MutableDataStore(); - const logs = []; - - const logger = new Logger({ - level: 'error', - dest: new Writable({ - objectMode: true, - write(event, _, callback) { - logs.push(event); - callback(); - }, - }), - }); - - // Loader that simulates processing malformed data - const malformedDataLoader = { - name: 'malformed-data-loader', - load: async (context) => { - // Simulate trying to parse malformed JSON - const malformedJson = '{ "id": "test", "name": "Missing closing brace"'; - - try { - // This would throw - const data = JSON.parse(malformedJson); - await context.store.set({ - id: 'should-not-exist', - data, - }); - } catch (error) { - context.logger.error(`Failed to parse JSON: ${error.message}`); - - // Store error info - await context.store.set({ - id: 'parse-error', - data: { - error: 'JSON Parse Error', - message: error.message, - recovered: true, - }, - }); - } - - // Add a valid entry to show the loader can continue - await context.store.set({ - id: 'valid-after-error', - data: { - error: 'None', - message: 'Successfully loaded after error', - recovered: true, - }, - }); - }, - }; - - const collections = { - malformed: defineCollection({ - loader: malformedDataLoader, - schema: z.object({ - error: z.string(), - message: z.string(), - recovered: z.boolean(), - }), - }), - }; - - const settings = createMinimalSettings(root); - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check for JSON error log - const jsonError = logs.find((log) => log.level === 'error' && log.message.includes('JSON')); - assert.ok(jsonError, 'Should log JSON parse error'); - - // Check that error was handled - const errorEntry = store.get('malformed', 'parse-error'); - assert.ok(errorEntry); - assert.equal(errorEntry.data.error, 'JSON Parse Error'); - assert.ok(errorEntry.data.recovered); - - // Check that loader continued after error - const validEntry = store.get('malformed', 'valid-after-error'); - assert.ok(validEntry); - assert.equal(validEntry.data.error, 'None'); - }); - - it('warns about duplicate IDs across multiple entries', async () => { - const root = createTempDir(); - const store = new MutableDataStore(); - const logs = []; - - const logger = new Logger({ - level: 'warn', - dest: new Writable({ - objectMode: true, - write(event, _, callback) { - logs.push(event); - callback(); - }, - }), - }); - - // Create data directory with test files - const dataDir = new URL('./src/data/', root); - await fs.mkdir(dataDir, { recursive: true }); - - // Write a JSON file with duplicate IDs - await fs.writeFile( - new URL('./dogs.json', dataDir), - JSON.stringify([ - { id: 'german-shepherd', breed: 'German Shepherd', size: 'Large' }, - { id: 'beagle', breed: 'Beagle', size: 'Small' }, - { id: 'german-shepherd', breed: 'German Shepherd Mix', size: 'Medium' }, // Duplicate - ]), - ); - - // Loader that processes array data and warns about duplicates - const duplicateCheckLoader = { - name: 'duplicate-check-loader', - load: async (context) => { - // Read and parse the file - const filePath = new URL('./dogs.json', dataDir); - const content = await fs.readFile(filePath, 'utf-8'); - const dogs = JSON.parse(content); - - const seenIds = new Set(); - - for (const dog of dogs) { - if (seenIds.has(dog.id)) { - context.logger.warn(`Duplicate id "${dog.id}" found in src/data/dogs.json`); - } - seenIds.add(dog.id); - - const parsed = await context.parseData({ - id: dog.id, - data: dog, - }); - - // Store will overwrite duplicates - await context.store.set({ - id: dog.id, - data: parsed, - }); - } - }, - }; - - const collections = { - dogs: defineCollection({ - loader: duplicateCheckLoader, - schema: z.object({ - id: z.string(), - breed: z.string(), - size: z.string(), - }), - }), - }; - - const settings = createMinimalSettings(root); - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check for duplicate warning - const duplicateWarning = logs.find( - (log) => - log.level === 'warn' && - log.message.includes('Duplicate id "german-shepherd"') && - log.message.includes('dogs.json'), - ); - assert.ok(duplicateWarning, 'Should warn about duplicate ID'); - - // Check entries - last duplicate wins - const entries = store.values('dogs'); - assert.equal(entries.length, 2); // Only 2 unique IDs - - const germanShepherd = store.get('dogs', 'german-shepherd'); - assert.ok(germanShepherd); - assert.equal(germanShepherd.data.breed, 'German Shepherd Mix'); // Last one wins - assert.equal(germanShepherd.data.size, 'Medium'); - }); - - it('handles missing required fields with helpful errors', async () => { - const root = createTempDir(); - const store = new MutableDataStore(); - const logs = []; - - const logger = new Logger({ - level: 'error', - dest: new Writable({ - objectMode: true, - write(event, _, callback) { - logs.push(event); - callback(); - }, - }), - }); - - // Loader with strict schema validation - const strictSchemaLoader = { - name: 'strict-schema-loader', - load: async (context) => { - const items = [ - { id: 'complete', title: 'Complete Item', priority: 'high', tags: ['important'] }, - { id: 'missing-title', priority: 'low', tags: [] }, // Missing required title - { id: 'missing-priority', title: 'No Priority' }, // Missing required priority - { id: 'invalid-tags', title: 'Bad Tags', priority: 'medium', tags: 'not-an-array' }, // Wrong type - ]; - - for (const item of items) { - try { - const parsed = await context.parseData({ - id: item.id, - data: item, - }); - - await context.store.set({ - id: item.id, - data: parsed, - }); - } catch (error) { - // Log detailed validation error - const issues = error.errors || []; - const fields = issues.map((issue) => issue.path.join('.')).join(', '); - context.logger.error( - `Validation failed for item "${item.id}": Missing or invalid fields: ${fields || error.message}`, - ); - } - } - }, - }; - - const collections = { - strictItems: defineCollection({ - loader: strictSchemaLoader, - schema: z.object({ - title: z.string(), - priority: z.enum(['low', 'medium', 'high']), - tags: z.array(z.string()).optional().default([]), - }), - }), - }; - - const settings = createMinimalSettings(root); - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check for specific validation errors - const validationLogs = logs.filter( - (log) => log.level === 'error' && log.message.includes('Validation failed'), - ); - assert.ok(validationLogs.length >= 2, 'Should have validation errors for invalid items'); - - // Only complete item should be stored - const completeItem = store.get('strictItems', 'complete'); - assert.ok(completeItem); - assert.equal(completeItem.data.title, 'Complete Item'); - assert.equal(completeItem.data.priority, 'high'); - assert.deepEqual(completeItem.data.tags, ['important']); - - // Invalid items should not be stored - assert.ok(!store.get('strictItems', 'missing-title')); - assert.ok(!store.get('strictItems', 'missing-priority')); - assert.ok(!store.get('strictItems', 'invalid-tags')); - }); -}); diff --git a/packages/astro/test/units/content-layer/loader-warnings.test.ts b/packages/astro/test/units/content-layer/loader-warnings.test.ts new file mode 100644 index 000000000000..7be355b1269d --- /dev/null +++ b/packages/astro/test/units/content-layer/loader-warnings.test.ts @@ -0,0 +1,576 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { z } from 'zod'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; +import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.ts'; +import { Writable } from 'node:stream'; +import fs from 'node:fs/promises'; + +describe('Content Layer - Loader Warnings', () => { + it('warns about missing data in loaders', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs: any[] = []; + + const logger = new AstroLogger({ + level: 'warn', + destination: new Writable({ + objectMode: true, + write(event: any, _: any, callback: any) { + logs.push(event); + callback(); + }, + }), + }); + + // Loader that simulates various warning scenarios + const warningLoader = { + name: 'warning-loader', + load: async (context: any) => { + // Warn about missing directory + context.logger.warn('Directory "src/content/non-existent-dir" does not exist'); + + // Add some valid entries + await context.store.set({ + id: 'valid-1', + data: { title: 'Valid Entry', status: 'ok' }, + }); + + // Try to add duplicate ID - this should be handled by the store + await context.store.set({ + id: 'duplicate-id', + data: { title: 'First Entry', value: 1 }, + }); + + // Second attempt with same ID (store will overwrite) + await context.store.set({ + id: 'duplicate-id', + data: { title: 'Second Entry', value: 2 }, + }); + + // Log warning about duplicate + context.logger.warn('Duplicate id "duplicate-id" found in collection'); + }, + }; + + const collections = { + warnings: defineCollection({ + loader: warningLoader, + schema: z.object({ + title: z.string(), + status: z.string().optional(), + value: z.number().optional(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for warning logs + const missingDirWarning = logs.find( + (log) => log.level === 'warn' && log.message.includes('does not exist'), + ); + assert.ok(missingDirWarning, 'Should warn about missing directory'); + + const duplicateWarning = logs.find( + (log) => log.level === 'warn' && log.message.includes('Duplicate id'), + ); + assert.ok(duplicateWarning, 'Should warn about duplicate ID'); + + // Verify entries + const validEntry: any = store.get('warnings', 'valid-1'); + assert.ok(validEntry); + assert.equal(validEntry.data.title, 'Valid Entry'); + + // Duplicate ID should have the second entry's data (overwritten) + const duplicateEntry: any = store.get('warnings', 'duplicate-id'); + assert.ok(duplicateEntry); + assert.equal(duplicateEntry.data.title, 'Second Entry'); + assert.equal(duplicateEntry.data.value, 2); + }); + + it('warns about no files found in pattern matching', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs: any[] = []; + + const logger = new AstroLogger({ + level: 'warn', + destination: new Writable({ + objectMode: true, + write(event: any, _: any, callback: any) { + logs.push(event); + callback(); + }, + }), + }); + + // Create an empty directory + const emptyDir = new URL('./src/content/empty/', root); + await fs.mkdir(emptyDir, { recursive: true }); + + // Loader that simulates glob pattern with no matches + const emptyPatternLoader = { + name: 'empty-pattern-loader', + load: async (context: any) => { + // Simulate checking for files and finding none + const pattern = '*.mdx'; + const base = 'src/content/empty'; + + context.logger.warn(`No files found matching pattern "${pattern}" in "${base}"`); + + // Store metadata about the empty result + await context.store.set({ + id: '_meta', + data: { + pattern, + base, + filesFound: 0, + message: 'No matching files', + }, + }); + }, + }; + + const collections = { + emptyPattern: defineCollection({ + loader: emptyPatternLoader, + schema: z.object({ + pattern: z.string().optional(), + base: z.string().optional(), + filesFound: z.number().optional(), + message: z.string().optional(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for warning + const noFilesWarning = logs.find( + (log) => log.level === 'warn' && log.message.includes('No files found matching'), + ); + assert.ok(noFilesWarning, 'Should warn about no files found'); + + // Check metadata + const meta: any = store.get('emptyPattern', '_meta'); + assert.ok(meta); + assert.equal(meta.data.filesFound, 0); + }); + + it('handles validation errors gracefully', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs: any[] = []; + + const logger = new AstroLogger({ + level: 'error', + destination: new Writable({ + objectMode: true, + write(event: any, _: any, callback: any) { + logs.push(event); + callback(); + }, + }), + }); + + // Loader that produces validation errors + const validationErrorLoader = { + name: 'validation-error-loader', + load: async (context: any) => { + const testData = [ + { id: 'item1', name: 'Valid Item', count: 5 }, + { id: 'item2', count: 10 }, // Missing required 'name' + { name: 'No ID Item', count: 15 }, // Would fail if 'id' is required by schema + { id: 'item3', name: 'Invalid Count', count: 'not-a-number' }, // Wrong type + ]; + + let successCount = 0; + let errorCount = 0; + + for (const item of testData) { + try { + const parsed = await context.parseData({ + id: item.id || 'generated-' + Date.now(), + data: item, + }); + + await context.store.set({ + id: item.id || 'generated-' + Date.now(), + data: parsed, + }); + successCount++; + } catch (error: any) { + errorCount++; + context.logger.error(`Validation failed for ${item.id || 'unknown'}: ${error.message}`); + } + } + + // Store summary + await context.store.set({ + id: '_summary', + data: { + name: 'Validation Summary', + count: 0, + validationStats: { + success: successCount, + errors: errorCount, + }, + }, + }); + }, + }; + + const collections = { + validated: defineCollection({ + loader: validationErrorLoader, + schema: z.object({ + name: z.string(), + count: z.number(), + validationStats: z + .object({ + success: z.number(), + errors: z.number(), + }) + .optional(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for validation error logs + const validationErrors = logs.filter( + (log) => log.level === 'error' && log.message.includes('Validation failed'), + ); + assert.ok(validationErrors.length > 0, 'Should log validation errors'); + + // Check valid entry + const validEntry: any = store.get('validated', 'item1'); + assert.ok(validEntry); + assert.equal(validEntry.data.name, 'Valid Item'); + assert.equal(validEntry.data.count, 5); + + // Check summary + const summary: any = store.get('validated', '_summary'); + assert.ok(summary); + assert.ok(summary.data.validationStats.errors > 0); + }); + + it('handles malformed data gracefully', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs: any[] = []; + + const logger = new AstroLogger({ + level: 'error', + destination: new Writable({ + objectMode: true, + write(event: any, _: any, callback: any) { + logs.push(event); + callback(); + }, + }), + }); + + // Loader that simulates processing malformed data + const malformedDataLoader = { + name: 'malformed-data-loader', + load: async (context: any) => { + // Simulate trying to parse malformed JSON + const malformedJson = '{ "id": "test", "name": "Missing closing brace"'; + + try { + // This would throw + const data = JSON.parse(malformedJson); + await context.store.set({ + id: 'should-not-exist', + data, + }); + } catch (error: any) { + context.logger.error(`Failed to parse JSON: ${error.message}`); + + // Store error info + await context.store.set({ + id: 'parse-error', + data: { + error: 'JSON Parse Error', + message: error.message, + recovered: true, + }, + }); + } + + // Add a valid entry to show the loader can continue + await context.store.set({ + id: 'valid-after-error', + data: { + error: 'None', + message: 'Successfully loaded after error', + recovered: true, + }, + }); + }, + }; + + const collections = { + malformed: defineCollection({ + loader: malformedDataLoader, + schema: z.object({ + error: z.string(), + message: z.string(), + recovered: z.boolean(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for JSON error log + const jsonError = logs.find((log) => log.level === 'error' && log.message.includes('JSON')); + assert.ok(jsonError, 'Should log JSON parse error'); + + // Check that error was handled + const errorEntry: any = store.get('malformed', 'parse-error'); + assert.ok(errorEntry); + assert.equal(errorEntry.data.error, 'JSON Parse Error'); + assert.ok(errorEntry.data.recovered); + + // Check that loader continued after error + const validEntry: any = store.get('malformed', 'valid-after-error'); + assert.ok(validEntry); + assert.equal(validEntry.data.error, 'None'); + }); + + it('warns about duplicate IDs across multiple entries', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs: any[] = []; + + const logger = new AstroLogger({ + level: 'warn', + destination: new Writable({ + objectMode: true, + write(event: any, _: any, callback: any) { + logs.push(event); + callback(); + }, + }), + }); + + // Create data directory with test files + const dataDir = new URL('./src/data/', root); + await fs.mkdir(dataDir, { recursive: true }); + + // Write a JSON file with duplicate IDs + await fs.writeFile( + new URL('./dogs.json', dataDir), + JSON.stringify([ + { id: 'german-shepherd', breed: 'German Shepherd', size: 'Large' }, + { id: 'beagle', breed: 'Beagle', size: 'Small' }, + { id: 'german-shepherd', breed: 'German Shepherd Mix', size: 'Medium' }, // Duplicate + ]), + ); + + // Loader that processes array data and warns about duplicates + const duplicateCheckLoader = { + name: 'duplicate-check-loader', + load: async (context: any) => { + // Read and parse the file + const filePath = new URL('./dogs.json', dataDir); + const content = await fs.readFile(filePath, 'utf-8'); + const dogs = JSON.parse(content); + + const seenIds = new Set(); + + for (const dog of dogs) { + if (seenIds.has(dog.id)) { + context.logger.warn(`Duplicate id "${dog.id}" found in src/data/dogs.json`); + } + seenIds.add(dog.id); + + const parsed = await context.parseData({ + id: dog.id, + data: dog, + }); + + // Store will overwrite duplicates + await context.store.set({ + id: dog.id, + data: parsed, + }); + } + }, + }; + + const collections = { + dogs: defineCollection({ + loader: duplicateCheckLoader, + schema: z.object({ + id: z.string(), + breed: z.string(), + size: z.string(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for duplicate warning + const duplicateWarning = logs.find( + (log) => + log.level === 'warn' && + log.message.includes('Duplicate id "german-shepherd"') && + log.message.includes('dogs.json'), + ); + assert.ok(duplicateWarning, 'Should warn about duplicate ID'); + + // Check entries - last duplicate wins + const entries = store.values('dogs'); + assert.equal(entries.length, 2); // Only 2 unique IDs + + const germanShepherd: any = store.get('dogs', 'german-shepherd'); + assert.ok(germanShepherd); + assert.equal(germanShepherd.data.breed, 'German Shepherd Mix'); // Last one wins + assert.equal(germanShepherd.data.size, 'Medium'); + }); + + it('handles missing required fields with helpful errors', async () => { + const root = createTempDir(); + const store = new MutableDataStore(); + const logs: any[] = []; + + const logger = new AstroLogger({ + level: 'error', + destination: new Writable({ + objectMode: true, + write(event: any, _: any, callback: any) { + logs.push(event); + callback(); + }, + }), + }); + + // Loader with strict schema validation + const strictSchemaLoader = { + name: 'strict-schema-loader', + load: async (context: any) => { + const items = [ + { id: 'complete', title: 'Complete Item', priority: 'high', tags: ['important'] }, + { id: 'missing-title', priority: 'low', tags: [] }, // Missing required title + { id: 'missing-priority', title: 'No Priority' }, // Missing required priority + { id: 'invalid-tags', title: 'Bad Tags', priority: 'medium', tags: 'not-an-array' }, // Wrong type + ]; + + for (const item of items) { + try { + const parsed = await context.parseData({ + id: item.id, + data: item, + }); + + await context.store.set({ + id: item.id, + data: parsed, + }); + } catch (error: any) { + // Log detailed validation error + const issues = error.errors || []; + const fields = issues.map((issue: any) => issue.path.join('.')).join(', '); + context.logger.error( + `Validation failed for item "${item.id}": Missing or invalid fields: ${fields || error.message}`, + ); + } + } + }, + }; + + const collections = { + strictItems: defineCollection({ + loader: strictSchemaLoader, + schema: z.object({ + title: z.string(), + priority: z.enum(['low', 'medium', 'high']), + tags: z.array(z.string()).optional().default([]), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for specific validation errors + const validationLogs = logs.filter( + (log) => log.level === 'error' && log.message.includes('Validation failed'), + ); + assert.ok(validationLogs.length >= 2, 'Should have validation errors for invalid items'); + + // Only complete item should be stored + const completeItem: any = store.get('strictItems', 'complete'); + assert.ok(completeItem); + assert.equal(completeItem.data.title, 'Complete Item'); + assert.equal(completeItem.data.priority, 'high'); + assert.deepEqual(completeItem.data.tags, ['important']); + + // Invalid items should not be stored + assert.ok(!store.get('strictItems', 'missing-title')); + assert.ok(!store.get('strictItems', 'missing-priority')); + assert.ok(!store.get('strictItems', 'invalid-tags')); + }); +}); diff --git a/packages/astro/test/units/content-layer/markdown-rendering.test.js b/packages/astro/test/units/content-layer/markdown-rendering.test.js deleted file mode 100644 index 8af4081de312..000000000000 --- a/packages/astro/test/units/content-layer/markdown-rendering.test.js +++ /dev/null @@ -1,749 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { ContentLayer } from '../../../dist/content/content-layer.js'; -import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; -import { Logger } from '../../../dist/core/logger/core.js'; -import { defineCollection } from '../../../dist/content/config.js'; -import { z } from 'zod'; -import { - createTempDir, - createTestConfigObserver, - createMinimalSettings, - parseSimpleMarkdownFrontmatter, -} from './test-helpers.js'; - -describe('Content Layer - Markdown Rendering', () => { - // Create a real temp directory for tests - const root = createTempDir(); - - it('renders markdown content through ContentLayer', async () => { - const store = new MutableDataStore(); - - // Inline loader with markdown content - const markdownLoader = { - name: 'test-markdown-loader', - load: async (context) => { - const posts = [ - { - id: 'post-1', - content: `--- -title: Test Post -description: This is a test post -tags: ["astro", "testing"] -publishedDate: 2024-01-15 ---- - -# Hello World - -This is the post content with **bold** and *italic* text.`, - }, - { - id: 'post-2', - content: `--- -title: Another Post -publishedDate: 2024-01-20 ---- - -## Another Title - -Content with [a link](https://astro.build).`, - }, - ]; - - for (const post of posts) { - // Parse the markdown content - const { data, body } = parseSimpleMarkdownFrontmatter(post.content, post.id); - - // Parse data through the schema - const parsedData = await context.parseData({ - id: post.id, - data, - }); - - await context.store.set({ - id: post.id, - data: parsedData, - body, - }); - } - }, - }; - - // Define collections - const collections = { - posts: defineCollection({ - loader: markdownLoader, - schema: z.object({ - title: z.string(), - description: z.string().optional(), - tags: z.array(z.string()).optional(), - publishedDate: z.coerce.date(), - }), - }), - }; - - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - // Create ContentLayer with test config observer - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - // Sync content - await contentLayer.sync(); - - // Verify markdown was processed - const post1 = store.get('posts', 'post-1'); - assert.ok(post1); - assert.equal(post1.data.title, 'Test Post'); - assert.equal(post1.data.description, 'This is a test post'); - assert.deepEqual(post1.data.tags, ['astro', 'testing']); - assert.ok(post1.data.publishedDate instanceof Date); - assert.ok(post1.body); - assert.ok(post1.body.includes('# Hello World')); - - const post2 = store.get('posts', 'post-2'); - assert.ok(post2); - assert.equal(post2.data.title, 'Another Post'); - assert.ok(post2.data.publishedDate instanceof Date); - assert.ok(post2.body); - assert.ok(post2.body.includes('## Another Title')); - }); - - it('renders markdown content with loader renderMarkdown', async () => { - const store = new MutableDataStore(); - - // Custom loader that uses renderMarkdown - const customMarkdownLoader = { - name: 'custom-markdown-loader', - load: async (context) => { - const markdownContent = `--- -title: Rendered Post -author: Test Author ---- - -# Rendered Content - -This content is processed by the loader using renderMarkdown. - -- List item 1 -- List item 2`; - - // Use the renderMarkdown function from context - const rendered = await context.renderMarkdown(markdownContent, { - fileURL: new URL('test.md', root), - }); - - await context.store.set({ - id: 'rendered-post', - data: { - title: 'Rendered Post', - author: 'Test Author', - }, - body: markdownContent, - rendered: { - html: rendered.html, - metadata: rendered.metadata, - }, - }); - }, - }; - - const collections = { - custom: defineCollection({ - loader: customMarkdownLoader, - schema: z.object({ - title: z.string(), - author: z.string(), - }), - }), - }; - - const settings = createMinimalSettings(root); - - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check that markdown was rendered - const entry = store.get('custom', 'rendered-post'); - assert.ok(entry); - assert.ok(entry.rendered); - assert.ok(entry.rendered.html); - // Check for heading - might have id attribute - assert.ok( - entry.rendered.html.includes('Rendered Content') && entry.rendered.html.includes('h1'), - ); - // Check for list items - assert.ok(entry.rendered.html.includes('List item 1')); - assert.ok(entry.rendered.metadata); - }); - - it('preserves markdown headings metadata', async () => { - const store = new MutableDataStore(); - - const customLoader = { - name: 'headings-test-loader', - load: async (context) => { - const content = `--- -title: Headings Test ---- - -# Main Title - -Some intro text. - -## Section 1 - -Section 1 content. - -### Subsection 1.1 - -More details. - -## Section 2 - -Section 2 content.`; - - const rendered = await context.renderMarkdown(content); - - await context.store.set({ - id: 'headings-test', - data: { title: 'Headings Test' }, - rendered: { - html: rendered.html, - metadata: rendered.metadata, - }, - }); - }, - }; - - const collections = { - headings: defineCollection({ - loader: customLoader, - }), - }; - - const settings = createMinimalSettings(root); - - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entry = store.get('headings', 'headings-test'); - assert.ok(entry); - assert.ok(entry.rendered); - assert.ok(entry.rendered.metadata); - assert.ok(entry.rendered.metadata.headings); - assert.ok(Array.isArray(entry.rendered.metadata.headings)); - - const headings = entry.rendered.metadata.headings; - assert.ok(headings.length >= 4); - - // Check heading structure - const h1 = headings.find((h) => h.depth === 1); - assert.ok(h1); - assert.equal(h1.text, 'Main Title'); - - const h2s = headings.filter((h) => h.depth === 2); - assert.ok(h2s.length >= 2); - }); - - it('handles markdown with no frontmatter', async () => { - const store = new MutableDataStore(); - - const noFrontmatterLoader = { - name: 'no-frontmatter-loader', - load: async (context) => { - const content = `# Just Markdown - -This file has no frontmatter, just content.`; - - // Parse content - should handle no frontmatter gracefully - const { data, body } = parseSimpleMarkdownFrontmatter(content, 'plain'); - - await context.store.set({ - id: 'plain', - data, - body, - }); - }, - }; - - const collections = { - noFrontmatter: defineCollection({ - loader: noFrontmatterLoader, - }), - }; - - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entry = store.get('noFrontmatter', 'plain'); - assert.ok(entry); - assert.ok(entry.body); - assert.ok(entry.body.includes('# Just Markdown')); - assert.ok(entry.body.includes('This file has no frontmatter')); - }); - - it('handles complex markdown with code blocks', async () => { - const store = new MutableDataStore(); - - const customLoader = { - name: 'code-test-loader', - load: async (context) => { - const content = `--- -title: Code Examples ---- - -# Code Examples - -Here's some JavaScript: - -\`\`\`javascript -function hello(name) { - return \`Hello, \${name}!\`; -} -\`\`\` - -And some inline code: \`const x = 42\`.`; - - const rendered = await context.renderMarkdown(content); - - await context.store.set({ - id: 'code-test', - data: { title: 'Code Examples' }, - rendered: { - html: rendered.html, - metadata: rendered.metadata, - }, - }); - }, - }; - - const collections = { - code: defineCollection({ - loader: customLoader, - }), - }; - - const settings = createMinimalSettings(root, { - config: { - markdown: { - syntaxHighlight: 'shiki', - shikiConfig: { - theme: 'github-dark', - }, - }, - }, - }); - - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entry = store.get('code', 'code-test'); - assert.ok(entry); - assert.ok(entry.rendered); - assert.ok(entry.rendered.html); - // Should have code block elements - assert.ok(entry.rendered.html.includes(' { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const frontmatterTestLoader = { - name: 'frontmatter-test-loader', - load: async (context) => { - const markdownWithFrontmatter = `--- -title: Test Post -description: A test post for renderMarkdown -tags: - - test - - markdown ---- - -# Hello World - -This is the body content. - -## Subheading - -More content here.`; - - const result = await context.renderMarkdown(markdownWithFrontmatter, { - fileURL: new URL('test.md', root), - }); - - // Store the frontmatter data that was parsed - const parsed = await context.parseData({ - id: 'frontmatter-test', - data: { - ...result.metadata.frontmatter, - rendered: true, - }, - }); - - await context.store.set({ - id: 'frontmatter-test', - data: parsed, - body: markdownWithFrontmatter, - }); - }, - }; - - const collections = { - frontmatterTest: defineCollection({ - loader: frontmatterTestLoader, - schema: z.object({ - title: z.string(), - description: z.string(), - tags: z.array(z.string()), - rendered: z.boolean(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entry = store.get('frontmatterTest', 'frontmatter-test'); - assert.ok(entry); - assert.equal(entry.data.title, 'Test Post'); - assert.equal(entry.data.description, 'A test post for renderMarkdown'); - assert.deepEqual(entry.data.tags, ['test', 'markdown']); - }); - - it('renderMarkdown excludes frontmatter from HTML output through loader', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const htmlTestLoader = { - name: 'html-test-loader', - load: async (context) => { - const markdownWithFrontmatter = `--- -title: Test Post ---- - -# Hello World`; - - const result = await context.renderMarkdown(markdownWithFrontmatter, { - fileURL: new URL('test.md', root), - }); - - await context.store.set({ - id: 'html-test', - data: { - html: result.html, - title: result.metadata.frontmatter.title || 'No title', - }, - }); - }, - }; - - const collections = { - htmlTest: defineCollection({ - loader: htmlTestLoader, - schema: z.object({ - html: z.string(), - title: z.string(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entry = store.get('htmlTest', 'html-test'); - assert.ok(entry); - // HTML should not contain frontmatter - assert.ok(!entry.data.html.includes('title:')); - assert.ok(!entry.data.html.includes('Test Post')); - // But should contain the rendered content - assert.ok(entry.data.html.includes('Hello World')); - // And we should have access to the frontmatter data separately - assert.equal(entry.data.title, 'Test Post'); - }); - - it('renderMarkdown extracts headings correctly through loader', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const headingsTestLoader = { - name: 'headings-test-loader', - load: async (context) => { - const markdown = `# Heading 1 -Some text - -## Heading 2 -More text - -### Heading 3 -Even more text - -## Another Heading 2`; - - const result = await context.renderMarkdown(markdown, { - fileURL: new URL('test.md', root), - }); - - // Extract heading information - const headings = result.metadata.headings.map((h) => ({ - depth: h.depth, - text: h.text, - })); - - await context.store.set({ - id: 'headings-test', - data: { - headingCount: result.metadata.headings.length, - headings: headings, - }, - }); - }, - }; - - const collections = { - headingsTest: defineCollection({ - loader: headingsTestLoader, - schema: z.object({ - headingCount: z.number(), - headings: z.array( - z.object({ - depth: z.number(), - text: z.string(), - }), - ), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entry = store.get('headingsTest', 'headings-test'); - assert.ok(entry); - assert.equal(entry.data.headingCount, 4); - assert.deepEqual(entry.data.headings, [ - { depth: 1, text: 'Heading 1' }, - { depth: 2, text: 'Heading 2' }, - { depth: 3, text: 'Heading 3' }, - { depth: 2, text: 'Another Heading 2' }, - ]); - }); - - it('renderMarkdown resolves relative image paths through loader', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const imageTestLoader = { - name: 'image-test-loader', - load: async (context) => { - const markdownWithImage = `# Post with Image - -![Local image](./image.png) -![Remote image](https://example.com/image.png)`; - - const fileURL = new URL('./virtual-post.md', root); - const result = await context.renderMarkdown(markdownWithImage, { - fileURL, - }); - - await context.store.set({ - id: 'image-test', - data: { - localImages: result.metadata.localImagePaths || [], - remoteImages: result.metadata.remoteImagePaths || [], - hasImages: true, - }, - }); - }, - }; - - const collections = { - imageTest: defineCollection({ - loader: imageTestLoader, - schema: z.object({ - localImages: z.array(z.string()), - remoteImages: z.array(z.string()), - hasImages: z.boolean(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entry = store.get('imageTest', 'image-test'); - assert.ok(entry); - assert.ok(entry.data.hasImages); - assert.equal(entry.data.localImages.length, 1); - assert.equal(entry.data.localImages[0], './image.png'); - assert.equal(entry.data.remoteImages.length, 0); // Remote images are not tracked in localImagePaths - }); - - it('renderMarkdown populates combined imagePaths in metadata', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const imagePathsLoader = { - name: 'imagepaths-test-loader', - load: async (context) => { - const markdownWithImages = `# Post with Images - -![Photo](./photo.jpg) -![Screenshot](screenshot.png)`; - - const fileURL = new URL('./test-post.md', root); - const result = await context.renderMarkdown(markdownWithImages, { fileURL }); - - await context.store.set({ - id: 'imagepaths-test', - data: { - imagePaths: result.metadata.imagePaths || [], - localImagePaths: result.metadata.localImagePaths || [], - }, - rendered: { - html: result.html, - metadata: result.metadata, - }, - filePath: 'test-post.md', - }); - }, - }; - - const collections = { - imagePathsTest: defineCollection({ - loader: imagePathsLoader, - schema: z.object({ - imagePaths: z.array(z.string()), - localImagePaths: z.array(z.string()), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - const entry = store.get('imagePathsTest', 'imagepaths-test'); - assert.ok(entry); - - // imagePaths should be the combined localImagePaths + remoteImagePaths - assert.ok(Array.isArray(entry.data.imagePaths), 'imagePaths should be an array'); - assert.equal(entry.data.imagePaths.length, 2, 'should have 2 image paths'); - assert.ok(entry.data.imagePaths.includes('./photo.jpg')); - assert.ok(entry.data.imagePaths.includes('screenshot.png')); - - // imagePaths should match localImagePaths when there are no remote images - assert.deepEqual(entry.data.imagePaths, entry.data.localImagePaths); - - // The rendered metadata should also have imagePaths for renderEntry to use - assert.ok(entry.rendered); - assert.ok(entry.rendered.metadata); - assert.ok(Array.isArray(entry.rendered.metadata.imagePaths)); - assert.equal(entry.rendered.metadata.imagePaths.length, 2); - }); -}); diff --git a/packages/astro/test/units/content-layer/markdown-rendering.test.ts b/packages/astro/test/units/content-layer/markdown-rendering.test.ts new file mode 100644 index 000000000000..0bbc45d4d5c2 --- /dev/null +++ b/packages/astro/test/units/content-layer/markdown-rendering.test.ts @@ -0,0 +1,749 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; +import { defineCollection } from '../../../dist/content/config.js'; +import { z } from 'zod'; +import { + createTempDir, + createTestConfigObserver, + createMinimalSettings, + parseSimpleMarkdownFrontmatter, +} from './test-helpers.ts'; + +describe('Content Layer - Markdown Rendering', () => { + // Create a real temp directory for tests + const root = createTempDir(); + + it('renders markdown content through ContentLayer', async () => { + const store = new MutableDataStore(); + + // Inline loader with markdown content + const markdownLoader = { + name: 'test-markdown-loader', + load: async (context: any) => { + const posts = [ + { + id: 'post-1', + content: `--- +title: Test Post +description: This is a test post +tags: ["astro", "testing"] +publishedDate: 2024-01-15 +--- + +# Hello World + +This is the post content with **bold** and *italic* text.`, + }, + { + id: 'post-2', + content: `--- +title: Another Post +publishedDate: 2024-01-20 +--- + +## Another Title + +Content with [a link](https://astro.build).`, + }, + ]; + + for (const post of posts) { + // Parse the markdown content + const { data, body } = parseSimpleMarkdownFrontmatter(post.content, post.id); + + // Parse data through the schema + const parsedData = await context.parseData({ + id: post.id, + data, + }); + + await context.store.set({ + id: post.id, + data: parsedData, + body, + }); + } + }, + }; + + // Define collections + const collections = { + posts: defineCollection({ + loader: markdownLoader, + schema: z.object({ + title: z.string(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + publishedDate: z.coerce.date(), + }), + }), + }; + + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + // Create ContentLayer with test config observer + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // Sync content + await contentLayer.sync(); + + // Verify markdown was processed + const post1: any = store.get('posts', 'post-1'); + assert.ok(post1); + assert.equal(post1.data.title, 'Test Post'); + assert.equal(post1.data.description, 'This is a test post'); + assert.deepEqual(post1.data.tags, ['astro', 'testing']); + assert.ok(post1.data.publishedDate instanceof Date); + assert.ok(post1.body); + assert.ok(post1.body.includes('# Hello World')); + + const post2: any = store.get('posts', 'post-2'); + assert.ok(post2); + assert.equal(post2.data.title, 'Another Post'); + assert.ok(post2.data.publishedDate instanceof Date); + assert.ok(post2.body); + assert.ok(post2.body.includes('## Another Title')); + }); + + it('renders markdown content with loader renderMarkdown', async () => { + const store = new MutableDataStore(); + + // Custom loader that uses renderMarkdown + const customMarkdownLoader = { + name: 'custom-markdown-loader', + load: async (context: any) => { + const markdownContent = `--- +title: Rendered Post +author: Test Author +--- + +# Rendered Content + +This content is processed by the loader using renderMarkdown. + +- List item 1 +- List item 2`; + + // Use the renderMarkdown function from context + const rendered = await context.renderMarkdown(markdownContent, { + fileURL: new URL('test.md', root), + }); + + await context.store.set({ + id: 'rendered-post', + data: { + title: 'Rendered Post', + author: 'Test Author', + }, + body: markdownContent, + rendered: { + html: rendered.html, + metadata: rendered.metadata, + }, + }); + }, + }; + + const collections = { + custom: defineCollection({ + loader: customMarkdownLoader, + schema: z.object({ + title: z.string(), + author: z.string(), + }), + }), + }; + + const settings = createMinimalSettings(root); + + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check that markdown was rendered + const entry: any = store.get('custom', 'rendered-post'); + assert.ok(entry); + assert.ok(entry.rendered); + assert.ok(entry.rendered.html); + // Check for heading - might have id attribute + assert.ok( + entry.rendered.html.includes('Rendered Content') && entry.rendered.html.includes('h1'), + ); + // Check for list items + assert.ok(entry.rendered.html.includes('List item 1')); + assert.ok(entry.rendered.metadata); + }); + + it('preserves markdown headings metadata', async () => { + const store = new MutableDataStore(); + + const customLoader = { + name: 'headings-test-loader', + load: async (context: any) => { + const content = `--- +title: Headings Test +--- + +# Main Title + +Some intro text. + +## Section 1 + +Section 1 content. + +### Subsection 1.1 + +More details. + +## Section 2 + +Section 2 content.`; + + const rendered = await context.renderMarkdown(content); + + await context.store.set({ + id: 'headings-test', + data: { title: 'Headings Test' }, + rendered: { + html: rendered.html, + metadata: rendered.metadata, + }, + }); + }, + }; + + const collections = { + headings: defineCollection({ + loader: customLoader, + }), + }; + + const settings = createMinimalSettings(root); + + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry: any = store.get('headings', 'headings-test'); + assert.ok(entry); + assert.ok(entry.rendered); + assert.ok(entry.rendered.metadata); + assert.ok(entry.rendered.metadata.headings); + assert.ok(Array.isArray(entry.rendered.metadata.headings)); + + const headings = entry.rendered.metadata.headings; + assert.ok(headings.length >= 4); + + // Check heading structure + const h1 = headings.find((h: any) => h.depth === 1); + assert.ok(h1); + assert.equal(h1.text, 'Main Title'); + + const h2s = headings.filter((h: any) => h.depth === 2); + assert.ok(h2s.length >= 2); + }); + + it('handles markdown with no frontmatter', async () => { + const store = new MutableDataStore(); + + const noFrontmatterLoader = { + name: 'no-frontmatter-loader', + load: async (context: any) => { + const content = `# Just Markdown + +This file has no frontmatter, just content.`; + + // Parse content - should handle no frontmatter gracefully + const { data, body } = parseSimpleMarkdownFrontmatter(content, 'plain'); + + await context.store.set({ + id: 'plain', + data, + body, + }); + }, + }; + + const collections = { + noFrontmatter: defineCollection({ + loader: noFrontmatterLoader, + }), + }; + + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry: any = store.get('noFrontmatter', 'plain'); + assert.ok(entry); + assert.ok(entry.body); + assert.ok(entry.body.includes('# Just Markdown')); + assert.ok(entry.body.includes('This file has no frontmatter')); + }); + + it('handles complex markdown with code blocks', async () => { + const store = new MutableDataStore(); + + const customLoader = { + name: 'code-test-loader', + load: async (context: any) => { + const content = `--- +title: Code Examples +--- + +# Code Examples + +Here's some JavaScript: + +\`\`\`javascript +function hello(name) { + return \`Hello, \${name}!\`; +} +\`\`\` + +And some inline code: \`const x = 42\`.`; + + const rendered = await context.renderMarkdown(content); + + await context.store.set({ + id: 'code-test', + data: { title: 'Code Examples' }, + rendered: { + html: rendered.html, + metadata: rendered.metadata, + }, + }); + }, + }; + + const collections = { + code: defineCollection({ + loader: customLoader, + }), + }; + + const settings = createMinimalSettings(root, { + config: { + markdown: { + syntaxHighlight: 'shiki', + shikiConfig: { + theme: 'github-dark', + }, + }, + }, + }); + + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry: any = store.get('code', 'code-test'); + assert.ok(entry); + assert.ok(entry.rendered); + assert.ok(entry.rendered.html); + // Should have code block elements + assert.ok(entry.rendered.html.includes(' { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const frontmatterTestLoader = { + name: 'frontmatter-test-loader', + load: async (context: any) => { + const markdownWithFrontmatter = `--- +title: Test Post +description: A test post for renderMarkdown +tags: + - test + - markdown +--- + +# Hello World + +This is the body content. + +## Subheading + +More content here.`; + + const result = await context.renderMarkdown(markdownWithFrontmatter, { + fileURL: new URL('test.md', root), + }); + + // Store the frontmatter data that was parsed + const parsed = await context.parseData({ + id: 'frontmatter-test', + data: { + ...result.metadata.frontmatter, + rendered: true, + }, + }); + + await context.store.set({ + id: 'frontmatter-test', + data: parsed, + body: markdownWithFrontmatter, + }); + }, + }; + + const collections = { + frontmatterTest: defineCollection({ + loader: frontmatterTestLoader, + schema: z.object({ + title: z.string(), + description: z.string(), + tags: z.array(z.string()), + rendered: z.boolean(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry: any = store.get('frontmatterTest', 'frontmatter-test'); + assert.ok(entry); + assert.equal(entry.data.title, 'Test Post'); + assert.equal(entry.data.description, 'A test post for renderMarkdown'); + assert.deepEqual(entry.data.tags, ['test', 'markdown']); + }); + + it('renderMarkdown excludes frontmatter from HTML output through loader', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const htmlTestLoader = { + name: 'html-test-loader', + load: async (context: any) => { + const markdownWithFrontmatter = `--- +title: Test Post +--- + +# Hello World`; + + const result = await context.renderMarkdown(markdownWithFrontmatter, { + fileURL: new URL('test.md', root), + }); + + await context.store.set({ + id: 'html-test', + data: { + html: result.html, + title: result.metadata.frontmatter.title || 'No title', + }, + }); + }, + }; + + const collections = { + htmlTest: defineCollection({ + loader: htmlTestLoader, + schema: z.object({ + html: z.string(), + title: z.string(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry: any = store.get('htmlTest', 'html-test'); + assert.ok(entry); + // HTML should not contain frontmatter + assert.ok(!entry.data.html.includes('title:')); + assert.ok(!entry.data.html.includes('Test Post')); + // But should contain the rendered content + assert.ok(entry.data.html.includes('Hello World')); + // And we should have access to the frontmatter data separately + assert.equal(entry.data.title, 'Test Post'); + }); + + it('renderMarkdown extracts headings correctly through loader', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const headingsTestLoader = { + name: 'headings-test-loader', + load: async (context: any) => { + const markdown = `# Heading 1 +Some text + +## Heading 2 +More text + +### Heading 3 +Even more text + +## Another Heading 2`; + + const result = await context.renderMarkdown(markdown, { + fileURL: new URL('test.md', root), + }); + + // Extract heading information + const headings = result.metadata.headings.map((h: any) => ({ + depth: h.depth, + text: h.text, + })); + + await context.store.set({ + id: 'headings-test', + data: { + headingCount: result.metadata.headings.length, + headings: headings, + }, + }); + }, + }; + + const collections = { + headingsTest: defineCollection({ + loader: headingsTestLoader, + schema: z.object({ + headingCount: z.number(), + headings: z.array( + z.object({ + depth: z.number(), + text: z.string(), + }), + ), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry: any = store.get('headingsTest', 'headings-test'); + assert.ok(entry); + assert.equal(entry.data.headingCount, 4); + assert.deepEqual(entry.data.headings, [ + { depth: 1, text: 'Heading 1' }, + { depth: 2, text: 'Heading 2' }, + { depth: 3, text: 'Heading 3' }, + { depth: 2, text: 'Another Heading 2' }, + ]); + }); + + it('renderMarkdown resolves relative image paths through loader', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const imageTestLoader = { + name: 'image-test-loader', + load: async (context: any) => { + const markdownWithImage = `# Post with Image + +![Local image](./image.png) +![Remote image](https://example.com/image.png)`; + + const fileURL = new URL('./virtual-post.md', root); + const result = await context.renderMarkdown(markdownWithImage, { + fileURL, + }); + + await context.store.set({ + id: 'image-test', + data: { + localImages: result.metadata.localImagePaths || [], + remoteImages: result.metadata.remoteImagePaths || [], + hasImages: true, + }, + }); + }, + }; + + const collections = { + imageTest: defineCollection({ + loader: imageTestLoader, + schema: z.object({ + localImages: z.array(z.string()), + remoteImages: z.array(z.string()), + hasImages: z.boolean(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry: any = store.get('imageTest', 'image-test'); + assert.ok(entry); + assert.ok(entry.data.hasImages); + assert.equal(entry.data.localImages.length, 1); + assert.equal(entry.data.localImages[0], './image.png'); + assert.equal(entry.data.remoteImages.length, 0); // Remote images are not tracked in localImagePaths + }); + + it('renderMarkdown populates combined imagePaths in metadata', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const imagePathsLoader = { + name: 'imagepaths-test-loader', + load: async (context: any) => { + const markdownWithImages = `# Post with Images + +![Photo](./photo.jpg) +![Screenshot](screenshot.png)`; + + const fileURL = new URL('./test-post.md', root); + const result = await context.renderMarkdown(markdownWithImages, { fileURL }); + + await context.store.set({ + id: 'imagepaths-test', + data: { + imagePaths: result.metadata.imagePaths || [], + localImagePaths: result.metadata.localImagePaths || [], + }, + rendered: { + html: result.html, + metadata: result.metadata, + }, + filePath: 'test-post.md', + }); + }, + }; + + const collections = { + imagePathsTest: defineCollection({ + loader: imagePathsLoader, + schema: z.object({ + imagePaths: z.array(z.string()), + localImagePaths: z.array(z.string()), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + const entry: any = store.get('imagePathsTest', 'imagepaths-test'); + assert.ok(entry); + + // imagePaths should be the combined localImagePaths + remoteImagePaths + assert.ok(Array.isArray(entry.data.imagePaths), 'imagePaths should be an array'); + assert.equal(entry.data.imagePaths.length, 2, 'should have 2 image paths'); + assert.ok(entry.data.imagePaths.includes('./photo.jpg')); + assert.ok(entry.data.imagePaths.includes('screenshot.png')); + + // imagePaths should match localImagePaths when there are no remote images + assert.deepEqual(entry.data.imagePaths, entry.data.localImagePaths); + + // The rendered metadata should also have imagePaths for renderEntry to use + assert.ok(entry.rendered); + assert.ok(entry.rendered.metadata); + assert.ok(Array.isArray(entry.rendered.metadata.imagePaths)); + assert.equal(entry.rendered.metadata.imagePaths.length, 2); + }); +}); diff --git a/packages/astro/test/units/content-layer/schema-validation.test.js b/packages/astro/test/units/content-layer/schema-validation.test.js deleted file mode 100644 index 1b58812bceb3..000000000000 --- a/packages/astro/test/units/content-layer/schema-validation.test.js +++ /dev/null @@ -1,618 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { z } from 'zod'; -import { defineCollection } from '../../../dist/content/config.js'; -import { ContentLayer } from '../../../dist/content/content-layer.js'; -import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; -import { Logger } from '../../../dist/core/logger/core.js'; -import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.js'; - -describe('Content Layer - Schema Validation', () => { - const root = createTempDir(); - - it('parses and coerces Date objects in schemas', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - // Loader that provides dates in various formats - const dateLoader = { - name: 'date-loader', - load: async (context) => { - const entries = [ - { - id: 'one', - publishedAt: '2021-01-01', // ISO date string - updatedAt: new Date('2021-01-02'), // Date object - createdAt: '2021-01-03T00:00:00.000Z', // Full ISO string - }, - { - id: 'two', - publishedAt: '2021-01-02', - updatedAt: new Date('2021-01-03'), - createdAt: 1609545600000, // Timestamp - }, - { - id: 'three', - publishedAt: '2021-01-03', - updatedAt: new Date('2021-01-04'), - createdAt: 'January 5, 2021', // Date string - }, - { - id: 'four%', // Special characters in ID - publishedAt: '2021-01-01', - updatedAt: new Date('2021-01-02'), - createdAt: '2021-01-03', - }, - ]; - - for (const entry of entries) { - const parsed = await context.parseData({ - id: entry.id, - data: entry, - }); - - await context.store.set({ - id: entry.id, - data: parsed, - }); - } - }, - }; - - const collections = { - withDates: defineCollection({ - loader: dateLoader, - schema: z.object({ - publishedAt: z.coerce.date(), - updatedAt: z.date(), - createdAt: z.coerce.date(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Verify all entries were stored - const entries = store.values('withDates'); - assert.equal(entries.length, 4); - - // Check IDs including special characters - const ids = entries.map((item) => item.id).sort(); - assert.deepEqual(ids, ['four%', 'one', 'three', 'two']); - - // Verify all dates are Date objects - for (const entry of entries) { - assert.ok(entry.data.publishedAt instanceof Date); - assert.ok(entry.data.updatedAt instanceof Date); - assert.ok(entry.data.createdAt instanceof Date); - } - - // Verify specific date values - const entryOne = store.get('withDates', 'one'); - assert.equal(entryOne.data.publishedAt.toISOString(), '2021-01-01T00:00:00.000Z'); - assert.equal(entryOne.data.updatedAt.toISOString(), '2021-01-02T00:00:00.000Z'); - assert.equal(entryOne.data.createdAt.toISOString(), '2021-01-03T00:00:00.000Z'); - - // Check timestamp conversion - const entryTwo = store.get('withDates', 'two'); - assert.equal(entryTwo.data.createdAt.toISOString(), '2021-01-02T00:00:00.000Z'); - - // Check date string parsing - just verify it's a valid Date - const entryThree = store.get('withDates', 'three'); - assert.ok(entryThree.data.createdAt instanceof Date); - assert.ok(!isNaN(entryThree.data.createdAt.getTime())); - }); - - it('handles custom IDs and slugs', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - // Loader that provides entries with custom slugs - const customSlugLoader = { - name: 'custom-slug-loader', - load: async (context) => { - const entries = [ - { - id: 'fancy-one', - slug: 'fancy-one', - title: 'First Entry', - }, - { - id: 'excellent-three', - slug: 'excellent-three', - title: 'Third Entry', - }, - { - id: 'interesting-two', - slug: 'interesting-two', - title: 'Second Entry', - }, - ]; - - for (const entry of entries) { - const parsed = await context.parseData({ - id: entry.id, - data: entry, - }); - - await context.store.set({ - id: entry.id, - data: parsed, - }); - } - }, - }; - - const collections = { - withCustomSlugs: defineCollection({ - loader: customSlugLoader, - schema: z.object({ - slug: z.string(), - title: z.string(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Verify custom IDs are preserved - const entries = store.values('withCustomSlugs'); - const ids = entries.map((item) => item.id).sort(); - assert.deepEqual(ids, ['excellent-three', 'fancy-one', 'interesting-two']); - - // Verify data is correct - const fancyOne = store.get('withCustomSlugs', 'fancy-one'); - assert.equal(fancyOne.data.slug, 'fancy-one'); - assert.equal(fancyOne.data.title, 'First Entry'); - }); - - it('supports union schemas (discriminated unions)', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - // Loader that provides different types of content - const unionLoader = { - name: 'union-loader', - load: async (context) => { - const entries = [ - { - id: 'post', - type: 'post', - title: 'My Post', - description: 'This is my post', - }, - { - id: 'newsletter', - type: 'newsletter', - subject: 'My Newsletter', - // Note: newsletters don't have title or description - }, - { - id: 'announcement', - type: 'announcement', - message: 'Important Update', - priority: 'high', - }, - ]; - - for (const entry of entries) { - const parsed = await context.parseData({ - id: entry.id, - data: entry, - }); - - await context.store.set({ - id: entry.id, - data: parsed, - }); - } - }, - }; - - // Union schema that accepts different shapes based on 'type' field - const collections = { - withUnionSchema: defineCollection({ - loader: unionLoader, - schema: z.discriminatedUnion('type', [ - z.object({ - type: z.literal('post'), - title: z.string(), - description: z.string(), - }), - z.object({ - type: z.literal('newsletter'), - subject: z.string(), - }), - z.object({ - type: z.literal('announcement'), - message: z.string(), - priority: z.enum(['low', 'medium', 'high']), - }), - ]), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Verify all entries were stored - const entries = store.values('withUnionSchema'); - assert.equal(entries.length, 3); - - // Verify post entry - const post = store.get('withUnionSchema', 'post'); - assert.deepEqual(post.data, { - type: 'post', - title: 'My Post', - description: 'This is my post', - }); - - // Verify newsletter entry - const newsletter = store.get('withUnionSchema', 'newsletter'); - assert.deepEqual(newsletter.data, { - type: 'newsletter', - subject: 'My Newsletter', - }); - - // Verify announcement entry - const announcement = store.get('withUnionSchema', 'announcement'); - assert.deepEqual(announcement.data, { - type: 'announcement', - message: 'Important Update', - priority: 'high', - }); - }); - - it('validates required fields in empty content', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logs = []; - - const logger = new Logger({ - level: 'error', - dest: { - write: (event) => { - logs.push(event); - return true; - }, - }, - }); - - // Loader that simulates empty markdown file scenario - const emptyContentLoader = { - name: 'empty-content-loader', - load: async (context) => { - // Simulate empty markdown file - no frontmatter data - const entries = [ - { - id: 'empty-file', - data: {}, // Empty frontmatter - body: '', // Empty body - }, - { - id: 'partial-file', - data: { - description: 'Has description but missing title', - }, - body: 'Some content', - }, - ]; - - for (const entry of entries) { - try { - const parsed = await context.parseData({ - id: entry.id, - data: entry.data, - }); - - await context.store.set({ - id: entry.id, - data: parsed, - body: entry.body, - }); - } catch (error) { - // Log validation error - context.logger.error(`Validation failed for ${entry.id}: ${error.message}`); - - // Check if it's a Zod error with issues - if (error.errors) { - const requiredFields = error.errors - .filter((issue) => issue.message === 'Required') - .map((issue) => `**${issue.path.join('.')}**: ${issue.message}`); - - if (requiredFields.length > 0) { - context.logger.error(requiredFields.join(', ')); - } - } - } - } - }, - }; - - const collections = { - requiredFields: defineCollection({ - loader: emptyContentLoader, - schema: z.object({ - title: z.string().min(1), - description: z.string().optional(), - publishedAt: z.date().optional(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check that validation errors were logged - const validationErrors = logs.filter((log) => log.level === 'error'); - assert.ok(validationErrors.length > 0); - - const titleRequiredError = logs.find( - (log) => log.level === 'error' && log.message.includes('**title**:'), - ); - assert.ok(titleRequiredError, 'Should have logged a human-readable error mentioning **title**'); - assert.ok( - !titleRequiredError.message.includes('[{'), - `Error message should not contain raw JSON, got:\n${titleRequiredError.message}`, - ); - - // Verify no entries were stored (both failed validation) - const entries = store.values('requiredFields'); - assert.equal(entries.length, 0); - }); - - it('validates ID types and rejects invalid IDs', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logs = []; - - const logger = new Logger({ - level: 'error', - dest: { - write: (event) => { - logs.push(event); - return true; - }, - }, - }); - - // Loader that provides entries with various ID types - const invalidIdLoader = { - name: 'invalid-id-loader', - load: async (context) => { - const entries = [ - { - id: 'valid-string-id', - data: { title: 'Valid Entry' }, - }, - { - id: 123, // Number ID - should be invalid - data: { title: 'Entry with number ID' }, - }, - { - id: null, // Null ID - data: { title: 'Entry with null ID' }, - }, - { - id: '', // Empty string ID - data: { title: 'Entry with empty ID' }, - }, - ]; - - for (const entry of entries) { - try { - // Validate ID type - if (typeof entry.id !== 'string' || !entry.id) { - throw new Error( - `Collection loader returned an entry with an invalid \`id\`: ${JSON.stringify(entry.id)}. IDs must be strings.`, - ); - } - - const parsed = await context.parseData({ - id: entry.id, - data: entry.data, - }); - - await context.store.set({ - id: entry.id, - data: parsed, - }); - } catch (error) { - context.logger.error(error.message); - } - } - }, - }; - - const collections = { - withIdValidation: defineCollection({ - loader: invalidIdLoader, - schema: z.object({ - title: z.string(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check for ID validation errors - const idErrors = logs.filter( - (log) => - log.level === 'error' && log.message.includes('returned an entry with an invalid `id`'), - ); - assert.ok(idErrors.length >= 2, 'Should have errors for invalid IDs'); - - // Only valid entry should be stored - const entries = store.values('withIdValidation'); - assert.equal(entries.length, 1); - assert.equal(entries[0].id, 'valid-string-id'); - }); - - it('handles empty collections gracefully', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - // Loader that returns no entries - const emptyLoader = { - name: 'empty-loader', - load: async (_context) => { - // Simulate an empty directory - no entries to load - // Just return without adding anything to the store - }, - }; - - const collections = { - emptyCollection: defineCollection({ - loader: emptyLoader, - schema: z.object({ - title: z.string(), - content: z.string(), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Verify collection exists but is empty - const entries = store.values('emptyCollection'); - assert.equal(entries.length, 0); - assert.deepEqual(entries, []); - - // Store should still be functional - assert.ok(store.scopedStore('emptyCollection')); - }); - - it('handles optional fields with defaults', async () => { - const store = new MutableDataStore(); - const settings = createMinimalSettings(root); - const logger = new Logger({ - dest: { write: () => true }, - level: 'silent', - }); - - const defaultsLoader = { - name: 'defaults-loader', - load: async (context) => { - const entries = [ - { - id: 'full-entry', - data: { - title: 'Full Entry', - draft: false, - tags: ['tag1', 'tag2'], - rating: 5, - }, - }, - { - id: 'minimal-entry', - data: { - title: 'Minimal Entry', - // All optional fields omitted - }, - }, - ]; - - for (const entry of entries) { - const parsed = await context.parseData({ - id: entry.id, - data: entry.data, - }); - - await context.store.set({ - id: entry.id, - data: parsed, - }); - } - }, - }; - - const collections = { - withDefaults: defineCollection({ - loader: defaultsLoader, - schema: z.object({ - title: z.string(), - draft: z.boolean().optional().default(true), - tags: z.array(z.string()).optional().default([]), - rating: z.number().optional().default(0), - }), - }), - }; - - const contentLayer = new ContentLayer({ - settings, - logger, - store, - contentConfigObserver: createTestConfigObserver(collections), - }); - - await contentLayer.sync(); - - // Check full entry - const fullEntry = store.get('withDefaults', 'full-entry'); - assert.equal(fullEntry.data.draft, false); - assert.deepEqual(fullEntry.data.tags, ['tag1', 'tag2']); - assert.equal(fullEntry.data.rating, 5); - - // Check minimal entry has defaults applied - const minimalEntry = store.get('withDefaults', 'minimal-entry'); - assert.equal(minimalEntry.data.draft, true); // Default value - assert.deepEqual(minimalEntry.data.tags, []); // Default value - assert.equal(minimalEntry.data.rating, 0); // Default value - }); -}); diff --git a/packages/astro/test/units/content-layer/schema-validation.test.ts b/packages/astro/test/units/content-layer/schema-validation.test.ts new file mode 100644 index 000000000000..a45f1dd7e575 --- /dev/null +++ b/packages/astro/test/units/content-layer/schema-validation.test.ts @@ -0,0 +1,618 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { z } from 'zod'; +import { defineCollection } from '../../../dist/content/config.js'; +import { ContentLayer } from '../../../dist/content/content-layer.js'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; +import { createTempDir, createTestConfigObserver, createMinimalSettings } from './test-helpers.ts'; + +describe('Content Layer - Schema Validation', () => { + const root = createTempDir(); + + it('parses and coerces Date objects in schemas', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + // Loader that provides dates in various formats + const dateLoader = { + name: 'date-loader', + load: async (context: any) => { + const entries = [ + { + id: 'one', + publishedAt: '2021-01-01', // ISO date string + updatedAt: new Date('2021-01-02'), // Date object + createdAt: '2021-01-03T00:00:00.000Z', // Full ISO string + }, + { + id: 'two', + publishedAt: '2021-01-02', + updatedAt: new Date('2021-01-03'), + createdAt: 1609545600000, // Timestamp + }, + { + id: 'three', + publishedAt: '2021-01-03', + updatedAt: new Date('2021-01-04'), + createdAt: 'January 5, 2021', // Date string + }, + { + id: 'four%', // Special characters in ID + publishedAt: '2021-01-01', + updatedAt: new Date('2021-01-02'), + createdAt: '2021-01-03', + }, + ]; + + for (const entry of entries) { + const parsed = await context.parseData({ + id: entry.id, + data: entry, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + const collections = { + withDates: defineCollection({ + loader: dateLoader, + schema: z.object({ + publishedAt: z.coerce.date(), + updatedAt: z.date(), + createdAt: z.coerce.date(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify all entries were stored + const entries = store.values('withDates'); + assert.equal(entries.length, 4); + + // Check IDs including special characters + const ids = entries.map((item) => item.id).sort(); + assert.deepEqual(ids, ['four%', 'one', 'three', 'two']); + + // Verify all dates are Date objects + for (const entry of entries) { + assert.ok(entry.data.publishedAt instanceof Date); + assert.ok(entry.data.updatedAt instanceof Date); + assert.ok(entry.data.createdAt instanceof Date); + } + + // Verify specific date values + const entryOne: any = store.get('withDates', 'one'); + assert.equal(entryOne.data.publishedAt.toISOString(), '2021-01-01T00:00:00.000Z'); + assert.equal(entryOne.data.updatedAt.toISOString(), '2021-01-02T00:00:00.000Z'); + assert.equal(entryOne.data.createdAt.toISOString(), '2021-01-03T00:00:00.000Z'); + + // Check timestamp conversion + const entryTwo: any = store.get('withDates', 'two'); + assert.equal(entryTwo.data.createdAt.toISOString(), '2021-01-02T00:00:00.000Z'); + + // Check date string parsing - just verify it's a valid Date + const entryThree: any = store.get('withDates', 'three'); + assert.ok(entryThree.data.createdAt instanceof Date); + assert.ok(!isNaN(entryThree.data.createdAt.getTime())); + }); + + it('handles custom IDs and slugs', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + // Loader that provides entries with custom slugs + const customSlugLoader = { + name: 'custom-slug-loader', + load: async (context: any) => { + const entries = [ + { + id: 'fancy-one', + slug: 'fancy-one', + title: 'First Entry', + }, + { + id: 'excellent-three', + slug: 'excellent-three', + title: 'Third Entry', + }, + { + id: 'interesting-two', + slug: 'interesting-two', + title: 'Second Entry', + }, + ]; + + for (const entry of entries) { + const parsed = await context.parseData({ + id: entry.id, + data: entry, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + const collections = { + withCustomSlugs: defineCollection({ + loader: customSlugLoader, + schema: z.object({ + slug: z.string(), + title: z.string(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify custom IDs are preserved + const entries = store.values('withCustomSlugs'); + const ids = entries.map((item) => item.id).sort(); + assert.deepEqual(ids, ['excellent-three', 'fancy-one', 'interesting-two']); + + // Verify data is correct + const fancyOne: any = store.get('withCustomSlugs', 'fancy-one'); + assert.equal(fancyOne.data.slug, 'fancy-one'); + assert.equal(fancyOne.data.title, 'First Entry'); + }); + + it('supports union schemas (discriminated unions)', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + // Loader that provides different types of content + const unionLoader = { + name: 'union-loader', + load: async (context: any) => { + const entries = [ + { + id: 'post', + type: 'post', + title: 'My Post', + description: 'This is my post', + }, + { + id: 'newsletter', + type: 'newsletter', + subject: 'My Newsletter', + // Note: newsletters don't have title or description + }, + { + id: 'announcement', + type: 'announcement', + message: 'Important Update', + priority: 'high', + }, + ]; + + for (const entry of entries) { + const parsed = await context.parseData({ + id: entry.id, + data: entry, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + // Union schema that accepts different shapes based on 'type' field + const collections = { + withUnionSchema: defineCollection({ + loader: unionLoader, + schema: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('post'), + title: z.string(), + description: z.string(), + }), + z.object({ + type: z.literal('newsletter'), + subject: z.string(), + }), + z.object({ + type: z.literal('announcement'), + message: z.string(), + priority: z.enum(['low', 'medium', 'high']), + }), + ]), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify all entries were stored + const entries = store.values('withUnionSchema'); + assert.equal(entries.length, 3); + + // Verify post entry + const post: any = store.get('withUnionSchema', 'post'); + assert.deepEqual(post.data, { + type: 'post', + title: 'My Post', + description: 'This is my post', + }); + + // Verify newsletter entry + const newsletter: any = store.get('withUnionSchema', 'newsletter'); + assert.deepEqual(newsletter.data, { + type: 'newsletter', + subject: 'My Newsletter', + }); + + // Verify announcement entry + const announcement: any = store.get('withUnionSchema', 'announcement'); + assert.deepEqual(announcement.data, { + type: 'announcement', + message: 'Important Update', + priority: 'high', + }); + }); + + it('validates required fields in empty content', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logs: any[] = []; + + const logger = new AstroLogger({ + level: 'error', + destination: { + write: (event: any) => { + logs.push(event); + return true; + }, + }, + }); + + // Loader that simulates empty markdown file scenario + const emptyContentLoader = { + name: 'empty-content-loader', + load: async (context: any) => { + // Simulate empty markdown file - no frontmatter data + const entries = [ + { + id: 'empty-file', + data: {}, // Empty frontmatter + body: '', // Empty body + }, + { + id: 'partial-file', + data: { + description: 'Has description but missing title', + }, + body: 'Some content', + }, + ]; + + for (const entry of entries) { + try { + const parsed = await context.parseData({ + id: entry.id, + data: entry.data, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + body: entry.body, + }); + } catch (error: any) { + // Log validation error + context.logger.error(`Validation failed for ${entry.id}: ${error.message}`); + + // Check if it's a Zod error with issues + if (error.errors) { + const requiredFields = error.errors + .filter((issue: any) => issue.message === 'Required') + .map((issue: any) => `**${issue.path.join('.')}**: ${issue.message}`); + + if (requiredFields.length > 0) { + context.logger.error(requiredFields.join(', ')); + } + } + } + } + }, + }; + + const collections = { + requiredFields: defineCollection({ + loader: emptyContentLoader, + schema: z.object({ + title: z.string().min(1), + description: z.string().optional(), + publishedAt: z.date().optional(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check that validation errors were logged + const validationErrors = logs.filter((log) => log.level === 'error'); + assert.ok(validationErrors.length > 0); + + const titleRequiredError = logs.find( + (log) => log.level === 'error' && log.message.includes('**title**:'), + ); + assert.ok(titleRequiredError, 'Should have logged a human-readable error mentioning **title**'); + assert.ok( + !titleRequiredError.message.includes('[{'), + `Error message should not contain raw JSON, got:\n${titleRequiredError.message}`, + ); + + // Verify no entries were stored (both failed validation) + const entries = store.values('requiredFields'); + assert.equal(entries.length, 0); + }); + + it('validates ID types and rejects invalid IDs', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logs: any[] = []; + + const logger = new AstroLogger({ + level: 'error', + destination: { + write: (event: any) => { + logs.push(event); + return true; + }, + }, + }); + + // Loader that provides entries with various ID types + const invalidIdLoader = { + name: 'invalid-id-loader', + load: async (context: any) => { + const entries = [ + { + id: 'valid-string-id', + data: { title: 'Valid Entry' }, + }, + { + id: 123, // Number ID - should be invalid + data: { title: 'Entry with number ID' }, + }, + { + id: null, // Null ID + data: { title: 'Entry with null ID' }, + }, + { + id: '', // Empty string ID + data: { title: 'Entry with empty ID' }, + }, + ]; + + for (const entry of entries) { + try { + // Validate ID type + if (typeof entry.id !== 'string' || !entry.id) { + throw new Error( + `Collection loader returned an entry with an invalid \`id\`: ${JSON.stringify(entry.id)}. IDs must be strings.`, + ); + } + + const parsed = await context.parseData({ + id: entry.id, + data: entry.data, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } catch (error: any) { + context.logger.error(error.message); + } + } + }, + }; + + const collections = { + withIdValidation: defineCollection({ + loader: invalidIdLoader, + schema: z.object({ + title: z.string(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check for ID validation errors + const idErrors = logs.filter( + (log) => + log.level === 'error' && log.message.includes('returned an entry with an invalid `id`'), + ); + assert.ok(idErrors.length >= 2, 'Should have errors for invalid IDs'); + + // Only valid entry should be stored + const entries = store.values('withIdValidation'); + assert.equal(entries.length, 1); + assert.equal(entries[0].id, 'valid-string-id'); + }); + + it('handles empty collections gracefully', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + // Loader that returns no entries + const emptyLoader = { + name: 'empty-loader', + load: async (_context: any) => { + // Simulate an empty directory - no entries to load + // Just return without adding anything to the store + }, + }; + + const collections = { + emptyCollection: defineCollection({ + loader: emptyLoader, + schema: z.object({ + title: z.string(), + content: z.string(), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Verify collection exists but is empty + const entries = store.values('emptyCollection'); + assert.equal(entries.length, 0); + assert.deepEqual(entries, []); + + // Store should still be functional + assert.ok(store.scopedStore('emptyCollection')); + }); + + it('handles optional fields with defaults', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const defaultsLoader = { + name: 'defaults-loader', + load: async (context: any) => { + const entries = [ + { + id: 'full-entry', + data: { + title: 'Full Entry', + draft: false, + tags: ['tag1', 'tag2'], + rating: 5, + }, + }, + { + id: 'minimal-entry', + data: { + title: 'Minimal Entry', + // All optional fields omitted + }, + }, + ]; + + for (const entry of entries) { + const parsed = await context.parseData({ + id: entry.id, + data: entry.data, + }); + + await context.store.set({ + id: entry.id, + data: parsed, + }); + } + }, + }; + + const collections = { + withDefaults: defineCollection({ + loader: defaultsLoader, + schema: z.object({ + title: z.string(), + draft: z.boolean().optional().default(true), + tags: z.array(z.string()).optional().default([]), + rating: z.number().optional().default(0), + }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + await contentLayer.sync(); + + // Check full entry + const fullEntry: any = store.get('withDefaults', 'full-entry'); + assert.equal(fullEntry.data.draft, false); + assert.deepEqual(fullEntry.data.tags, ['tag1', 'tag2']); + assert.equal(fullEntry.data.rating, 5); + + // Check minimal entry has defaults applied + const minimalEntry: any = store.get('withDefaults', 'minimal-entry'); + assert.equal(minimalEntry.data.draft, true); // Default value + assert.deepEqual(minimalEntry.data.tags, []); // Default value + assert.equal(minimalEntry.data.rating, 0); // Default value + }); +}); diff --git a/packages/astro/test/units/content-layer/store-persistence.test.js b/packages/astro/test/units/content-layer/store-persistence.test.js deleted file mode 100644 index 303a19f16bb3..000000000000 --- a/packages/astro/test/units/content-layer/store-persistence.test.js +++ /dev/null @@ -1,213 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import fs from 'node:fs/promises'; -import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; -import { createTempDir } from './test-helpers.js'; - -describe('Content Layer - Store Persistence', () => { - it('updates the store on new builds', async () => { - const tempDir = createTempDir(); - const dataStoreFile = new URL('./data-store.json', tempDir); - - // First build - create initial data - const store1 = new MutableDataStore(); - store1.set('dogs', 'beagle', { - id: 'beagle', - data: { breed: 'Beagle', temperament: ['Friendly'] }, - }); - - // Save to disk - await fs.writeFile(dataStoreFile, store1.toString()); - - // Second build - load from disk and update - const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); - - // Verify existing data persists - const beagle = store2.get('dogs', 'beagle'); - assert.ok(beagle); - assert.equal(beagle.data.breed, 'Beagle'); - - // Add new data - store2.set('dogs', 'poodle', { - id: 'poodle', - data: { breed: 'Poodle', temperament: ['Intelligent'] }, - }); - - // Save again - await fs.writeFile(dataStoreFile, store2.toString()); - - // Third build - verify both entries exist - const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); - assert.equal(store3.values('dogs').length, 2); - assert.ok(store3.get('dogs', 'beagle')); - assert.ok(store3.get('dogs', 'poodle')); - }); - - it('clears the store on new build with force flag', async () => { - const tempDir = createTempDir(); - const dataStoreFile = new URL('./data-store.json', tempDir); - - // First build - create data - const store1 = new MutableDataStore(); - store1.set('dogs', 'beagle', { - id: 'beagle', - data: { breed: 'Beagle' }, - }); - store1.metaStore().set('content-config-digest', 'digest1'); - - await fs.writeFile(dataStoreFile, store1.toString()); - - // Second build with force flag - should clear - const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); - - // Simulate force flag by clearing all - store2.clearAll(); - - // Add different data - store2.set('cats', 'siamese', { - id: 'siamese', - data: { breed: 'Siamese' }, - }); - - await fs.writeFile(dataStoreFile, store2.toString()); - - // Verify old data is gone, new data exists - const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); - assert.equal(store3.values('dogs').length, 0); - assert.equal(store3.values('cats').length, 1); - assert.ok(store3.get('cats', 'siamese')); - }); - - it('clears the store on new build if the content config has changed', async () => { - const tempDir = createTempDir(); - const dataStoreFile = new URL('./data-store.json', tempDir); - - // First build - const store1 = new MutableDataStore(); - store1.set('dogs', 'beagle', { - id: 'beagle', - data: { breed: 'Beagle' }, - }); - store1.metaStore().set('content-config-digest', 'digest1'); - - await fs.writeFile(dataStoreFile, store1.toString()); - - // Second build with different config digest - const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); - const previousDigest = store2.metaStore().get('content-config-digest'); - const newDigest = 'digest2'; - - if (previousDigest && previousDigest !== newDigest) { - // Content config changed, clear store - store2.clearAll(); - } - - store2.metaStore().set('content-config-digest', newDigest); - - // Add new data - store2.set('cats', 'tabby', { - id: 'tabby', - data: { breed: 'Tabby' }, - }); - - await fs.writeFile(dataStoreFile, store2.toString()); - - // Verify - const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); - assert.equal(store3.values('dogs').length, 0); // Old data cleared - assert.equal(store3.values('cats').length, 1); // New data exists - assert.equal(store3.metaStore().get('content-config-digest'), 'digest2'); - }); - - it('clears the store on new build if the Astro config has changed', async () => { - const tempDir = createTempDir(); - const dataStoreFile = new URL('./data-store.json', tempDir); - - // First build - const store1 = new MutableDataStore(); - store1.set('dogs', 'beagle', { - id: 'beagle', - data: { breed: 'Beagle' }, - }); - store1.metaStore().set('astro-config-digest', 'astroDigest1'); - - await fs.writeFile(dataStoreFile, store1.toString()); - - // Second build with different astro config - const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); - const previousAstroDigest = store2.metaStore().get('astro-config-digest'); - const newAstroDigest = 'astroDigest2'; - - if (previousAstroDigest && previousAstroDigest !== newAstroDigest) { - // Astro config changed, clear store - store2.clearAll(); - } - - store2.metaStore().set('astro-config-digest', newAstroDigest); - - // Add new data - store2.set('birds', 'robin', { - id: 'robin', - data: { name: 'Robin' }, - }); - - await fs.writeFile(dataStoreFile, store2.toString()); - - // Verify - const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); - assert.equal(store3.values('dogs').length, 0); // Old data cleared - assert.equal(store3.values('birds').length, 1); // New data exists - assert.equal(store3.metaStore().get('astro-config-digest'), 'astroDigest2'); - }); - - it('can handle references being renamed after a build', async () => { - const tempDir = createTempDir(); - const dataStoreFile = new URL('./data-store.json', tempDir); - - // First build - entry with reference - const store1 = new MutableDataStore(); - store1.set('cats', 'siamese', { - id: 'siamese', - data: { breed: 'Siamese' }, - }); - store1.set('posts', 'post1', { - id: 'post1', - data: { - title: 'My Cat', - cat: { collection: 'cats', id: 'siamese' }, - }, - }); - - await fs.writeFile(dataStoreFile, store1.toString()); - - // Second build - rename the cat entry - const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); - - // Remove old entry - store2.delete('cats', 'siamese'); - - // Add renamed entry - store2.set('cats', 'siamese-cat', { - id: 'siamese-cat', - data: { breed: 'Siamese' }, - }); - - // Update the reference - const post = store2.get('posts', 'post1'); - if (post) { - post.data.cat = { collection: 'cats', id: 'siamese-cat' }; - store2.set('posts', 'post1', post); - } - - await fs.writeFile(dataStoreFile, store2.toString()); - - // Verify - const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); - assert.ok(!store3.get('cats', 'siamese')); // Old entry gone - assert.ok(store3.get('cats', 'siamese-cat')); // New entry exists - - const updatedPost = store3.get('posts', 'post1'); - assert.equal(updatedPost.data.cat.id, 'siamese-cat'); // Reference updated - }); -}); diff --git a/packages/astro/test/units/content-layer/store-persistence.test.ts b/packages/astro/test/units/content-layer/store-persistence.test.ts new file mode 100644 index 000000000000..2f6caa3ddd7f --- /dev/null +++ b/packages/astro/test/units/content-layer/store-persistence.test.ts @@ -0,0 +1,213 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { createTempDir } from './test-helpers.ts'; + +describe('Content Layer - Store Persistence', () => { + it('updates the store on new builds', async () => { + const tempDir = createTempDir(); + const dataStoreFile = new URL('./data-store.json', tempDir); + + // First build - create initial data + const store1 = new MutableDataStore(); + store1.set('dogs', 'beagle', { + id: 'beagle', + data: { breed: 'Beagle', temperament: ['Friendly'] }, + }); + + // Save to disk + await fs.writeFile(dataStoreFile, store1.toString()); + + // Second build - load from disk and update + const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + + // Verify existing data persists + const beagle = store2.get('dogs', 'beagle'); + assert.ok(beagle); + assert.equal(beagle.data.breed, 'Beagle'); + + // Add new data + store2.set('dogs', 'poodle', { + id: 'poodle', + data: { breed: 'Poodle', temperament: ['Intelligent'] }, + }); + + // Save again + await fs.writeFile(dataStoreFile, store2.toString()); + + // Third build - verify both entries exist + const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + assert.equal(store3.values('dogs').length, 2); + assert.ok(store3.get('dogs', 'beagle')); + assert.ok(store3.get('dogs', 'poodle')); + }); + + it('clears the store on new build with force flag', async () => { + const tempDir = createTempDir(); + const dataStoreFile = new URL('./data-store.json', tempDir); + + // First build - create data + const store1 = new MutableDataStore(); + store1.set('dogs', 'beagle', { + id: 'beagle', + data: { breed: 'Beagle' }, + }); + store1.metaStore().set('content-config-digest', 'digest1'); + + await fs.writeFile(dataStoreFile, store1.toString()); + + // Second build with force flag - should clear + const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + + // Simulate force flag by clearing all + store2.clearAll(); + + // Add different data + store2.set('cats', 'siamese', { + id: 'siamese', + data: { breed: 'Siamese' }, + }); + + await fs.writeFile(dataStoreFile, store2.toString()); + + // Verify old data is gone, new data exists + const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + assert.equal(store3.values('dogs').length, 0); + assert.equal(store3.values('cats').length, 1); + assert.ok(store3.get('cats', 'siamese')); + }); + + it('clears the store on new build if the content config has changed', async () => { + const tempDir = createTempDir(); + const dataStoreFile = new URL('./data-store.json', tempDir); + + // First build + const store1 = new MutableDataStore(); + store1.set('dogs', 'beagle', { + id: 'beagle', + data: { breed: 'Beagle' }, + }); + store1.metaStore().set('content-config-digest', 'digest1'); + + await fs.writeFile(dataStoreFile, store1.toString()); + + // Second build with different config digest + const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + const previousDigest = store2.metaStore().get('content-config-digest'); + const newDigest = 'digest2'; + + if (previousDigest && previousDigest !== newDigest) { + // Content config changed, clear store + store2.clearAll(); + } + + store2.metaStore().set('content-config-digest', newDigest); + + // Add new data + store2.set('cats', 'tabby', { + id: 'tabby', + data: { breed: 'Tabby' }, + }); + + await fs.writeFile(dataStoreFile, store2.toString()); + + // Verify + const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + assert.equal(store3.values('dogs').length, 0); // Old data cleared + assert.equal(store3.values('cats').length, 1); // New data exists + assert.equal(store3.metaStore().get('content-config-digest'), 'digest2'); + }); + + it('clears the store on new build if the Astro config has changed', async () => { + const tempDir = createTempDir(); + const dataStoreFile = new URL('./data-store.json', tempDir); + + // First build + const store1 = new MutableDataStore(); + store1.set('dogs', 'beagle', { + id: 'beagle', + data: { breed: 'Beagle' }, + }); + store1.metaStore().set('astro-config-digest', 'astroDigest1'); + + await fs.writeFile(dataStoreFile, store1.toString()); + + // Second build with different astro config + const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + const previousAstroDigest = store2.metaStore().get('astro-config-digest'); + const newAstroDigest = 'astroDigest2'; + + if (previousAstroDigest && previousAstroDigest !== newAstroDigest) { + // Astro config changed, clear store + store2.clearAll(); + } + + store2.metaStore().set('astro-config-digest', newAstroDigest); + + // Add new data + store2.set('birds', 'robin', { + id: 'robin', + data: { name: 'Robin' }, + }); + + await fs.writeFile(dataStoreFile, store2.toString()); + + // Verify + const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + assert.equal(store3.values('dogs').length, 0); // Old data cleared + assert.equal(store3.values('birds').length, 1); // New data exists + assert.equal(store3.metaStore().get('astro-config-digest'), 'astroDigest2'); + }); + + it('can handle references being renamed after a build', async () => { + const tempDir = createTempDir(); + const dataStoreFile = new URL('./data-store.json', tempDir); + + // First build - entry with reference + const store1 = new MutableDataStore(); + store1.set('cats', 'siamese', { + id: 'siamese', + data: { breed: 'Siamese' }, + }); + store1.set('posts', 'post1', { + id: 'post1', + data: { + title: 'My Cat', + cat: { collection: 'cats', id: 'siamese' }, + }, + }); + + await fs.writeFile(dataStoreFile, store1.toString()); + + // Second build - rename the cat entry + const store2 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + + // Remove old entry + store2.delete('cats', 'siamese'); + + // Add renamed entry + store2.set('cats', 'siamese-cat', { + id: 'siamese-cat', + data: { breed: 'Siamese' }, + }); + + // Update the reference + const post = store2.get('posts', 'post1'); + if (post) { + post.data.cat = { collection: 'cats', id: 'siamese-cat' }; + store2.set('posts', 'post1', post); + } + + await fs.writeFile(dataStoreFile, store2.toString()); + + // Verify + const store3 = await MutableDataStore.fromFile(fileURLToPath(dataStoreFile)); + assert.ok(!store3.get('cats', 'siamese')); // Old entry gone + assert.ok(store3.get('cats', 'siamese-cat')); // New entry exists + + const updatedPost: any = store3.get('posts', 'post1'); + assert.equal(updatedPost.data.cat.id, 'siamese-cat'); // Reference updated + }); +}); diff --git a/packages/astro/test/units/content-layer/test-helpers.js b/packages/astro/test/units/content-layer/test-helpers.js deleted file mode 100644 index 4f85716f2dee..000000000000 --- a/packages/astro/test/units/content-layer/test-helpers.js +++ /dev/null @@ -1,137 +0,0 @@ -import path from 'node:path'; -import { tmpdir } from 'node:os'; -import { mkdtempSync } from 'node:fs'; -import { pathToFileURL } from 'node:url'; - -/** - * Creates a temporary directory for tests - * @param {string} prefix - Optional prefix for the temp directory name - * @returns {URL} The file URL of the created temp directory - */ -export function createTempDir(prefix = 'astro-test-') { - const tempDir = mkdtempSync(path.join(tmpdir(), prefix)); - return pathToFileURL(tempDir + path.sep); -} - -/** - * Creates a test content config observer for unit tests - * @param {Object} collections - The collections configuration - * @returns {Object} A mock content config observer - */ -export function createTestConfigObserver(collections) { - const contentConfig = { - status: 'loaded', - config: { - collections, - digest: 'test-digest', - }, - }; - - return { - get: () => contentConfig, - set: () => {}, - subscribe: (fn) => { - // Call immediately with current config - fn(contentConfig); - return () => {}; - }, - }; -} - -/** - * Creates minimal Astro settings for content layer tests - * @param {URL} root - The root URL for the test - * @param {Object} overrides - Optional overrides for specific settings - * @returns {Object} Astro settings object - */ -export function createMinimalSettings(root, overrides = {}) { - const defaultConfig = { - root, - srcDir: new URL('./src/', root), - cacheDir: new URL('./.cache/', root), - markdown: {}, - experimental: {}, - }; - - const settings = { - config: { - ...defaultConfig, - ...(overrides.config || {}), - }, - dotAstroDir: new URL('./.astro/', root), - contentEntryTypes: [], - dataEntryTypes: [], - }; - - // Apply non-config overrides - Object.keys(overrides).forEach((key) => { - if (key !== 'config') { - settings[key] = overrides[key]; - } - }); - - return settings; -} - -/** - * Simple YAML frontmatter parser for markdown files - * @param {string} contents - The file contents - * @param {string} fileUrl - The file URL - * @returns {Object} Parsed frontmatter data, body, and slug - */ -export function parseSimpleMarkdownFrontmatter(contents, fileUrl) { - const lines = contents.split('\n'); - const frontmatterStart = lines.findIndex((l) => l === '---'); - const frontmatterEnd = lines.findIndex((l, i) => i > frontmatterStart && l === '---'); - - if (frontmatterStart === -1 || frontmatterEnd === -1) { - const slug = path.basename(fileUrl.pathname || fileUrl, '.md'); - return { data: {}, body: contents, slug, rawData: {} }; - } - - const frontmatterLines = lines.slice(frontmatterStart + 1, frontmatterEnd); - const body = lines.slice(frontmatterEnd + 1).join('\n'); - - // Parse YAML-like frontmatter - const data = {}; - for (const line of frontmatterLines) { - const [key, ...valueParts] = line.split(':'); - if (key && valueParts.length) { - const value = valueParts.join(':').trim(); - if (value.startsWith('[') && value.endsWith(']')) { - // Parse YAML-style arrays - const arrayContent = value.slice(1, -1); - data[key.trim()] = arrayContent - .split(',') - .map((item) => item.trim().replace(/^["']|["']$/g, '')) - .filter((item) => item.length > 0); - } else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { - // Keep dates as strings for schema to parse - data[key.trim()] = value; - } else { - // Remove quotes if present - data[key.trim()] = value.replace(/^["']|["']$/g, ''); - } - } - } - - const slug = path.basename(fileUrl.pathname || fileUrl, '.md'); - return { data, body, slug, rawData: data }; -} - -/** - * Creates a markdown entry type configuration - * @param {Function} getEntryInfo - Optional custom getEntryInfo function - * @returns {Object} Entry type configuration for markdown files - */ -export function createMarkdownEntryType(getEntryInfo = parseSimpleMarkdownFrontmatter) { - return { - extensions: ['.md'], - getEntryInfo: async ({ contents, fileUrl }) => { - if (typeof fileUrl === 'string') { - return getEntryInfo(contents, fileUrl); - } - return getEntryInfo(contents, fileUrl); - }, - }; -} diff --git a/packages/astro/test/units/content-layer/test-helpers.ts b/packages/astro/test/units/content-layer/test-helpers.ts new file mode 100644 index 000000000000..df3beb649756 --- /dev/null +++ b/packages/astro/test/units/content-layer/test-helpers.ts @@ -0,0 +1,122 @@ +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { mkdtempSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +/** + * Creates a temporary directory for tests + */ +export function createTempDir(prefix = 'astro-test-'): URL { + const tempDir = mkdtempSync(path.join(tmpdir(), prefix)); + return pathToFileURL(tempDir + path.sep); +} + +/** + * Creates a test content config observer for unit tests + */ +export function createTestConfigObserver(collections: Record): any { + const contentConfig = { + status: 'loaded' as const, + config: { + collections, + digest: 'test-digest', + }, + }; + + return { + get: () => contentConfig, + set: () => {}, + subscribe: (fn: (config: typeof contentConfig) => void) => { + fn(contentConfig); + return () => {}; + }, + }; +} + +/** + * Creates minimal Astro settings for content layer tests + */ +export function createMinimalSettings(root: URL, overrides: Record = {}): any { + const defaultConfig = { + root, + srcDir: new URL('./src/', root), + cacheDir: new URL('./.cache/', root), + markdown: {}, + experimental: {}, + }; + + const settings: Record = { + config: { + ...defaultConfig, + ...(overrides.config || {}), + }, + dotAstroDir: new URL('./.astro/', root), + contentEntryTypes: [], + dataEntryTypes: [], + }; + + Object.keys(overrides).forEach((key) => { + if (key !== 'config') { + settings[key] = overrides[key]; + } + }); + + return settings; +} + +/** + * Simple YAML frontmatter parser for markdown files + */ +export function parseSimpleMarkdownFrontmatter(contents: string, fileUrl: string | URL) { + const lines = contents.split('\n'); + const frontmatterStart = lines.findIndex((l: string) => l === '---'); + const frontmatterEnd = lines.findIndex( + (l: string, i: number) => i > frontmatterStart && l === '---', + ); + + if (frontmatterStart === -1 || frontmatterEnd === -1) { + const pathname = typeof fileUrl === 'string' ? fileUrl : fileUrl.pathname; + const slug = path.basename(pathname, '.md'); + return { data: {} as Record, body: contents, slug, rawData: {} }; + } + + const frontmatterLines = lines.slice(frontmatterStart + 1, frontmatterEnd); + const body = lines.slice(frontmatterEnd + 1).join('\n'); + + const data: Record = {}; + for (const line of frontmatterLines) { + const [key, ...valueParts] = line.split(':'); + if (key && valueParts.length) { + const value = valueParts.join(':').trim(); + if (value.startsWith('[') && value.endsWith(']')) { + const arrayContent = value.slice(1, -1); + data[key.trim()] = arrayContent + .split(',') + .map((item: string) => item.trim().replace(/^["']|["']$/g, '')) + .filter((item: string) => item.length > 0); + } else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + data[key.trim()] = value; + } else { + data[key.trim()] = value.replace(/^["']|["']$/g, ''); + } + } + } + + const pathname = typeof fileUrl === 'string' ? fileUrl : fileUrl.pathname; + const slug = path.basename(pathname, '.md'); + return { data, body, slug, rawData: data }; +} + +/** + * Creates a markdown entry type configuration + */ +export function createMarkdownEntryType( + getEntryInfo: (contents: string, fileUrl: string | URL) => any = parseSimpleMarkdownFrontmatter, +) { + return { + extensions: ['.md'], + getEntryInfo: async ({ contents, fileUrl }: { contents: string; fileUrl: string | URL }) => { + return getEntryInfo(contents, fileUrl); + }, + }; +} diff --git a/packages/astro/test/units/cookies/delete.test.js b/packages/astro/test/units/cookies/delete.test.js deleted file mode 100644 index 0c16c9ed0255..000000000000 --- a/packages/astro/test/units/cookies/delete.test.js +++ /dev/null @@ -1,100 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { AstroCookies } from '../../../dist/core/cookies/index.js'; - -describe('astro/src/core/cookies', () => { - describe('Astro.cookies.delete', () => { - it('creates a Set-Cookie header to delete it', () => { - let req = new Request('http://example.com/', { - headers: { - cookie: 'foo=bar', - }, - }); - let cookies = new AstroCookies(req); - assert.equal(cookies.get('foo').value, 'bar'); - - cookies.delete('foo'); - let headers = Array.from(cookies.headers()); - assert.equal(headers.length, 1); - }); - - it('calling cookies.get() after returns undefined', () => { - let req = new Request('http://example.com/', { - headers: { - cookie: 'foo=bar', - }, - }); - let cookies = new AstroCookies(req); - assert.equal(cookies.get('foo').value, 'bar'); - - cookies.delete('foo'); - assert.equal(cookies.get('foo'), undefined); - }); - - it('calling cookies.has() after returns false', () => { - let req = new Request('http://example.com/', { - headers: { - cookie: 'foo=bar', - }, - }); - let cookies = new AstroCookies(req); - assert.equal(cookies.has('foo'), true); - - cookies.delete('foo'); - assert.equal(cookies.has('foo'), false); - }); - - it('deletes a cookie with attributes', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - - cookies.delete('foo', { - domain: 'example.com', - path: '/subpath/', - priority: 'high', - secure: true, - httpOnly: true, - sameSite: 'strict', - }); - - let headers = Array.from(cookies.headers()); - assert.equal(headers.length, 1); - assert.equal(headers[0].includes('foo=deleted'), true); - assert.equal(headers[0].includes('Expires=Thu, 01 Jan 1970 00:00:00 GMT'), true); - assert.equal(/Domain=example.com/.test(headers[0]), true); - assert.equal(headers[0].includes('Path=/subpath/'), true); - assert.equal(headers[0].includes('Priority=High'), true); - assert.equal(headers[0].includes('Secure'), true); - assert.equal(headers[0].includes('HttpOnly'), true); - assert.equal(headers[0].includes('SameSite=Strict'), true); - }); - - it('ignores expires option', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - - cookies.delete('foo', { - expires: new Date(), - }); - - let headers = Array.from(cookies.headers()); - assert.equal(headers.length, 1); - assert.equal(headers[0].includes('foo=deleted'), true); - assert.equal(headers[0].includes('Expires=Thu, 01 Jan 1970 00:00:00 GMT'), true); - }); - - it('ignores maxAge option', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - - cookies.delete('foo', { - maxAge: 60, - }); - - let headers = Array.from(cookies.headers()); - assert.equal(headers.length, 1); - assert.equal(headers[0].includes('foo=deleted'), true); - assert.equal(headers[0].includes('Expires=Thu, 01 Jan 1970 00:00:00 GMT'), true); - }); - }); -}); diff --git a/packages/astro/test/units/cookies/delete.test.ts b/packages/astro/test/units/cookies/delete.test.ts new file mode 100644 index 000000000000..f6a04507c4af --- /dev/null +++ b/packages/astro/test/units/cookies/delete.test.ts @@ -0,0 +1,100 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { AstroCookies } from '../../../dist/core/cookies/index.js'; + +describe('astro/src/core/cookies', () => { + describe('Astro.cookies.delete', () => { + it('creates a Set-Cookie header to delete it', () => { + let req = new Request('http://example.com/', { + headers: { + cookie: 'foo=bar', + }, + }); + let cookies = new AstroCookies(req); + assert.equal(cookies.get('foo')!.value, 'bar'); + + cookies.delete('foo'); + let headers = Array.from(cookies.headers()); + assert.equal(headers.length, 1); + }); + + it('calling cookies.get() after returns undefined', () => { + let req = new Request('http://example.com/', { + headers: { + cookie: 'foo=bar', + }, + }); + let cookies = new AstroCookies(req); + assert.equal(cookies.get('foo')!.value, 'bar'); + + cookies.delete('foo'); + assert.equal(cookies.get('foo'), undefined); + }); + + it('calling cookies.has() after returns false', () => { + let req = new Request('http://example.com/', { + headers: { + cookie: 'foo=bar', + }, + }); + let cookies = new AstroCookies(req); + assert.equal(cookies.has('foo'), true); + + cookies.delete('foo'); + assert.equal(cookies.has('foo'), false); + }); + + it('deletes a cookie with attributes', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + + cookies.delete('foo', { + domain: 'example.com', + path: '/subpath/', + priority: 'high', + secure: true, + httpOnly: true, + sameSite: 'strict', + } as any); + + let headers = Array.from(cookies.headers()); + assert.equal(headers.length, 1); + assert.equal(headers[0].includes('foo=deleted'), true); + assert.equal(headers[0].includes('Expires=Thu, 01 Jan 1970 00:00:00 GMT'), true); + assert.equal(/Domain=example.com/.test(headers[0]), true); + assert.equal(headers[0].includes('Path=/subpath/'), true); + assert.equal(headers[0].includes('Priority=High'), true); + assert.equal(headers[0].includes('Secure'), true); + assert.equal(headers[0].includes('HttpOnly'), true); + assert.equal(headers[0].includes('SameSite=Strict'), true); + }); + + it('ignores expires option', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + + cookies.delete('foo', { + expires: new Date(), + } as any); + + let headers = Array.from(cookies.headers()); + assert.equal(headers.length, 1); + assert.equal(headers[0].includes('foo=deleted'), true); + assert.equal(headers[0].includes('Expires=Thu, 01 Jan 1970 00:00:00 GMT'), true); + }); + + it('ignores maxAge option', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + + cookies.delete('foo', { + maxAge: 60, + } as any); + + let headers = Array.from(cookies.headers()); + assert.equal(headers.length, 1); + assert.equal(headers[0].includes('foo=deleted'), true); + assert.equal(headers[0].includes('Expires=Thu, 01 Jan 1970 00:00:00 GMT'), true); + }); + }); +}); diff --git a/packages/astro/test/units/cookies/error.test.js b/packages/astro/test/units/cookies/error.test.js deleted file mode 100644 index 6a5a3186f88e..000000000000 --- a/packages/astro/test/units/cookies/error.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { AstroCookies } from '../../../dist/core/cookies/index.js'; - -describe('astro/src/core/cookies', () => { - describe('errors', () => { - it('Produces an error if the response is already sent', () => { - const req = new Request('http://example.com/', {}); - const cookies = new AstroCookies(req); - req[Symbol.for('astro.responseSent')] = true; - try { - cookies.set('foo', 'bar'); - assert.equal(false, true); - } catch (err) { - assert.equal(err.name, 'ResponseSentError'); - } - }); - }); -}); diff --git a/packages/astro/test/units/cookies/error.test.ts b/packages/astro/test/units/cookies/error.test.ts new file mode 100644 index 000000000000..53abc941765f --- /dev/null +++ b/packages/astro/test/units/cookies/error.test.ts @@ -0,0 +1,19 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { AstroCookies } from '../../../dist/core/cookies/index.js'; + +describe('astro/src/core/cookies', () => { + describe('errors', () => { + it('Produces an error if the response is already sent', () => { + const req = new Request('http://example.com/', {}); + const cookies = new AstroCookies(req); + (req as any)[Symbol.for('astro.responseSent')] = true; + try { + cookies.set('foo', 'bar'); + assert.equal(false, true); + } catch (err: any) { + assert.equal(err.name, 'ResponseSentError'); + } + }); + }); +}); diff --git a/packages/astro/test/units/cookies/get.test.js b/packages/astro/test/units/cookies/get.test.js deleted file mode 100644 index 6fb0b06bd875..000000000000 --- a/packages/astro/test/units/cookies/get.test.js +++ /dev/null @@ -1,191 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { AstroCookies } from '../../../dist/core/cookies/index.js'; - -const encode = (data) => { - const dataSerialized = typeof data === 'string' ? data : JSON.stringify(data); - return Buffer.from(dataSerialized).toString('base64'); -}; - -const decode = (str) => { - return Buffer.from(str, 'base64').toString(); -}; - -describe('astro/src/core/cookies', () => { - describe('Astro.cookies.get', () => { - it('gets the cookie value', () => { - const req = new Request('http://example.com/', { - headers: { - cookie: 'foo=bar', - }, - }); - const cookies = new AstroCookies(req); - assert.equal(cookies.get('foo').value, 'bar'); - }); - - it('gets the cookie value with default decode', () => { - const url = 'http://localhost/?hello=world&foo=bar#hash'; - const req = new Request('http://example.com/', { - headers: { - cookie: `url=${encodeURIComponent(url)}`, - }, - }); - const cookies = new AstroCookies(req); - // by default decodeURIComponent is used on the value - assert.equal(cookies.get('url').value, url); - }); - - it('gets the cookie value with custom decode', () => { - const url = 'http://localhost/?hello=world&foo=bar#hash'; - const req = new Request('http://example.com/', { - headers: { - cookie: `url=${encode(url)}`, - }, - }); - const cookies = new AstroCookies(req); - - assert.ok(cookies.has('url')); - assert.equal(cookies.get('url', { decode }).value, url); - assert.equal(cookies.get('url').value, encode(url)); - }); - - it("Returns undefined is the value doesn't exist", () => { - const req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - let cookie = cookies.get('foo'); - assert.equal(cookie, undefined); - }); - - it('does not return values from Object.prototype when no cookie header is present', () => { - const req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - // These are properties that exist on Object.prototype - assert.equal(cookies.get('toString'), undefined); - assert.equal(cookies.get('constructor'), undefined); - assert.equal(cookies.get('hasOwnProperty'), undefined); - assert.equal(cookies.get('valueOf'), undefined); - }); - - it('handles malformed cookie values gracefully', () => { - // Test with invalid URI sequence (e.g., incomplete percent encoding) - const req = new Request('http://example.com/', { - headers: { - cookie: 'malformed=0:%', - }, - }); - let cookies = new AstroCookies(req); - // Should return the unparsed value instead of throwing - assert.equal(cookies.get('malformed').value, '0:%'); - }); - - describe('.json()', () => { - it('returns a JavaScript object', () => { - const req = new Request('http://example.com/', { - headers: { - cookie: 'foo=%7B%22key%22%3A%22value%22%7D', - }, - }); - let cookies = new AstroCookies(req); - - const json = cookies.get('foo').json(); - assert.equal(typeof json, 'object'); - assert.equal(json.key, 'value'); - }); - }); - - describe('.number()', () => { - it('Coerces into a number', () => { - const req = new Request('http://example.com/', { - headers: { - cookie: 'foo=22', - }, - }); - let cookies = new AstroCookies(req); - - const value = cookies.get('foo').number(); - assert.equal(typeof value, 'number'); - assert.equal(value, 22); - }); - - it('Coerces non-number into NaN', () => { - const req = new Request('http://example.com/', { - headers: { - cookie: 'foo=bar', - }, - }); - let cookies = new AstroCookies(req); - - const value = cookies.get('foo').number(); - assert.equal(typeof value, 'number'); - assert.equal(Number.isNaN(value), true); - }); - }); - - describe('.boolean()', () => { - it('Coerces true into `true`', () => { - const req = new Request('http://example.com/', { - headers: { - cookie: 'foo=true', - }, - }); - let cookies = new AstroCookies(req); - - const value = cookies.get('foo').boolean(); - assert.equal(typeof value, 'boolean'); - assert.equal(value, true); - }); - - it('Coerces false into `false`', () => { - const req = new Request('http://example.com/', { - headers: { - cookie: 'foo=false', - }, - }); - let cookies = new AstroCookies(req); - - const value = cookies.get('foo').boolean(); - assert.equal(typeof value, 'boolean'); - assert.equal(value, false); - }); - - it('Coerces 1 into `true`', () => { - const req = new Request('http://example.com/', { - headers: { - cookie: 'foo=1', - }, - }); - let cookies = new AstroCookies(req); - - const value = cookies.get('foo').boolean(); - assert.equal(typeof value, 'boolean'); - assert.equal(value, true); - }); - - it('Coerces 0 into `false`', () => { - const req = new Request('http://example.com/', { - headers: { - cookie: 'foo=0', - }, - }); - let cookies = new AstroCookies(req); - - const value = cookies.get('foo').boolean(); - assert.equal(typeof value, 'boolean'); - assert.equal(value, false); - }); - - it('Coerces truthy strings into `true`', () => { - const req = new Request('http://example.com/', { - headers: { - cookie: 'foo=bar', - }, - }); - let cookies = new AstroCookies(req); - - const value = cookies.get('foo').boolean(); - assert.equal(typeof value, 'boolean'); - assert.equal(value, true); - }); - }); - }); -}); diff --git a/packages/astro/test/units/cookies/get.test.ts b/packages/astro/test/units/cookies/get.test.ts new file mode 100644 index 000000000000..c8c2ce0a687d --- /dev/null +++ b/packages/astro/test/units/cookies/get.test.ts @@ -0,0 +1,191 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { AstroCookies } from '../../../dist/core/cookies/index.js'; + +const encode = (data: any) => { + const dataSerialized = typeof data === 'string' ? data : JSON.stringify(data); + return Buffer.from(dataSerialized).toString('base64'); +}; + +const decode = (str: string) => { + return Buffer.from(str, 'base64').toString(); +}; + +describe('astro/src/core/cookies', () => { + describe('Astro.cookies.get', () => { + it('gets the cookie value', () => { + const req = new Request('http://example.com/', { + headers: { + cookie: 'foo=bar', + }, + }); + const cookies = new AstroCookies(req); + assert.equal(cookies.get('foo')!.value, 'bar'); + }); + + it('gets the cookie value with default decode', () => { + const url = 'http://localhost/?hello=world&foo=bar#hash'; + const req = new Request('http://example.com/', { + headers: { + cookie: `url=${encodeURIComponent(url)}`, + }, + }); + const cookies = new AstroCookies(req); + // by default decodeURIComponent is used on the value + assert.equal(cookies.get('url')!.value, url); + }); + + it('gets the cookie value with custom decode', () => { + const url = 'http://localhost/?hello=world&foo=bar#hash'; + const req = new Request('http://example.com/', { + headers: { + cookie: `url=${encode(url)}`, + }, + }); + const cookies = new AstroCookies(req); + + assert.ok(cookies.has('url')); + assert.equal(cookies.get('url', { decode })!.value, url); + assert.equal(cookies.get('url')!.value, encode(url)); + }); + + it("Returns undefined is the value doesn't exist", () => { + const req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + let cookie = cookies.get('foo')!; + assert.equal(cookie, undefined); + }); + + it('does not return values from Object.prototype when no cookie header is present', () => { + const req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + // These are properties that exist on Object.prototype + assert.equal(cookies.get('toString'), undefined); + assert.equal(cookies.get('constructor'), undefined); + assert.equal(cookies.get('hasOwnProperty'), undefined); + assert.equal(cookies.get('valueOf'), undefined); + }); + + it('handles malformed cookie values gracefully', () => { + // Test with invalid URI sequence (e.g., incomplete percent encoding) + const req = new Request('http://example.com/', { + headers: { + cookie: 'malformed=0:%', + }, + }); + let cookies = new AstroCookies(req); + // Should return the unparsed value instead of throwing + assert.equal(cookies.get('malformed')!.value, '0:%'); + }); + + describe('.json()', () => { + it('returns a JavaScript object', () => { + const req = new Request('http://example.com/', { + headers: { + cookie: 'foo=%7B%22key%22%3A%22value%22%7D', + }, + }); + let cookies = new AstroCookies(req); + + const json = cookies.get('foo')!.json(); + assert.equal(typeof json, 'object'); + assert.equal(json.key, 'value'); + }); + }); + + describe('.number()', () => { + it('Coerces into a number', () => { + const req = new Request('http://example.com/', { + headers: { + cookie: 'foo=22', + }, + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo')!.number(); + assert.equal(typeof value, 'number'); + assert.equal(value, 22); + }); + + it('Coerces non-number into NaN', () => { + const req = new Request('http://example.com/', { + headers: { + cookie: 'foo=bar', + }, + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo')!.number(); + assert.equal(typeof value, 'number'); + assert.equal(Number.isNaN(value), true); + }); + }); + + describe('.boolean()', () => { + it('Coerces true into `true`', () => { + const req = new Request('http://example.com/', { + headers: { + cookie: 'foo=true', + }, + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo')!.boolean(); + assert.equal(typeof value, 'boolean'); + assert.equal(value, true); + }); + + it('Coerces false into `false`', () => { + const req = new Request('http://example.com/', { + headers: { + cookie: 'foo=false', + }, + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo')!.boolean(); + assert.equal(typeof value, 'boolean'); + assert.equal(value, false); + }); + + it('Coerces 1 into `true`', () => { + const req = new Request('http://example.com/', { + headers: { + cookie: 'foo=1', + }, + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo')!.boolean(); + assert.equal(typeof value, 'boolean'); + assert.equal(value, true); + }); + + it('Coerces 0 into `false`', () => { + const req = new Request('http://example.com/', { + headers: { + cookie: 'foo=0', + }, + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo')!.boolean(); + assert.equal(typeof value, 'boolean'); + assert.equal(value, false); + }); + + it('Coerces truthy strings into `true`', () => { + const req = new Request('http://example.com/', { + headers: { + cookie: 'foo=bar', + }, + }); + let cookies = new AstroCookies(req); + + const value = cookies.get('foo')!.boolean(); + assert.equal(typeof value, 'boolean'); + assert.equal(value, true); + }); + }); + }); +}); diff --git a/packages/astro/test/units/cookies/has.test.js b/packages/astro/test/units/cookies/has.test.ts similarity index 100% rename from packages/astro/test/units/cookies/has.test.js rename to packages/astro/test/units/cookies/has.test.ts diff --git a/packages/astro/test/units/cookies/merge.test.js b/packages/astro/test/units/cookies/merge.test.ts similarity index 100% rename from packages/astro/test/units/cookies/merge.test.js rename to packages/astro/test/units/cookies/merge.test.ts diff --git a/packages/astro/test/units/cookies/set.test.js b/packages/astro/test/units/cookies/set.test.js deleted file mode 100644 index d5863ef3a638..000000000000 --- a/packages/astro/test/units/cookies/set.test.js +++ /dev/null @@ -1,113 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { AstroCookies } from '../../../dist/core/cookies/index.js'; - -describe('astro/src/core/cookies', () => { - describe('Astro.cookies.set', () => { - it('Sets a cookie value that can be serialized', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - cookies.set('foo', 'bar'); - let headers = Array.from(cookies.headers()); - assert.equal(headers.length, 1); - assert.equal(headers[0], 'foo=bar'); - }); - - it('Sets a cookie value that can be serialized w/ defaultencodeURIComponent behavior', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - const url = 'http://localhost/path'; - cookies.set('url', url); - let headers = Array.from(cookies.headers()); - assert.equal(headers.length, 1); - // by default cookie value is URI encoded - assert.equal(headers[0], `url=${encodeURIComponent(url)}`); - }); - - it('Sets a cookie value that can be serialized w/ custom encode behavior', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - const url = 'http://localhost/path'; - // set encode option to the identity function - cookies.set('url', url, { encode: (o) => o }); - let headers = Array.from(cookies.headers()); - assert.equal(headers.length, 1); - // no longer URI encoded - assert.equal(headers[0], `url=${url}`); - }); - - it('Can set cookie options', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - cookies.set('foo', 'bar', { - httpOnly: true, - path: '/subpath/', - }); - let headers = Array.from(cookies.headers()); - assert.equal(headers.length, 1); - assert.equal(headers[0], 'foo=bar; Path=/subpath/; HttpOnly'); - }); - - it('Can pass a JavaScript object that will be serialized', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - cookies.set('options', { one: 'two', three: 4 }); - let headers = Array.from(cookies.headers()); - assert.equal(headers.length, 1); - assert.equal(JSON.parse(decodeURIComponent(headers[0].slice(8))).one, 'two'); - }); - - it('Can pass a number', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - cookies.set('one', 2); - let headers = Array.from(cookies.headers()); - assert.equal(headers.length, 1); - assert.equal(headers[0], 'one=2'); - }); - - it('Can pass a boolean', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - cookies.set('admin', true); - assert.equal(cookies.get('admin').boolean(), true); - let headers = Array.from(cookies.headers()); - assert.equal(headers.length, 1); - assert.equal(headers[0], 'admin=true'); - }); - - it('Can get the value after setting', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - cookies.set('foo', 'bar'); - let r = cookies.get('foo'); - assert.equal(r.value, 'bar'); - }); - - it('Can get the JavaScript object after setting', () => { - let req = new Request('http://example.com/'); - let cookies = new AstroCookies(req); - cookies.set('options', { one: 'two', three: 4 }); - let cook = cookies.get('options'); - let value = cook.json(); - assert.equal(typeof value, 'object'); - assert.equal(value.one, 'two'); - assert.equal(typeof value.three, 'number'); - assert.equal(value.three, 4); - }); - - it('Overrides a value in the request', () => { - let req = new Request('http://example.com/', { - headers: { - cookie: 'foo=bar', - }, - }); - let cookies = new AstroCookies(req); - assert.equal(cookies.get('foo').value, 'bar'); - - // Set a new value - cookies.set('foo', 'baz'); - assert.equal(cookies.get('foo').value, 'baz'); - }); - }); -}); diff --git a/packages/astro/test/units/cookies/set.test.ts b/packages/astro/test/units/cookies/set.test.ts new file mode 100644 index 000000000000..201574d8fd07 --- /dev/null +++ b/packages/astro/test/units/cookies/set.test.ts @@ -0,0 +1,113 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { AstroCookies } from '../../../dist/core/cookies/index.js'; + +describe('astro/src/core/cookies', () => { + describe('Astro.cookies.set', () => { + it('Sets a cookie value that can be serialized', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('foo', 'bar'); + let headers = Array.from(cookies.headers()); + assert.equal(headers.length, 1); + assert.equal(headers[0], 'foo=bar'); + }); + + it('Sets a cookie value that can be serialized w/ defaultencodeURIComponent behavior', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + const url = 'http://localhost/path'; + cookies.set('url', url); + let headers = Array.from(cookies.headers()); + assert.equal(headers.length, 1); + // by default cookie value is URI encoded + assert.equal(headers[0], `url=${encodeURIComponent(url)}`); + }); + + it('Sets a cookie value that can be serialized w/ custom encode behavior', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + const url = 'http://localhost/path'; + // set encode option to the identity function + cookies.set('url', url, { encode: (o) => o }); + let headers = Array.from(cookies.headers()); + assert.equal(headers.length, 1); + // no longer URI encoded + assert.equal(headers[0], `url=${url}`); + }); + + it('Can set cookie options', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('foo', 'bar', { + httpOnly: true, + path: '/subpath/', + }); + let headers = Array.from(cookies.headers()); + assert.equal(headers.length, 1); + assert.equal(headers[0], 'foo=bar; Path=/subpath/; HttpOnly'); + }); + + it('Can pass a JavaScript object that will be serialized', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('options', { one: 'two', three: 4 }); + let headers = Array.from(cookies.headers()); + assert.equal(headers.length, 1); + assert.equal(JSON.parse(decodeURIComponent(headers[0].slice(8))).one, 'two'); + }); + + it('Can pass a number', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('one', 2 as any); + let headers = Array.from(cookies.headers()); + assert.equal(headers.length, 1); + assert.equal(headers[0], 'one=2'); + }); + + it('Can pass a boolean', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('admin', true as any); + assert.equal(cookies.get('admin')!.boolean(), true); + let headers = Array.from(cookies.headers()); + assert.equal(headers.length, 1); + assert.equal(headers[0], 'admin=true'); + }); + + it('Can get the value after setting', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('foo', 'bar'); + let r = cookies.get('foo')!; + assert.equal(r.value, 'bar'); + }); + + it('Can get the JavaScript object after setting', () => { + let req = new Request('http://example.com/'); + let cookies = new AstroCookies(req); + cookies.set('options', { one: 'two', three: 4 }); + let cook = cookies.get('options')!; + let value = cook.json(); + assert.equal(typeof value, 'object'); + assert.equal(value.one, 'two'); + assert.equal(typeof value.three, 'number'); + assert.equal(value.three, 4); + }); + + it('Overrides a value in the request', () => { + let req = new Request('http://example.com/', { + headers: { + cookie: 'foo=bar', + }, + }); + let cookies = new AstroCookies(req); + assert.equal(cookies.get('foo')!.value, 'bar'); + + // Set a new value + cookies.set('foo', 'baz'); + assert.equal(cookies.get('foo')!.value, 'baz'); + }); + }); +}); diff --git a/packages/astro/test/units/csp/common.test.js b/packages/astro/test/units/csp/common.test.js deleted file mode 100644 index a3e0cc0eb5f3..000000000000 --- a/packages/astro/test/units/csp/common.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { getDirectives } from '../../../dist/core/csp/common.js'; - -/** - * - * @param {{ - * csp: import('../../../dist/types/astro.js').AstroSettings['config']['security']['csp']; - * injected: Array - * }} param0 - * @returns {import('../../../dist/types/astro.js').AstroSettings} - */ -function buildSettings({ csp, injected }) { - /** @type {any} */ - const settings = { - config: { - security: { csp }, - }, - injectedCsp: { - fontResources: new Set(injected), - }, - }; - return settings; -} - -describe('CSP common', () => { - it('getDirectives()', () => { - assert.deepStrictEqual( - getDirectives( - buildSettings({ - csp: false, - injected: [], - }), - ), - [], - ); - assert.deepStrictEqual( - getDirectives( - buildSettings({ - csp: true, - injected: [], - }), - ), - [], - ); - assert.deepStrictEqual( - getDirectives( - buildSettings({ - csp: { - algorithm: 'SHA-256', - directives: ['font-src test'], - }, - injected: [], - }), - ), - ['font-src test'], - ); - assert.deepStrictEqual( - getDirectives( - buildSettings({ - csp: { - algorithm: 'SHA-256', - directives: ['img-src test'], - }, - injected: ["'self'", 'foo'], - }), - ), - ['img-src test', "font-src 'self' foo"], - ); - assert.deepStrictEqual( - getDirectives( - buildSettings({ - csp: { - algorithm: 'SHA-256', - directives: ["font-src 'self' ", 'img-src test'], - }, - injected: ["'self'", 'foo'], - }), - ), - ["font-src 'self' foo", 'img-src test'], - ); - }); -}); diff --git a/packages/astro/test/units/csp/common.test.ts b/packages/astro/test/units/csp/common.test.ts new file mode 100644 index 000000000000..816e4e3dad5a --- /dev/null +++ b/packages/astro/test/units/csp/common.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getDirectives } from '../../../dist/core/csp/common.js'; + +function buildSettings({ csp, injected }: { csp: any; injected: string[] }): any { + const settings = { + config: { + security: { csp }, + }, + injectedCsp: { + fontResources: new Set(injected), + }, + }; + return settings; +} + +describe('CSP common', () => { + it('getDirectives()', () => { + assert.deepStrictEqual( + getDirectives( + buildSettings({ + csp: false, + injected: [], + }), + ), + [], + ); + assert.deepStrictEqual( + getDirectives( + buildSettings({ + csp: true, + injected: [], + }), + ), + [], + ); + assert.deepStrictEqual( + getDirectives( + buildSettings({ + csp: { + algorithm: 'SHA-256', + directives: ['font-src test'], + }, + injected: [], + }), + ), + ['font-src test'], + ); + assert.deepStrictEqual( + getDirectives( + buildSettings({ + csp: { + algorithm: 'SHA-256', + directives: ['img-src test'], + }, + injected: ["'self'", 'foo'], + }), + ), + ['img-src test', "font-src 'self' foo"], + ); + assert.deepStrictEqual( + getDirectives( + buildSettings({ + csp: { + algorithm: 'SHA-256', + directives: ["font-src 'self' ", 'img-src test'], + }, + injected: ["'self'", 'foo'], + }), + ), + ["font-src 'self' foo", 'img-src test'], + ); + }); +}); diff --git a/packages/astro/test/units/csp/rendering.test.js b/packages/astro/test/units/csp/rendering.test.js deleted file mode 100644 index 6fe37b85da66..000000000000 --- a/packages/astro/test/units/csp/rendering.test.js +++ /dev/null @@ -1,468 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { RenderContext } from '../../../dist/core/render-context.js'; -import { - createComponent, - maybeRenderHead, - render, - renderHead, -} from '../../../dist/runtime/server/index.js'; -import { createBasicPipeline } from '../test-utils.js'; - -// #region Test Utilities - -/** - * Creates a pipeline with CSP configuration - * @param {Partial} cspConfig - */ -function createCspPipeline(cspConfig = {}) { - const pipeline = createBasicPipeline(); - pipeline.manifest = { - ...pipeline.manifest, - shouldInjectCspMetaTags: true, - csp: { - cspDestination: cspConfig.cspDestination, - algorithm: cspConfig.algorithm || 'SHA-256', - scriptHashes: cspConfig.scriptHashes || [], - scriptResources: cspConfig.scriptResources || [], - styleHashes: cspConfig.styleHashes || [], - styleResources: cspConfig.styleResources || [], - directives: cspConfig.directives || [], - isStrictDynamic: cspConfig.isStrictDynamic || false, - }, - }; - return pipeline; -} - -/** - * Renders a page component and returns HTML and headers - * @param {any} PageComponent - * @param {any} pipeline - * @param {boolean} prerender - */ -async function renderPage(PageComponent, pipeline, prerender = true) { - const PageModule = { default: PageComponent }; - const request = new Request('http://localhost/'); - const routeData = { - type: 'page', - pathname: '/index', - component: 'src/pages/index.astro', - params: {}, - prerender, - }; - - const renderContext = await RenderContext.create({ pipeline, request, routeData }); - const response = await renderContext.render(PageModule); - const html = await response.text(); - - return { html, response }; -} - -// #endregion - -// #region Reusable Components - -/** Simple page component */ -const SimplePage = createComponent((result) => { - return render` - ${renderHead(result)} - ${maybeRenderHead(result)}

    Test

    - `; -}); - -// #endregion - -// #region Tests - -describe('CSP Rendering', () => { - describe('Style Hashes', () => { - it('should contain style hashes in meta tag when CSS is imported', async () => { - const pipeline = createCspPipeline({ - styleHashes: ['sha256-abc123', 'sha256-def456'], - }); - - const { html } = await renderPage(SimplePage, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - assert.ok(content.includes('sha256-abc123'), 'Should include first style hash'); - assert.ok(content.includes('sha256-def456'), 'Should include second style hash'); - assert.ok(content.includes('style-src'), 'Should have style-src directive'); - }); - - // Note: Inline style hashing requires the full build pipeline - // and cannot be easily unit tested. This is tested in integration tests. - }); - - describe('Script Hashes', () => { - it('should contain script hashes in meta tag when using client islands', async () => { - const pipeline = createCspPipeline({ - scriptHashes: ['sha256-xyz789', 'sha256-uvw456'], - }); - - const { html } = await renderPage(SimplePage, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - assert.ok(content.includes('sha256-xyz789'), 'Should include first script hash'); - assert.ok(content.includes('sha256-uvw456'), 'Should include second script hash'); - assert.ok(content.includes('script-src'), 'Should have script-src directive'); - }); - }); - - describe('Hash Algorithms', () => { - it('should generate hashes with SHA-512 algorithm', async () => { - const pipeline = createCspPipeline({ - algorithm: 'SHA-512', - scriptHashes: ['sha512-longhash123abc'], - }); - - const { html } = await renderPage(SimplePage, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - assert.ok(content.includes('sha512-'), 'Should use sha512 prefix'); - assert.ok(content.includes('sha512-longhash123abc'), 'Should include SHA-512 hash'); - }); - - it('should generate hashes with SHA-384 algorithm', async () => { - const pipeline = createCspPipeline({ - algorithm: 'SHA-384', - scriptHashes: ['sha384-mediumhash456'], - }); - - const { html } = await renderPage(SimplePage, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - assert.ok(content.includes('sha384-'), 'Should use sha384 prefix'); - assert.ok(content.includes('sha384-mediumhash456'), 'Should include SHA-384 hash'); - }); - }); - - describe('Custom Hashes', () => { - it('should render user-provided hashes', async () => { - const pipeline = createCspPipeline({ - styleHashes: ['sha512-hash1', 'sha384-hash2'], - scriptHashes: ['sha512-hash3', 'sha384-hash4'], - }); - - const { html } = await renderPage(SimplePage, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - assert.ok(content.includes('sha384-hash2'), 'Should include custom style hash 1'); - assert.ok(content.includes('sha384-hash4'), 'Should include custom script hash 1'); - assert.ok(content.includes('sha512-hash1'), 'Should include custom style hash 2'); - assert.ok(content.includes('sha512-hash3'), 'Should include custom script hash 2'); - }); - }); - - describe('Additional Directives', () => { - it('should include additional directives', async () => { - const pipeline = createCspPipeline({ - directives: ["img-src 'self' 'https://example.com'"], - }); - - const { html } = await renderPage(SimplePage, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - assert.ok( - content.includes("img-src 'self' 'https://example.com'"), - 'Should include custom directive', - ); - }); - - it('should handle directives that do not require values', async () => { - const pipeline = createCspPipeline({ - directives: [ - 'upgrade-insecure-requests', - 'sandbox', - 'trusted-types', - 'report-uri https://endpoint.example.com', - ], - }); - - const { html } = await renderPage(SimplePage, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - assert.ok(content.includes('upgrade-insecure-requests'), 'Should include upgrade directive'); - assert.ok(content.includes('sandbox'), 'Should include sandbox directive'); - assert.ok(content.includes('trusted-types'), 'Should include trusted-types directive'); - assert.ok( - content.includes('report-uri https://endpoint.example.com'), - 'Should include report-uri directive', - ); - }); - }); - - describe('Custom Resources', () => { - it('should include custom resources for script-src and style-src', async () => { - const pipeline = createCspPipeline({ - styleResources: ['https://cdn.example.com', 'https://styles.cdn.example.com'], - scriptResources: ['https://cdn.example.com', 'https://scripts.cdn.example.com'], - }); - - const { html } = await renderPage(SimplePage, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - assert.ok( - content.includes('script-src https://cdn.example.com https://scripts.cdn.example.com'), - 'Should include script resources', - ); - assert.ok( - content.includes('style-src https://cdn.example.com https://styles.cdn.example.com'), - 'Should include style resources', - ); - }); - }); - - describe('Runtime CSP API - Astro.csp', () => { - it('should allow injecting custom script resources and hashes via Astro.csp', async () => { - const pipeline = createCspPipeline({ - directives: ["img-src 'self'"], - scriptResources: ['https://global.cdn.example.com'], - }); - - const PageWithCspApi = createComponent((result) => { - const Astro = result.createAstro({}, {}); - - // Use runtime CSP API - Astro.csp.insertScriptResource('https://scripts.cdn.example.com'); - Astro.csp.insertScriptHash('sha256-customHash'); - Astro.csp.insertDirective("default-src 'self'"); - Astro.csp.insertDirective('img-src https://images.cdn.example.com'); - - return render` - ${renderHead(result)} - ${maybeRenderHead(result)}

    Scripts

    - `; - }); - - const { html } = await renderPage(PageWithCspApi, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - // Check resources are merged and deduplicated - assert.ok( - content.includes( - 'script-src https://global.cdn.example.com https://scripts.cdn.example.com', - ), - 'Should merge script resources', - ); - assert.ok(content.includes("style-src 'self'"), 'Should have default style-src'); - - // Check hashes - assert.ok(content.includes('sha256-customHash'), 'Should include custom hash'); - - // Check directives are merged - assert.ok(content.includes("default-src 'self'"), 'Should include default-src'); - assert.ok( - content.includes("img-src 'self' https://images.cdn.example.com"), - 'Should merge img-src directives', - ); - }); - - it('should allow injecting custom style resources and hashes via Astro.csp', async () => { - const pipeline = createCspPipeline({ - directives: ["img-src 'self'"], - styleResources: ['https://global.cdn.example.com'], - }); - - const PageWithStyleApi = createComponent((result) => { - const Astro = result.createAstro({}, {}); - - // Use runtime CSP API for styles - Astro.csp.insertStyleResource('https://styles.cdn.example.com'); - Astro.csp.insertStyleHash('sha256-customStyleHash'); - Astro.csp.insertDirective("default-src 'self'"); - Astro.csp.insertDirective('img-src https://images.cdn.example.com'); - - return render` - ${renderHead(result)} - ${maybeRenderHead(result)}

    Styles

    - `; - }); - - const { html } = await renderPage(PageWithStyleApi, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - // Check style resources are merged - assert.ok( - content.includes('style-src https://global.cdn.example.com https://styles.cdn.example.com'), - 'Should merge style resources', - ); - assert.ok(content.includes("script-src 'self'"), 'Should have default script-src'); - - // Check hashes - assert.ok(content.includes('sha256-customStyleHash'), 'Should include custom style hash'); - - // Check directives are merged - assert.ok(content.includes("default-src 'self'"), 'Should include default-src'); - assert.ok( - content.includes("img-src 'self' https://images.cdn.example.com"), - 'Should merge img-src directives', - ); - }); - }); - - describe('Strict Dynamic', () => { - it("should add 'strict-dynamic' when enabled", async () => { - const pipeline = createCspPipeline({ - isStrictDynamic: true, - scriptHashes: ['sha256-test123'], - }); - - const { html } = await renderPage(SimplePage, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - assert.ok(content.includes("'strict-dynamic'"), "Should include 'strict-dynamic' keyword"); - }); - }); - - describe('CSP Delivery Methods', () => { - it('should serve CSP via meta tag for prerendered pages (default)', async () => { - const pipeline = createCspPipeline({ - styleHashes: ['sha256-test123'], - }); - - const { html, response } = await renderPage(SimplePage, pipeline, true); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - assert.ok(meta.length > 0, 'Should have CSP meta tag'); - assert.equal( - response.headers.get('content-security-policy'), - null, - 'Should not have CSP header', - ); - }); - - it('should serve CSP via headers for SSR/dynamic pages', async () => { - const pipeline = createCspPipeline({ - cspDestination: 'header', - styleHashes: ['sha256-test123'], - styleResources: ['https://styles.cdn.example.com'], - }); - - const { html, response } = await renderPage(SimplePage, pipeline, false); - const $ = cheerio.load(html); - - const header = response.headers.get('content-security-policy'); - assert.ok(header, 'Should have CSP header'); - assert.ok(header.includes('style-src'), 'Header should include style-src'); - assert.ok( - header.includes('https://styles.cdn.example.com'), - 'Header should include style resources', - ); - assert.ok(header.includes("script-src 'self'"), 'Header should include script-src'); - assert.ok(header.includes('sha256-test123'), 'Header should include hash'); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - assert.equal(meta.attr('content'), undefined, 'Should not have CSP meta tag'); - }); - }); - - describe('Font Source Directive', () => { - it('should not inject font-src by default when fonts are not used', async () => { - const pipeline = createCspPipeline({ - scriptHashes: ['sha256-test123'], - }); - - const { html } = await renderPage(SimplePage, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - assert.equal(content.includes('font-src'), false, 'Should not include font-src directive'); - }); - }); - - describe('CSP Content Parsing', () => { - it('should generate well-formed CSP content', async () => { - const pipeline = createCspPipeline({ - directives: ["img-src 'self'", "default-src 'none'"], - scriptHashes: ['sha256-abc123'], - scriptResources: ['https://cdn.example.com'], - styleHashes: ['sha256-def456'], - styleResources: ['https://styles.example.com'], - }); - - const { html } = await renderPage(SimplePage, pipeline); - const $ = cheerio.load(html); - - const meta = $('meta[http-equiv="Content-Security-Policy"]'); - const content = meta.attr('content'); - - // Parse CSP content into structured array - const parsed = content - .split(';') - .map((part) => part.trim()) - .filter((part) => part.length > 0) - .map((part) => { - const [directive, ...resources] = part.split(/\s+/); - return { directive, resources }; - }); - - // Check that all directives are present - const directives = parsed.map((p) => p.directive); - assert.ok(directives.includes('img-src'), 'Should have img-src'); - assert.ok(directives.includes('default-src'), 'Should have default-src'); - assert.ok(directives.includes('script-src'), 'Should have script-src'); - assert.ok(directives.includes('style-src'), 'Should have style-src'); - - // Check script-src has both resources and hashes - const scriptSrc = parsed.find((p) => p.directive === 'script-src'); - assert.ok( - scriptSrc.resources.includes('https://cdn.example.com'), - 'script-src should include resource', - ); - assert.ok( - scriptSrc.resources.some((r) => r.includes('sha256-abc123')), - 'script-src should include hash', - ); - - // Check style-src has both resources and hashes - const styleSrc = parsed.find((p) => p.directive === 'style-src'); - assert.ok( - styleSrc.resources.includes('https://styles.example.com'), - 'style-src should include resource', - ); - assert.ok( - styleSrc.resources.some((r) => r.includes('sha256-def456')), - 'style-src should include hash', - ); - }); - }); -}); - -// #endregion diff --git a/packages/astro/test/units/csp/rendering.test.ts b/packages/astro/test/units/csp/rendering.test.ts new file mode 100644 index 000000000000..ae6d13c79034 --- /dev/null +++ b/packages/astro/test/units/csp/rendering.test.ts @@ -0,0 +1,482 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { RenderContext } from '../../../dist/core/render-context.js'; +import { + createComponent, + maybeRenderHead, + render, + renderHead, +} from '../../../dist/runtime/server/index.js'; +import type { SSRManifestCSP } from '../../../dist/types/public/internal.js'; +import type { Pipeline } from '../../../dist/core/render/index.js'; +import { createBasicPipeline } from '../test-utils.ts'; + +// #region Test Utilities + +function createCspPipeline(cspConfig: Partial = {}): Pipeline { + const pipeline = createBasicPipeline(); + // manifest is readonly, so we use Object.defineProperty to override it for testing + Object.defineProperty(pipeline, 'manifest', { + value: { + ...pipeline.manifest, + shouldInjectCspMetaTags: true, + csp: { + cspDestination: cspConfig.cspDestination, + algorithm: cspConfig.algorithm || 'SHA-256', + scriptHashes: cspConfig.scriptHashes || [], + scriptResources: cspConfig.scriptResources || [], + styleHashes: cspConfig.styleHashes || [], + styleResources: cspConfig.styleResources || [], + directives: cspConfig.directives || [], + isStrictDynamic: cspConfig.isStrictDynamic || false, + }, + }, + writable: false, + configurable: true, + }); + return pipeline; +} + +async function renderPage( + PageComponent: ReturnType, + pipeline: Pipeline, + prerender = true, +): Promise<{ html: string; response: Response }> { + const PageModule = { default: PageComponent }; + const request = new Request('http://localhost/'); + const routeData = { + type: 'page' as const, + route: '/index', + pathname: '/index', + component: 'src/pages/index.astro', + params: [] as string[], + segments: [] as any[], + pattern: /^\/$/ as RegExp, + distURL: [] as URL[], + prerender, + fallbackRoutes: [] as any[], + isIndex: true, + origin: 'project' as const, + }; + + const renderContext = await RenderContext.create({ + pipeline, + request, + routeData, + pathname: '/index', + clientAddress: '127.0.0.1', + }); + const response = await renderContext.render(PageModule); + const html = await response.text(); + + return { html, response }; +} + +// #endregion + +// #region Reusable Components + +/** Simple page component */ +const SimplePage = createComponent(() => { + return render` + ${renderHead()} + ${maybeRenderHead()}

    Test

    + `; +}); + +// #endregion + +// #region Tests + +describe('CSP Rendering', () => { + describe('Style Hashes', () => { + it('should contain style hashes in meta tag when CSS is imported', async () => { + const pipeline = createCspPipeline({ + styleHashes: ['sha256-abc123', 'sha256-def456'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + assert.ok(content.includes('sha256-abc123'), 'Should include first style hash'); + assert.ok(content.includes('sha256-def456'), 'Should include second style hash'); + assert.ok(content.includes('style-src'), 'Should have style-src directive'); + }); + + // Note: Inline style hashing requires the full build pipeline + // and cannot be easily unit tested. This is tested in integration tests. + }); + + describe('Script Hashes', () => { + it('should contain script hashes in meta tag when using client islands', async () => { + const pipeline = createCspPipeline({ + scriptHashes: ['sha256-xyz789', 'sha256-uvw456'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + assert.ok(content.includes('sha256-xyz789'), 'Should include first script hash'); + assert.ok(content.includes('sha256-uvw456'), 'Should include second script hash'); + assert.ok(content.includes('script-src'), 'Should have script-src directive'); + }); + }); + + describe('Hash Algorithms', () => { + it('should generate hashes with SHA-512 algorithm', async () => { + const pipeline = createCspPipeline({ + algorithm: 'SHA-512', + scriptHashes: ['sha512-longhash123abc'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + assert.ok(content.includes('sha512-'), 'Should use sha512 prefix'); + assert.ok(content.includes('sha512-longhash123abc'), 'Should include SHA-512 hash'); + }); + + it('should generate hashes with SHA-384 algorithm', async () => { + const pipeline = createCspPipeline({ + algorithm: 'SHA-384', + scriptHashes: ['sha384-mediumhash456'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + assert.ok(content.includes('sha384-'), 'Should use sha384 prefix'); + assert.ok(content.includes('sha384-mediumhash456'), 'Should include SHA-384 hash'); + }); + }); + + describe('Custom Hashes', () => { + it('should render user-provided hashes', async () => { + const pipeline = createCspPipeline({ + styleHashes: ['sha512-hash1', 'sha384-hash2'], + scriptHashes: ['sha512-hash3', 'sha384-hash4'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + assert.ok(content.includes('sha384-hash2'), 'Should include custom style hash 1'); + assert.ok(content.includes('sha384-hash4'), 'Should include custom script hash 1'); + assert.ok(content.includes('sha512-hash1'), 'Should include custom style hash 2'); + assert.ok(content.includes('sha512-hash3'), 'Should include custom script hash 2'); + }); + }); + + describe('Additional Directives', () => { + it('should include additional directives', async () => { + const pipeline = createCspPipeline({ + directives: ["img-src 'self' 'https://example.com'"], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + assert.ok( + content.includes("img-src 'self' 'https://example.com'"), + 'Should include custom directive', + ); + }); + + it('should handle directives that do not require values', async () => { + const pipeline = createCspPipeline({ + directives: [ + 'upgrade-insecure-requests', + 'sandbox', + 'trusted-types', + 'report-uri https://endpoint.example.com', + ], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + assert.ok(content.includes('upgrade-insecure-requests'), 'Should include upgrade directive'); + assert.ok(content.includes('sandbox'), 'Should include sandbox directive'); + assert.ok(content.includes('trusted-types'), 'Should include trusted-types directive'); + assert.ok( + content.includes('report-uri https://endpoint.example.com'), + 'Should include report-uri directive', + ); + }); + }); + + describe('Custom Resources', () => { + it('should include custom resources for script-src and style-src', async () => { + const pipeline = createCspPipeline({ + styleResources: ['https://cdn.example.com', 'https://styles.cdn.example.com'], + scriptResources: ['https://cdn.example.com', 'https://scripts.cdn.example.com'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + assert.ok( + content.includes('script-src https://cdn.example.com https://scripts.cdn.example.com'), + 'Should include script resources', + ); + assert.ok( + content.includes('style-src https://cdn.example.com https://styles.cdn.example.com'), + 'Should include style resources', + ); + }); + }); + + describe('Runtime CSP API - Astro.csp', () => { + it('should allow injecting custom script resources and hashes via Astro.csp', async () => { + const pipeline = createCspPipeline({ + directives: ["img-src 'self'"], + scriptResources: ['https://global.cdn.example.com'], + }); + + const PageWithCspApi = createComponent((result: any) => { + const Astro = result.createAstro({}, {}); + + // Use runtime CSP API + Astro.csp.insertScriptResource('https://scripts.cdn.example.com'); + Astro.csp.insertScriptHash('sha256-customHash'); + Astro.csp.insertDirective("default-src 'self'"); + Astro.csp.insertDirective('img-src https://images.cdn.example.com'); + + return render` + ${renderHead()} + ${maybeRenderHead()}

    Scripts

    + `; + }); + + const { html } = await renderPage(PageWithCspApi, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + // Check resources are merged and deduplicated + assert.ok( + content.includes( + 'script-src https://global.cdn.example.com https://scripts.cdn.example.com', + ), + 'Should merge script resources', + ); + assert.ok(content.includes("style-src 'self'"), 'Should have default style-src'); + + // Check hashes + assert.ok(content.includes('sha256-customHash'), 'Should include custom hash'); + + // Check directives are merged + assert.ok(content.includes("default-src 'self'"), 'Should include default-src'); + assert.ok( + content.includes("img-src 'self' https://images.cdn.example.com"), + 'Should merge img-src directives', + ); + }); + + it('should allow injecting custom style resources and hashes via Astro.csp', async () => { + const pipeline = createCspPipeline({ + directives: ["img-src 'self'"], + styleResources: ['https://global.cdn.example.com'], + }); + + const PageWithStyleApi = createComponent((result: any) => { + const Astro = result.createAstro({}, {}); + + // Use runtime CSP API for styles + Astro.csp.insertStyleResource('https://styles.cdn.example.com'); + Astro.csp.insertStyleHash('sha256-customStyleHash'); + Astro.csp.insertDirective("default-src 'self'"); + Astro.csp.insertDirective('img-src https://images.cdn.example.com'); + + return render` + ${renderHead()} + ${maybeRenderHead()}

    Styles

    + `; + }); + + const { html } = await renderPage(PageWithStyleApi, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + // Check style resources are merged + assert.ok( + content.includes('style-src https://global.cdn.example.com https://styles.cdn.example.com'), + 'Should merge style resources', + ); + assert.ok(content.includes("script-src 'self'"), 'Should have default script-src'); + + // Check hashes + assert.ok(content.includes('sha256-customStyleHash'), 'Should include custom style hash'); + + // Check directives are merged + assert.ok(content.includes("default-src 'self'"), 'Should include default-src'); + assert.ok( + content.includes("img-src 'self' https://images.cdn.example.com"), + 'Should merge img-src directives', + ); + }); + }); + + describe('Strict Dynamic', () => { + it("should add 'strict-dynamic' when enabled", async () => { + const pipeline = createCspPipeline({ + isStrictDynamic: true, + scriptHashes: ['sha256-test123'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + assert.ok(content.includes("'strict-dynamic'"), "Should include 'strict-dynamic' keyword"); + }); + }); + + describe('CSP Delivery Methods', () => { + it('should serve CSP via meta tag for prerendered pages (default)', async () => { + const pipeline = createCspPipeline({ + styleHashes: ['sha256-test123'], + }); + + const { html, response } = await renderPage(SimplePage, pipeline, true); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + assert.ok(meta.length > 0, 'Should have CSP meta tag'); + assert.equal( + response.headers.get('content-security-policy'), + null, + 'Should not have CSP header', + ); + }); + + it('should serve CSP via headers for SSR/dynamic pages', async () => { + const pipeline = createCspPipeline({ + cspDestination: 'header', + styleHashes: ['sha256-test123'], + styleResources: ['https://styles.cdn.example.com'], + }); + + const { html, response } = await renderPage(SimplePage, pipeline, false); + const $ = cheerio.load(html); + + const header = response.headers.get('content-security-policy'); + assert.ok(header, 'Should have CSP header'); + assert.ok(header.includes('style-src'), 'Header should include style-src'); + assert.ok( + header.includes('https://styles.cdn.example.com'), + 'Header should include style resources', + ); + assert.ok(header.includes("script-src 'self'"), 'Header should include script-src'); + assert.ok(header.includes('sha256-test123'), 'Header should include hash'); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + assert.equal(meta.attr('content'), undefined, 'Should not have CSP meta tag'); + }); + }); + + describe('Font Source Directive', () => { + it('should not inject font-src by default when fonts are not used', async () => { + const pipeline = createCspPipeline({ + scriptHashes: ['sha256-test123'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + assert.equal(content.includes('font-src'), false, 'Should not include font-src directive'); + }); + }); + + describe('CSP Content Parsing', () => { + it('should generate well-formed CSP content', async () => { + const pipeline = createCspPipeline({ + directives: ["img-src 'self'", "default-src 'none'"], + scriptHashes: ['sha256-abc123'], + scriptResources: ['https://cdn.example.com'], + styleHashes: ['sha256-def456'], + styleResources: ['https://styles.example.com'], + }); + + const { html } = await renderPage(SimplePage, pipeline); + const $ = cheerio.load(html); + + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const content = meta.attr('content')!; + + // Parse CSP content into structured array + const parsed = content + .split(';') + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => { + const [directive, ...resources] = part.split(/\s+/); + return { directive, resources }; + }); + + // Check that all directives are present + const directives = parsed.map((p) => p.directive); + assert.ok(directives.includes('img-src'), 'Should have img-src'); + assert.ok(directives.includes('default-src'), 'Should have default-src'); + assert.ok(directives.includes('script-src'), 'Should have script-src'); + assert.ok(directives.includes('style-src'), 'Should have style-src'); + + // Check script-src has both resources and hashes + const scriptSrc = parsed.find((p) => p.directive === 'script-src'); + assert.ok( + scriptSrc!.resources.includes('https://cdn.example.com'), + 'script-src should include resource', + ); + assert.ok( + scriptSrc!.resources.some((r) => r.includes('sha256-abc123')), + 'script-src should include hash', + ); + + // Check style-src has both resources and hashes + const styleSrc = parsed.find((p) => p.directive === 'style-src'); + assert.ok( + styleSrc!.resources.includes('https://styles.example.com'), + 'style-src should include resource', + ); + assert.ok( + styleSrc!.resources.some((r) => r.includes('sha256-def456')), + 'style-src should include hash', + ); + }); + }); +}); + +// #endregion diff --git a/packages/astro/test/units/csp/runtime.test.js b/packages/astro/test/units/csp/runtime.test.ts similarity index 100% rename from packages/astro/test/units/csp/runtime.test.js rename to packages/astro/test/units/csp/runtime.test.ts diff --git a/packages/astro/test/units/dev/base-rewrite.test.ts b/packages/astro/test/units/dev/base-rewrite.test.ts new file mode 100644 index 000000000000..925f1a49a091 --- /dev/null +++ b/packages/astro/test/units/dev/base-rewrite.test.ts @@ -0,0 +1,160 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + evaluateBaseRewrite, + resolveDevRoot, +} from '../../../dist/vite-plugin-astro-server/base.js'; + +// #region resolveDevRoot +describe('resolveDevRoot', () => { + it('resolves /docs base without site', () => { + const { devRoot, devRootReplacement } = resolveDevRoot('/docs'); + assert.equal(devRoot, '/docs'); + assert.equal(devRootReplacement, ''); + }); + + it('resolves /docs/ base with trailing slash', () => { + const { devRoot, devRootReplacement } = resolveDevRoot('/docs/'); + assert.equal(devRoot, '/docs/'); + assert.equal(devRootReplacement, '/'); + }); + + it('resolves / base (root)', () => { + const { devRoot, devRootReplacement } = resolveDevRoot('/'); + assert.equal(devRoot, '/'); + assert.equal(devRootReplacement, '/'); + }); + + it('resolves empty base as /', () => { + const { devRoot, devRootReplacement } = resolveDevRoot(''); + assert.equal(devRoot, '/'); + assert.equal(devRootReplacement, '/'); + }); + + it('uses site pathname when site is provided', () => { + const { devRoot } = resolveDevRoot('/docs/', 'https://example.com'); + assert.equal(devRoot, '/docs/'); + }); + + it('absolute base overrides site pathname', () => { + // `/app/` is absolute, so the site's `/prefix/` pathname is irrelevant + const { devRoot } = resolveDevRoot('/app/', 'https://example.com/prefix/'); + assert.equal(devRoot, '/app/'); + }); +}); +// #endregion + +// #region evaluateBaseRewrite — rewrite +describe('evaluateBaseRewrite — rewrite', () => { + it('rewrites URL starting with base by stripping base', () => { + const result = evaluateBaseRewrite('/docs/about', '/docs/about', undefined, '/docs/', '/'); + assert.equal(result.action, 'rewrite'); + if (result.action === 'rewrite') { + assert.equal(result.newUrl, '/about'); + } + }); + + it('rewrites root base request to /', () => { + const result = evaluateBaseRewrite('/docs/', '/docs/', undefined, '/docs/', '/'); + assert.equal(result.action, 'rewrite'); + if (result.action === 'rewrite') { + assert.equal(result.newUrl, '/'); + } + }); + + it('preserves query params after rewrite', () => { + const result = evaluateBaseRewrite( + '/docs/page?foo=bar', + '/docs/page', + undefined, + '/docs/', + '/', + ); + assert.equal(result.action, 'rewrite'); + if (result.action === 'rewrite') { + assert.equal(result.newUrl, '/page?foo=bar'); + } + }); + + it('ensures rewritten URL starts with /', () => { + // devRootReplacement is '' (no trailing slash on devRoot), so stripping + // '/docs' from '/docs/about' yields '/about' which already starts with / + const result = evaluateBaseRewrite('/docs/about', '/docs/about', undefined, '/docs', ''); + assert.equal(result.action, 'rewrite'); + if (result.action === 'rewrite') { + assert.ok(result.newUrl.startsWith('/')); + } + }); + + it('rewrites exact base match (no trailing content)', () => { + const result = evaluateBaseRewrite('/docs', '/docs', undefined, '/docs', ''); + assert.equal(result.action, 'rewrite'); + if (result.action === 'rewrite') { + assert.equal(result.newUrl, '/'); + } + }); +}); +// #endregion + +// #region evaluateBaseRewrite — not-found-subpath +describe('evaluateBaseRewrite — not-found-subpath', () => { + it('returns not-found-subpath for / when base is not /', () => { + const result = evaluateBaseRewrite('/', '/', undefined, '/docs/', '/'); + assert.equal(result.action, 'not-found-subpath'); + if (result.action === 'not-found-subpath') { + assert.equal(result.pathname, '/'); + assert.equal(result.devRoot, '/docs/'); + } + }); + + it('returns not-found-subpath for /index.html', () => { + const result = evaluateBaseRewrite('/index.html', '/index.html', undefined, '/docs/', '/'); + assert.equal(result.action, 'not-found-subpath'); + if (result.action === 'not-found-subpath') { + assert.equal(result.pathname, '/index.html'); + } + }); +}); +// #endregion + +// #region evaluateBaseRewrite — not-found (HTML) +describe('evaluateBaseRewrite — not-found', () => { + it('returns not-found for non-base URL with text/html accept', () => { + const result = evaluateBaseRewrite('/other', '/other', 'text/html', '/docs/', '/'); + assert.equal(result.action, 'not-found'); + if (result.action === 'not-found') { + assert.equal(result.pathname, '/other'); + } + }); + + it('returns not-found when accept includes text/html among others', () => { + const result = evaluateBaseRewrite( + '/other', + '/other', + 'text/html, application/xhtml+xml', + '/docs/', + '/', + ); + assert.equal(result.action, 'not-found'); + }); +}); +// #endregion + +// #region evaluateBaseRewrite — check-public +describe('evaluateBaseRewrite — check-public', () => { + it('returns check-public for non-base URL without HTML accept', () => { + const result = evaluateBaseRewrite('/favicon.ico', '/favicon.ico', 'image/*', '/docs/', '/'); + assert.equal(result.action, 'check-public'); + }); + + it('returns check-public when accept header is undefined', () => { + const result = evaluateBaseRewrite('/script.js', '/script.js', undefined, '/docs/', '/'); + assert.equal(result.action, 'check-public'); + }); + + it('returns check-public for non-HTML accept types', () => { + const result = evaluateBaseRewrite('/api/data', '/api/data', 'application/json', '/docs/', '/'); + assert.equal(result.action, 'check-public'); + }); +}); +// #endregion diff --git a/packages/astro/test/units/dev/base.test.js b/packages/astro/test/units/dev/base.test.js deleted file mode 100644 index f230ad563c1b..000000000000 --- a/packages/astro/test/units/dev/base.test.js +++ /dev/null @@ -1,112 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { createFixture, createRequestAndResponse, runInContainer } from '../test-utils.js'; - -describe('base configuration', () => { - describe('with trailingSlash: "never"', () => { - describe('index route', () => { - it('Requests that include a trailing slash 404', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': `

    testing

    `, - }); - - await runInContainer( - { - inlineConfig: { - root: fixture.path, - base: '/docs', - trailingSlash: 'never', - }, - }, - async (container) => { - const { req, res, done } = createRequestAndResponse({ - method: 'GET', - url: '/docs/', - }); - container.handle(req, res); - await done; - assert.equal(res.statusCode, 404); - }, - ); - }); - - it('Requests that exclude a trailing slash 200', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': `

    testing

    `, - }); - - await runInContainer( - { - fs, - inlineConfig: { - root: fixture.path, - base: '/docs', - trailingSlash: 'never', - }, - }, - async (container) => { - const { req, res, done } = createRequestAndResponse({ - method: 'GET', - url: '/docs', - }); - container.handle(req, res); - await done; - assert.equal(res.statusCode, 200); - }, - ); - }); - }); - - describe('sub route', () => { - it('Requests that include a trailing slash 404', async () => { - const fixture = await createFixture({ - '/src/pages/sub/index.astro': `

    testing

    `, - }); - - await runInContainer( - { - inlineConfig: { - root: fixture.path, - base: '/docs', - trailingSlash: 'never', - }, - }, - async (container) => { - const { req, res, done } = createRequestAndResponse({ - method: 'GET', - url: '/docs/sub/', - }); - container.handle(req, res); - await done; - assert.equal(res.statusCode, 404); - }, - ); - }); - - it('Requests that exclude a trailing slash 200', async () => { - const fixture = await createFixture({ - '/src/pages/sub/index.astro': `

    testing

    `, - }); - - await runInContainer( - { - inlineConfig: { - root: fixture.path, - base: '/docs', - trailingSlash: 'never', - }, - }, - async (container) => { - const { req, res, done } = createRequestAndResponse({ - method: 'GET', - url: '/docs/sub', - }); - container.handle(req, res); - await done; - assert.equal(res.statusCode, 200); - }, - ); - }); - }); - }); -}); diff --git a/packages/astro/test/units/dev/dev.test.js b/packages/astro/test/units/dev/dev.test.js deleted file mode 100644 index 8867976feba8..000000000000 --- a/packages/astro/test/units/dev/dev.test.js +++ /dev/null @@ -1,196 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { createFixture, createRequestAndResponse, runInContainer } from '../test-utils.js'; - -describe('dev container', () => { - it('can render requests', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': ` - --- - const name = 'Testing'; - --- - - {name} - -

    {name}

    - - - `, - }); - - await runInContainer({ inlineConfig: { root: fixture.path } }, async (container) => { - const { req, res, text } = createRequestAndResponse({ - method: 'GET', - url: '/', - }); - container.handle(req, res); - const html = await text(); - const $ = cheerio.load(html); - assert.equal(res.statusCode, 200); - assert.equal($('h1').length, 1); - }); - }); - - it('Allows dynamic segments in injected routes', async () => { - const fixture = await createFixture({ - '/src/components/test.astro': `

    {Astro.params.slug}

    `, - '/src/pages/test-[slug].astro': `

    {Astro.params.slug}

    `, - }); - - await runInContainer( - { - inlineConfig: { - root: fixture.path, - output: 'server', - integrations: [ - { - name: '@astrojs/test-integration', - hooks: { - 'astro:config:setup': ({ injectRoute }) => { - injectRoute({ - pattern: '/another-[slug]', - entrypoint: './src/components/test.astro', - }); - }, - }, - }, - ], - }, - }, - async (container) => { - let r = createRequestAndResponse({ - method: 'GET', - url: '/test-one', - }); - container.handle(r.req, r.res); - await r.done; - assert.equal(r.res.statusCode, 200); - - // Try with the injected route - r = createRequestAndResponse({ - method: 'GET', - url: '/another-two', - }); - container.handle(r.req, r.res); - await r.done; - assert.equal(r.res.statusCode, 200); - }, - ); - }); - - it('Serves injected 404 route for any 404', async () => { - const fixture = await createFixture({ - '/src/components/404.astro': `

    Custom 404

    `, - '/src/pages/page.astro': `

    Regular page

    `, - }); - - await runInContainer( - { - inlineConfig: { - root: fixture.path, - output: 'server', - integrations: [ - { - name: '@astrojs/test-integration', - hooks: { - 'astro:config:setup': ({ injectRoute }) => { - injectRoute({ - pattern: '/404', - entrypoint: './src/components/404.astro', - }); - }, - }, - }, - ], - }, - }, - async (container) => { - { - // Regular pages are served as expected. - const r = createRequestAndResponse({ method: 'GET', url: '/page' }); - container.handle(r.req, r.res); - await r.done; - const doc = await r.text(); - assert.equal(doc.includes('Regular page'), true); - assert.equal(r.res.statusCode, 200); - } - { - // `/404` serves the custom 404 page as expected. - const r = createRequestAndResponse({ method: 'GET', url: '/404' }); - container.handle(r.req, r.res); - await r.done; - const doc = await r.text(); - assert.equal(doc.includes('Custom 404'), true); - assert.equal(r.res.statusCode, 404); - } - { - // A nonexistent page also serves the custom 404 page. - const r = createRequestAndResponse({ method: 'GET', url: '/other-page' }); - container.handle(r.req, r.res); - await r.done; - const doc = await r.text(); - assert.equal(doc.includes('Custom 404'), true); - assert.equal(r.res.statusCode, 404); - } - }, - ); - }); - - it('items in public/ are not available from root when using a base', async () => { - const fixture = await createFixture({ - '/public/test.txt': `Test`, - }); - - await runInContainer( - { - inlineConfig: { - root: fixture.path, - base: '/sub/', - }, - }, - async (container) => { - // First try the subpath - let r = createRequestAndResponse({ - method: 'GET', - url: '/sub/test.txt', - }); - - container.handle(r.req, r.res); - await r.done; - - assert.equal(r.res.statusCode, 200); - - // Next try the root path - r = createRequestAndResponse({ - method: 'GET', - url: '/test.txt', - }); - - container.handle(r.req, r.res); - await r.done; - - assert.equal(r.res.statusCode, 404); - }, - ); - }); - - it('items in public/ are available from root when not using a base', async () => { - const fixture = await createFixture({ - '/public/test.txt': `Test`, - }); - - await runInContainer({ inlineConfig: { root: fixture.path } }, async (container) => { - // Try the root path - let r = createRequestAndResponse({ - method: 'GET', - url: '/test.txt', - }); - - container.handle(r.req, r.res); - await r.done; - - assert.equal(r.res.statusCode, 200); - }); - }); -}); diff --git a/packages/astro/test/units/dev/error-pages.test.js b/packages/astro/test/units/dev/error-pages.test.js deleted file mode 100644 index 6578f143f98a..000000000000 --- a/packages/astro/test/units/dev/error-pages.test.js +++ /dev/null @@ -1,290 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { ensure404Route } from '../../../dist/core/routing/astro-designed-error-pages.js'; -import { createFixture, createRequestAndResponse, runInContainer } from '../test-utils.js'; - -describe('Dev pipeline - error pages', () => { - describe('Custom 404', () => { - it('renders the custom 404.astro page for unmatched routes', async () => { - const fixture = await createFixture({ - '/src/pages/404.astro': `

    Custom 404

    `, - '/src/pages/index.astro': `

    Home

    `, - }); - - await runInContainer({ inlineConfig: { root: fixture.path } }, async (container) => { - const r = createRequestAndResponse({ method: 'GET', url: '/does-not-exist' }); - container.handle(r.req, r.res); - await r.done; - - assert.equal(r.res.statusCode, 404); - const html = await r.text(); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Custom 404'); - }); - }); - - it('renders the built-in Astro 404 page when no custom 404.astro exists', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': `

    Home

    `, - }); - - await runInContainer({ inlineConfig: { root: fixture.path } }, async (container) => { - const r = createRequestAndResponse({ method: 'GET', url: '/does-not-exist' }); - container.handle(r.req, r.res); - await r.done; - - assert.equal(r.res.statusCode, 404); - }); - }); - - it('serves the custom 404 page for the /404 path itself', async () => { - const fixture = await createFixture({ - '/src/pages/404.astro': `

    Custom 404

    `, - '/src/pages/index.astro': `

    Home

    `, - }); - - await runInContainer({ inlineConfig: { root: fixture.path } }, async (container) => { - const r = createRequestAndResponse({ method: 'GET', url: '/404' }); - container.handle(r.req, r.res); - await r.done; - - assert.equal(r.res.statusCode, 404); - const html = await r.text(); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Custom 404'); - }); - }); - }); - - describe('Custom 500', () => { - it('renders the custom 500.astro page when a route throws', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': `--- -throw new Error('boom'); ----`, - '/src/pages/500.astro': `

    Server Error

    `, - }); - - await runInContainer( - { inlineConfig: { root: fixture.path, output: 'server' } }, - async (container) => { - const r = createRequestAndResponse({ method: 'GET', url: '/' }); - container.handle(r.req, r.res); - await r.done; - - assert.equal(r.res.statusCode, 500); - const html = await r.text(); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Server Error'); - }, - ); - }); - - it('renders the dev overlay when no custom 500.astro exists and a route throws', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': `--- -throw new Error('boom'); ----`, - }); - - await runInContainer( - { inlineConfig: { root: fixture.path, output: 'server' } }, - async (container) => { - const r = createRequestAndResponse({ method: 'GET', url: '/' }); - container.handle(r.req, r.res); - await r.done; - - assert.equal(r.res.statusCode, 500); - const html = await r.text(); - // Dev overlay is emitted when DevApp throws (no custom 500 to catch it) - assert.ok(html.includes('/@vite/client')); - }, - ); - }); - - it('renders the custom 500.astro page when an error originates in middleware', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': `

    Home

    `, - '/src/pages/500.astro': `

    Server Error

    `, - '/src/middleware.js': ` -export const onRequest = (_ctx, _next) => { - throw new Error('middleware error'); -}; -`, - }); - - await runInContainer( - { inlineConfig: { root: fixture.path, output: 'server' } }, - async (container) => { - const r = createRequestAndResponse({ method: 'GET', url: '/' }); - container.handle(r.req, r.res); - await r.done; - - assert.equal(r.res.statusCode, 500); - const html = await r.text(); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'Server Error'); - }, - ); - }); - - it('falls back to the dev overlay when the custom 500.astro itself throws', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': `--- -throw new Error('page error'); ----`, - '/src/pages/500.astro': `--- -throw new Error('500 page also broken'); ----`, - }); - - await runInContainer( - { inlineConfig: { root: fixture.path, output: 'server' } }, - async (container) => { - const r = createRequestAndResponse({ method: 'GET', url: '/' }); - container.handle(r.req, r.res); - await r.done; - - assert.equal(r.res.statusCode, 500); - const html = await r.text(); - // Escalated to dev overlay after custom 500 also threw - assert.ok(html.includes('/@vite/client')); - }, - ); - }); - - it('re-throws AstroError MiddlewareNoDataOrNextCalled immediately without rendering a 500 page', async () => { - // Middleware that neither calls next() nor returns a Response triggers - // MiddlewareNoDataOrNextCalled. DevApp re-throws this class of AstroError - // immediately rather than attempting to render the 500 page, because the - // error indicates a programming mistake in the middleware itself. - const fixture = await createFixture({ - '/src/pages/index.astro': `

    Home

    `, - '/src/pages/500.astro': `

    Server Error

    `, - '/src/middleware.js': ` -export const onRequest = (_ctx, _next) => { - // intentionally not calling next() and not returning — triggers MiddlewareNoDataOrNextCalled -}; -`, - }); - - await runInContainer( - { inlineConfig: { root: fixture.path, output: 'server' } }, - async (container) => { - const r = createRequestAndResponse({ method: 'GET', url: '/' }); - container.handle(r.req, r.res); - await r.done; - - assert.equal(r.res.statusCode, 500); - const html = await r.text(); - // MiddlewareNoDataOrNextCalled is re-thrown straight to the dev overlay, - // bypassing the custom 500 page entirely. - assert.ok(html.includes('/@vite/client')); - // The custom 500 page should NOT have been rendered. - assert.ok(!html.includes('Server Error')); - }, - ); - }); - }); - - describe('ensure404Route', () => { - it('adds the default /404 route when none exists in the manifest', () => { - /** @type {{ routes: any[] }} */ - const manifest = { routes: [] }; - ensure404Route(manifest); - - const route404 = manifest.routes.find((r) => r.route === '/404'); - assert.ok(route404, 'A /404 route should be added when none exists'); - }); - - it('does not add a duplicate /404 route when one already exists', () => { - /** @type {{ routes: any[] }} */ - const manifest = { - routes: [ - { - route: '/404', - component: 'src/pages/404.astro', - params: [], - pathname: '/404', - distURL: [], - pattern: /^\/404\/?$/, - segments: [[{ content: '404', dynamic: false, spread: false }]], - type: 'page', - prerender: false, - fallbackRoutes: [], - isIndex: false, - origin: 'project', - }, - ], - }; - ensure404Route(manifest); - ensure404Route(manifest); // call twice to verify idempotency - - const count = manifest.routes.filter((r) => r.route === '/404').length; - assert.equal(count, 1, 'There should be exactly one /404 route'); - }); - - it('preserves the user-provided 404 component rather than substituting the default', () => { - const userComponent = 'src/pages/404.astro'; - /** @type {{ routes: any[] }} */ - const manifest = { - routes: [ - { - route: '/404', - component: userComponent, - params: [], - pathname: '/404', - distURL: [], - pattern: /^\/404\/?$/, - segments: [[{ content: '404', dynamic: false, spread: false }]], - type: 'page', - prerender: false, - fallbackRoutes: [], - isIndex: false, - origin: 'project', - }, - ], - }; - ensure404Route(manifest); - - const route404 = manifest.routes.find((r) => r.route === '/404'); - assert.equal( - route404?.component, - userComponent, - 'User-provided 404 component should not be replaced by the default', - ); - }); - - it('does not affect /500 routes', () => { - /** @type {{ routes: any[] }} */ - const manifest = { - routes: [ - { - route: '/500', - component: 'src/pages/500.astro', - params: [], - pathname: '/500', - distURL: [], - pattern: /^\/500\/?$/, - segments: [[{ content: '500', dynamic: false, spread: false }]], - type: 'page', - prerender: false, - fallbackRoutes: [], - isIndex: false, - origin: 'project', - }, - ], - }; - ensure404Route(manifest); - - // /404 should be added (no user 404 exists), /500 should be untouched - const count500 = manifest.routes.filter((r) => r.route === '/500').length; - assert.equal(count500, 1, '/500 route count should remain exactly 1'); - - const has404 = manifest.routes.some((r) => r.route === '/404'); - assert.ok(has404, 'Default /404 should have been added'); - }); - }); -}); diff --git a/packages/astro/test/units/dev/error-pages.test.ts b/packages/astro/test/units/dev/error-pages.test.ts new file mode 100644 index 000000000000..992c1f3c9492 --- /dev/null +++ b/packages/astro/test/units/dev/error-pages.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { ensure404Route } from '../../../dist/core/routing/astro-designed-error-pages.js'; + +describe('ensure404Route', () => { + it('adds the default /404 route when none exists in the manifest', () => { + const manifest: any = { routes: [] }; + ensure404Route(manifest); + + const route404 = manifest.routes.find((r: any) => r.route === '/404'); + assert.ok(route404, 'A /404 route should be added when none exists'); + }); + + it('does not add a duplicate /404 route when one already exists', () => { + const manifest: any = { + routes: [ + { + route: '/404', + component: 'src/pages/404.astro', + params: [], + pathname: '/404', + distURL: [], + pattern: /^\/404\/?$/, + segments: [[{ content: '404', dynamic: false, spread: false }]], + type: 'page', + prerender: false, + fallbackRoutes: [], + isIndex: false, + origin: 'project', + }, + ], + }; + ensure404Route(manifest); + ensure404Route(manifest); // call twice to verify idempotency + + const count = manifest.routes.filter((r: any) => r.route === '/404').length; + assert.equal(count, 1, 'There should be exactly one /404 route'); + }); + + it('preserves the user-provided 404 component rather than substituting the default', () => { + const userComponent = 'src/pages/404.astro'; + const manifest: any = { + routes: [ + { + route: '/404', + component: userComponent, + params: [], + pathname: '/404', + distURL: [], + pattern: /^\/404\/?$/, + segments: [[{ content: '404', dynamic: false, spread: false }]], + type: 'page', + prerender: false, + fallbackRoutes: [], + isIndex: false, + origin: 'project', + }, + ], + }; + ensure404Route(manifest); + + const route404 = manifest.routes.find((r: any) => r.route === '/404'); + assert.equal( + route404?.component, + userComponent, + 'User-provided 404 component should not be replaced by the default', + ); + }); + + it('does not affect /500 routes', () => { + const manifest: any = { + routes: [ + { + route: '/500', + component: 'src/pages/500.astro', + params: [], + pathname: '/500', + distURL: [], + pattern: /^\/500\/?$/, + segments: [[{ content: '500', dynamic: false, spread: false }]], + type: 'page', + prerender: false, + fallbackRoutes: [], + isIndex: false, + origin: 'project', + }, + ], + }; + ensure404Route(manifest); + + // /404 should be added (no user 404 exists), /500 should be untouched + const count500 = manifest.routes.filter((r: any) => r.route === '/500').length; + assert.equal(count500, 1, '/500 route count should remain exactly 1'); + + const has404 = manifest.routes.some((r: any) => r.route === '/404'); + assert.ok(has404, 'Default /404 should have been added'); + }); +}); diff --git a/packages/astro/test/units/dev/hydration.test.js b/packages/astro/test/units/dev/hydration.test.js deleted file mode 100644 index 03962a416fa0..000000000000 --- a/packages/astro/test/units/dev/hydration.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { createFixture, createRequestAndResponse, runInContainer } from '../test-utils.js'; - -describe('hydration', () => { - it( - 'should not crash when reassigning a hydrated component', - { skip: true, todo: "It seems that `components/Client.svelte` isn't found" }, - async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': ` - --- - import Svelte from '../components/Client.svelte'; - const Foo = Svelte; - const Bar = Svelte; - --- - - testing - - - - - - `, - }); - - await runInContainer( - { - inlineConfig: { - root: fixture.path, - logLevel: 'silent', - }, - }, - async (container) => { - const { req, res, done } = createRequestAndResponse({ - method: 'GET', - url: '/', - }); - container.handle(req, res); - await done; - assert.equal( - res.statusCode, - 200, - "We get a 200 because the error occurs in the template, but we didn't crash!", - ); - }, - ); - }, - ); -}); diff --git a/packages/astro/test/units/dev/restart.test.js b/packages/astro/test/units/dev/restart.test.js deleted file mode 100644 index 9d5664cbf246..000000000000 --- a/packages/astro/test/units/dev/restart.test.js +++ /dev/null @@ -1,233 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; - -import { - createContainerWithAutomaticRestart, - startContainer, -} from '../../../dist/core/dev/index.js'; -import { createFixture, createRequestAndResponse } from '../test-utils.js'; - -/** @type {import('astro').AstroInlineConfig} */ -const defaultInlineConfig = { - logLevel: 'silent', -}; - -function isStarted(container) { - return !!container.viteServer.httpServer?.listening; -} - -// Checking for restarts may hang if no restarts happen, so set a 20s timeout for each test -describe('dev container restarts', { timeout: 20000 }, () => { - it('Surfaces config errors on restarts', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': ` - - Test - -

    Test

    - - - `, - '/astro.config.mjs': ``, - }); - - const restart = await createContainerWithAutomaticRestart({ - inlineConfig: { - ...defaultInlineConfig, - root: fixture.path, - }, - }); - - try { - let r = createRequestAndResponse({ - method: 'GET', - url: '/', - }); - restart.container.handle(r.req, r.res); - let html = await r.text(); - const $ = cheerio.load(html); - assert.equal(r.res.statusCode, 200); - assert.equal($('h1').length, 1); - - // Create an error - let restartComplete = restart.restarted(); - await fixture.writeFile('/astro.config.mjs', 'const foo = bar'); - // TODO: fix this hack - restart.container.viteServer.watcher.emit( - 'change', - fixture.getPath('/astro.config.mjs').replace(/\\/g, '/'), - ); - - // Wait for the restart to finish - let hmrError = await restartComplete; - assert.ok(hmrError instanceof Error); - - // Do it a second time to make sure we are still watching - - restartComplete = restart.restarted(); - await fixture.writeFile('/astro.config.mjs', 'const foo = bar2'); - // TODO: fix this hack - restart.container.viteServer.watcher.emit( - 'change', - fixture.getPath('/astro.config.mjs').replace(/\\/g, '/'), - ); - - hmrError = await restartComplete; - assert.ok(hmrError instanceof Error); - } finally { - await restart.container.close(); - } - }); - - it('Restarts the container if previously started', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': ` - - Test - -

    Test

    - - - `, - '/astro.config.mjs': ``, - }); - - const restart = await createContainerWithAutomaticRestart({ - inlineConfig: { - ...defaultInlineConfig, - root: fixture.path, - }, - }); - await startContainer(restart.container); - assert.equal(isStarted(restart.container), true); - - try { - // Trigger a change - let restartComplete = restart.restarted(); - await fixture.writeFile('/astro.config.mjs', ''); - // TODO: fix this hack - restart.container.viteServer.watcher.emit( - 'change', - fixture.getPath('/astro.config.mjs').replace(/\\/g, '/'), - ); - await restartComplete; - - assert.equal(isStarted(restart.container), true); - } finally { - await restart.container.close(); - } - }); - - it('Is able to restart project using Tailwind + astro.config.ts', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': ``, - '/astro.config.ts': ``, - }); - - const restart = await createContainerWithAutomaticRestart({ - inlineConfig: { - ...defaultInlineConfig, - root: fixture.path, - }, - }); - await startContainer(restart.container); - assert.equal(isStarted(restart.container), true); - - try { - // Trigger a change - let restartComplete = restart.restarted(); - await fixture.writeFile('/astro.config.ts', ''); - // TODO: fix this hack - restart.container.viteServer.watcher.emit( - 'change', - fixture.getPath('/astro.config.mjs').replace(/\\/g, '/'), - ); - await restartComplete; - - assert.equal(isStarted(restart.container), true); - } finally { - await restart.container.close(); - } - }); - - it('Is able to restart project on package.json changes', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': ``, - }); - - const restart = await createContainerWithAutomaticRestart({ - inlineConfig: { - ...defaultInlineConfig, - root: fixture.path, - }, - }); - await startContainer(restart.container); - assert.equal(isStarted(restart.container), true); - - try { - let restartComplete = restart.restarted(); - await fixture.writeFile('/package.json', `{}`); - // TODO: fix this hack - restart.container.viteServer.watcher.emit( - 'change', - fixture.getPath('/package.json').replace(/\\/g, '/'), - ); - await restartComplete; - } finally { - await restart.container.close(); - } - }); - - it('Is able to restart on viteServer.restart API call', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': ``, - }); - - const restart = await createContainerWithAutomaticRestart({ - inlineConfig: { - ...defaultInlineConfig, - root: fixture.path, - }, - }); - await startContainer(restart.container); - assert.equal(isStarted(restart.container), true); - - try { - let restartComplete = restart.restarted(); - await restart.container.viteServer.restart(); - await restartComplete; - } finally { - await restart.container.close(); - } - }); - - it('Is able to restart project on .astro/settings.json changes', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': ``, - '/.astro/settings.json': `{}`, - }); - - const restart = await createContainerWithAutomaticRestart({ - inlineConfig: { - ...defaultInlineConfig, - root: fixture.path, - }, - }); - await startContainer(restart.container); - assert.equal(isStarted(restart.container), true); - - try { - let restartComplete = restart.restarted(); - await fixture.writeFile('/.astro/settings.json', `{ }`); - // TODO: fix this hack - restart.container.viteServer.watcher.emit( - 'change', - fixture.getPath('/.astro/settings.json').replace(/\\/g, '/'), - ); - await restartComplete; - } finally { - await restart.container.close(); - } - }); -}); diff --git a/packages/astro/test/units/dev/sec-fetch.test.js b/packages/astro/test/units/dev/sec-fetch.test.js deleted file mode 100644 index 21de0902c69e..000000000000 --- a/packages/astro/test/units/dev/sec-fetch.test.js +++ /dev/null @@ -1,201 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { secFetchMiddleware } from '../../../dist/vite-plugin-astro-server/sec-fetch.js'; -import { createRequestAndResponse, defaultLogger } from '../test-utils.js'; - -/** - * Helper to run a request through the secFetchMiddleware and return whether - * it was blocked (response ended with 403) or allowed (next() was called). - */ -function runMiddleware(headers, allowedDomains) { - const middleware = secFetchMiddleware(defaultLogger, allowedDomains); - const { req, res, done } = createRequestAndResponse({ - method: 'GET', - url: '/src/pages/index.astro', - headers, - }); - - let nextCalled = false; - middleware(req, res, () => { - nextCalled = true; - res.statusCode = 200; - res.end(); - }); - - return done.then(() => ({ - nextCalled, - statusCode: res.statusCode, - })); -} - -describe('secFetchMiddleware', () => { - describe('allows same-origin requests', () => { - it('allows requests with Sec-Fetch-Site: same-origin', async () => { - const result = await runMiddleware({ - 'sec-fetch-site': 'same-origin', - 'sec-fetch-mode': 'cors', - }); - assert.equal(result.nextCalled, true); - }); - - it('allows requests with Sec-Fetch-Site: same-site', async () => { - const result = await runMiddleware({ - 'sec-fetch-site': 'same-site', - 'sec-fetch-mode': 'cors', - }); - assert.equal(result.nextCalled, true); - }); - - it('allows requests with Sec-Fetch-Site: none (direct navigation)', async () => { - const result = await runMiddleware({ - 'sec-fetch-site': 'none', - 'sec-fetch-mode': 'navigate', - }); - assert.equal(result.nextCalled, true); - }); - }); - - describe('allows requests without Sec-Fetch headers', () => { - it('allows requests with no Sec-Fetch headers (non-browser clients)', async () => { - const result = await runMiddleware({}); - assert.equal(result.nextCalled, true); - }); - }); - - describe('allows cross-origin navigation requests', () => { - it('allows cross-site navigate requests', async () => { - const result = await runMiddleware({ - 'sec-fetch-site': 'cross-site', - 'sec-fetch-mode': 'navigate', - }); - assert.equal(result.nextCalled, true); - }); - - it('allows cross-origin navigate requests', async () => { - const result = await runMiddleware({ - 'sec-fetch-site': 'cross-site', - 'sec-fetch-mode': 'nested-navigate', - }); - assert.equal(result.nextCalled, true); - }); - }); - - describe('allows websocket requests', () => { - it('allows cross-site websocket upgrades (HMR)', async () => { - const result = await runMiddleware({ - 'sec-fetch-site': 'cross-site', - 'sec-fetch-mode': 'websocket', - }); - assert.equal(result.nextCalled, true); - }); - }); - - describe('blocks cross-origin subresource requests', () => { - it('blocks cross-site no-cors requests (script tag from another origin)', async () => { - const result = await runMiddleware({ - 'sec-fetch-site': 'cross-site', - 'sec-fetch-mode': 'no-cors', - 'sec-fetch-dest': 'script', - }); - assert.equal(result.nextCalled, false); - assert.equal(result.statusCode, 403); - }); - - it('blocks cross-site cors requests', async () => { - const result = await runMiddleware({ - 'sec-fetch-site': 'cross-site', - 'sec-fetch-mode': 'cors', - 'sec-fetch-dest': 'script', - }); - assert.equal(result.nextCalled, false); - assert.equal(result.statusCode, 403); - }); - - it('blocks cross-origin no-cors requests', async () => { - const result = await runMiddleware({ - 'sec-fetch-site': 'cross-site', - 'sec-fetch-mode': 'no-cors', - 'sec-fetch-dest': 'empty', - }); - assert.equal(result.nextCalled, false); - assert.equal(result.statusCode, 403); - }); - - it('blocks cross-site requests with no Sec-Fetch-Mode', async () => { - const result = await runMiddleware({ - 'sec-fetch-site': 'cross-site', - }); - assert.equal(result.nextCalled, false); - assert.equal(result.statusCode, 403); - }); - }); - - describe('allowedDomains support', () => { - const allowedDomains = [ - { hostname: 'myproxy.example.com', protocol: 'https' }, - { hostname: '*.ngrok.io', protocol: 'https' }, - ]; - - it('allows cross-site request when Origin matches an allowed domain', async () => { - const result = await runMiddleware( - { - 'sec-fetch-site': 'cross-site', - 'sec-fetch-mode': 'cors', - origin: 'https://myproxy.example.com', - }, - allowedDomains, - ); - assert.equal(result.nextCalled, true); - }); - - it('allows cross-site request when Origin matches a wildcard allowed domain', async () => { - const result = await runMiddleware( - { - 'sec-fetch-site': 'cross-site', - 'sec-fetch-mode': 'cors', - origin: 'https://abc123.ngrok.io', - }, - allowedDomains, - ); - assert.equal(result.nextCalled, true); - }); - - it('blocks cross-site request when Origin does not match allowed domains', async () => { - const result = await runMiddleware( - { - 'sec-fetch-site': 'cross-site', - 'sec-fetch-mode': 'cors', - origin: 'https://evil.example.com', - }, - allowedDomains, - ); - assert.equal(result.nextCalled, false); - assert.equal(result.statusCode, 403); - }); - - it('blocks cross-site request when no Origin header is present', async () => { - const result = await runMiddleware( - { - 'sec-fetch-site': 'cross-site', - 'sec-fetch-mode': 'cors', - }, - allowedDomains, - ); - assert.equal(result.nextCalled, false); - assert.equal(result.statusCode, 403); - }); - - it('blocks cross-site request when allowedDomains is empty', async () => { - const result = await runMiddleware( - { - 'sec-fetch-site': 'cross-site', - 'sec-fetch-mode': 'cors', - origin: 'https://myproxy.example.com', - }, - [], - ); - assert.equal(result.nextCalled, false); - assert.equal(result.statusCode, 403); - }); - }); -}); diff --git a/packages/astro/test/units/dev/sec-fetch.test.ts b/packages/astro/test/units/dev/sec-fetch.test.ts new file mode 100644 index 000000000000..ab92cbd57471 --- /dev/null +++ b/packages/astro/test/units/dev/sec-fetch.test.ts @@ -0,0 +1,205 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { RemotePattern } from '@astrojs/internal-helpers/remote'; +import { secFetchMiddleware } from '../../../dist/vite-plugin-astro-server/sec-fetch.js'; +import { createRequestAndResponse, defaultLogger } from '../test-utils.ts'; + +/** + * Helper to run a request through the secFetchMiddleware and return whether + * it was blocked (response ended with 403) or allowed (next() was called). + */ +function runMiddleware( + headers: Record, + allowedDomains?: Partial[], +): Promise<{ nextCalled: boolean; statusCode: number }> { + const middleware = secFetchMiddleware(defaultLogger, allowedDomains); + const { req, res, done } = createRequestAndResponse({ + method: 'GET', + url: '/src/pages/index.astro', + headers, + }); + + let nextCalled = false; + middleware(req, res, () => { + nextCalled = true; + res.statusCode = 200; + res.end(); + }); + + return done.then(() => ({ + nextCalled, + statusCode: res.statusCode, + })); +} + +describe('secFetchMiddleware', () => { + describe('allows same-origin requests', () => { + it('allows requests with Sec-Fetch-Site: same-origin', async () => { + const result = await runMiddleware({ + 'sec-fetch-site': 'same-origin', + 'sec-fetch-mode': 'cors', + }); + assert.equal(result.nextCalled, true); + }); + + it('allows requests with Sec-Fetch-Site: same-site', async () => { + const result = await runMiddleware({ + 'sec-fetch-site': 'same-site', + 'sec-fetch-mode': 'cors', + }); + assert.equal(result.nextCalled, true); + }); + + it('allows requests with Sec-Fetch-Site: none (direct navigation)', async () => { + const result = await runMiddleware({ + 'sec-fetch-site': 'none', + 'sec-fetch-mode': 'navigate', + }); + assert.equal(result.nextCalled, true); + }); + }); + + describe('allows requests without Sec-Fetch headers', () => { + it('allows requests with no Sec-Fetch headers (non-browser clients)', async () => { + const result = await runMiddleware({}); + assert.equal(result.nextCalled, true); + }); + }); + + describe('allows cross-origin navigation requests', () => { + it('allows cross-site navigate requests', async () => { + const result = await runMiddleware({ + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'navigate', + }); + assert.equal(result.nextCalled, true); + }); + + it('allows cross-origin navigate requests', async () => { + const result = await runMiddleware({ + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'nested-navigate', + }); + assert.equal(result.nextCalled, true); + }); + }); + + describe('allows websocket requests', () => { + it('allows cross-site websocket upgrades (HMR)', async () => { + const result = await runMiddleware({ + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'websocket', + }); + assert.equal(result.nextCalled, true); + }); + }); + + describe('blocks cross-origin subresource requests', () => { + it('blocks cross-site no-cors requests (script tag from another origin)', async () => { + const result = await runMiddleware({ + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'no-cors', + 'sec-fetch-dest': 'script', + }); + assert.equal(result.nextCalled, false); + assert.equal(result.statusCode, 403); + }); + + it('blocks cross-site cors requests', async () => { + const result = await runMiddleware({ + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'cors', + 'sec-fetch-dest': 'script', + }); + assert.equal(result.nextCalled, false); + assert.equal(result.statusCode, 403); + }); + + it('blocks cross-origin no-cors requests', async () => { + const result = await runMiddleware({ + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'no-cors', + 'sec-fetch-dest': 'empty', + }); + assert.equal(result.nextCalled, false); + assert.equal(result.statusCode, 403); + }); + + it('blocks cross-site requests with no Sec-Fetch-Mode', async () => { + const result = await runMiddleware({ + 'sec-fetch-site': 'cross-site', + }); + assert.equal(result.nextCalled, false); + assert.equal(result.statusCode, 403); + }); + }); + + describe('allowedDomains support', () => { + const allowedDomains: Partial[] = [ + { hostname: 'myproxy.example.com', protocol: 'https' }, + { hostname: '*.ngrok.io', protocol: 'https' }, + ]; + + it('allows cross-site request when Origin matches an allowed domain', async () => { + const result = await runMiddleware( + { + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'cors', + origin: 'https://myproxy.example.com', + }, + allowedDomains, + ); + assert.equal(result.nextCalled, true); + }); + + it('allows cross-site request when Origin matches a wildcard allowed domain', async () => { + const result = await runMiddleware( + { + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'cors', + origin: 'https://abc123.ngrok.io', + }, + allowedDomains, + ); + assert.equal(result.nextCalled, true); + }); + + it('blocks cross-site request when Origin does not match allowed domains', async () => { + const result = await runMiddleware( + { + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'cors', + origin: 'https://evil.example.com', + }, + allowedDomains, + ); + assert.equal(result.nextCalled, false); + assert.equal(result.statusCode, 403); + }); + + it('blocks cross-site request when no Origin header is present', async () => { + const result = await runMiddleware( + { + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'cors', + }, + allowedDomains, + ); + assert.equal(result.nextCalled, false); + assert.equal(result.statusCode, 403); + }); + + it('blocks cross-site request when allowedDomains is empty', async () => { + const result = await runMiddleware( + { + 'sec-fetch-site': 'cross-site', + 'sec-fetch-mode': 'cors', + origin: 'https://myproxy.example.com', + }, + [], + ); + assert.equal(result.nextCalled, false); + assert.equal(result.statusCode, 403); + }); + }); +}); diff --git a/packages/astro/test/units/dev/trailing-slash-decision.test.ts b/packages/astro/test/units/dev/trailing-slash-decision.test.ts new file mode 100644 index 000000000000..374c4383c81f --- /dev/null +++ b/packages/astro/test/units/dev/trailing-slash-decision.test.ts @@ -0,0 +1,150 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { evaluateTrailingSlash } from '../../../dist/vite-plugin-astro-server/trailing-slash.js'; + +// #region internal paths +describe('evaluateTrailingSlash — internal paths', () => { + it('passes through /@vite/client', () => { + const result = evaluateTrailingSlash('/@vite/client', '', 'never'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('passes through /@fs/ paths', () => { + const result = evaluateTrailingSlash('/@fs/project/src/main.ts', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('passes through /@id/ paths', () => { + const result = evaluateTrailingSlash('/@id/module', '', 'never'); + assert.deepEqual(result, { action: 'next' }); + }); +}); +// #endregion + +// #region duplicate trailing slashes +describe('evaluateTrailingSlash — duplicate trailing slashes', () => { + it('redirects /about// to /about/', () => { + const result = evaluateTrailingSlash('/about//', '', 'ignore'); + assert.equal(result.action, 'redirect'); + if (result.action === 'redirect') { + assert.equal(result.status, 301); + assert.equal(result.location, '/about/'); + } + }); + + it('redirects /about/// to /about/', () => { + const result = evaluateTrailingSlash('/about///', '', 'ignore'); + assert.equal(result.action, 'redirect'); + if (result.action === 'redirect') { + assert.equal(result.location, '/about/'); + } + }); + + it('preserves query string in redirect', () => { + const result = evaluateTrailingSlash('/about//', '?foo=bar', 'ignore'); + assert.equal(result.action, 'redirect'); + if (result.action === 'redirect') { + assert.equal(result.location, '/about/?foo=bar'); + } + }); + + it('collapses only trailing slashes, not internal ones', () => { + const result = evaluateTrailingSlash('/blog//post//', '', 'ignore'); + assert.equal(result.action, 'redirect'); + if (result.action === 'redirect') { + // collapseDuplicateTrailingSlashes only collapses trailing slashes + assert.equal(result.location, '/blog//post/'); + } + }); +}); +// #endregion + +// #region trailingSlash: 'never' +describe('evaluateTrailingSlash — trailingSlash: "never"', () => { + it('rejects /about/ (has trailing slash)', () => { + const result = evaluateTrailingSlash('/about/', '', 'never'); + assert.equal(result.action, 'reject'); + if (result.action === 'reject') { + assert.equal(result.status, 404); + assert.equal(result.pathname, '/about/'); + } + }); + + it('passes /about (no trailing slash)', () => { + const result = evaluateTrailingSlash('/about', '', 'never'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('exempts root path / (always allowed)', () => { + const result = evaluateTrailingSlash('/', '', 'never'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('rejects /blog/post/ (nested with trailing slash)', () => { + const result = evaluateTrailingSlash('/blog/post/', '', 'never'); + assert.equal(result.action, 'reject'); + }); +}); +// #endregion + +// #region trailingSlash: 'always' +describe('evaluateTrailingSlash — trailingSlash: "always"', () => { + it('rejects /about (no trailing slash)', () => { + const result = evaluateTrailingSlash('/about', '', 'always'); + assert.equal(result.action, 'reject'); + if (result.action === 'reject') { + assert.equal(result.status, 404); + assert.equal(result.pathname, '/about'); + } + }); + + it('passes /about/ (has trailing slash)', () => { + const result = evaluateTrailingSlash('/about/', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('exempts paths with file extension', () => { + const result = evaluateTrailingSlash('/styles.css', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('exempts .html file extension', () => { + const result = evaluateTrailingSlash('/page.html', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('exempts .js file extension', () => { + const result = evaluateTrailingSlash('/script.js', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('passes root path /', () => { + const result = evaluateTrailingSlash('/', '', 'always'); + assert.deepEqual(result, { action: 'next' }); + }); +}); +// #endregion + +// #region trailingSlash: 'ignore' +describe('evaluateTrailingSlash — trailingSlash: "ignore"', () => { + it('passes /about', () => { + const result = evaluateTrailingSlash('/about', '', 'ignore'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('passes /about/', () => { + const result = evaluateTrailingSlash('/about/', '', 'ignore'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('passes /', () => { + const result = evaluateTrailingSlash('/', '', 'ignore'); + assert.deepEqual(result, { action: 'next' }); + }); + + it('still redirects duplicate slashes', () => { + const result = evaluateTrailingSlash('/about//', '', 'ignore'); + assert.equal(result.action, 'redirect'); + }); +}); +// #endregion diff --git a/packages/astro/test/units/env/env-validators.test.js b/packages/astro/test/units/env/env-validators.test.js deleted file mode 100644 index 1668408a9726..000000000000 --- a/packages/astro/test/units/env/env-validators.test.js +++ /dev/null @@ -1,682 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { - getEnvFieldType, - validateEnvVariable, - validateEnvPrefixAgainstSchema, -} from '../../../dist/env/validators.js'; - -/** - * @typedef {Parameters} Params - */ - -const createFixture = () => { - /** - * @type {{ value: Params[1]; options: Params[2] }} input - */ - let input; - - return { - /** - * @param {Params[1]} value - * @param {Params[2]} options - */ - givenInput(value, options) { - input = { value, options }; - }, - /** - * @param {import("../../../src/env/validators.js").ValidationResultValue} value - */ - thenResultShouldBeValid(value) { - const result = validateEnvVariable(input.value, input.options); - assert.equal(result.ok, true); - assert.equal(result.value, value); - input = undefined; - }, - /** - * @param {string | Array} providedErrors - */ - thenResultShouldBeInvalid(providedErrors) { - const result = validateEnvVariable(input.value, input.options); - assert.equal(result.ok, false); - const errors = typeof providedErrors === 'string' ? [providedErrors] : providedErrors; - assert.equal( - result.errors.every((element) => errors.includes(element)), - true, - ); - input = undefined; - }, - }; -}; - -describe('astro:env validators', () => { - /** @type {ReturnType} */ - let fixture; - - before(() => { - fixture = createFixture(); - }); - - it('types codegen should return the right string based on the field options', () => { - assert.equal( - getEnvFieldType({ - type: 'string', - }), - 'string', - ); - - assert.equal( - getEnvFieldType({ - type: 'string', - optional: true, - }), - 'string | undefined', - ); - - assert.equal( - getEnvFieldType({ - type: 'string', - optional: true, - default: 'abc', - }), - 'string', - ); - - assert.equal( - getEnvFieldType({ - type: 'number', - }), - 'number', - ); - - assert.equal( - getEnvFieldType({ - type: 'number', - optional: true, - }), - 'number | undefined', - ); - - assert.equal( - getEnvFieldType({ - type: 'number', - optional: true, - default: 456, - }), - 'number', - ); - - assert.equal( - getEnvFieldType({ - type: 'boolean', - }), - 'boolean', - ); - - assert.equal( - getEnvFieldType({ - type: 'boolean', - optional: true, - }), - 'boolean | undefined', - ); - - assert.equal( - getEnvFieldType({ - type: 'boolean', - optional: true, - default: true, - }), - 'boolean', - ); - - assert.equal( - getEnvFieldType({ - type: 'enum', - values: ['A'], - }), - "'A'", - ); - - assert.equal( - getEnvFieldType({ - type: 'enum', - values: ['A', 'B'], - }), - "'A' | 'B'", - ); - - assert.equal( - getEnvFieldType({ - type: 'enum', - optional: true, - values: ['A'], - }), - "'A' | undefined", - ); - assert.equal( - getEnvFieldType({ - type: 'enum', - optional: true, - values: ['A', 'B'], - default: 'A', - }), - "'A' | 'B'", - ); - }); - - describe('string field', () => { - it('Should fail if the variable is missing', () => { - fixture.givenInput(undefined, { - type: 'string', - }); - fixture.thenResultShouldBeInvalid('missing'); - }); - - it('Should not fail is the variable type is incorrect', () => { - fixture.givenInput('123456', { - type: 'string', - }); - fixture.thenResultShouldBeValid('123456'); - }); - - it('Should fail if conditions are not met', () => { - fixture.givenInput('abcdef', { - type: 'string', - max: 6, - }); - fixture.thenResultShouldBeValid('abcdef'); - - fixture.givenInput('abcdef', { - type: 'string', - max: 3, - }); - fixture.thenResultShouldBeInvalid('max'); - - fixture.givenInput('abc', { - type: 'string', - min: 1, - }); - fixture.thenResultShouldBeValid('abc'); - - fixture.givenInput('abc', { - type: 'string', - min: 5, - }); - fixture.thenResultShouldBeInvalid('min'); - - fixture.givenInput('abc', { - type: 'string', - length: 3, - }); - fixture.thenResultShouldBeValid('abc'); - - fixture.givenInput('abc', { - type: 'string', - length: 10, - }); - fixture.thenResultShouldBeInvalid('length'); - - fixture.givenInput('abc', { - type: 'string', - url: true, - }); - fixture.thenResultShouldBeInvalid('url'); - - fixture.givenInput('https://example.com', { - type: 'string', - url: true, - }); - fixture.thenResultShouldBeValid('https://example.com'); - - fixture.givenInput('abc', { - type: 'string', - includes: 'cd', - }); - fixture.thenResultShouldBeInvalid('includes'); - - fixture.givenInput('abc', { - type: 'string', - includes: 'bc', - }); - fixture.thenResultShouldBeValid('abc'); - - fixture.givenInput('abc', { - type: 'string', - startsWith: 'za', - }); - fixture.thenResultShouldBeInvalid('startsWith'); - - fixture.givenInput('abc', { - type: 'string', - startsWith: 'ab', - }); - fixture.thenResultShouldBeValid('abc'); - - fixture.givenInput('abc', { - type: 'string', - endsWith: 'za', - }); - fixture.thenResultShouldBeInvalid('endsWith'); - - fixture.givenInput('abc', { - type: 'string', - endsWith: 'bc', - }); - fixture.thenResultShouldBeValid('abc'); - - fixture.givenInput('abcd', { - type: 'string', - startsWith: 'ab', - endsWith: 'cd', - }); - fixture.thenResultShouldBeValid('abcd'); - - fixture.givenInput(undefined, { - type: 'string', - min: 5, - }); - fixture.thenResultShouldBeInvalid('missing'); - - fixture.givenInput('ab', { - type: 'string', - startsWith: 'x', - min: 5, - }); - fixture.thenResultShouldBeInvalid(['startsWith', 'min']); - }); - - it('Should not fail if the optional variable is missing', () => { - fixture.givenInput(undefined, { - type: 'string', - optional: true, - }); - fixture.thenResultShouldBeValid(undefined); - }); - - it('Should not fail if the variable is missing and a default value is provided', () => { - fixture.givenInput(undefined, { - type: 'string', - default: 'abc', - }); - fixture.thenResultShouldBeValid('abc'); - }); - - it('Should not take a default value is the variable is not missing', () => { - fixture.givenInput('abc', { - type: 'string', - default: 'def', - }); - fixture.thenResultShouldBeValid('abc'); - }); - }); - - describe('number field', () => { - it('Should fail if the variable is missing', () => { - fixture.givenInput(undefined, { - type: 'number', - }); - fixture.thenResultShouldBeInvalid('missing'); - }); - - it('Should fail is the variable type is incorrect', () => { - fixture.givenInput('abc', { - type: 'number', - }); - fixture.thenResultShouldBeInvalid('type'); - }); - - it('Should fail if conditions are not met', () => { - fixture.givenInput('10', { - type: 'number', - gt: 15, - }); - fixture.thenResultShouldBeInvalid('gt'); - - fixture.givenInput('10', { - type: 'number', - gt: 10, - }); - fixture.thenResultShouldBeInvalid('gt'); - - fixture.givenInput('10', { - type: 'number', - gt: 5, - }); - fixture.thenResultShouldBeValid(10); - - fixture.givenInput('20', { - type: 'number', - min: 25, - }); - fixture.thenResultShouldBeInvalid('min'); - - fixture.givenInput('20', { - type: 'number', - min: 20, - }); - fixture.thenResultShouldBeValid(20); - - fixture.givenInput('20', { - type: 'number', - min: 5, - }); - fixture.thenResultShouldBeValid(20); - - fixture.givenInput('15', { - type: 'number', - lt: 10, - }); - fixture.thenResultShouldBeInvalid('lt'); - - fixture.givenInput('10', { - type: 'number', - lt: 10, - }); - fixture.thenResultShouldBeInvalid('lt'); - - fixture.givenInput('5', { - type: 'number', - lt: 10, - }); - fixture.thenResultShouldBeValid(5); - - fixture.givenInput('25', { - type: 'number', - max: 20, - }); - fixture.thenResultShouldBeInvalid('max'); - - fixture.givenInput('25', { - type: 'number', - max: 25, - }); - fixture.thenResultShouldBeValid(25); - - fixture.givenInput('25', { - type: 'number', - max: 30, - }); - fixture.thenResultShouldBeValid(25); - - fixture.givenInput('4.5', { - type: 'number', - int: true, - }); - fixture.thenResultShouldBeInvalid('int'); - - fixture.givenInput('25', { - type: 'number', - int: true, - }); - fixture.thenResultShouldBeValid(25); - - fixture.givenInput('4', { - type: 'number', - int: false, - }); - fixture.thenResultShouldBeInvalid('int'); - - fixture.givenInput('4.5', { - type: 'number', - int: false, - }); - fixture.thenResultShouldBeValid(4.5); - - fixture.givenInput(undefined, { - type: 'number', - gt: 10, - }); - fixture.thenResultShouldBeInvalid('missing'); - }); - - it('Should accept integers', () => { - fixture.givenInput('12345', { - type: 'number', - }); - fixture.thenResultShouldBeValid(12345); - }); - - it('Should accept floats', () => { - fixture.givenInput('12.34', { - type: 'number', - }); - fixture.thenResultShouldBeValid(12.34); - }); - - it('Should not fail if the optional variable is missing', () => { - fixture.givenInput(undefined, { - type: 'number', - optional: true, - }); - fixture.thenResultShouldBeValid(undefined); - }); - - it('Should not fail if the variable is missing and a default value is provided', () => { - fixture.givenInput(undefined, { - type: 'number', - default: 123, - }); - fixture.thenResultShouldBeValid(123); - }); - - it('Should not take a default value is the variable is not missing', () => { - fixture.givenInput('123', { - type: 'number', - default: 456, - }); - fixture.thenResultShouldBeValid(123); - }); - }); - - describe('boolean field', () => { - it('Should fail if the variable is missing', () => { - fixture.givenInput(undefined, { - type: 'boolean', - }); - fixture.thenResultShouldBeInvalid('missing'); - }); - - it('Should fail is the variable type is incorrect', () => { - fixture.givenInput('abc', { - type: 'boolean', - }); - fixture.thenResultShouldBeInvalid('type'); - }); - - it('Should not fail if the optional variable is missing', () => { - fixture.givenInput(undefined, { - type: 'boolean', - optional: true, - }); - fixture.thenResultShouldBeValid(undefined); - }); - - it('Should not fail if the variable is missing and a default value is provided', () => { - fixture.givenInput(undefined, { - type: 'boolean', - default: true, - }); - fixture.thenResultShouldBeValid(true); - }); - - it('Should not take a default value is the variable is not missing', () => { - fixture.givenInput('true', { - type: 'boolean', - default: false, - }); - fixture.thenResultShouldBeValid(true); - }); - }); - - describe('enum field', () => { - it('Should fail if the variable is missing', () => { - fixture.givenInput(undefined, { - type: 'enum', - values: ['a', 'b'], - }); - fixture.thenResultShouldBeInvalid('missing'); - }); - - it('Should fail is the variable type is incorrect', () => { - fixture.givenInput('true', { - type: 'enum', - values: ['a', 'b'], - }); - fixture.thenResultShouldBeInvalid('type'); - }); - - it('Should not fail if the optional variable is missing', () => { - fixture.givenInput(undefined, { - type: 'enum', - values: ['a', 'b'], - optional: true, - }); - fixture.thenResultShouldBeValid(undefined); - }); - - it('Should not fail if the variable is missing and a default value is provided', () => { - fixture.givenInput(undefined, { - type: 'enum', - values: ['a', 'b'], - default: 'a', - }); - fixture.thenResultShouldBeValid('a'); - }); - - it('Should not take a default value is the variable is not missing', () => { - fixture.givenInput('b', { - type: 'enum', - values: ['a', 'b'], - default: 'a', - }); - fixture.thenResultShouldBeValid('b'); - }); - }); -}); - -describe('validateEnvPrefixAgainstSchema', () => { - /** - * Helper to build a minimal config object matching the shape - * validateEnvPrefixAgainstSchema expects. - * - * @param {Record} schema - * @param {string | string[] | undefined} envPrefix - */ - function makeConfig(schema, envPrefix) { - return /** @type {any} */ ({ - env: { schema }, - vite: envPrefix !== undefined ? { envPrefix } : {}, - }); - } - - it('should not throw when schema is empty', () => { - assert.doesNotThrow(() => { - validateEnvPrefixAgainstSchema(makeConfig({}, ['PUBLIC_', 'API_'])); - }); - }); - - it('should not throw when envPrefix is not set', () => { - assert.doesNotThrow(() => { - validateEnvPrefixAgainstSchema( - makeConfig( - { API_SECRET: { context: 'server', access: 'secret', type: 'string' } }, - undefined, - ), - ); - }); - }); - - it('should not throw when envPrefix does not match any secret variable', () => { - assert.doesNotThrow(() => { - validateEnvPrefixAgainstSchema( - makeConfig({ DB_PASSWORD: { context: 'server', access: 'secret', type: 'string' } }, [ - 'PUBLIC_', - 'API_', - ]), - ); - }); - }); - - it('should not throw when envPrefix matches a public variable', () => { - assert.doesNotThrow(() => { - validateEnvPrefixAgainstSchema( - makeConfig({ API_URL: { context: 'server', access: 'public', type: 'string' } }, [ - 'PUBLIC_', - 'API_', - ]), - ); - }); - }); - - it('should throw when envPrefix matches a secret variable (array prefix)', () => { - assert.throws( - () => { - validateEnvPrefixAgainstSchema( - makeConfig({ API_SECRET: { context: 'server', access: 'secret', type: 'string' } }, [ - 'PUBLIC_', - 'API_', - ]), - ); - }, - (err) => { - assert.equal(err.name, 'EnvPrefixConflictsWithSecret'); - assert.equal(err.message.includes('API_SECRET'), true); - return true; - }, - ); - }); - - it('should throw when envPrefix matches a secret variable (string prefix)', () => { - assert.throws( - () => { - validateEnvPrefixAgainstSchema( - makeConfig( - { SECRET_KEY: { context: 'server', access: 'secret', type: 'string' } }, - 'SECRET_', - ), - ); - }, - (err) => { - assert.equal(err.name, 'EnvPrefixConflictsWithSecret'); - assert.equal(err.message.includes('SECRET_KEY'), true); - return true; - }, - ); - }); - - it('should list all conflicting secret variables in the error', () => { - assert.throws( - () => { - validateEnvPrefixAgainstSchema( - makeConfig( - { - API_SECRET: { context: 'server', access: 'secret', type: 'string' }, - API_KEY: { context: 'server', access: 'secret', type: 'string' }, - API_URL: { context: 'server', access: 'public', type: 'string' }, - }, - ['PUBLIC_', 'API_'], - ), - ); - }, - (err) => { - assert.equal(err.name, 'EnvPrefixConflictsWithSecret'); - assert.equal(err.message.includes('API_SECRET'), true); - assert.equal(err.message.includes('API_KEY'), true); - assert.equal(err.message.includes('API_URL'), false); - return true; - }, - ); - }); - - it('should not throw when only the default PUBLIC_ prefix is used', () => { - assert.doesNotThrow(() => { - validateEnvPrefixAgainstSchema( - makeConfig( - { DB_PASSWORD: { context: 'server', access: 'secret', type: 'string' } }, - 'PUBLIC_', - ), - ); - }); - }); -}); diff --git a/packages/astro/test/units/env/env-validators.test.ts b/packages/astro/test/units/env/env-validators.test.ts new file mode 100644 index 000000000000..51f436877e0b --- /dev/null +++ b/packages/astro/test/units/env/env-validators.test.ts @@ -0,0 +1,659 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { + getEnvFieldType, + validateEnvVariable, + validateEnvPrefixAgainstSchema, +} from '../../../dist/env/validators.js'; + +type Params = Parameters; + +const createFixture = () => { + let input: { value: Params[0]; options: Params[1] } | undefined; + + return { + givenInput(value: Params[0], options: Params[1]) { + input = { value, options }; + }, + thenResultShouldBeValid(value: any) { + const result: any = validateEnvVariable(input!.value, input!.options); + assert.equal(result.ok, true); + assert.equal(result.value, value); + input = undefined; + }, + thenResultShouldBeInvalid(providedErrors: string | string[]) { + const result: any = validateEnvVariable(input!.value, input!.options); + assert.equal(result.ok, false); + const errors = typeof providedErrors === 'string' ? [providedErrors] : providedErrors; + assert.equal( + result.errors.every((element: string) => errors.includes(element)), + true, + ); + input = undefined; + }, + }; +}; + +describe('astro:env validators', () => { + let fixture: ReturnType; + + before(() => { + fixture = createFixture(); + }); + + it('types codegen should return the right string based on the field options', () => { + assert.equal( + getEnvFieldType({ + type: 'string', + }), + 'string', + ); + + assert.equal( + getEnvFieldType({ + type: 'string', + optional: true, + }), + 'string | undefined', + ); + + assert.equal( + getEnvFieldType({ + type: 'string', + optional: true, + default: 'abc', + }), + 'string', + ); + + assert.equal( + getEnvFieldType({ + type: 'number', + }), + 'number', + ); + + assert.equal( + getEnvFieldType({ + type: 'number', + optional: true, + }), + 'number | undefined', + ); + + assert.equal( + getEnvFieldType({ + type: 'number', + optional: true, + default: 456, + }), + 'number', + ); + + assert.equal( + getEnvFieldType({ + type: 'boolean', + }), + 'boolean', + ); + + assert.equal( + getEnvFieldType({ + type: 'boolean', + optional: true, + }), + 'boolean | undefined', + ); + + assert.equal( + getEnvFieldType({ + type: 'boolean', + optional: true, + default: true, + }), + 'boolean', + ); + + assert.equal( + getEnvFieldType({ + type: 'enum', + values: ['A'], + }), + "'A'", + ); + + assert.equal( + getEnvFieldType({ + type: 'enum', + values: ['A', 'B'], + }), + "'A' | 'B'", + ); + + assert.equal( + getEnvFieldType({ + type: 'enum', + optional: true, + values: ['A'], + }), + "'A' | undefined", + ); + assert.equal( + getEnvFieldType({ + type: 'enum', + optional: true, + values: ['A', 'B'], + default: 'A', + }), + "'A' | 'B'", + ); + }); + + describe('string field', () => { + it('Should fail if the variable is missing', () => { + fixture.givenInput(undefined, { + type: 'string', + }); + fixture.thenResultShouldBeInvalid('missing'); + }); + + it('Should not fail is the variable type is incorrect', () => { + fixture.givenInput('123456', { + type: 'string', + }); + fixture.thenResultShouldBeValid('123456'); + }); + + it('Should fail if conditions are not met', () => { + fixture.givenInput('abcdef', { + type: 'string', + max: 6, + }); + fixture.thenResultShouldBeValid('abcdef'); + + fixture.givenInput('abcdef', { + type: 'string', + max: 3, + }); + fixture.thenResultShouldBeInvalid('max'); + + fixture.givenInput('abc', { + type: 'string', + min: 1, + }); + fixture.thenResultShouldBeValid('abc'); + + fixture.givenInput('abc', { + type: 'string', + min: 5, + }); + fixture.thenResultShouldBeInvalid('min'); + + fixture.givenInput('abc', { + type: 'string', + length: 3, + }); + fixture.thenResultShouldBeValid('abc'); + + fixture.givenInput('abc', { + type: 'string', + length: 10, + }); + fixture.thenResultShouldBeInvalid('length'); + + fixture.givenInput('abc', { + type: 'string', + url: true, + }); + fixture.thenResultShouldBeInvalid('url'); + + fixture.givenInput('https://example.com', { + type: 'string', + url: true, + }); + fixture.thenResultShouldBeValid('https://example.com'); + + fixture.givenInput('abc', { + type: 'string', + includes: 'cd', + }); + fixture.thenResultShouldBeInvalid('includes'); + + fixture.givenInput('abc', { + type: 'string', + includes: 'bc', + }); + fixture.thenResultShouldBeValid('abc'); + + fixture.givenInput('abc', { + type: 'string', + startsWith: 'za', + }); + fixture.thenResultShouldBeInvalid('startsWith'); + + fixture.givenInput('abc', { + type: 'string', + startsWith: 'ab', + }); + fixture.thenResultShouldBeValid('abc'); + + fixture.givenInput('abc', { + type: 'string', + endsWith: 'za', + }); + fixture.thenResultShouldBeInvalid('endsWith'); + + fixture.givenInput('abc', { + type: 'string', + endsWith: 'bc', + }); + fixture.thenResultShouldBeValid('abc'); + + fixture.givenInput('abcd', { + type: 'string', + startsWith: 'ab', + endsWith: 'cd', + }); + fixture.thenResultShouldBeValid('abcd'); + + fixture.givenInput(undefined, { + type: 'string', + min: 5, + }); + fixture.thenResultShouldBeInvalid('missing'); + + fixture.givenInput('ab', { + type: 'string', + startsWith: 'x', + min: 5, + }); + fixture.thenResultShouldBeInvalid(['startsWith', 'min']); + }); + + it('Should not fail if the optional variable is missing', () => { + fixture.givenInput(undefined, { + type: 'string', + optional: true, + }); + fixture.thenResultShouldBeValid(undefined); + }); + + it('Should not fail if the variable is missing and a default value is provided', () => { + fixture.givenInput(undefined, { + type: 'string', + default: 'abc', + }); + fixture.thenResultShouldBeValid('abc'); + }); + + it('Should not take a default value is the variable is not missing', () => { + fixture.givenInput('abc', { + type: 'string', + default: 'def', + }); + fixture.thenResultShouldBeValid('abc'); + }); + }); + + describe('number field', () => { + it('Should fail if the variable is missing', () => { + fixture.givenInput(undefined, { + type: 'number', + }); + fixture.thenResultShouldBeInvalid('missing'); + }); + + it('Should fail is the variable type is incorrect', () => { + fixture.givenInput('abc', { + type: 'number', + }); + fixture.thenResultShouldBeInvalid('type'); + }); + + it('Should fail if conditions are not met', () => { + fixture.givenInput('10', { + type: 'number', + gt: 15, + }); + fixture.thenResultShouldBeInvalid('gt'); + + fixture.givenInput('10', { + type: 'number', + gt: 10, + }); + fixture.thenResultShouldBeInvalid('gt'); + + fixture.givenInput('10', { + type: 'number', + gt: 5, + }); + fixture.thenResultShouldBeValid(10); + + fixture.givenInput('20', { + type: 'number', + min: 25, + }); + fixture.thenResultShouldBeInvalid('min'); + + fixture.givenInput('20', { + type: 'number', + min: 20, + }); + fixture.thenResultShouldBeValid(20); + + fixture.givenInput('20', { + type: 'number', + min: 5, + }); + fixture.thenResultShouldBeValid(20); + + fixture.givenInput('15', { + type: 'number', + lt: 10, + }); + fixture.thenResultShouldBeInvalid('lt'); + + fixture.givenInput('10', { + type: 'number', + lt: 10, + }); + fixture.thenResultShouldBeInvalid('lt'); + + fixture.givenInput('5', { + type: 'number', + lt: 10, + }); + fixture.thenResultShouldBeValid(5); + + fixture.givenInput('25', { + type: 'number', + max: 20, + }); + fixture.thenResultShouldBeInvalid('max'); + + fixture.givenInput('25', { + type: 'number', + max: 25, + }); + fixture.thenResultShouldBeValid(25); + + fixture.givenInput('25', { + type: 'number', + max: 30, + }); + fixture.thenResultShouldBeValid(25); + + fixture.givenInput('4.5', { + type: 'number', + int: true, + }); + fixture.thenResultShouldBeInvalid('int'); + + fixture.givenInput('25', { + type: 'number', + int: true, + }); + fixture.thenResultShouldBeValid(25); + + fixture.givenInput('4', { + type: 'number', + int: false, + }); + fixture.thenResultShouldBeInvalid('int'); + + fixture.givenInput('4.5', { + type: 'number', + int: false, + }); + fixture.thenResultShouldBeValid(4.5); + + fixture.givenInput(undefined, { + type: 'number', + gt: 10, + }); + fixture.thenResultShouldBeInvalid('missing'); + }); + + it('Should accept integers', () => { + fixture.givenInput('12345', { + type: 'number', + }); + fixture.thenResultShouldBeValid(12345); + }); + + it('Should accept floats', () => { + fixture.givenInput('12.34', { + type: 'number', + }); + fixture.thenResultShouldBeValid(12.34); + }); + + it('Should not fail if the optional variable is missing', () => { + fixture.givenInput(undefined, { + type: 'number', + optional: true, + }); + fixture.thenResultShouldBeValid(undefined); + }); + + it('Should not fail if the variable is missing and a default value is provided', () => { + fixture.givenInput(undefined, { + type: 'number', + default: 123, + }); + fixture.thenResultShouldBeValid(123); + }); + + it('Should not take a default value is the variable is not missing', () => { + fixture.givenInput('123', { + type: 'number', + default: 456, + }); + fixture.thenResultShouldBeValid(123); + }); + }); + + describe('boolean field', () => { + it('Should fail if the variable is missing', () => { + fixture.givenInput(undefined, { + type: 'boolean', + }); + fixture.thenResultShouldBeInvalid('missing'); + }); + + it('Should fail is the variable type is incorrect', () => { + fixture.givenInput('abc', { + type: 'boolean', + }); + fixture.thenResultShouldBeInvalid('type'); + }); + + it('Should not fail if the optional variable is missing', () => { + fixture.givenInput(undefined, { + type: 'boolean', + optional: true, + }); + fixture.thenResultShouldBeValid(undefined); + }); + + it('Should not fail if the variable is missing and a default value is provided', () => { + fixture.givenInput(undefined, { + type: 'boolean', + default: true, + }); + fixture.thenResultShouldBeValid(true); + }); + + it('Should not take a default value is the variable is not missing', () => { + fixture.givenInput('true', { + type: 'boolean', + default: false, + }); + fixture.thenResultShouldBeValid(true); + }); + }); + + describe('enum field', () => { + it('Should fail if the variable is missing', () => { + fixture.givenInput(undefined, { + type: 'enum', + values: ['a', 'b'], + }); + fixture.thenResultShouldBeInvalid('missing'); + }); + + it('Should fail is the variable type is incorrect', () => { + fixture.givenInput('true', { + type: 'enum', + values: ['a', 'b'], + }); + fixture.thenResultShouldBeInvalid('type'); + }); + + it('Should not fail if the optional variable is missing', () => { + fixture.givenInput(undefined, { + type: 'enum', + values: ['a', 'b'], + optional: true, + }); + fixture.thenResultShouldBeValid(undefined); + }); + + it('Should not fail if the variable is missing and a default value is provided', () => { + fixture.givenInput(undefined, { + type: 'enum', + values: ['a', 'b'], + default: 'a', + }); + fixture.thenResultShouldBeValid('a'); + }); + + it('Should not take a default value is the variable is not missing', () => { + fixture.givenInput('b', { + type: 'enum', + values: ['a', 'b'], + default: 'a', + }); + fixture.thenResultShouldBeValid('b'); + }); + }); +}); + +describe('validateEnvPrefixAgainstSchema', () => { + function makeConfig(schema: Record, envPrefix?: string | string[]): any { + return { + env: { schema }, + vite: envPrefix !== undefined ? { envPrefix } : {}, + }; + } + + it('should not throw when schema is empty', () => { + assert.doesNotThrow(() => { + validateEnvPrefixAgainstSchema(makeConfig({}, ['PUBLIC_', 'API_'])); + }); + }); + + it('should not throw when envPrefix is not set', () => { + assert.doesNotThrow(() => { + validateEnvPrefixAgainstSchema( + makeConfig( + { API_SECRET: { context: 'server', access: 'secret', type: 'string' } }, + undefined, + ), + ); + }); + }); + + it('should not throw when envPrefix does not match any secret variable', () => { + assert.doesNotThrow(() => { + validateEnvPrefixAgainstSchema( + makeConfig({ DB_PASSWORD: { context: 'server', access: 'secret', type: 'string' } }, [ + 'PUBLIC_', + 'API_', + ]), + ); + }); + }); + + it('should not throw when envPrefix matches a public variable', () => { + assert.doesNotThrow(() => { + validateEnvPrefixAgainstSchema( + makeConfig({ API_URL: { context: 'server', access: 'public', type: 'string' } }, [ + 'PUBLIC_', + 'API_', + ]), + ); + }); + }); + + it('should throw when envPrefix matches a secret variable (array prefix)', () => { + assert.throws( + () => { + validateEnvPrefixAgainstSchema( + makeConfig({ API_SECRET: { context: 'server', access: 'secret', type: 'string' } }, [ + 'PUBLIC_', + 'API_', + ]), + ); + }, + (err: any) => { + assert.equal(err.name, 'EnvPrefixConflictsWithSecret'); + assert.equal(err.message.includes('API_SECRET'), true); + return true; + }, + ); + }); + + it('should throw when envPrefix matches a secret variable (string prefix)', () => { + assert.throws( + () => { + validateEnvPrefixAgainstSchema( + makeConfig( + { SECRET_KEY: { context: 'server', access: 'secret', type: 'string' } }, + 'SECRET_', + ), + ); + }, + (err: any) => { + assert.equal(err.name, 'EnvPrefixConflictsWithSecret'); + assert.equal(err.message.includes('SECRET_KEY'), true); + return true; + }, + ); + }); + + it('should list all conflicting secret variables in the error', () => { + assert.throws( + () => { + validateEnvPrefixAgainstSchema( + makeConfig( + { + API_SECRET: { context: 'server', access: 'secret', type: 'string' }, + API_KEY: { context: 'server', access: 'secret', type: 'string' }, + API_URL: { context: 'server', access: 'public', type: 'string' }, + }, + ['PUBLIC_', 'API_'], + ), + ); + }, + (err: any) => { + assert.equal(err.name, 'EnvPrefixConflictsWithSecret'); + assert.equal(err.message.includes('API_SECRET'), true); + assert.equal(err.message.includes('API_KEY'), true); + assert.equal(err.message.includes('API_URL'), false); + return true; + }, + ); + }); + + it('should not throw when only the default PUBLIC_ prefix is used', () => { + assert.doesNotThrow(() => { + validateEnvPrefixAgainstSchema( + makeConfig( + { DB_PASSWORD: { context: 'server', access: 'secret', type: 'string' } }, + 'PUBLIC_', + ), + ); + }); + }); +}); diff --git a/packages/astro/test/units/errors/dev-utils.test.js b/packages/astro/test/units/errors/dev-utils.test.ts similarity index 100% rename from packages/astro/test/units/errors/dev-utils.test.js rename to packages/astro/test/units/errors/dev-utils.test.ts diff --git a/packages/astro/test/units/errors/errors.test.js b/packages/astro/test/units/errors/errors.test.ts similarity index 100% rename from packages/astro/test/units/errors/errors.test.js rename to packages/astro/test/units/errors/errors.test.ts diff --git a/packages/astro/test/units/errors/zod-error-map.test.ts b/packages/astro/test/units/errors/zod-error-map.test.ts new file mode 100644 index 000000000000..622858a24792 --- /dev/null +++ b/packages/astro/test/units/errors/zod-error-map.test.ts @@ -0,0 +1,193 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { errorMap } from '../../../dist/core/errors/zod-error-map.js'; + +/** Extract the message string from errorMap's return value. */ +function getMessage(result: ReturnType): string { + if (typeof result === 'string') return result; + if (result && typeof result === 'object' && 'message' in result) return result.message; + throw new Error(`Expected a message, got ${JSON.stringify(result)}`); +} + +// #region invalid_type +describe('errorMap — invalid_type', () => { + it('formats expected vs received message', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_type', + expected: 'string', + input: 42, + path: [], + message: '', + }), + ); + assert.match(msg, /Expected type `"string"`/); + assert.match(msg, /received `"number"`/); + }); + + it('includes bold path prefix for nested paths', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_type', + expected: 'boolean', + input: 'hello', + path: ['config', 'enabled'], + message: '', + }), + ); + assert.match(msg, /\*\*config\.enabled\*\*/); + assert.match(msg, /Expected type `"boolean"`/); + }); + + it('shows "Required" when received is undefined', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_type', + expected: 'string', + input: undefined, + path: ['name'], + message: 'Required', + }), + ); + assert.match(msg, /Required/); + }); + + it('handles root-level path (empty path)', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_type', + expected: 'object', + input: 'bad', + path: [], + message: '', + }), + ); + // No bold prefix when path is empty + assert.ok(!msg.includes('**')); + assert.match(msg, /Expected type `"object"`/); + }); +}); +// #endregion + +// #region invalid_union +describe('errorMap — invalid_union', () => { + it('deduplicates common type errors across union members', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_union', + input: 123, + path: [], + message: '', + errors: [ + [ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + input: 123, + path: ['key'], + message: '', + } as any, + ], + [ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + input: 123, + path: ['key'], + message: '', + } as any, + ], + ], + }), + ); + assert.match(msg, /Did not match union/); + assert.match(msg, /\*\*key\*\*/); + assert.match(msg, /Expected type/); + assert.match(msg, /received/); + }); + + it('shows expected shapes when type errors differ across union members', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_union', + input: { wrong: true }, + path: [], + message: '', + errors: [ + [ + { + code: 'invalid_type', + expected: 'string', + input: { wrong: true }, + path: ['a'], + message: '', + }, + ], + [ + { + code: 'invalid_type', + expected: 'number', + input: { wrong: true }, + path: ['b'], + message: '', + }, + ], + ], + }), + ); + assert.match(msg, /Did not match union/); + assert.match(msg, /Expected type/); + }); + + it('handles nested path for union error', () => { + const msg = getMessage( + errorMap({ + code: 'invalid_union', + input: 'bad', + path: ['items', 0], + message: '', + errors: [ + [ + { + code: 'invalid_type', + expected: 'string', + input: 'bad', + path: ['items', 0, 'type'], + message: '', + }, + ], + ], + }), + ); + assert.match(msg, /\*\*items\.0\*\*/); + }); +}); +// #endregion + +// #region fallback +describe('errorMap — fallback behavior', () => { + it('returns message with path prefix for issues with a message', () => { + const msg = getMessage( + errorMap({ + code: 'custom' as any, + path: ['setting'], + message: 'Invalid value', + input: undefined, + }), + ); + assert.match(msg, /\*\*setting\*\*: Invalid value/); + }); + + it('returns undefined for unknown code without message', () => { + const result = errorMap({ + code: 'custom' as any, + path: [], + input: undefined, + message: undefined as any, + }); + assert.equal(result, undefined); + }); +}); +// #endregion diff --git a/packages/astro/test/units/i18n/astro_i18n.test.js b/packages/astro/test/units/i18n/astro_i18n.test.js deleted file mode 100644 index 5714c9cf8629..000000000000 --- a/packages/astro/test/units/i18n/astro_i18n.test.js +++ /dev/null @@ -1,1905 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { toRoutingStrategy } from '../../../dist/core/app/common.js'; -import { validateConfig } from '../../../dist/core/config/validate.js'; -import { MissingLocale } from '../../../dist/core/errors/errors-data.js'; -import { AstroError } from '../../../dist/core/errors/index.js'; -import { - getLocaleAbsoluteUrl, - getLocaleAbsoluteUrlList, - getLocaleRelativeUrl, - getLocaleRelativeUrlList, -} from '../../../dist/i18n/index.js'; -import { parseLocale } from '../../../dist/i18n/utils.js'; - -describe('getLocaleRelativeUrl', () => { - it('should correctly return the URL with the base', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - experimental: { - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'en_US', - 'es', - { - path: 'italiano', - codes: ['it', 'it-VA'], - }, - ], - }, - }, - }; - - // directory format - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/blog/', - trailingSlash: 'always', - format: 'directory', - ...config.experimental.i18n, - }), - '/blog/', - ); - assert.equal( - getLocaleRelativeUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - }), - '/blog/es/', - ); - - // file format - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'file', - }), - '/blog/', - ); - assert.equal( - getLocaleRelativeUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'file', - }), - '/blog/es/', - ); - - assert.equal( - getLocaleRelativeUrl({ - locale: 'it-VA', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'file', - }), - '/blog/italiano/', - ); - }); - - it('should correctly return the URL without base', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'es'], - }, - }, - }; - - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - }), - '/', - ); - assert.equal( - getLocaleRelativeUrl({ - locale: 'es', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - }), - '/es/', - ); - - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'file', - }), - '/', - ); - - assert.equal( - getLocaleRelativeUrl({ - locale: 'es', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'file', - }), - '/es', - ); - - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'directory', - }), - '/', - ); - }); - - it('should correctly handle the trailing slash', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'es', - { - path: 'italiano', - codes: ['it', 'it-VA'], - }, - ], - }, - }; - // directory format - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/blog', - ...config.i18n, - trailingSlash: 'never', - format: 'directory', - }), - '/blog', - ); - assert.equal( - getLocaleRelativeUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - }), - '/blog/es/', - ); - - assert.equal( - getLocaleRelativeUrl({ - locale: 'it-VA', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - }), - '/blog/italiano/', - ); - - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/blog/', - ...config.i18n, - trailingSlash: 'ignore', - format: 'directory', - }), - '/blog/', - ); - - // directory file - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/blog', - ...config.i18n, - trailingSlash: 'never', - format: 'file', - }), - '/blog', - ); - assert.equal( - getLocaleRelativeUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - }), - '/blog/es/', - ); - - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - // ignore + file => no trailing slash - base: '/blog', - ...config.i18n, - trailingSlash: 'ignore', - format: 'file', - }), - '/blog', - ); - }); - - it('should normalize locales by default', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'en_AU'], - }, - }; - - assert.equal( - getLocaleRelativeUrl({ - locale: 'en_US', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - '/blog/en-us/', - ); - - assert.equal( - getLocaleRelativeUrl({ - locale: 'en_US', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - normalizeLocale: false, - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - '/blog/en_US/', - ); - - assert.equal( - getLocaleRelativeUrl({ - locale: 'en_AU', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - '/blog/en-au/', - ); - }); - - it('should return the default locale when routing strategy is [pathname-prefix-always]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - i18n: { - defaultLocale: 'en', - locales: ['en', 'es', 'en_US', 'en_AU'], - routing: { - prefixDefaultLocale: true, - }, - }, - }; - - // directory format - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/blog/', - trailingSlash: 'always', - format: 'directory', - ...config.i18n, - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - '/blog/en/', - ); - assert.equal( - getLocaleRelativeUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - '/blog/es/', - ); - - // file format - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - '/blog/en/', - ); - assert.equal( - getLocaleRelativeUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - '/blog/es/', - ); - }); - - it('should return the default locale when routing strategy is [pathname-prefix-always-no-redirect]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - i18n: { - defaultLocale: 'en', - locales: ['en', 'es', 'en_US', 'en_AU'], - routing: { - prefixDefaultLocale: true, - redirectToDefaultLocale: false, - }, - }, - }; - - // directory format - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/blog/', - trailingSlash: 'always', - format: 'directory', - ...config.i18n, - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - '/blog/en/', - ); - assert.equal( - getLocaleRelativeUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - '/blog/es/', - ); - - // file format - assert.equal( - getLocaleRelativeUrl({ - locale: 'en', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - '/blog/en/', - ); - assert.equal( - getLocaleRelativeUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - '/blog/es/', - ); - }); -}); - -describe('getLocaleRelativeUrlList', () => { - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'en_US', - 'es', - { - path: 'italiano', - codes: ['it', 'it-VA'], - }, - ], - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleRelativeUrlList({ - locale: 'en', - base: '/blog', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'directory', - }), - ['/blog', '/blog/en-us', '/blog/es', '/blog/italiano'], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: always]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'en_US', - 'es', - { - path: 'italiano', - codes: ['it', 'it-VA'], - }, - ], - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleRelativeUrlList({ - locale: 'en', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - }), - ['/blog/', '/blog/en-us/', '/blog/es/', '/blog/italiano/'], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: always]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleRelativeUrlList({ - locale: 'en', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'file', - }), - ['/blog/', '/blog/en-us/', '/blog/es/'], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: never]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleRelativeUrlList({ - locale: 'en', - base: '/blog', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'file', - }), - ['/blog', '/blog/en-us', '/blog/es'], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: ignore]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleRelativeUrlList({ - locale: 'en', - base: '/blog', - ...config.experimental.i18n, - trailingSlash: 'ignore', - format: 'file', - }), - ['/blog', '/blog/en-us', '/blog/es'], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleRelativeUrlList({ - locale: 'en', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'ignore', - format: 'directory', - }), - ['/blog/', '/blog/en-us/', '/blog/es/'], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never, routingStrategy: pathname-prefix-always]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - routing: { - prefixDefaultLocale: true, - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleRelativeUrlList({ - locale: 'en', - base: '/blog', - ...config.i18n, - trailingSlash: 'never', - format: 'directory', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - ['/blog/en', '/blog/en-us', '/blog/es'], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never, routingStrategy: pathname-prefix-always-no-redirect]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - routing: { - prefixDefaultLocale: true, - redirectToDefaultLocale: false, - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleRelativeUrlList({ - locale: 'en', - base: '/blog', - ...config.i18n, - trailingSlash: 'never', - format: 'directory', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - ['/blog/en', '/blog/en-us', '/blog/es'], - ); - }); -}); - -describe('getLocaleAbsoluteUrl', () => { - describe('with [prefix-other-locales]', () => { - it('should correctly return the URL with the base', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'en_US', - 'es', - { - path: 'italiano', - codes: ['it', 'it-VA'], - }, - ], - domains: { - es: 'https://es.example.com', - }, - routingStrategy: 'prefix-other-locales', - }, - }; - - // directory format - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - ...config.i18n, - }), - 'https://example.com/blog/', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }), - 'https://example.com/blog/es/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - isBuild: true, - }), - 'https://es.example.com/blog/', - ); - - assert.throws( - () => - getLocaleAbsoluteUrl({ - locale: 'ff', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }), - - new AstroError({ - ...MissingLocale, - message: MissingLocale.message('ff'), - }), - ); - - // file format - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - }), - 'https://example.com/blog/', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - }), - 'https://example.com/blog/es/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'it-VA', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - }), - 'https://example.com/blog/italiano/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - isBuild: true, - }), - 'https://es.example.com/blog/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - prependWith: 'some-name', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - path: 'first-post', - isBuild: true, - }), - 'https://es.example.com/blog/some-name/first-post/', - ); - - // en isn't mapped to a domain - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - prependWith: 'some-name', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - path: 'first-post', - isBuild: true, - }), - 'https://example.com/blog/some-name/first-post/', - ); - }); - }); - describe('with [prefix-always]', () => { - it('should correctly return the URL with the base', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - domains: { - es: 'https://es.example.com', - }, - routing: { - prefixDefaultLocale: true, - }, - }, - }; - - // directory format - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - ...config.i18n, - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/en/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/es/', - ); - - // file format - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/en/', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/es/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - isBuild: true, - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://es.example.com/blog/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - prependWith: 'some-name', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - path: 'first-post', - isBuild: true, - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://es.example.com/blog/some-name/first-post/', - ); - }); - it('should correctly return the URL without base', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - i18n: { - defaultLocale: 'en', - locales: ['en', 'es'], - routing: { - prefixDefaultLocale: true, - }, - }, - }; - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/en/', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/es/', - ); - }); - - it('should correctly handle the trailing slash', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - i18n: { - defaultLocale: 'en', - locales: ['en', 'es'], - routing: { - prefixDefaultLocale: true, - }, - }, - }; - // directory format - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog', - ...config.i18n, - trailingSlash: 'never', - format: 'directory', - site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/en', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/es/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - ...config.i18n, - trailingSlash: 'ignore', - format: 'directory', - site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/en/', - ); - - // directory file - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog', - ...config.i18n, - trailingSlash: 'never', - format: 'file', - site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/en', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/es/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - // ignore + file => no trailing slash - base: '/blog', - ...config.i18n, - trailingSlash: 'ignore', - format: 'file', - site: 'https://example.com', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/en', - ); - }); - - it('should normalize locales', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'en_AU'], - routingStrategy: 'pathname-prefix-always', - }, - }, - }; - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - }), - '/blog/en-us/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en_AU', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - }), - '/blog/en-au/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - normalizeLocale: true, - }), - '/blog/en-us/', - ); - }); - - it('should return the default locale when routing strategy is [pathname-prefix-always]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - i18n: { - defaultLocale: 'en', - locales: ['en', 'es', 'en_US', 'en_AU'], - routing: { - prefixDefaultLocale: true, - }, - }, - }; - - // directory format - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - trailingSlash: 'always', - site: 'https://example.com', - format: 'directory', - ...config.i18n, - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/en/', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'directory', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/es/', - ); - - // file format - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - ...config.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'file', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/en/', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'file', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/es/', - ); - }); - - it('should return the default locale when routing strategy is [pathname-prefix-always-no-redirect]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - i18n: { - defaultLocale: 'en', - locales: ['en', 'es', 'en_US', 'en_AU'], - routing: { - prefixDefaultLocale: true, - redirectToDefaultLocale: false, - }, - }, - }; - - // directory format - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - trailingSlash: 'always', - site: 'https://example.com', - format: 'directory', - ...config.i18n, - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/en/', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'directory', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/es/', - ); - - // file format - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - ...config.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'file', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/en/', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.i18n, - site: 'https://example.com', - trailingSlash: 'always', - format: 'file', - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - 'https://example.com/blog/es/', - ); - }); - }); - describe('with [prefix-other-locales]', () => { - it('should correctly return the URL without base', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'es', - { - path: 'italiano', - codes: ['it', 'it-VA'], - }, - ], - routingStrategy: 'prefix-other-locales', - }, - }, - }; - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }), - 'https://example.com/', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }), - 'https://example.com/es/', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'it-VA', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }), - 'https://example.com/italiano/', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'directory', - site: 'https://example.com', - }), - 'https://example.com', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'directory', - site: 'https://example.com', - }), - 'https://example.com/es', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'it-VA', - base: '/', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'directory', - site: 'https://example.com', - }), - 'https://example.com/italiano', - ); - }); - - it('should correctly handle the trailing slash', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'es'], - routingStrategy: 'prefix-other-locales', - }, - }, - }; - // directory format - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'directory', - site: 'https://example.com', - }), - 'https://example.com/blog', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - site: 'https://example.com', - }), - 'https://example.com/blog/es/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'ignore', - format: 'directory', - site: 'https://example.com', - }), - 'https://example.com/blog/', - ); - - // directory file - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - base: '/blog', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'file', - site: 'https://example.com', - }), - 'https://example.com/blog', - ); - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'es', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - }), - 'https://example.com/blog/es/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en', - // ignore + file => no trailing slash - base: '/blog', - ...config.experimental.i18n, - trailingSlash: 'ignore', - format: 'file', - site: 'https://example.com', - }), - 'https://example.com/blog', - ); - }); - - it('should normalize locales', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - base: '/blog', - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'en_AU'], - routingStrategy: 'prefix-other-locales', - }, - }, - }; - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - }), - '/blog/en-us/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en_AU', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - }), - '/blog/en-au/', - ); - - assert.equal( - getLocaleAbsoluteUrl({ - locale: 'en_US', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'always', - format: 'directory', - normalizeLocale: true, - }), - '/blog/en-us/', - ); - }); - }); -}); - -describe('getLocaleAbsoluteUrlList', () => { - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never]', async () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = await validateConfig( - { - trailingSlash: 'never', - format: 'directory', - site: 'https://example.com', - base: '/blog', - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'en_US', - 'es', - { - path: 'italiano', - codes: ['it', 'it-VA'], - }, - ], - }, - }, - process.cwd(), - ); - // directory format - assert.deepEqual( - getLocaleAbsoluteUrlList({ - locale: 'en', - ...config, - ...config.i18n, - isBuild: true, - }), - [ - 'https://example.com/blog', - 'https://example.com/blog/en-us', - 'https://example.com/blog/es', - 'https://example.com/blog/italiano', - ], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: always]', async () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = await validateConfig( - { - trailingSlash: 'always', - format: 'directory', - base: '/blog/', - site: 'https://example.com', - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - }, - }, - process.cwd(), - ); - // directory format - assert.deepEqual( - getLocaleAbsoluteUrlList({ - locale: 'en', - ...config, - ...config.i18n, - }), - [ - 'https://example.com/blog/', - 'https://example.com/blog/en-us/', - 'https://example.com/blog/es/', - ], - ); - }); - - it('should retrieve the correct list of base URL with locales and path [format: directory, trailingSlash: always]', async () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = await validateConfig( - { - format: 'directory', - site: 'https://example.com/', - trailingSlash: 'always', - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - routing: { - prefixDefaultLocale: true, - }, - }, - }, - process.cwd(), - ); - // directory format - assert.deepEqual( - getLocaleAbsoluteUrlList({ - locale: 'en', - path: 'download', - ...config, - ...config.i18n, - strategy: toRoutingStrategy(config.i18n.routing, {}), - }), - [ - 'https://example.com/en/download/', - 'https://example.com/en-us/download/', - 'https://example.com/es/download/', - ], - ); - }); - - it('should retrieve the correct list of base URL with locales and path [format: directory, trailingSlash: always, domains]', async () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = await validateConfig( - { - format: 'directory', - output: 'server', - site: 'https://example.com/', - trailingSlash: 'always', - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - routing: { - prefixDefaultLocale: true, - }, - domains: { - es: 'https://es.example.com', - }, - }, - }, - process.cwd(), - ); - // directory format - assert.deepEqual( - getLocaleAbsoluteUrlList({ - locale: 'en', - path: 'download', - ...config, - ...config.i18n, - strategy: toRoutingStrategy(config.i18n.routing, {}), - isBuild: true, - }), - [ - 'https://example.com/en/download/', - 'https://example.com/en-us/download/', - 'https://es.example.com/download/', - ], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: always]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - i18n: { - defaultLocale: 'en', - locales: [ - 'en', - 'en_US', - 'es', - { - path: 'italiano', - codes: ['it', 'it-VA'], - }, - ], - }, - }; - // directory format - assert.deepEqual( - getLocaleAbsoluteUrlList({ - locale: 'en', - base: '/blog/', - ...config.i18n, - trailingSlash: 'always', - format: 'file', - site: 'https://example.com', - }), - [ - 'https://example.com/blog/', - 'https://example.com/blog/en-us/', - 'https://example.com/blog/es/', - 'https://example.com/blog/italiano/', - ], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: never]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleAbsoluteUrlList({ - locale: 'en', - base: '/blog', - ...config.experimental.i18n, - trailingSlash: 'never', - format: 'file', - site: 'https://example.com', - }), - ['https://example.com/blog', 'https://example.com/blog/en-us', 'https://example.com/blog/es'], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: ignore]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleAbsoluteUrlList({ - locale: 'en', - base: '/blog', - ...config.experimental.i18n, - trailingSlash: 'ignore', - format: 'file', - site: 'https://example.com', - }), - ['https://example.com/blog', 'https://example.com/blog/en-us', 'https://example.com/blog/es'], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleAbsoluteUrlList({ - locale: 'en', - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'ignore', - format: 'directory', - site: 'https://example.com', - }), - [ - 'https://example.com/blog/', - 'https://example.com/blog/en-us/', - 'https://example.com/blog/es/', - ], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore, routingStrategy: pathname-prefix-always]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - routing: { - prefixDefaultLocale: true, - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleAbsoluteUrlList({ - locale: 'en', - base: '/blog/', - ...config.i18n, - strategy: toRoutingStrategy(config.i18n.routing, {}), - trailingSlash: 'ignore', - format: 'directory', - site: 'https://example.com', - }), - [ - 'https://example.com/blog/en/', - 'https://example.com/blog/en-us/', - 'https://example.com/blog/es/', - ], - ); - }); - - it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore, routingStrategy: pathname-prefix-always-no-redirect]', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - routing: { - prefixDefaultLocale: true, - redirectToDefaultLocale: false, - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleAbsoluteUrlList({ - locale: 'en', - base: '/blog/', - ...config.i18n, - strategy: toRoutingStrategy(config.i18n.routing, {}), - trailingSlash: 'ignore', - format: 'directory', - site: 'https://example.com', - }), - [ - 'https://example.com/blog/en/', - 'https://example.com/blog/en-us/', - 'https://example.com/blog/es/', - ], - ); - }); - - it('should retrieve the correct list of base URLs, swapped with the correct domain', () => { - /** - * - * @type {import("../../../dist/@types").AstroUserConfig} - */ - const config = { - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], - routingStrategy: 'pathname-prefix-always', - domains: { - es: 'https://es.example.com', - en: 'https://example.uk', - }, - }, - }, - }; - // directory format - assert.deepEqual( - getLocaleAbsoluteUrlList({ - base: '/blog/', - ...config.experimental.i18n, - trailingSlash: 'ignore', - format: 'directory', - site: 'https://example.com', - isBuild: true, - }), - [ - 'https://example.uk/blog/', - 'https://example.com/blog/en-us/', - 'https://es.example.com/blog/', - ], - ); - }); -}); - -describe('parse accept-header', () => { - it('should be parsed correctly', () => { - assert.deepEqual(parseLocale('*'), [{ locale: '*', qualityValue: undefined }]); - assert.deepEqual(parseLocale('fr'), [{ locale: 'fr', qualityValue: undefined }]); - assert.deepEqual(parseLocale('fr;q=0.6'), [{ locale: 'fr', qualityValue: 0.6 }]); - assert.deepEqual(parseLocale('fr;q=0.6,fr-CA;q=0.5'), [ - { locale: 'fr', qualityValue: 0.6 }, - { locale: 'fr-CA', qualityValue: 0.5 }, - ]); - - assert.deepEqual(parseLocale('fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5'), [ - { locale: 'fr-CH', qualityValue: undefined }, - { locale: 'fr', qualityValue: 0.9 }, - { locale: 'en', qualityValue: 0.8 }, - { locale: 'de', qualityValue: 0.7 }, - { locale: '*', qualityValue: 0.5 }, - ]); - }); - - it('should not return incorrect quality values', () => { - assert.deepEqual(parseLocale('wrong'), [{ locale: 'wrong', qualityValue: undefined }]); - assert.deepEqual(parseLocale('fr;f=0.7'), [{ locale: 'fr', qualityValue: undefined }]); - assert.deepEqual(parseLocale('fr;q=something'), [{ locale: 'fr', qualityValue: undefined }]); - assert.deepEqual(parseLocale('fr;q=1000'), [{ locale: 'fr', qualityValue: undefined }]); - }); -}); diff --git a/packages/astro/test/units/i18n/astro_i18n.test.ts b/packages/astro/test/units/i18n/astro_i18n.test.ts new file mode 100644 index 000000000000..fd051bf04f83 --- /dev/null +++ b/packages/astro/test/units/i18n/astro_i18n.test.ts @@ -0,0 +1,1786 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { AstroConfig } from '../../../dist/types/public/config.js'; +import { toRoutingStrategy } from '../../../dist/core/app/common.js'; +import { validateConfig } from '../../../dist/core/config/validate.js'; +import { MissingLocale } from '../../../dist/core/errors/errors-data.js'; +import { AstroError } from '../../../dist/core/errors/index.js'; +import { + getLocaleAbsoluteUrl, + getLocaleAbsoluteUrlList, + getLocaleRelativeUrl, + getLocaleRelativeUrlList, +} from '../../../dist/i18n/index.js'; +import { parseLocale } from '../../../dist/i18n/utils.js'; + +type I18nRouting = NonNullable['routing']; +type I18nRoutingInput = Partial> | 'manual' | undefined; + +// Helper wrappers that accept partial config objects (matching original JS test behavior). +// The i18n functions require full config types but these tests intentionally pass subsets. +const relativeUrl = (opts: Record) => + getLocaleRelativeUrl(opts as unknown as Parameters[0]); +const relativeUrlList = (opts: Record) => + getLocaleRelativeUrlList(opts as unknown as Parameters[0]); +const absoluteUrl = (opts: Record) => + getLocaleAbsoluteUrl(opts as unknown as Parameters[0]); +const absoluteUrlList = (opts: Record) => + getLocaleAbsoluteUrlList(opts as unknown as Parameters[0]); +const routingStrategy = (routing?: I18nRoutingInput, domains?: Record) => + toRoutingStrategy(routing as I18nRouting, domains); + +describe('getLocaleRelativeUrl', () => { + it('should correctly return the URL with the base', () => { + const config = { + base: '/blog', + experimental: { + i18n: { + defaultLocale: 'en', + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], + }, + }, + }; + + // directory format + assert.equal( + relativeUrl({ + locale: 'en', + base: '/blog/', + trailingSlash: 'always', + format: 'directory', + ...config.experimental.i18n, + }), + '/blog/', + ); + assert.equal( + relativeUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }), + '/blog/es/', + ); + + // file format + assert.equal( + relativeUrl({ + locale: 'en', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + }), + '/blog/', + ); + assert.equal( + relativeUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + }), + '/blog/es/', + ); + + assert.equal( + relativeUrl({ + locale: 'it-VA', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + }), + '/blog/italiano/', + ); + }); + + it('should correctly return the URL without base', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es'], + }, + }, + }; + + assert.equal( + relativeUrl({ + locale: 'en', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }), + '/', + ); + assert.equal( + relativeUrl({ + locale: 'es', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }), + '/es/', + ); + + assert.equal( + relativeUrl({ + locale: 'en', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'file', + }), + '/', + ); + + assert.equal( + relativeUrl({ + locale: 'es', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'file', + }), + '/es', + ); + + assert.equal( + relativeUrl({ + locale: 'en', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'directory', + }), + '/', + ); + }); + + it('should correctly handle the trailing slash', () => { + const config = { + i18n: { + defaultLocale: 'en', + locales: [ + 'en', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], + }, + }; + // directory format + assert.equal( + relativeUrl({ + locale: 'en', + base: '/blog', + ...config.i18n, + trailingSlash: 'never', + format: 'directory', + }), + '/blog', + ); + assert.equal( + relativeUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + }), + '/blog/es/', + ); + + assert.equal( + relativeUrl({ + locale: 'it-VA', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + }), + '/blog/italiano/', + ); + + assert.equal( + relativeUrl({ + locale: 'en', + base: '/blog/', + ...config.i18n, + trailingSlash: 'ignore', + format: 'directory', + }), + '/blog/', + ); + + // directory file + assert.equal( + relativeUrl({ + locale: 'en', + base: '/blog', + ...config.i18n, + trailingSlash: 'never', + format: 'file', + }), + '/blog', + ); + assert.equal( + relativeUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + }), + '/blog/es/', + ); + + assert.equal( + relativeUrl({ + locale: 'en', + // ignore + file => no trailing slash + base: '/blog', + ...config.i18n, + trailingSlash: 'ignore', + format: 'file', + }), + '/blog', + ); + }); + + it('should normalize locales by default', () => { + const config = { + base: '/blog', + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'en_AU'], + }, + }; + + assert.equal( + relativeUrl({ + locale: 'en_US', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + strategy: routingStrategy(), + }), + '/blog/en-us/', + ); + + assert.equal( + relativeUrl({ + locale: 'en_US', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + normalizeLocale: false, + strategy: routingStrategy(), + }), + '/blog/en_US/', + ); + + assert.equal( + relativeUrl({ + locale: 'en_AU', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + strategy: routingStrategy(), + }), + '/blog/en-au/', + ); + }); + + it('should return the default locale when routing strategy is [pathname-prefix-always]', () => { + const config = { + base: '/blog', + i18n: { + defaultLocale: 'en', + locales: ['en', 'es', 'en_US', 'en_AU'], + routing: { + prefixDefaultLocale: true, + }, + }, + }; + + // directory format + assert.equal( + relativeUrl({ + locale: 'en', + base: '/blog/', + trailingSlash: 'always', + format: 'directory', + ...config.i18n, + strategy: routingStrategy(config.i18n.routing), + }), + '/blog/en/', + ); + assert.equal( + relativeUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + strategy: routingStrategy(config.i18n.routing), + }), + '/blog/es/', + ); + + // file format + assert.equal( + relativeUrl({ + locale: 'en', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + strategy: routingStrategy(config.i18n.routing), + }), + '/blog/en/', + ); + assert.equal( + relativeUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + strategy: routingStrategy(config.i18n.routing), + }), + '/blog/es/', + ); + }); + + it('should return the default locale when routing strategy is [pathname-prefix-always-no-redirect]', () => { + const config = { + base: '/blog', + i18n: { + defaultLocale: 'en', + locales: ['en', 'es', 'en_US', 'en_AU'], + routing: { + prefixDefaultLocale: true, + redirectToDefaultLocale: false, + }, + }, + }; + + // directory format + assert.equal( + relativeUrl({ + locale: 'en', + base: '/blog/', + trailingSlash: 'always', + format: 'directory', + ...config.i18n, + strategy: routingStrategy(config.i18n.routing), + }), + '/blog/en/', + ); + assert.equal( + relativeUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + strategy: routingStrategy(config.i18n.routing), + }), + '/blog/es/', + ); + + // file format + assert.equal( + relativeUrl({ + locale: 'en', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + strategy: routingStrategy(config.i18n.routing), + }), + '/blog/en/', + ); + assert.equal( + relativeUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + strategy: routingStrategy(config.i18n.routing), + }), + '/blog/es/', + ); + }); +}); + +describe('getLocaleRelativeUrlList', () => { + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never]', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], + }, + }, + }; + // directory format + assert.deepEqual( + relativeUrlList({ + locale: 'en', + base: '/blog', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'directory', + }), + ['/blog', '/blog/en-us', '/blog/es', '/blog/italiano'], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: always]', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], + }, + }, + }; + // directory format + assert.deepEqual( + relativeUrlList({ + locale: 'en', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }), + ['/blog/', '/blog/en-us/', '/blog/es/', '/blog/italiano/'], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: always]', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + assert.deepEqual( + relativeUrlList({ + locale: 'en', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + }), + ['/blog/', '/blog/en-us/', '/blog/es/'], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: never]', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + assert.deepEqual( + relativeUrlList({ + locale: 'en', + base: '/blog', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'file', + }), + ['/blog', '/blog/en-us', '/blog/es'], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: ignore]', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + assert.deepEqual( + relativeUrlList({ + locale: 'en', + base: '/blog', + ...config.experimental.i18n, + trailingSlash: 'ignore', + format: 'file', + }), + ['/blog', '/blog/en-us', '/blog/es'], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore]', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + assert.deepEqual( + relativeUrlList({ + locale: 'en', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'ignore', + format: 'directory', + }), + ['/blog/', '/blog/en-us/', '/blog/es/'], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never, routingStrategy: pathname-prefix-always]', () => { + const config = { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + routing: { + prefixDefaultLocale: true, + }, + }, + }; + // directory format + assert.deepEqual( + relativeUrlList({ + locale: 'en', + base: '/blog', + ...config.i18n, + trailingSlash: 'never', + format: 'directory', + strategy: routingStrategy(config.i18n.routing), + }), + ['/blog/en', '/blog/en-us', '/blog/es'], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never, routingStrategy: pathname-prefix-always-no-redirect]', () => { + const config = { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + routing: { + prefixDefaultLocale: true, + redirectToDefaultLocale: false, + }, + }, + }; + // directory format + assert.deepEqual( + relativeUrlList({ + locale: 'en', + base: '/blog', + ...config.i18n, + trailingSlash: 'never', + format: 'directory', + strategy: routingStrategy(config.i18n.routing), + }), + ['/blog/en', '/blog/en-us', '/blog/es'], + ); + }); +}); + +describe('getLocaleAbsoluteUrl', () => { + describe('with [prefix-other-locales]', () => { + it('should correctly return the URL with the base', () => { + const config = { + base: '/blog', + i18n: { + defaultLocale: 'en', + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], + domains: { + es: 'https://es.example.com', + }, + routingStrategy: 'prefix-other-locales', + }, + }; + + // directory format + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog/', + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + ...config.i18n, + }), + 'https://example.com/blog/', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }), + 'https://example.com/blog/es/', + ); + + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + isBuild: true, + }), + 'https://es.example.com/blog/', + ); + + assert.throws( + () => + absoluteUrl({ + locale: 'ff', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }), + + new AstroError({ + ...MissingLocale, + message: MissingLocale.message('ff'), + }), + ); + + // file format + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }), + 'https://example.com/blog/', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }), + 'https://example.com/blog/es/', + ); + + assert.equal( + absoluteUrl({ + locale: 'it-VA', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }), + 'https://example.com/blog/italiano/', + ); + + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + isBuild: true, + }), + 'https://es.example.com/blog/', + ); + + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + prependWith: 'some-name', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + path: 'first-post', + isBuild: true, + }), + 'https://es.example.com/blog/some-name/first-post/', + ); + + // en isn't mapped to a domain + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog/', + prependWith: 'some-name', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + path: 'first-post', + isBuild: true, + }), + 'https://example.com/blog/some-name/first-post/', + ); + }); + }); + describe('with [prefix-always]', () => { + it('should correctly return the URL with the base', () => { + const config = { + base: '/blog', + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + domains: { + es: 'https://es.example.com', + }, + routing: { + prefixDefaultLocale: true, + }, + }, + }; + + // directory format + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog/', + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + ...config.i18n, + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/en/', + ); + + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/es/', + ); + + // file format + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/en/', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/es/', + ); + + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + isBuild: true, + strategy: routingStrategy(config.i18n.routing), + }), + 'https://es.example.com/blog/', + ); + + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + prependWith: 'some-name', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + path: 'first-post', + isBuild: true, + strategy: routingStrategy(config.i18n.routing), + }), + 'https://es.example.com/blog/some-name/first-post/', + ); + }); + it('should correctly return the URL without base', () => { + const config = { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es'], + routing: { + prefixDefaultLocale: true, + }, + }, + }; + + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/en/', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/es/', + ); + }); + + it('should correctly handle the trailing slash', () => { + const config = { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es'], + routing: { + prefixDefaultLocale: true, + }, + }, + }; + // directory format + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog', + ...config.i18n, + trailingSlash: 'never', + format: 'directory', + site: 'https://example.com', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/en', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/es/', + ); + + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.i18n, + trailingSlash: 'ignore', + format: 'directory', + site: 'https://example.com', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/en/', + ); + + // directory file + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog', + ...config.i18n, + trailingSlash: 'never', + format: 'file', + site: 'https://example.com', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/en', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/es/', + ); + + assert.equal( + absoluteUrl({ + locale: 'en', + // ignore + file => no trailing slash + base: '/blog', + ...config.i18n, + trailingSlash: 'ignore', + format: 'file', + site: 'https://example.com', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/en', + ); + }); + + it('should normalize locales', () => { + const config = { + base: '/blog', + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'en_AU'], + routingStrategy: 'pathname-prefix-always', + }, + }, + }; + + assert.equal( + absoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }), + '/blog/en-us/', + ); + + assert.equal( + absoluteUrl({ + locale: 'en_AU', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }), + '/blog/en-au/', + ); + + assert.equal( + absoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + normalizeLocale: true, + }), + '/blog/en-us/', + ); + }); + + it('should return the default locale when routing strategy is [pathname-prefix-always]', () => { + const config = { + base: '/blog', + i18n: { + defaultLocale: 'en', + locales: ['en', 'es', 'en_US', 'en_AU'], + routing: { + prefixDefaultLocale: true, + }, + }, + }; + + // directory format + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog/', + trailingSlash: 'always', + site: 'https://example.com', + format: 'directory', + ...config.i18n, + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/en/', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'directory', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/es/', + ); + + // file format + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'file', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/en/', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'file', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/es/', + ); + }); + + it('should return the default locale when routing strategy is [pathname-prefix-always-no-redirect]', () => { + const config = { + base: '/blog', + i18n: { + defaultLocale: 'en', + locales: ['en', 'es', 'en_US', 'en_AU'], + routing: { + prefixDefaultLocale: true, + redirectToDefaultLocale: false, + }, + }, + }; + + // directory format + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog/', + trailingSlash: 'always', + site: 'https://example.com', + format: 'directory', + ...config.i18n, + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/en/', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'directory', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/es/', + ); + + // file format + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'file', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/en/', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.i18n, + site: 'https://example.com', + trailingSlash: 'always', + format: 'file', + strategy: routingStrategy(config.i18n.routing), + }), + 'https://example.com/blog/es/', + ); + }); + }); + describe('with [prefix-other-locales]', () => { + it('should correctly return the URL without base', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: [ + 'en', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], + routingStrategy: 'prefix-other-locales', + }, + }, + }; + + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }), + 'https://example.com/', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }), + 'https://example.com/es/', + ); + assert.equal( + absoluteUrl({ + locale: 'it-VA', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }), + 'https://example.com/italiano/', + ); + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'directory', + site: 'https://example.com', + }), + 'https://example.com', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'directory', + site: 'https://example.com', + }), + 'https://example.com/es', + ); + assert.equal( + absoluteUrl({ + locale: 'it-VA', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'directory', + site: 'https://example.com', + }), + 'https://example.com/italiano', + ); + }); + + it('should correctly handle the trailing slash', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es'], + routingStrategy: 'prefix-other-locales', + }, + }, + }; + // directory format + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'directory', + site: 'https://example.com', + }), + 'https://example.com/blog', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }), + 'https://example.com/blog/es/', + ); + + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'ignore', + format: 'directory', + site: 'https://example.com', + }), + 'https://example.com/blog/', + ); + + // directory file + assert.equal( + absoluteUrl({ + locale: 'en', + base: '/blog', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'file', + site: 'https://example.com', + }), + 'https://example.com/blog', + ); + assert.equal( + absoluteUrl({ + locale: 'es', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }), + 'https://example.com/blog/es/', + ); + + assert.equal( + absoluteUrl({ + locale: 'en', + // ignore + file => no trailing slash + base: '/blog', + ...config.experimental.i18n, + trailingSlash: 'ignore', + format: 'file', + site: 'https://example.com', + }), + 'https://example.com/blog', + ); + }); + + it('should normalize locales', () => { + const config = { + base: '/blog', + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'en_AU'], + routingStrategy: 'prefix-other-locales', + }, + }, + }; + + assert.equal( + absoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }), + '/blog/en-us/', + ); + + assert.equal( + absoluteUrl({ + locale: 'en_AU', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + }), + '/blog/en-au/', + ); + + assert.equal( + absoluteUrl({ + locale: 'en_US', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + normalizeLocale: true, + }), + '/blog/en-us/', + ); + }); + }); +}); + +describe('getLocaleAbsoluteUrlList', () => { + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: never]', async () => { + const config: AstroConfig = await validateConfig( + { + trailingSlash: 'never', + format: 'directory', + site: 'https://example.com', + base: '/blog', + i18n: { + defaultLocale: 'en', + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], + }, + }, + process.cwd(), + 'build', + ); + // directory format + assert.deepEqual( + absoluteUrlList({ + locale: 'en', + ...config, + ...config.i18n, + isBuild: true, + }), + [ + 'https://example.com/blog', + 'https://example.com/blog/en-us', + 'https://example.com/blog/es', + 'https://example.com/blog/italiano', + ], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: always]', async () => { + const config: AstroConfig = await validateConfig( + { + trailingSlash: 'always', + format: 'directory', + base: '/blog/', + site: 'https://example.com', + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + process.cwd(), + 'build', + ); + // directory format + assert.deepEqual( + absoluteUrlList({ + locale: 'en', + ...config, + ...config.i18n, + }), + [ + 'https://example.com/blog/', + 'https://example.com/blog/en-us/', + 'https://example.com/blog/es/', + ], + ); + }); + + it('should retrieve the correct list of base URL with locales and path [format: directory, trailingSlash: always]', async () => { + const config: AstroConfig = await validateConfig( + { + format: 'directory', + site: 'https://example.com/', + trailingSlash: 'always', + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + routing: { + prefixDefaultLocale: true, + }, + }, + }, + process.cwd(), + 'build', + ); + // directory format + assert.deepEqual( + absoluteUrlList({ + locale: 'en', + path: 'download', + ...config, + ...config.i18n!, + strategy: routingStrategy(config.i18n!.routing), + }), + [ + 'https://example.com/en/download/', + 'https://example.com/en-us/download/', + 'https://example.com/es/download/', + ], + ); + }); + + it('should retrieve the correct list of base URL with locales and path [format: directory, trailingSlash: always, domains]', async () => { + const config: AstroConfig = await validateConfig( + { + format: 'directory', + output: 'server', + site: 'https://example.com/', + trailingSlash: 'always', + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + routing: { + prefixDefaultLocale: true, + }, + domains: { + es: 'https://es.example.com', + }, + }, + }, + process.cwd(), + 'build', + ); + // directory format + assert.deepEqual( + absoluteUrlList({ + locale: 'en', + path: 'download', + ...config, + ...config.i18n!, + strategy: routingStrategy(config.i18n!.routing), + isBuild: true, + }), + [ + 'https://example.com/en/download/', + 'https://example.com/en-us/download/', + 'https://es.example.com/download/', + ], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: always]', () => { + const config = { + i18n: { + defaultLocale: 'en', + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], + }, + }; + // directory format + assert.deepEqual( + absoluteUrlList({ + locale: 'en', + base: '/blog/', + ...config.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }), + [ + 'https://example.com/blog/', + 'https://example.com/blog/en-us/', + 'https://example.com/blog/es/', + 'https://example.com/blog/italiano/', + ], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: never]', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + assert.deepEqual( + absoluteUrlList({ + locale: 'en', + base: '/blog', + ...config.experimental.i18n, + trailingSlash: 'never', + format: 'file', + site: 'https://example.com', + }), + ['https://example.com/blog', 'https://example.com/blog/en-us', 'https://example.com/blog/es'], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: ignore]', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + assert.deepEqual( + absoluteUrlList({ + locale: 'en', + base: '/blog', + ...config.experimental.i18n, + trailingSlash: 'ignore', + format: 'file', + site: 'https://example.com', + }), + ['https://example.com/blog', 'https://example.com/blog/en-us', 'https://example.com/blog/es'], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore]', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + }, + }, + }; + // directory format + assert.deepEqual( + absoluteUrlList({ + locale: 'en', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'ignore', + format: 'directory', + site: 'https://example.com', + }), + [ + 'https://example.com/blog/', + 'https://example.com/blog/en-us/', + 'https://example.com/blog/es/', + ], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore, routingStrategy: pathname-prefix-always]', () => { + const config = { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + routing: { + prefixDefaultLocale: true, + }, + }, + }; + // directory format + assert.deepEqual( + absoluteUrlList({ + locale: 'en', + base: '/blog/', + ...config.i18n, + strategy: routingStrategy(config.i18n.routing), + trailingSlash: 'ignore', + format: 'directory', + site: 'https://example.com', + }), + [ + 'https://example.com/blog/en/', + 'https://example.com/blog/en-us/', + 'https://example.com/blog/es/', + ], + ); + }); + + it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: ignore, routingStrategy: pathname-prefix-always-no-redirect]', () => { + const config = { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + routing: { + prefixDefaultLocale: true, + redirectToDefaultLocale: false, + }, + }, + }; + // directory format + assert.deepEqual( + absoluteUrlList({ + locale: 'en', + base: '/blog/', + ...config.i18n, + strategy: routingStrategy(config.i18n.routing), + trailingSlash: 'ignore', + format: 'directory', + site: 'https://example.com', + }), + [ + 'https://example.com/blog/en/', + 'https://example.com/blog/en-us/', + 'https://example.com/blog/es/', + ], + ); + }); + + it('should retrieve the correct list of base URLs, swapped with the correct domain', () => { + const config = { + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'en_US', 'es'], + routingStrategy: 'pathname-prefix-always', + domains: { + es: 'https://es.example.com', + en: 'https://example.uk', + }, + }, + }, + }; + // directory format + assert.deepEqual( + absoluteUrlList({ + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'ignore', + format: 'directory', + site: 'https://example.com', + isBuild: true, + }), + [ + 'https://example.uk/blog/', + 'https://example.com/blog/en-us/', + 'https://es.example.com/blog/', + ], + ); + }); +}); + +describe('parse accept-header', () => { + it('should be parsed correctly', () => { + assert.deepEqual(parseLocale('*'), [{ locale: '*', qualityValue: undefined }]); + assert.deepEqual(parseLocale('fr'), [{ locale: 'fr', qualityValue: undefined }]); + assert.deepEqual(parseLocale('fr;q=0.6'), [{ locale: 'fr', qualityValue: 0.6 }]); + assert.deepEqual(parseLocale('fr;q=0.6,fr-CA;q=0.5'), [ + { locale: 'fr', qualityValue: 0.6 }, + { locale: 'fr-CA', qualityValue: 0.5 }, + ]); + + assert.deepEqual(parseLocale('fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5'), [ + { locale: 'fr-CH', qualityValue: undefined }, + { locale: 'fr', qualityValue: 0.9 }, + { locale: 'en', qualityValue: 0.8 }, + { locale: 'de', qualityValue: 0.7 }, + { locale: '*', qualityValue: 0.5 }, + ]); + }); + + it('should not return incorrect quality values', () => { + assert.deepEqual(parseLocale('wrong'), [{ locale: 'wrong', qualityValue: undefined }]); + assert.deepEqual(parseLocale('fr;f=0.7'), [{ locale: 'fr', qualityValue: undefined }]); + assert.deepEqual(parseLocale('fr;q=something'), [{ locale: 'fr', qualityValue: undefined }]); + assert.deepEqual(parseLocale('fr;q=1000'), [{ locale: 'fr', qualityValue: undefined }]); + }); +}); diff --git a/packages/astro/test/units/i18n/create-manifest.test.js b/packages/astro/test/units/i18n/create-manifest.test.js deleted file mode 100644 index e800a4309e30..000000000000 --- a/packages/astro/test/units/i18n/create-manifest.test.js +++ /dev/null @@ -1,126 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { createI18nFallbackRoutes } from '../../../dist/core/routing/create-manifest.js'; -import { createRouteData } from '../mocks.js'; - -const BASE_CONFIG = { - base: '/', - trailingSlash: 'ignore', -}; - -function makeI18n(overrides = {}) { - return { - defaultLocale: 'en', - locales: ['en', 'es'], - routing: {}, - domains: {}, - ...overrides, - }; -} - -describe('createI18nFallbackRoutes — prefix-other-locales, es → en fallback', () => { - it('creates a fallback route for /start when /es/start does not exist', () => { - const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); - const routes = [enStart]; - - createI18nFallbackRoutes(routes, makeI18n({ fallback: { es: 'en' } }), BASE_CONFIG); - - const fallback = enStart.fallbackRoutes.find((r) => r.route === '/es/start'); - assert.ok(fallback, 'expected fallback route /es/start'); - assert.equal(fallback.type, 'fallback'); - assert.equal(fallback.pathname, '/es/start'); - }); - - it('does not create a fallback when /es/start already exists', () => { - const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); - const esStart = createRouteData({ route: '/es/start', pathname: '/es/start', type: 'page' }); - const routes = [enStart, esStart]; - - createI18nFallbackRoutes(routes, makeI18n({ fallback: { es: 'en' } }), BASE_CONFIG); - - assert.equal(enStart.fallbackRoutes.length, 0); - }); - - it('creates fallback routes for multiple EN pages without ES equivalents', () => { - const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); - const enAbout = createRouteData({ route: '/about', pathname: '/about', type: 'page' }); - const routes = [enStart, enAbout]; - - createI18nFallbackRoutes(routes, makeI18n({ fallback: { es: 'en' } }), BASE_CONFIG); - - assert.ok(enStart.fallbackRoutes.find((r) => r.route === '/es/start')); - assert.ok(enAbout.fallbackRoutes.find((r) => r.route === '/es/about')); - }); - - it('does not double-prefix: /es/start only once when real ES page exists for a different route', () => { - const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); - const esOther = createRouteData({ route: '/es/other', pathname: '/es/other', type: 'page' }); - const routes = [enStart, esOther]; - - createI18nFallbackRoutes(routes, makeI18n({ fallback: { es: 'en' } }), BASE_CONFIG); - - const fallbacks = enStart.fallbackRoutes.filter((r) => r.route === '/es/start'); - assert.equal(fallbacks.length, 1, 'should not create duplicate fallback routes'); - - // The /es/ prefix should not be applied twice: no /es/es/start - const doublePrefixed = enStart.fallbackRoutes.find((r) => r.route.includes('/es/es/')); - assert.equal(doublePrefixed, undefined, 'double-prefixed route /es/es/start should not exist'); - }); -}); - -describe('createI18nFallbackRoutes — prefix-always, root redirect', () => { - it('creates a / fallback route pointing to the default locale index', () => { - const enIndex = createRouteData({ route: '/en', pathname: '/en', type: 'page' }); - const routes = [enIndex]; - - createI18nFallbackRoutes( - routes, - makeI18n({ - defaultLocale: 'en', - locales: ['en', 'pt'], - routing: { prefixDefaultLocale: true, redirectToDefaultLocale: true }, - }), - BASE_CONFIG, - ); - - const rootFallback = routes.find((r) => r.route === '/' && r.type === 'fallback'); - assert.ok(rootFallback, 'expected a root / fallback route for prefix-always strategy'); - }); -}); - -describe('createI18nFallbackRoutes — multiple fallback locales', () => { - it('creates fallback routes for both it and spanish when fallback to en', () => { - const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); - const routes = [enStart]; - - createI18nFallbackRoutes( - routes, - makeI18n({ - defaultLocale: 'en', - locales: ['en', 'it', { path: 'spanish', codes: ['es', 'es-AR'] }], - fallback: { it: 'en', spanish: 'en' }, - }), - BASE_CONFIG, - ); - - const itFallback = enStart.fallbackRoutes.find((r) => r.route === '/it/start'); - const esFallback = enStart.fallbackRoutes.find((r) => r.route === '/spanish/start'); - assert.ok(itFallback, 'expected fallback for /it/start'); - assert.ok(esFallback, 'expected fallback for /spanish/start'); - assert.equal(itFallback.type, 'fallback'); - assert.equal(esFallback.type, 'fallback'); - }); -}); - -describe('createI18nFallbackRoutes — no fallback config', () => { - it('does not generate any fallback routes when fallback is not configured', () => { - const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); - const routes = [enStart]; - - createI18nFallbackRoutes(routes, makeI18n(), BASE_CONFIG); - - assert.equal(enStart.fallbackRoutes.length, 0); - assert.equal(routes.length, 1); - }); -}); diff --git a/packages/astro/test/units/i18n/create-manifest.test.ts b/packages/astro/test/units/i18n/create-manifest.test.ts new file mode 100644 index 000000000000..8eac50a2f1ef --- /dev/null +++ b/packages/astro/test/units/i18n/create-manifest.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createI18nFallbackRoutes } from '../../../dist/core/routing/create-manifest.js'; +import type { AstroConfig } from '../../../dist/types/public/config.js'; +import { createRouteData } from '../mocks.ts'; + +const BASE_CONFIG: Pick = { + base: '/', + trailingSlash: 'ignore', +}; + +function makeI18n(overrides: Record = {}): NonNullable { + return { + defaultLocale: 'en', + locales: ['en', 'es'], + routing: {}, + domains: {}, + ...overrides, + } as NonNullable; +} + +describe('createI18nFallbackRoutes — prefix-other-locales, es → en fallback', () => { + it('creates a fallback route for /start when /es/start does not exist', () => { + const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); + const routes = [enStart]; + + createI18nFallbackRoutes(routes, makeI18n({ fallback: { es: 'en' } }), BASE_CONFIG); + + const fallback = enStart.fallbackRoutes.find((r) => r.route === '/es/start'); + assert.ok(fallback, 'expected fallback route /es/start'); + assert.equal(fallback.type, 'fallback'); + assert.equal(fallback.pathname, '/es/start'); + }); + + it('does not create a fallback when /es/start already exists', () => { + const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); + const esStart = createRouteData({ route: '/es/start', pathname: '/es/start', type: 'page' }); + const routes = [enStart, esStart]; + + createI18nFallbackRoutes(routes, makeI18n({ fallback: { es: 'en' } }), BASE_CONFIG); + + assert.equal(enStart.fallbackRoutes.length, 0); + }); + + it('creates fallback routes for multiple EN pages without ES equivalents', () => { + const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); + const enAbout = createRouteData({ route: '/about', pathname: '/about', type: 'page' }); + const routes = [enStart, enAbout]; + + createI18nFallbackRoutes(routes, makeI18n({ fallback: { es: 'en' } }), BASE_CONFIG); + + assert.ok(enStart.fallbackRoutes.find((r) => r.route === '/es/start')); + assert.ok(enAbout.fallbackRoutes.find((r) => r.route === '/es/about')); + }); + + it('does not double-prefix: /es/start only once when real ES page exists for a different route', () => { + const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); + const esOther = createRouteData({ route: '/es/other', pathname: '/es/other', type: 'page' }); + const routes = [enStart, esOther]; + + createI18nFallbackRoutes(routes, makeI18n({ fallback: { es: 'en' } }), BASE_CONFIG); + + const fallbacks = enStart.fallbackRoutes.filter((r) => r.route === '/es/start'); + assert.equal(fallbacks.length, 1, 'should not create duplicate fallback routes'); + + // The /es/ prefix should not be applied twice: no /es/es/start + const doublePrefixed = enStart.fallbackRoutes.find((r) => r.route.includes('/es/es/')); + assert.equal(doublePrefixed, undefined, 'double-prefixed route /es/es/start should not exist'); + }); +}); + +describe('createI18nFallbackRoutes — prefix-always, root redirect', () => { + it('creates a / fallback route pointing to the default locale index', () => { + const enIndex = createRouteData({ route: '/en', pathname: '/en', type: 'page' }); + const routes = [enIndex]; + + createI18nFallbackRoutes( + routes, + makeI18n({ + defaultLocale: 'en', + locales: ['en', 'pt'], + routing: { prefixDefaultLocale: true, redirectToDefaultLocale: true }, + }), + BASE_CONFIG, + ); + + const rootFallback = routes.find((r) => r.route === '/' && r.type === 'fallback'); + assert.ok(rootFallback, 'expected a root / fallback route for prefix-always strategy'); + }); +}); + +describe('createI18nFallbackRoutes — multiple fallback locales', () => { + it('creates fallback routes for both it and spanish when fallback to en', () => { + const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); + const routes = [enStart]; + + createI18nFallbackRoutes( + routes, + makeI18n({ + defaultLocale: 'en', + locales: ['en', 'it', { path: 'spanish', codes: ['es', 'es-AR'] }], + fallback: { it: 'en', spanish: 'en' }, + }), + BASE_CONFIG, + ); + + const itFallback = enStart.fallbackRoutes.find((r) => r.route === '/it/start'); + const esFallback = enStart.fallbackRoutes.find((r) => r.route === '/spanish/start'); + assert.ok(itFallback, 'expected fallback for /it/start'); + assert.ok(esFallback, 'expected fallback for /spanish/start'); + assert.equal(itFallback.type, 'fallback'); + assert.equal(esFallback.type, 'fallback'); + }); +}); + +describe('createI18nFallbackRoutes — no fallback config', () => { + it('does not generate any fallback routes when fallback is not configured', () => { + const enStart = createRouteData({ route: '/start', pathname: '/start', type: 'page' }); + const routes = [enStart]; + + createI18nFallbackRoutes(routes, makeI18n(), BASE_CONFIG); + + assert.equal(enStart.fallbackRoutes.length, 0); + assert.equal(routes.length, 1); + }); +}); diff --git a/packages/astro/test/units/i18n/fallback.test.js b/packages/astro/test/units/i18n/fallback.test.js deleted file mode 100644 index 01b91856216a..000000000000 --- a/packages/astro/test/units/i18n/fallback.test.js +++ /dev/null @@ -1,413 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { computeFallbackRoute } from '../../../dist/i18n/fallback.js'; -import { makeFallbackOptions } from './test-helpers.js'; - -describe('computeFallbackRoute', () => { - describe('when response status is not 404', () => { - it('returns none for 200 (success)', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/missing', - responseStatus: 200, - currentLocale: 'es', - fallback: { es: 'en' }, - }), - ); - - assert.equal(result.type, 'none'); - }); - - it('returns none for 301 (redirect)', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/redirect', - responseStatus: 301, - currentLocale: 'es', - fallback: { es: 'en' }, - }), - ); - - assert.equal(result.type, 'none'); - }); - - it('returns none for 302 (temporary redirect)', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/redirect', - responseStatus: 302, - currentLocale: 'es', - fallback: { es: 'en' }, - }), - ); - - assert.equal(result.type, 'none'); - }); - - it('returns none for 403 (forbidden)', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/forbidden', - responseStatus: 403, - currentLocale: 'es', - fallback: { es: 'en' }, - }), - ); - - assert.equal(result.type, 'none'); - }); - - it('returns none for 500 (server error)', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/error', - responseStatus: 500, - currentLocale: 'es', - fallback: { es: 'en' }, - }), - ); - - assert.equal(result.type, 'none'); - }); - }); - - describe('when no fallback configured', () => { - it('returns none for empty fallback object', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/missing', - responseStatus: 404, - currentLocale: 'es', - fallback: {}, - }), - ); - - assert.equal(result.type, 'none'); - }); - }); - - describe('when locale not in fallback config', () => { - it('returns none if current locale has no fallback', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/pt/missing', - responseStatus: 404, - currentLocale: 'pt', - fallback: { es: 'en' }, // Only es has fallback - }), - ); - - assert.equal(result.type, 'none'); - }); - }); - - describe('with fallbackType: redirect', () => { - it('returns redirect decision for fallback locale', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/missing', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'redirect', - strategy: 'pathname-prefix-always', - }), - ); - - assert.equal(result.type, 'redirect'); - assert.equal(result.pathname, '/en/missing'); - }); - - it('removes default locale prefix for prefix-other-locales strategy', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/missing', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'redirect', - strategy: 'pathname-prefix-other-locales', - defaultLocale: 'en', - }), - ); - - assert.equal(result.type, 'redirect'); - assert.equal(result.pathname, '/missing'); // No /en/ prefix - }); - - it('handles base path correctly', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/new-site/es/missing', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'redirect', - strategy: 'pathname-prefix-always', - base: '/new-site', - }), - ); - - assert.equal(result.type, 'redirect'); - assert.equal(result.pathname, '/new-site/en/missing'); - }); - - it('handles base path with prefix-other-locales strategy', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/new-site/es/missing', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'redirect', - strategy: 'pathname-prefix-other-locales', - defaultLocale: 'en', - base: '/new-site', - }), - ); - - assert.equal(result.type, 'redirect'); - assert.equal(result.pathname, '/new-site/missing'); - }); - - it('handles fallback to non-default locale', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/pt/missing', - responseStatus: 404, - currentLocale: 'pt', - fallback: { pt: 'es' }, // Fallback to Spanish, not English - fallbackType: 'redirect', - strategy: 'pathname-prefix-other-locales', - defaultLocale: 'en', - }), - ); - - assert.equal(result.type, 'redirect'); - assert.equal(result.pathname, '/es/missing'); - }); - - it('only triggers for 404 status, not 3xx', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/redirect', - responseStatus: 301, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'redirect', - }), - ); - - assert.equal(result.type, 'none'); - }); - - it('triggers for 404 status', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/notfound', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'redirect', - }), - ); - - assert.equal(result.type, 'redirect'); - }); - - it('only triggers for 404 status, not 5xx', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/error', - responseStatus: 500, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'redirect', - }), - ); - - assert.equal(result.type, 'none'); - }); - }); - - describe('with fallbackType: rewrite', () => { - it('returns rewrite decision for fallback locale', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/missing', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'rewrite', - strategy: 'pathname-prefix-always', - }), - ); - - assert.equal(result.type, 'rewrite'); - assert.equal(result.pathname, '/en/missing'); - }); - - it('removes default locale prefix for prefix-other-locales strategy', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/missing', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'rewrite', - strategy: 'pathname-prefix-other-locales', - defaultLocale: 'en', - }), - ); - - assert.equal(result.type, 'rewrite'); - assert.equal(result.pathname, '/missing'); - }); - - it('handles base path correctly', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/new-site/es/missing', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'rewrite', - strategy: 'pathname-prefix-always', - base: '/new-site', - }), - ); - - assert.equal(result.type, 'rewrite'); - assert.equal(result.pathname, '/new-site/en/missing'); - }); - - it('works with dynamic routes', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/blog/my-post', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'rewrite', - strategy: 'pathname-prefix-always', - }), - ); - - assert.equal(result.type, 'rewrite'); - assert.equal(result.pathname, '/en/blog/my-post'); - }); - - it('handles deep nested paths', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/blog/2024/01/post', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'rewrite', - strategy: 'pathname-prefix-always', - }), - ); - - assert.equal(result.type, 'rewrite'); - assert.equal(result.pathname, '/en/blog/2024/01/post'); - }); - }); - - describe('locale extraction from pathname', () => { - it('finds locale in first segment', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/page', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'redirect', - }), - ); - - assert.equal(result.type, 'redirect'); - }); - - it('handles paths without locale gracefully', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/page', - responseStatus: 404, - currentLocale: undefined, - fallback: { es: 'en' }, - fallbackType: 'redirect', - }), - ); - - assert.equal(result.type, 'none'); - }); - - it('handles granular locale configurations (object format)', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/spanish/page', - responseStatus: 404, - currentLocale: 'es', - locales: ['en', { path: 'spanish', codes: ['es', 'es-ES'] }, 'pt'], - fallback: { spanish: 'en' }, - fallbackType: 'redirect', - strategy: 'pathname-prefix-always', - }), - ); - - assert.equal(result.type, 'redirect'); - assert.equal(result.pathname, '/en/page'); - }); - }); - - describe('edge cases', () => { - it('handles root path', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'redirect', - strategy: 'pathname-prefix-always', - }), - ); - - assert.equal(result.type, 'redirect'); - assert.equal(result.pathname, '/en/'); - }); - - it('handles pathname without trailing slash', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'redirect', - strategy: 'pathname-prefix-always', - }), - ); - - assert.equal(result.type, 'redirect'); - assert.equal(result.pathname, '/en'); - }); - - it('preserves trailing content after locale replacement', () => { - const result = computeFallbackRoute( - makeFallbackOptions({ - pathname: '/es/a/b/c/d', - responseStatus: 404, - currentLocale: 'es', - fallback: { es: 'en' }, - fallbackType: 'rewrite', - strategy: 'pathname-prefix-always', - }), - ); - - assert.equal(result.type, 'rewrite'); - assert.equal(result.pathname, '/en/a/b/c/d'); - }); - }); -}); diff --git a/packages/astro/test/units/i18n/fallback.test.ts b/packages/astro/test/units/i18n/fallback.test.ts new file mode 100644 index 000000000000..4119b3137cdf --- /dev/null +++ b/packages/astro/test/units/i18n/fallback.test.ts @@ -0,0 +1,450 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { computeFallbackRoute } from '../../../dist/i18n/fallback.js'; +import type { FallbackRouteResult } from '../../../dist/i18n/fallback.js'; +import { makeFallbackOptions } from './test-helpers.ts'; + +describe('computeFallbackRoute', () => { + describe('when response status is not 404', () => { + it('returns none for 200 (success)', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/missing', + responseStatus: 200, + currentLocale: 'es', + fallback: { es: 'en' }, + }), + ); + + assert.equal(result.type, 'none'); + }); + + it('returns none for 301 (redirect)', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/redirect', + responseStatus: 301, + currentLocale: 'es', + fallback: { es: 'en' }, + }), + ); + + assert.equal(result.type, 'none'); + }); + + it('returns none for 302 (temporary redirect)', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/redirect', + responseStatus: 302, + currentLocale: 'es', + fallback: { es: 'en' }, + }), + ); + + assert.equal(result.type, 'none'); + }); + + it('returns none for 403 (forbidden)', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/forbidden', + responseStatus: 403, + currentLocale: 'es', + fallback: { es: 'en' }, + }), + ); + + assert.equal(result.type, 'none'); + }); + + it('returns none for 500 (server error)', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/error', + responseStatus: 500, + currentLocale: 'es', + fallback: { es: 'en' }, + }), + ); + + assert.equal(result.type, 'none'); + }); + }); + + describe('when no fallback configured', () => { + it('returns none for empty fallback object', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/missing', + responseStatus: 404, + currentLocale: 'es', + fallback: {}, + }), + ); + + assert.equal(result.type, 'none'); + }); + }); + + describe('when locale not in fallback config', () => { + it('returns none if current locale has no fallback', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/pt/missing', + responseStatus: 404, + currentLocale: 'pt', + fallback: { es: 'en' }, // Only es has fallback + }), + ); + + assert.equal(result.type, 'none'); + }); + }); + + describe('with fallbackType: redirect', () => { + it('returns redirect decision for fallback locale', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/missing', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'redirect', + strategy: 'pathname-prefix-always', + }), + ); + + assert.equal(result.type, 'redirect'); + assert.equal( + (result as Extract).pathname, + '/en/missing', + ); + }); + + it('removes default locale prefix for prefix-other-locales strategy', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/missing', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'redirect', + strategy: 'pathname-prefix-other-locales', + defaultLocale: 'en', + }), + ); + + assert.equal(result.type, 'redirect'); + assert.equal( + (result as Extract).pathname, + '/missing', + ); // No /en/ prefix + }); + + it('handles base path correctly', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/new-site/es/missing', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'redirect', + strategy: 'pathname-prefix-always', + base: '/new-site', + }), + ); + + assert.equal(result.type, 'redirect'); + assert.equal( + (result as Extract).pathname, + '/new-site/en/missing', + ); + }); + + it('handles base path with prefix-other-locales strategy', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/new-site/es/missing', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'redirect', + strategy: 'pathname-prefix-other-locales', + defaultLocale: 'en', + base: '/new-site', + }), + ); + + assert.equal(result.type, 'redirect'); + assert.equal( + (result as Extract).pathname, + '/new-site/missing', + ); + }); + + it('handles fallback to non-default locale', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/pt/missing', + responseStatus: 404, + currentLocale: 'pt', + fallback: { pt: 'es' }, // Fallback to Spanish, not English + fallbackType: 'redirect', + strategy: 'pathname-prefix-other-locales', + defaultLocale: 'en', + }), + ); + + assert.equal(result.type, 'redirect'); + assert.equal( + (result as Extract).pathname, + '/es/missing', + ); + }); + + it('only triggers for 404 status, not 3xx', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/redirect', + responseStatus: 301, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }), + ); + + assert.equal(result.type, 'none'); + }); + + it('triggers for 404 status', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/notfound', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }), + ); + + assert.equal(result.type, 'redirect'); + }); + + it('only triggers for 404 status, not 5xx', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/error', + responseStatus: 500, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }), + ); + + assert.equal(result.type, 'none'); + }); + }); + + describe('with fallbackType: rewrite', () => { + it('returns rewrite decision for fallback locale', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/missing', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'rewrite', + strategy: 'pathname-prefix-always', + }), + ); + + assert.equal(result.type, 'rewrite'); + assert.equal( + (result as Extract).pathname, + '/en/missing', + ); + }); + + it('removes default locale prefix for prefix-other-locales strategy', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/missing', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'rewrite', + strategy: 'pathname-prefix-other-locales', + defaultLocale: 'en', + }), + ); + + assert.equal(result.type, 'rewrite'); + assert.equal( + (result as Extract).pathname, + '/missing', + ); + }); + + it('handles base path correctly', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/new-site/es/missing', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'rewrite', + strategy: 'pathname-prefix-always', + base: '/new-site', + }), + ); + + assert.equal(result.type, 'rewrite'); + assert.equal( + (result as Extract).pathname, + '/new-site/en/missing', + ); + }); + + it('works with dynamic routes', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/blog/my-post', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'rewrite', + strategy: 'pathname-prefix-always', + }), + ); + + assert.equal(result.type, 'rewrite'); + assert.equal( + (result as Extract).pathname, + '/en/blog/my-post', + ); + }); + + it('handles deep nested paths', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/blog/2024/01/post', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'rewrite', + strategy: 'pathname-prefix-always', + }), + ); + + assert.equal(result.type, 'rewrite'); + assert.equal( + (result as Extract).pathname, + '/en/blog/2024/01/post', + ); + }); + }); + + describe('locale extraction from pathname', () => { + it('finds locale in first segment', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/page', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }), + ); + + assert.equal(result.type, 'redirect'); + }); + + it('handles paths without locale gracefully', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/page', + responseStatus: 404, + currentLocale: undefined, + fallback: { es: 'en' }, + fallbackType: 'redirect', + }), + ); + + assert.equal(result.type, 'none'); + }); + + it('handles granular locale configurations (object format)', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/spanish/page', + responseStatus: 404, + currentLocale: 'es', + locales: ['en', { path: 'spanish', codes: ['es', 'es-ES'] }, 'pt'], + fallback: { spanish: 'en' }, + fallbackType: 'redirect', + strategy: 'pathname-prefix-always', + }), + ); + + assert.equal(result.type, 'redirect'); + assert.equal( + (result as Extract).pathname, + '/en/page', + ); + }); + }); + + describe('edge cases', () => { + it('handles root path', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'redirect', + strategy: 'pathname-prefix-always', + }), + ); + + assert.equal(result.type, 'redirect'); + assert.equal((result as Extract).pathname, '/en/'); + }); + + it('handles pathname without trailing slash', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'redirect', + strategy: 'pathname-prefix-always', + }), + ); + + assert.equal(result.type, 'redirect'); + assert.equal((result as Extract).pathname, '/en'); + }); + + it('preserves trailing content after locale replacement', () => { + const result: FallbackRouteResult = computeFallbackRoute( + makeFallbackOptions({ + pathname: '/es/a/b/c/d', + responseStatus: 404, + currentLocale: 'es', + fallback: { es: 'en' }, + fallbackType: 'rewrite', + strategy: 'pathname-prefix-always', + }), + ); + + assert.equal(result.type, 'rewrite'); + assert.equal( + (result as Extract).pathname, + '/en/a/b/c/d', + ); + }); + }); +}); diff --git a/packages/astro/test/units/i18n/i18n-app.test.js b/packages/astro/test/units/i18n/i18n-app.test.js deleted file mode 100644 index 34df87a42535..000000000000 --- a/packages/astro/test/units/i18n/i18n-app.test.js +++ /dev/null @@ -1,453 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { createComponent, render } from '../../../dist/runtime/server/index.js'; -import { createI18nMiddleware } from '../../../dist/i18n/middleware.js'; -import { createTestApp, createPage } from '../mocks.js'; -import { dynamicPart, staticPart } from '../routing/test-helpers.js'; - -/** - * @param {Partial<{ - * defaultLocale: string, - * locales: import('../../../src/types/public/config.js').Locales, - * strategy: string, - * fallbackType: 'redirect' | 'rewrite', - * fallback: Record, - * }>} [overrides] - */ -function makeI18nConfig(overrides = {}) { - return { - defaultLocale: overrides.defaultLocale ?? 'en', - locales: overrides.locales ?? ['en', 'fr', 'es'], - strategy: overrides.strategy ?? 'pathname-prefix-always', - fallbackType: overrides.fallbackType ?? 'rewrite', - fallback: 'fallback' in overrides ? overrides.fallback : {}, - domains: {}, - domainLookupTable: {}, - }; -} - -const localePage = createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - return render`

    ${Astro.currentLocale}

    ${Astro.url.pathname}

    `; -}); - -const notFoundPage = createComponent(() => { - return render`

    404 Not Found

    `; -}); - -/** Shorthand for a locale-prefixed catch-all route */ -function localeCatchAll(locale) { - return createPage(localePage, { - route: `/${locale}/[...slug]`, - segments: [[staticPart(locale)], [dynamicPart('slug')]], - pathname: undefined, - }); -} - -describe('i18n via App - prefix-always', () => { - const i18n = makeI18nConfig({ strategy: 'pathname-prefix-always' }); - const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); - - function createI18nApp() { - return createTestApp([localeCatchAll('en'), localeCatchAll('fr')], { - i18n, - middleware: () => ({ onRequest: middleware }), - }); - } - - it('renders a page with Astro.currentLocale set to en', async () => { - const app = createI18nApp(); - const res = await app.render(new Request('http://example.com/en/about')); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'en'); - }); - - it('renders a page with Astro.currentLocale set to fr', async () => { - const app = createI18nApp(); - const res = await app.render(new Request('http://example.com/fr/about')); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'fr'); - }); - - it('redirects root / to /en/', async () => { - const app = createI18nApp(); - const res = await app.render(new Request('http://example.com/')); - assert.equal(res.status, 302); - assert.ok(res.headers.get('Location')?.includes('/en')); - }); - - it('returns 404 for path without locale prefix', async () => { - const app = createI18nApp(); - const res = await app.render(new Request('http://example.com/about')); - assert.equal(res.status, 404); - }); -}); - -describe('i18n via App - prefix-other-locales', () => { - const i18n = makeI18nConfig({ strategy: 'pathname-prefix-other-locales' }); - const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); - - function createI18nApp() { - return createTestApp( - [ - createPage(localePage, { - route: '/[...slug]', - segments: [[dynamicPart('slug')]], - pathname: undefined, - }), - localeCatchAll('fr'), - ], - { i18n, middleware: () => ({ onRequest: middleware }) }, - ); - } - - it('renders default locale without prefix', async () => { - const app = createI18nApp(); - const res = await app.render(new Request('http://example.com/about')); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'en'); - }); - - it('renders non-default locale with prefix', async () => { - const app = createI18nApp(); - const res = await app.render(new Request('http://example.com/fr/about')); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'fr'); - }); - - it('returns 404 with Location header when default locale used with prefix', async () => { - const app = createI18nApp(); - const res = await app.render(new Request('http://example.com/en/about')); - assert.equal(res.status, 404); - }); -}); - -describe('i18n via App - with base path', () => { - const i18n = makeI18nConfig({ strategy: 'pathname-prefix-always' }); - const middleware = createI18nMiddleware(i18n, '/docs/', 'ignore', 'directory'); - - function createI18nApp() { - return createTestApp([localeCatchAll('en')], { - base: '/docs/', - i18n, - middleware: () => ({ onRequest: middleware }), - }); - } - - it('renders with base path and locale', async () => { - const app = createI18nApp(); - const res = await app.render(new Request('http://example.com/docs/en/about')); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'en'); - }); - - it('redirects base path root to base + default locale', async () => { - const app = createI18nApp(); - const res = await app.render(new Request('http://example.com/docs/')); - assert.equal(res.status, 302); - assert.ok(res.headers.get('Location')?.includes('/docs/en')); - }); -}); - -describe('i18n via App - domains-prefix-always', () => { - const i18n = makeI18nConfig({ - strategy: 'domains-prefix-always', - locales: ['en', 'pt', 'it'], - defaultLocale: 'en', - }); - i18n.domainLookupTable = { - 'https://example.pt': 'pt', - 'https://it.example.com': 'it', - }; - i18n.domains = { - pt: 'https://example.pt', - it: 'https://it.example.com', - }; - - const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); - - function createDomainApp() { - return createTestApp([localeCatchAll('en'), localeCatchAll('pt'), localeCatchAll('it')], { - i18n, - middleware: () => ({ onRequest: middleware }), - }); - } - - it('renders Portuguese locale when request comes from example.pt', async () => { - const app = createDomainApp(); - const res = await app.render( - new Request('https://example.pt/about', { - headers: { 'X-Forwarded-Host': 'example.pt', 'X-Forwarded-Proto': 'https' }, - }), - ); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'pt'); - }); - - it('renders Italian locale when request comes from it.example.com', async () => { - const app = createDomainApp(); - const res = await app.render( - new Request('https://it.example.com/about', { - headers: { 'X-Forwarded-Host': 'it.example.com', 'X-Forwarded-Proto': 'https' }, - }), - ); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'it'); - }); - - it('renders English locale for non-domain request with /en/ prefix', async () => { - const app = createDomainApp(); - const res = await app.render(new Request('http://example.com/en/about')); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'en'); - }); - - it('uses Host header as fallback when X-Forwarded-Host is absent', async () => { - const app = createDomainApp(); - const res = await app.render( - new Request('https://example.pt/about', { - headers: { Host: 'example.pt' }, - }), - ); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'pt'); - }); - - it('protocol mismatch: HTTP request to HTTPS-configured domain', async () => { - const app = createDomainApp(); - const res = await app.render( - new Request('http://example.pt/about', { - headers: { 'X-Forwarded-Host': 'example.pt', 'X-Forwarded-Proto': 'http' }, - }), - ); - assert.equal(res.status, 404); - }); - - it('port in X-Forwarded-Host is stripped before matching', async () => { - const app = createDomainApp(); - const res = await app.render( - new Request('https://example.pt:8080/about', { - headers: { 'X-Forwarded-Host': 'example.pt:8080', 'X-Forwarded-Proto': 'https' }, - }), - ); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'pt'); - }); - - it('unknown domain falls through to normal pathname routing', async () => { - const app = createDomainApp(); - const res = await app.render( - new Request('https://unknown.com/en/about', { - headers: { 'X-Forwarded-Host': 'unknown.com', 'X-Forwarded-Proto': 'https' }, - }), - ); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'en'); - }); - - it('missing both Host and X-Forwarded-Host falls through to pathname routing', async () => { - const app = createDomainApp(); - const res = await app.render(new Request('http://localhost/en/about')); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'en'); - }); - - it('trailing slash is preserved on domain pathname', async () => { - const app = createDomainApp(); - const res = await app.render( - new Request('https://example.pt/about/', { - headers: { 'X-Forwarded-Host': 'example.pt', 'X-Forwarded-Proto': 'https' }, - }), - ); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'pt'); - assert.ok($('#path').text().endsWith('/')); - }); -}); - -describe('i18n via App - domains-prefix-other-locales', () => { - const i18n = makeI18nConfig({ - strategy: 'domains-prefix-other-locales', - locales: ['en', 'pt'], - defaultLocale: 'en', - }); - i18n.domainLookupTable = { 'https://example.pt': 'pt' }; - i18n.domains = { pt: 'https://example.pt' }; - - const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); - - function createDomainApp() { - return createTestApp( - [ - createPage(localePage, { - route: '/[...slug]', - segments: [[dynamicPart('slug')]], - pathname: undefined, - }), - localeCatchAll('pt'), - ], - { i18n, middleware: () => ({ onRequest: middleware }) }, - ); - } - - it('renders Portuguese from domain without locale prefix in URL', async () => { - const app = createDomainApp(); - const res = await app.render( - new Request('https://example.pt/about', { - headers: { 'X-Forwarded-Host': 'example.pt', 'X-Forwarded-Proto': 'https' }, - }), - ); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'pt'); - }); - - it('renders default locale without prefix on non-domain request', async () => { - const app = createDomainApp(); - const res = await app.render(new Request('http://example.com/about')); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'en'); - }); -}); - -// #15098: Invalid locale in URL should render 404, not the [locale] page -describe('i18n via App - invalid locale with dynamic [locale] route (#15098)', () => { - const i18n = makeI18nConfig({ - strategy: 'pathname-prefix-always', - locales: ['en', 'de'], - fallback: undefined, - }); - const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); - - function createApp() { - return createTestApp( - [ - createPage(localePage, { - route: '/[locale]', - segments: [[dynamicPart('locale')]], - pathname: undefined, - }), - createPage(notFoundPage, { route: '/404', component: '404.astro' }), - ], - { i18n, middleware: () => ({ onRequest: middleware }) }, - ); - } - - it('valid locale /en/ returns 200 with locale page', async () => { - const app = createApp(); - const res = await app.render(new Request('http://example.com/en/')); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'en'); - }); - - it('valid locale /de/ returns 200 with correct currentLocale', async () => { - const app = createApp(); - const res = await app.render(new Request('http://example.com/de/')); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'de'); - }); - - it('invalid locale /asdf/ returns 404 with 404 page content', async () => { - const app = createApp(); - const res = await app.render(new Request('http://example.com/asdf/')); - assert.equal(res.status, 404); - const $ = cheerio.load(await res.text()); - assert.equal( - $('#not-found').text(), - '404 Not Found', - 'Should render 404.astro, not [locale]/index.astro', - ); - }); - - it('invalid locale /xyz/ does not render the locale page', async () => { - const app = createApp(); - const res = await app.render(new Request('http://example.com/xyz/')); - assert.equal(res.status, 404); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), '', 'Should NOT contain locale page content'); - }); -}); - -// #12385: Domain i18n should resolve locale even with port in Host header -describe('i18n via App - domain with localhost and ports (#12385)', () => { - const i18n = makeI18nConfig({ - strategy: 'domains-prefix-other-locales', - locales: ['en', 'zh'], - }); - i18n.domainLookupTable = { 'http://zh.test': 'zh' }; - i18n.domains = { zh: 'http://zh.test' }; - - const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); - - function createApp() { - return createTestApp( - [ - createPage(localePage, { - route: '/[...slug]', - segments: [[dynamicPart('slug')]], - pathname: undefined, - }), - localeCatchAll('zh'), - ], - { i18n, middleware: () => ({ onRequest: middleware }) }, - ); - } - - it('zh.test without port resolves to Chinese locale', async () => { - const app = createApp(); - const res = await app.render( - new Request('http://zh.test/about', { headers: { Host: 'zh.test' } }), - ); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'zh'); - }); - - it('zh.test:4321 with port in Host header resolves to Chinese locale', async () => { - const app = createApp(); - const res = await app.render( - new Request('http://zh.test:4321/about', { headers: { Host: 'zh.test:4321' } }), - ); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'zh'); - }); - - it('zh.test:4321 via X-Forwarded-Host resolves to Chinese locale', async () => { - const app = createApp(); - const res = await app.render( - new Request('http://localhost:4321/about', { - headers: { 'X-Forwarded-Host': 'zh.test:4321', 'X-Forwarded-Proto': 'http' }, - }), - ); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'zh'); - }); - - it('default locale on non-matching domain works without prefix', async () => { - const app = createApp(); - const res = await app.render(new Request('http://test/about', { headers: { Host: 'test' } })); - assert.equal(res.status, 200); - const $ = cheerio.load(await res.text()); - assert.equal($('#locale').text(), 'en'); - }); -}); diff --git a/packages/astro/test/units/i18n/i18n-app.test.ts b/packages/astro/test/units/i18n/i18n-app.test.ts new file mode 100644 index 000000000000..8e887dd2b045 --- /dev/null +++ b/packages/astro/test/units/i18n/i18n-app.test.ts @@ -0,0 +1,539 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import type { RoutingStrategies } from '../../../dist/core/app/common.js'; +import { createI18nMiddleware } from '../../../dist/i18n/middleware.js'; +import { createComponent, render } from '../../../dist/runtime/server/index.js'; +import type { Locales } from '../../../dist/types/public/config.js'; +import { createPage, createTestApp } from '../mocks.ts'; +import { dynamicPart, spreadPart, staticPart } from '../routing/test-helpers.ts'; + +interface I18nConfigOverrides { + defaultLocale?: string; + locales?: Locales; + strategy?: RoutingStrategies; + fallbackType?: 'redirect' | 'rewrite'; + fallback?: Record; + domains?: Record; + domainLookupTable?: Record; +} + +function makeI18nConfig(overrides: I18nConfigOverrides = {}) { + return { + defaultLocale: overrides.defaultLocale ?? 'en', + locales: overrides.locales ?? (['en', 'fr', 'es'] as Locales), + strategy: overrides.strategy ?? ('pathname-prefix-always' as RoutingStrategies), + fallbackType: overrides.fallbackType ?? ('rewrite' as const), + fallback: 'fallback' in overrides ? overrides.fallback : ({} as Record), + domains: overrides.domains ?? ({} as Record), + domainLookupTable: overrides.domainLookupTable ?? ({} as Record), + }; +} + +const localePage = createComponent((result, props, slots) => { + const Astro = result.createAstro(props, slots); + return render`

    ${Astro.currentLocale}

    ${Astro.url.pathname}

    `; +}); + +const notFoundPage = createComponent(() => { + return render`

    404 Not Found

    `; +}); + +/** Shorthand for a locale-prefixed catch-all route */ +function localeCatchAll(locale: string) { + return createPage(localePage, { + route: `/${locale}/[...slug]`, + segments: [[staticPart(locale)], [dynamicPart('slug')]], + pathname: undefined, + }); +} + +describe('i18n via App - prefix-always', () => { + const i18n = makeI18nConfig({ strategy: 'pathname-prefix-always' }); + const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); + + function createI18nApp() { + return createTestApp([localeCatchAll('en'), localeCatchAll('fr')], { + i18n, + middleware: () => ({ onRequest: middleware }), + }); + } + + it('renders a page with Astro.currentLocale set to en', async () => { + const app = createI18nApp(); + const res = await app.render(new Request('http://example.com/en/about')); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'en'); + }); + + it('renders a page with Astro.currentLocale set to fr', async () => { + const app = createI18nApp(); + const res = await app.render(new Request('http://example.com/fr/about')); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'fr'); + }); + + it('redirects root / to /en/', async () => { + const app = createI18nApp(); + const res = await app.render(new Request('http://example.com/')); + assert.equal(res.status, 302); + assert.ok(res.headers.get('Location')?.includes('/en')); + }); + + it('returns 404 for path without locale prefix', async () => { + const app = createI18nApp(); + const res = await app.render(new Request('http://example.com/about')); + assert.equal(res.status, 404); + }); +}); + +describe('i18n via App - prefix-other-locales', () => { + const i18n = makeI18nConfig({ strategy: 'pathname-prefix-other-locales' }); + const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); + + function createI18nApp() { + return createTestApp( + [ + createPage(localePage, { + route: '/[...slug]', + segments: [[dynamicPart('slug')]], + pathname: undefined, + }), + localeCatchAll('fr'), + ], + { i18n, middleware: () => ({ onRequest: middleware }) }, + ); + } + + it('renders default locale without prefix', async () => { + const app = createI18nApp(); + const res = await app.render(new Request('http://example.com/about')); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'en'); + }); + + it('renders non-default locale with prefix', async () => { + const app = createI18nApp(); + const res = await app.render(new Request('http://example.com/fr/about')); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'fr'); + }); + + it('returns 404 with Location header when default locale used with prefix', async () => { + const app = createI18nApp(); + const res = await app.render(new Request('http://example.com/en/about')); + assert.equal(res.status, 404); + }); +}); + +describe('i18n via App - with base path', () => { + const i18n = makeI18nConfig({ strategy: 'pathname-prefix-always' }); + const middleware = createI18nMiddleware(i18n, '/docs/', 'ignore', 'directory'); + + function createI18nApp() { + return createTestApp([localeCatchAll('en')], { + base: '/docs/', + i18n, + middleware: () => ({ onRequest: middleware }), + }); + } + + it('renders with base path and locale', async () => { + const app = createI18nApp(); + const res = await app.render(new Request('http://example.com/docs/en/about')); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'en'); + }); + + it('redirects base path root to base + default locale', async () => { + const app = createI18nApp(); + const res = await app.render(new Request('http://example.com/docs/')); + assert.equal(res.status, 302); + assert.ok(res.headers.get('Location')?.includes('/docs/en')); + }); +}); + +describe('i18n via App - domains-prefix-always', () => { + const i18n = makeI18nConfig({ + strategy: 'domains-prefix-always', + locales: ['en', 'pt', 'it'], + defaultLocale: 'en', + }); + i18n.domainLookupTable = { + 'https://example.pt': 'pt', + 'https://it.example.com': 'it', + }; + i18n.domains = { + pt: 'https://example.pt', + it: 'https://it.example.com', + }; + + const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); + + function createDomainApp() { + return createTestApp([localeCatchAll('en'), localeCatchAll('pt'), localeCatchAll('it')], { + i18n, + middleware: () => ({ onRequest: middleware }), + }); + } + + it('renders Portuguese locale when request comes from example.pt', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('https://example.pt/about', { + headers: { 'X-Forwarded-Host': 'example.pt', 'X-Forwarded-Proto': 'https' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'pt'); + }); + + it('renders Italian locale when request comes from it.example.com', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('https://it.example.com/about', { + headers: { 'X-Forwarded-Host': 'it.example.com', 'X-Forwarded-Proto': 'https' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'it'); + }); + + it('renders English locale for non-domain request with /en/ prefix', async () => { + const app = createDomainApp(); + const res = await app.render(new Request('http://example.com/en/about')); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'en'); + }); + + it('uses Host header as fallback when X-Forwarded-Host is absent', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('https://example.pt/about', { + headers: { Host: 'example.pt' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'pt'); + }); + + it('protocol mismatch: HTTP request to HTTPS-configured domain', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('http://example.pt/about', { + headers: { 'X-Forwarded-Host': 'example.pt', 'X-Forwarded-Proto': 'http' }, + }), + ); + assert.equal(res.status, 404); + }); + + it('port in X-Forwarded-Host is stripped before matching', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('https://example.pt:8080/about', { + headers: { 'X-Forwarded-Host': 'example.pt:8080', 'X-Forwarded-Proto': 'https' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'pt'); + }); + + it('unknown domain falls through to normal pathname routing', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('https://unknown.com/en/about', { + headers: { 'X-Forwarded-Host': 'unknown.com', 'X-Forwarded-Proto': 'https' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'en'); + }); + + it('missing both Host and X-Forwarded-Host falls through to pathname routing', async () => { + const app = createDomainApp(); + const res = await app.render(new Request('http://localhost/en/about')); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'en'); + }); + + it('trailing slash is preserved on domain pathname', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('https://example.pt/about/', { + headers: { 'X-Forwarded-Host': 'example.pt', 'X-Forwarded-Proto': 'https' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'pt'); + assert.ok($('#path').text().endsWith('/')); + }); +}); + +describe('i18n via App - domains-prefix-always with trailingSlash: never', () => { + const i18n = makeI18nConfig({ + strategy: 'domains-prefix-always', + locales: ['fi', 'en'], + defaultLocale: 'fi', + domainLookupTable: { + 'https://example.com': 'en', + 'https://example.fi': 'fi', + }, + domains: { + en: 'https://example.com', + fi: 'https://example.fi', + }, + }); + + const middleware = createI18nMiddleware(i18n, '/', 'never', 'directory'); + + /** Like localeCatchAll but with spread param and trailingSlash: never */ + function localeSpreadCatchAll(locale: string) { + return createPage(localePage, { + route: `/${locale}/[...slug]`, + segments: [[staticPart(locale)], [spreadPart('slug')]], + pathname: undefined, + trailingSlash: 'never', + }); + } + + function createDomainApp() { + return createTestApp([localeSpreadCatchAll('fi'), localeSpreadCatchAll('en')], { + i18n, + trailingSlash: 'never', + middleware: () => ({ onRequest: middleware }), + }); + } + + it('root path of en domain-mapped locale returns 200 (not 404)', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('https://example.com/', { + headers: { 'X-Forwarded-Host': 'example.com', 'X-Forwarded-Proto': 'https' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'en'); + }); + + it('non-root path of en domain-mapped locale returns 200', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('https://example.com/about', { + headers: { 'X-Forwarded-Host': 'example.com', 'X-Forwarded-Proto': 'https' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'en'); + }); + + it('root path of fi domain-mapped locale returns 200 (not 404)', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('https://example.fi/', { + headers: { 'X-Forwarded-Host': 'example.fi', 'X-Forwarded-Proto': 'https' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'fi'); + }); + + it('non-root path of fi domain-mapped locale returns 200', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('https://example.fi/about', { + headers: { 'X-Forwarded-Host': 'example.fi', 'X-Forwarded-Proto': 'https' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'fi'); + }); +}); + +describe('i18n via App - domains-prefix-other-locales', () => { + const i18n = makeI18nConfig({ + strategy: 'domains-prefix-other-locales', + locales: ['en', 'pt'], + defaultLocale: 'en', + }); + i18n.domainLookupTable = { 'https://example.pt': 'pt' }; + i18n.domains = { pt: 'https://example.pt' }; + + const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); + + function createDomainApp() { + return createTestApp( + [ + createPage(localePage, { + route: '/[...slug]', + segments: [[dynamicPart('slug')]], + pathname: undefined, + }), + localeCatchAll('pt'), + ], + { i18n, middleware: () => ({ onRequest: middleware }) }, + ); + } + + it('renders Portuguese from domain without locale prefix in URL', async () => { + const app = createDomainApp(); + const res = await app.render( + new Request('https://example.pt/about', { + headers: { 'X-Forwarded-Host': 'example.pt', 'X-Forwarded-Proto': 'https' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'pt'); + }); + + it('renders default locale without prefix on non-domain request', async () => { + const app = createDomainApp(); + const res = await app.render(new Request('http://example.com/about')); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'en'); + }); +}); + +// #15098: Invalid locale in URL should render 404, not the [locale] page +describe('i18n via App - invalid locale with dynamic [locale] route (#15098)', () => { + const i18n = makeI18nConfig({ + strategy: 'pathname-prefix-always', + locales: ['en', 'de'], + fallback: undefined, + }); + const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); + + function createApp() { + return createTestApp( + [ + createPage(localePage, { + route: '/[locale]', + segments: [[dynamicPart('locale')]], + pathname: undefined, + }), + createPage(notFoundPage, { route: '/404', component: '404.astro' }), + ], + { i18n, middleware: () => ({ onRequest: middleware }) }, + ); + } + + it('valid locale /en/ returns 200 with locale page', async () => { + const app = createApp(); + const res = await app.render(new Request('http://example.com/en/')); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'en'); + }); + + it('valid locale /de/ returns 200 with correct currentLocale', async () => { + const app = createApp(); + const res = await app.render(new Request('http://example.com/de/')); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'de'); + }); + + it('invalid locale /asdf/ returns 404 with 404 page content', async () => { + const app = createApp(); + const res = await app.render(new Request('http://example.com/asdf/')); + assert.equal(res.status, 404); + const $ = cheerio.load(await res.text()); + assert.equal( + $('#not-found').text(), + '404 Not Found', + 'Should render 404.astro, not [locale]/index.astro', + ); + }); + + it('invalid locale /xyz/ does not render the locale page', async () => { + const app = createApp(); + const res = await app.render(new Request('http://example.com/xyz/')); + assert.equal(res.status, 404); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), '', 'Should NOT contain locale page content'); + }); +}); + +// #12385: Domain i18n should resolve locale even with port in Host header +describe('i18n via App - domain with localhost and ports (#12385)', () => { + const i18n = makeI18nConfig({ + strategy: 'domains-prefix-other-locales', + locales: ['en', 'zh'], + }); + i18n.domainLookupTable = { 'http://zh.test': 'zh' }; + i18n.domains = { zh: 'http://zh.test' }; + + const middleware = createI18nMiddleware(i18n, '/', 'ignore', 'directory'); + + function createApp() { + return createTestApp( + [ + createPage(localePage, { + route: '/[...slug]', + segments: [[dynamicPart('slug')]], + pathname: undefined, + }), + localeCatchAll('zh'), + ], + { i18n, middleware: () => ({ onRequest: middleware }) }, + ); + } + + it('zh.test without port resolves to Chinese locale', async () => { + const app = createApp(); + const res = await app.render( + new Request('http://zh.test/about', { headers: { Host: 'zh.test' } }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'zh'); + }); + + it('zh.test:4321 with port in Host header resolves to Chinese locale', async () => { + const app = createApp(); + const res = await app.render( + new Request('http://zh.test:4321/about', { headers: { Host: 'zh.test:4321' } }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'zh'); + }); + + it('zh.test:4321 via X-Forwarded-Host resolves to Chinese locale', async () => { + const app = createApp(); + const res = await app.render( + new Request('http://localhost:4321/about', { + headers: { 'X-Forwarded-Host': 'zh.test:4321', 'X-Forwarded-Proto': 'http' }, + }), + ); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'zh'); + }); + + it('default locale on non-matching domain works without prefix', async () => { + const app = createApp(); + const res = await app.render(new Request('http://test/about', { headers: { Host: 'test' } })); + assert.equal(res.status, 200); + const $ = cheerio.load(await res.text()); + assert.equal($('#locale').text(), 'en'); + }); +}); diff --git a/packages/astro/test/units/i18n/i18n-middleware.test.js b/packages/astro/test/units/i18n/i18n-middleware.test.js deleted file mode 100644 index a9ead1e47778..000000000000 --- a/packages/astro/test/units/i18n/i18n-middleware.test.js +++ /dev/null @@ -1,213 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { beforeEach, describe, it } from 'node:test'; -import { createI18nMiddleware } from '../../../dist/i18n/middleware.js'; -import { createMockAPIContext } from '../mocks.js'; - -/** - * Creates a "page" response that mimics what the render pipeline returns. - * The `X-Astro-Route-Type: page` header is what the i18n middleware reads - * to decide whether to apply routing logic. - * - * @param {string} body - * @param {number} [status] - * @param {Record} [extraHeaders] - */ -function makePageResponse(body, status = 200, extraHeaders = {}) { - return new Response(body, { - status, - headers: { 'X-Astro-Route-Type': 'page', ...extraHeaders }, - }); -} - -/** - * Creates a minimal i18n manifest. - * @param {Partial<{ - * defaultLocale: string, - * locales: import('../../../src/types/public/config.js').Locales, - * strategy: import('../../../dist/core/app/common.js').RoutingStrategies, - * fallbackType: 'redirect' | 'rewrite', - * fallback: Record, - * domainLookupTable: Record, - * domains: Record, - * }>} [overrides] - */ -function makeI18nManifest(overrides = {}) { - return { - defaultLocale: overrides.defaultLocale ?? 'en', - locales: overrides.locales ?? ['en', 'it'], - strategy: overrides.strategy ?? 'pathname-prefix-always', - fallbackType: overrides.fallbackType ?? 'rewrite', - fallback: overrides.fallback ?? {}, - domains: overrides.domains ?? {}, - domainLookupTable: overrides.domainLookupTable ?? {}, - }; -} - -describe('createI18nMiddleware', () => { - it('returns a passthrough handler when i18n config is undefined', async () => { - const handler = createI18nMiddleware(undefined, '/', 'ignore', 'directory'); - const ctx = createMockAPIContext({ url: 'http://localhost/anything' }); - const pageResponse = makePageResponse('original'); - - const result = await handler(ctx, async () => pageResponse); - - assert.equal(result, pageResponse, 'should return the exact same response object'); - }); - - describe('pathname-prefix-always strategy', () => { - /** @type {import('astro').MiddlewareHandler} */ - let handler; - - beforeEach(() => { - handler = createI18nMiddleware( - makeI18nManifest({ strategy: 'pathname-prefix-always' }), - '/', - 'ignore', - 'directory', - ); - }); - - it('returns null-body 404 for a non-locale-prefixed path', async () => { - const ctx = createMockAPIContext({ url: 'http://localhost/blog' }); - const next = async () => makePageResponse('Blog should not render'); - - const result = await handler(ctx, next); - - assert.equal(result.status, 404); - assert.equal(result.body, null, 'Body should be null so the App reroutes to the 404 page'); - }); - - it('passes through a locale-prefixed path', async () => { - const ctx = createMockAPIContext({ url: 'http://localhost/en/start' }); - const next = async () => makePageResponse('en page'); - - const result = await handler(ctx, next); - - assert.equal(result.status, 200); - assert.equal(await result.text(), 'en page'); - }); - - it('redirects root / to /{defaultLocale}/', async () => { - const ctx = createMockAPIContext({ url: 'http://localhost/' }); - const next = async () => makePageResponse('root'); - - const result = await handler(ctx, next); - - assert.equal(result.status, 302); - assert.ok( - result.headers.get('Location')?.includes('/en'), - `expected Location to contain /en, got: ${result.headers.get('Location')}`, - ); - }); - }); - - describe('pathname-prefix-other-locales strategy', () => { - /** @type {import('astro').MiddlewareHandler} */ - let handler; - - beforeEach(() => { - handler = createI18nMiddleware( - makeI18nManifest({ strategy: 'pathname-prefix-other-locales' }), - '/', - 'ignore', - 'directory', - ); - }); - - it('passes through un-prefixed paths for the default locale', async () => { - const ctx = createMockAPIContext({ url: 'http://localhost/blog' }); - const next = async () => makePageResponse('en blog'); - - const result = await handler(ctx, next); - - assert.equal(result.status, 200); - }); - - it('returns 404 when default locale prefix is used', async () => { - const ctx = createMockAPIContext({ url: 'http://localhost/en/blog' }); - const next = async () => makePageResponse('should not be visible'); - - const result = await handler(ctx, next); - - assert.equal(result.status, 404); - }); - }); - - describe('fallback routing', () => { - it('redirects to fallback locale path when fallbackType is redirect', async () => { - const handler = createI18nMiddleware( - makeI18nManifest({ - strategy: 'pathname-prefix-always', - fallbackType: 'redirect', - fallback: { it: 'en' }, - }), - '/', - 'ignore', - 'directory', - ); - const ctx = createMockAPIContext({ url: 'http://localhost/it/start' }); - const next = async () => makePageResponse('no it page', 404); - - const result = await handler(ctx, next); - - assert.equal(result.status, 302); - assert.equal(result.headers.get('Location'), '/en/start'); - }); - - it('rewrites to fallback locale path when fallbackType is rewrite', async () => { - const handler = createI18nMiddleware( - makeI18nManifest({ - strategy: 'pathname-prefix-always', - fallbackType: 'rewrite', - fallback: { it: 'en' }, - }), - '/', - 'ignore', - 'directory', - ); - const ctx = createMockAPIContext({ - url: 'http://localhost/it/start', - rewrite: async (path) => new Response(`rewritten to ${path}`, { status: 200 }), - }); - const next = async () => makePageResponse('no it page', 404); - - const result = await handler(ctx, next); - - assert.equal(result.status, 200); - assert.equal(await result.text(), 'rewritten to /en/start'); - }); - }); - - describe('early-return guards', () => { - it('passes through when X-Astro-Reroute is no and no fallback is configured', async () => { - const handler = createI18nMiddleware( - makeI18nManifest({ fallback: undefined }), - '/', - 'ignore', - 'directory', - ); - const ctx = createMockAPIContext({ url: 'http://localhost/404' }); - const pageResponse = new Response('not found', { - status: 404, - headers: { 'X-Astro-Route-Type': 'page', 'X-Astro-Reroute': 'no' }, - }); - - const result = await handler(ctx, async () => pageResponse); - - assert.equal(result, pageResponse, 'should return the exact same response'); - }); - - it('passes through when route type is not page or fallback', async () => { - const handler = createI18nMiddleware(makeI18nManifest(), '/', 'ignore', 'directory'); - const ctx = createMockAPIContext({ url: 'http://localhost/api/data' }); - const endpointResponse = new Response('{"ok":true}', { - headers: { 'X-Astro-Route-Type': 'endpoint' }, - }); - - const result = await handler(ctx, async () => endpointResponse); - - assert.equal(result, endpointResponse, 'should return the exact same response'); - }); - }); -}); diff --git a/packages/astro/test/units/i18n/i18n-middleware.test.ts b/packages/astro/test/units/i18n/i18n-middleware.test.ts new file mode 100644 index 000000000000..d8ff04c0b40e --- /dev/null +++ b/packages/astro/test/units/i18n/i18n-middleware.test.ts @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import type { MiddlewareHandler } from 'astro'; +import type { RoutingStrategies } from '../../../dist/core/app/common.js'; +import type { Locales } from '../../../dist/types/public/config.js'; +import { createI18nMiddleware } from '../../../dist/i18n/middleware.js'; +import { createMockAPIContext } from '../mocks.ts'; + +/** + * Creates a "page" response that mimics what the render pipeline returns. + * The `X-Astro-Route-Type: page` header is what the i18n middleware reads + * to decide whether to apply routing logic. + */ +function makePageResponse( + body: string, + status = 200, + extraHeaders: Record = {}, +): Response { + return new Response(body, { + status, + headers: { 'X-Astro-Route-Type': 'page', ...extraHeaders }, + }); +} + +interface I18nManifestOverrides { + defaultLocale?: string; + locales?: Locales; + strategy?: RoutingStrategies; + fallbackType?: 'redirect' | 'rewrite'; + fallback?: Record; + domainLookupTable?: Record; + domains?: Record; +} + +/** + * Creates a minimal i18n manifest. + */ +function makeI18nManifest(overrides: I18nManifestOverrides = {}) { + return { + defaultLocale: overrides.defaultLocale ?? 'en', + locales: overrides.locales ?? ['en', 'it'], + strategy: overrides.strategy ?? ('pathname-prefix-always' as RoutingStrategies), + fallbackType: overrides.fallbackType ?? ('rewrite' as const), + fallback: overrides.fallback ?? {}, + domains: overrides.domains ?? {}, + domainLookupTable: overrides.domainLookupTable ?? {}, + }; +} + +/** Calls the handler and asserts the result is a Response (not void). */ +async function callHandler( + handler: MiddlewareHandler, + ...args: Parameters +): Promise { + const result = await handler(...args); + assert.ok(result instanceof Response, 'expected handler to return a Response'); + return result; +} + +describe('createI18nMiddleware', () => { + it('returns a passthrough handler when i18n config is undefined', async () => { + const handler = createI18nMiddleware(undefined, '/', 'ignore', 'directory'); + const ctx = createMockAPIContext({ url: 'http://localhost/anything' }); + const pageResponse = makePageResponse('original'); + + const result = await callHandler(handler, ctx, async () => pageResponse); + + assert.equal(result, pageResponse, 'should return the exact same response object'); + }); + + describe('pathname-prefix-always strategy', () => { + let handler: MiddlewareHandler; + + beforeEach(() => { + handler = createI18nMiddleware( + makeI18nManifest({ strategy: 'pathname-prefix-always' }), + '/', + 'ignore', + 'directory', + ); + }); + + it('returns null-body 404 for a non-locale-prefixed path', async () => { + const ctx = createMockAPIContext({ url: 'http://localhost/blog' }); + const next = async () => makePageResponse('Blog should not render'); + + const result = await callHandler(handler, ctx, next); + + assert.equal(result.status, 404); + assert.equal(result.body, null, 'Body should be null so the App reroutes to the 404 page'); + }); + + it('passes through a locale-prefixed path', async () => { + const ctx = createMockAPIContext({ url: 'http://localhost/en/start' }); + const next = async () => makePageResponse('en page'); + + const result = await callHandler(handler, ctx, next); + + assert.equal(result.status, 200); + assert.equal(await result.text(), 'en page'); + }); + + it('redirects root / to /{defaultLocale}/', async () => { + const ctx = createMockAPIContext({ url: 'http://localhost/' }); + const next = async () => makePageResponse('root'); + + const result = await callHandler(handler, ctx, next); + + assert.equal(result.status, 302); + assert.ok( + result.headers.get('Location')?.includes('/en'), + `expected Location to contain /en, got: ${result.headers.get('Location')}`, + ); + }); + }); + + describe('pathname-prefix-other-locales strategy', () => { + let handler: MiddlewareHandler; + + beforeEach(() => { + handler = createI18nMiddleware( + makeI18nManifest({ strategy: 'pathname-prefix-other-locales' }), + '/', + 'ignore', + 'directory', + ); + }); + + it('passes through un-prefixed paths for the default locale', async () => { + const ctx = createMockAPIContext({ url: 'http://localhost/blog' }); + const next = async () => makePageResponse('en blog'); + + const result = await callHandler(handler, ctx, next); + + assert.equal(result.status, 200); + }); + + it('returns 404 when default locale prefix is used', async () => { + const ctx = createMockAPIContext({ url: 'http://localhost/en/blog' }); + const next = async () => makePageResponse('should not be visible'); + + const result = await callHandler(handler, ctx, next); + + assert.equal(result.status, 404); + }); + }); + + describe('fallback routing', () => { + it('redirects to fallback locale path when fallbackType is redirect', async () => { + const handler = createI18nMiddleware( + makeI18nManifest({ + strategy: 'pathname-prefix-always', + fallbackType: 'redirect', + fallback: { it: 'en' }, + }), + '/', + 'ignore', + 'directory', + ); + const ctx = createMockAPIContext({ url: 'http://localhost/it/start' }); + const next = async () => makePageResponse('no it page', 404); + + const result = await callHandler(handler, ctx, next); + + assert.equal(result.status, 302); + assert.equal(result.headers.get('Location'), '/en/start'); + }); + + it('rewrites to fallback locale path when fallbackType is rewrite', async () => { + const handler = createI18nMiddleware( + makeI18nManifest({ + strategy: 'pathname-prefix-always', + fallbackType: 'rewrite', + fallback: { it: 'en' }, + }), + '/', + 'ignore', + 'directory', + ); + const ctx = createMockAPIContext({ + url: 'http://localhost/it/start', + rewrite: async (_path: string) => new Response(`rewritten to ${_path}`, { status: 200 }), + } as any); + const next = async () => makePageResponse('no it page', 404); + + const result = await callHandler(handler, ctx, next); + + assert.equal(result.status, 200); + assert.equal(await result.text(), 'rewritten to /en/start'); + }); + }); + + describe('early-return guards', () => { + it('passes through when X-Astro-Reroute is no and no fallback is configured', async () => { + const handler = createI18nMiddleware( + makeI18nManifest({ fallback: undefined }), + '/', + 'ignore', + 'directory', + ); + const ctx = createMockAPIContext({ url: 'http://localhost/404' }); + const pageResponse = new Response('not found', { + status: 404, + headers: { 'X-Astro-Route-Type': 'page', 'X-Astro-Reroute': 'no' }, + }); + + const result = await callHandler(handler, ctx, async () => pageResponse); + + assert.equal(result, pageResponse, 'should return the exact same response'); + }); + + it('passes through when route type is not page or fallback', async () => { + const handler = createI18nMiddleware(makeI18nManifest(), '/', 'ignore', 'directory'); + const ctx = createMockAPIContext({ url: 'http://localhost/api/data' }); + const endpointResponse = new Response('{"ok":true}', { + headers: { 'X-Astro-Route-Type': 'endpoint' }, + }); + + const result = await callHandler(handler, ctx, async () => endpointResponse); + + assert.equal(result, endpointResponse, 'should return the exact same response'); + }); + }); +}); diff --git a/packages/astro/test/units/i18n/i18n-routing-static.test.js b/packages/astro/test/units/i18n/i18n-routing-static.test.js deleted file mode 100644 index d61276892b9a..000000000000 --- a/packages/astro/test/units/i18n/i18n-routing-static.test.js +++ /dev/null @@ -1,551 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { renderPath } from '../../../dist/core/build/generate.js'; -import { createMockAstroSource, createRouteData } from '../mocks.js'; -import { createMockPrerenderer, createStaticBuildOptions } from '../build/test-helpers.js'; - -async function renderAndAssertPath(prerenderer, pathname, route, options, expectedPathSuffix) { - const result = await renderPath({ - prerenderer, - pathname, - route, - options, - logger: options.logger, - }); - assert.ok(result !== null, `expected a result for ${pathname}`); - assert.ok( - result.outFile.pathname.endsWith(expectedPathSuffix), - `expected outFile to end with ${expectedPathSuffix}, got ${result.outFile.pathname}`, - ); - return result; -} - -describe('[SSG] i18n routing — prefix-always', () => { - let options; - - const pages = { - 'src/pages/index.astro': createMockAstroSource('

    I am index

    '), - 'src/pages/404.astro': createMockAstroSource("

    Can't find the page you're looking for.

    "), - 'src/pages/500.astro': createMockAstroSource('

    Unexpected error.

    '), - 'src/pages/en/start.astro': createMockAstroSource('

    Start

    '), - 'src/pages/pt/start.astro': createMockAstroSource('

    Oi essa e start

    '), - 'src/pages/spanish/start.astro': createMockAstroSource('

    Espanol

    '), - }; - - const prerenderer = createMockPrerenderer({ - '/en/start': '

    Start

    ', - '/pt/start': '

    Oi essa e start

    ', - '/spanish/start': '

    Espanol

    ', - }); - - before(async () => { - options = await createStaticBuildOptions({ - pages, - inlineConfig: { - base: '/new-site', - i18n: { - defaultLocale: 'en', - locales: ['en', 'pt', { path: 'spanish', codes: ['es', 'es-ar'] }], - routing: { prefixDefaultLocale: true, redirectToDefaultLocale: true }, - }, - }, - }); - }); - - it('renders English start page at /en/start/', async () => { - const route = options.routesList.routes.find((r) => r.route === '/en/start'); - assert.ok(route); - const result = await renderAndAssertPath( - prerenderer, - '/en/start', - route, - options, - '/en/start/index.html', - ); - assert.ok(result.body.toString().includes('Start')); - }); - - it('renders Portuguese start page at /pt/start/', async () => { - const route = options.routesList.routes.find((r) => r.route === '/pt/start'); - assert.ok(route); - const result = await renderAndAssertPath( - prerenderer, - '/pt/start', - route, - options, - '/pt/start/index.html', - ); - assert.ok(result.body.toString().includes('Oi essa e start')); - }); - - it('renders Spanish start page at /spanish/start/', async () => { - const route = options.routesList.routes.find((r) => r.route === '/spanish/start'); - assert.ok(route); - const result = await renderAndAssertPath( - prerenderer, - '/spanish/start', - route, - options, - '/spanish/start/index.html', - ); - assert.ok(result.body.toString().includes('Espanol')); - }); - - it('does not write /it/start (no Italian pages)', async () => { - const result = await renderPath({ - prerenderer: createMockPrerenderer({ '/it/start': new Response(null, { status: 404 }) }), - pathname: '/it/start', - route: createRouteData({ route: '/it/start', type: 'page' }), - options, - logger: options.logger, - }); - assert.equal(result, null); - }); -}); - -describe('[SSG] i18n routing — prefix-other-locales', () => { - let options; - - const pages = { - 'src/pages/start.astro': createMockAstroSource('

    Start

    '), - 'src/pages/pt/start.astro': createMockAstroSource('

    Oi essa e start

    '), - }; - - const prerenderer = createMockPrerenderer({ - '/start': '

    Start

    ', - '/pt/start': '

    Oi essa e start

    ', - }); - - before(async () => { - options = await createStaticBuildOptions({ - pages, - inlineConfig: { - base: '/new-site', - i18n: { - defaultLocale: 'en', - locales: ['en', 'pt', 'it'], - routing: { prefixDefaultLocale: false }, - }, - }, - }); - }); - - it('renders default locale (en) at root /start/', async () => { - const route = options.routesList.routes.find((r) => r.route === '/start'); - assert.ok(route); - const result = await renderAndAssertPath( - prerenderer, - '/start', - route, - options, - '/start/index.html', - ); - assert.ok(result.body.toString().includes('Start')); - }); - - it('renders Portuguese start page at /pt/start/', async () => { - const route = options.routesList.routes.find((r) => r.route === '/pt/start'); - assert.ok(route); - const result = await renderAndAssertPath( - prerenderer, - '/pt/start', - route, - options, - '/pt/start/index.html', - ); - assert.ok(result.body.toString().includes('Oi essa e start')); - }); - - it('does not create an /en/start route (default locale has no prefix)', () => { - const enStartRoute = options.routesList.routes.find((r) => r.route === '/en/start'); - assert.equal(enStartRoute, undefined); - }); -}); - -describe('[SSG] i18n routing — pathname-prefix-always, no redirect to default locale', () => { - let options; - - const pages = { - 'src/pages/index.astro': createMockAstroSource('

    I am index

    '), - }; - - const prerenderer = createMockPrerenderer({ - '/': '

    I am index

    ', - }); - - before(async () => { - options = await createStaticBuildOptions({ - pages, - inlineConfig: { - base: '/new-site', - i18n: { - defaultLocale: 'en', - locales: ['en', 'pt'], - routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false }, - }, - }, - }); - }); - - it('renders / with page content, not a redirect', async () => { - const route = options.routesList.routes.find((r) => r.route === '/'); - assert.ok(route); - const result = await renderAndAssertPath(prerenderer, '/', route, options, '/index.html'); - assert.ok(result.body.toString().includes('I am index')); - }); -}); - -describe('[SSG] i18n routing — fallback (it → en, spanish → en)', () => { - let options; - - const pages = { - 'src/pages/start.astro': createMockAstroSource('

    Start

    '), - 'src/pages/pt/start.astro': createMockAstroSource('

    Oi essa e start: pt

    '), - }; - - const prerenderer = createMockPrerenderer({ - '/start': '

    Start

    ', - '/pt/start': '

    Oi essa e start: pt

    ', - }); - - before(async () => { - options = await createStaticBuildOptions({ - pages, - inlineConfig: { - base: 'new-site', - i18n: { - defaultLocale: 'en', - locales: ['en', 'pt', 'it', { path: 'spanish', codes: ['es', 'es-AR'] }], - routing: { prefixDefaultLocale: false }, - fallback: { it: 'en', spanish: 'en' }, - }, - }, - }); - }); - - it('renders /start/ in English', async () => { - const route = options.routesList.routes.find((r) => r.route === '/start'); - assert.ok(route); - const result = await renderAndAssertPath( - prerenderer, - '/start', - route, - options, - '/start/index.html', - ); - assert.ok(result.body.toString().includes('Start')); - }); - - it('renders /pt/start/ in Portuguese', async () => { - const route = options.routesList.routes.find((r) => r.route === '/pt/start'); - assert.ok(route); - const result = await renderAndAssertPath( - prerenderer, - '/pt/start', - route, - options, - '/pt/start/index.html', - ); - assert.ok(result.body.toString().includes('Oi essa e start')); - }); - - it('renders /spanish/start/ as a redirect to /start (fallback)', async () => { - const startRoute = options.routesList.routes.find((r) => r.route === '/start'); - const fallbackRoute = startRoute?.fallbackRoutes.find((r) => r.route === '/spanish/start'); - assert.ok(fallbackRoute, 'expected fallback route for /spanish/start'); - - const result = await renderPath({ - prerenderer: createMockPrerenderer({ - '/spanish/start': new Response(null, { - status: 302, - headers: { location: '/new-site/start' }, - }), - }), - pathname: '/spanish/start', - route: fallbackRoute, - options, - logger: options.logger, - }); - assert.ok(result !== null); - assert.ok(result.body.toString().includes('http-equiv="refresh"')); - assert.ok(result.body.toString().includes('/new-site/start')); - }); - - it('renders /it/start/ as a redirect to /start (fallback)', async () => { - const startRoute = options.routesList.routes.find((r) => r.route === '/start'); - const fallbackRoute = startRoute?.fallbackRoutes.find((r) => r.route === '/it/start'); - assert.ok(fallbackRoute, 'expected fallback route for /it/start'); - - const result = await renderPath({ - prerenderer: createMockPrerenderer({ - '/it/start': new Response(null, { status: 302, headers: { location: '/new-site/start' } }), - }), - pathname: '/it/start', - route: fallbackRoute, - options, - logger: options.logger, - }); - assert.ok(result !== null); - assert.ok(result.body.toString().includes('http-equiv="refresh"')); - assert.ok(result.body.toString().includes('/new-site/start')); - }); - - it('does not write /fr/start (no French locale or fallback)', async () => { - const result = await renderPath({ - prerenderer: createMockPrerenderer({ '/fr/start': new Response(null, { status: 404 }) }), - pathname: '/fr/start', - route: createRouteData({ route: '/fr/start', type: 'page' }), - options, - logger: options.logger, - }); - assert.equal(result, null); - }); -}); - -describe('[SSG] i18n routing — fallback with prefix-always (it → en)', () => { - let options; - - const pages = { - 'src/pages/en/start.astro': createMockAstroSource('

    Start

    '), - }; - - before(async () => { - options = await createStaticBuildOptions({ - pages, - inlineConfig: { - base: '/new-site', - i18n: { - defaultLocale: 'en', - locales: ['en', 'pt', 'it'], - routing: { prefixDefaultLocale: true }, - fallback: { it: 'en' }, - }, - }, - }); - }); - - it('renders /it/start/ as a redirect to /new-site/en/start (fallback)', async () => { - const enRoute = options.routesList.routes.find((r) => r.route === '/en/start'); - const fallbackRoute = enRoute?.fallbackRoutes.find((r) => r.route === '/it/start'); - assert.ok(fallbackRoute, 'expected fallback route for /it/start'); - - const result = await renderPath({ - prerenderer: createMockPrerenderer({ - '/it/start': new Response(null, { - status: 302, - headers: { location: '/new-site/en/start' }, - }), - }), - pathname: '/it/start', - route: fallbackRoute, - options, - logger: options.logger, - }); - assert.ok(result !== null); - assert.ok(result.body.toString().includes('/new-site/en/start')); - }); -}); - -describe('[SSG] i18n routing — fallback rewrite with dynamic routes (es → en)', () => { - let options; - - const pages = { - 'src/pages/index.astro': createMockAstroSource('Index'), - 'src/pages/test.astro': createMockAstroSource('test'), - }; - - before(async () => { - options = await createStaticBuildOptions({ - pages, - inlineConfig: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'es'], - routing: { fallbackType: 'rewrite', prefixDefaultLocale: false }, - fallback: { es: 'en' }, - }, - }, - }); - }); - - it('renders /es/slug-1 via fallback rewrite', async () => { - const result = await renderPath({ - prerenderer: createMockPrerenderer({ - '/es/slug-1': 'slug-1', - }), - pathname: '/es/slug-1', - route: createRouteData({ route: '/es/slug-1', type: 'fallback' }), - options, - logger: options.logger, - }); - assert.ok(result !== null); - assert.ok(result.body.toString().includes('slug-1')); - }); - - it('renders /es/page-1 via fallback rewrite', async () => { - const result = await renderPath({ - prerenderer: createMockPrerenderer({ - '/es/page-1': 'page-1', - }), - pathname: '/es/page-1', - route: createRouteData({ route: '/es/page-1', type: 'fallback' }), - options, - logger: options.logger, - }); - assert.ok(result !== null); - assert.ok(result.body.toString().includes('page-1')); - }); - - it('renders /es/test via fallback rewrite', async () => { - const testRoute = options.routesList.routes.find((r) => r.route === '/test'); - const fallbackRoute = testRoute?.fallbackRoutes.find((r) => r.route === '/es/test'); - assert.ok(fallbackRoute, 'expected fallback route for /es/test'); - - const result = await renderPath({ - prerenderer: createMockPrerenderer({ - '/es/test': 'test', - }), - pathname: '/es/test', - route: fallbackRoute, - options, - logger: options.logger, - }); - assert.ok(result !== null); - assert.ok(result.body.toString().includes('test')); - }); -}); - -describe('[SSG] i18n routing — fallback rewrite with locale-like filenames (de → en)', () => { - let options; - - const pages = { - 'src/pages/index.astro': createMockAstroSource('Index'), - 'src/pages/denmark.astro': createMockAstroSource('Denmark'), - 'src/pages/norway.astro': createMockAstroSource('Norway'), - 'src/pages/destinations/index.astro': createMockAstroSource( - 'Destination: Index', - ), - 'src/pages/destinations/denmark.astro': createMockAstroSource( - 'Destination: Denmark', - ), - 'src/pages/destinations/norway.astro': createMockAstroSource( - 'Destination: Norway', - ), - 'src/pages/trade/index.astro': createMockAstroSource('Trade: Index'), - 'src/pages/trade/denmark.astro': createMockAstroSource('Trade: Denmark'), - 'src/pages/trade/norway.astro': createMockAstroSource('Trade: Norway'), - }; - - const prerenderer = createMockPrerenderer({ - '/': 'Index', - '/norway': 'Norway', - '/denmark': 'Denmark', - '/destinations': 'Destination: Index', - '/destinations/denmark': - 'Destination: Denmark', - '/destinations/norway': 'Destination: Norway', - '/trade': 'Trade: Index', - '/trade/denmark': 'Trade: Denmark', - '/trade/norway': 'Trade: Norway', - '/de/norway': 'Norway', - '/de/denmark': 'Denmark', - '/de/destinations/denmark': - 'Destination: Denmark', - '/de/destinations/norway': - 'Destination: Norway', - '/de/trade/denmark': 'Trade: Denmark', - '/de/trade/norway': 'Trade: Norway', - }); - - before(async () => { - options = await createStaticBuildOptions({ - pages, - inlineConfig: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'de'], - routing: { fallbackType: 'rewrite', prefixDefaultLocale: false }, - fallback: { de: 'en' }, - }, - }, - }); - }); - - for (const [en, de, expected] of [ - ['/norway', '/de/norway', 'Norway'], - ['/denmark', '/de/denmark', 'Denmark'], - ['/destinations/denmark', '/de/destinations/denmark', 'Destination: Denmark'], - ['/destinations/norway', '/de/destinations/norway', 'Destination: Norway'], - ['/trade/denmark', '/de/trade/denmark', 'Trade: Denmark'], - ['/trade/norway', '/de/trade/norway', 'Trade: Norway'], - ]) { - it(`renders ${en} (EN)`, async () => { - const route = options.routesList.routes.find((r) => r.route === en); - assert.ok(route, `expected route ${en}`); - const result = await renderPath({ - prerenderer, - pathname: en, - route, - options, - logger: options.logger, - }); - assert.ok(result !== null); - assert.ok(result.body.toString().includes(expected)); - }); - - it(`renders ${de} via fallback rewrite`, async () => { - const enRoute = options.routesList.routes.find((r) => r.route === en); - const fallbackRoute = enRoute?.fallbackRoutes.find((r) => r.route === de); - assert.ok(fallbackRoute, `expected fallback route ${de}`); - const result = await renderPath({ - prerenderer, - pathname: de, - route: fallbackRoute, - options, - logger: options.logger, - }); - assert.ok(result !== null); - assert.ok(result.body.toString().includes(expected)); - }); - } -}); - -describe('[SSG] i18n routing — page starting with locale-like segment', () => { - let options; - - const pages = { - 'src/pages/endurance.astro': createMockAstroSource('

    Endurance

    '), - }; - - const prerenderer = createMockPrerenderer({ - '/endurance': '

    Endurance

    ', - }); - - before(async () => { - options = await createStaticBuildOptions({ - pages, - inlineConfig: { - i18n: { - defaultLocale: 'spanish', - locales: ['en', 'pt', 'it', { path: 'spanish', codes: ['es', 'es-SP'] }], - routing: { prefixDefaultLocale: false }, - }, - }, - }); - }); - - it('renders /endurance/ (not treated as a locale prefix)', async () => { - const route = options.routesList.routes.find((r) => r.route === '/endurance'); - assert.ok(route); - const result = await renderPath({ - prerenderer, - pathname: '/endurance', - route, - options, - logger: options.logger, - }); - assert.ok(result !== null); - assert.ok(result.body.toString().includes('Endurance')); - }); -}); diff --git a/packages/astro/test/units/i18n/i18n-routing-static.test.ts b/packages/astro/test/units/i18n/i18n-routing-static.test.ts new file mode 100644 index 000000000000..09fc9a5ffe02 --- /dev/null +++ b/packages/astro/test/units/i18n/i18n-routing-static.test.ts @@ -0,0 +1,557 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import type { StaticBuildOptions } from '../../../dist/core/build/types.js'; +import { renderPath } from '../../../dist/core/build/generate.js'; +import { createMockAstroSource, createRouteData } from '../mocks.ts'; +import { createMockPrerenderer, createStaticBuildOptions } from '../build/test-helpers.ts'; + +async function renderAndAssertPath( + prerenderer: ReturnType, + pathname: string, + route: Parameters[0]['route'], + options: StaticBuildOptions, + expectedPathSuffix: string, +) { + const result = await renderPath({ + prerenderer, + pathname, + route, + options, + logger: options.logger, + }); + assert.ok(result !== null, `expected a result for ${pathname}`); + assert.ok( + result.outFile.pathname.endsWith(expectedPathSuffix), + `expected outFile to end with ${expectedPathSuffix}, got ${result.outFile.pathname}`, + ); + return result; +} + +describe('[SSG] i18n routing — prefix-always', () => { + let options: StaticBuildOptions; + + const pages: Record = { + 'src/pages/index.astro': createMockAstroSource('

    I am index

    '), + 'src/pages/404.astro': createMockAstroSource("

    Can't find the page you're looking for.

    "), + 'src/pages/500.astro': createMockAstroSource('

    Unexpected error.

    '), + 'src/pages/en/start.astro': createMockAstroSource('

    Start

    '), + 'src/pages/pt/start.astro': createMockAstroSource('

    Oi essa e start

    '), + 'src/pages/spanish/start.astro': createMockAstroSource('

    Espanol

    '), + }; + + const prerenderer = createMockPrerenderer({ + '/en/start': '

    Start

    ', + '/pt/start': '

    Oi essa e start

    ', + '/spanish/start': '

    Espanol

    ', + }); + + before(async () => { + options = await createStaticBuildOptions({ + pages, + inlineConfig: { + base: '/new-site', + i18n: { + defaultLocale: 'en', + locales: ['en', 'pt', { path: 'spanish', codes: ['es', 'es-ar'] }], + routing: { prefixDefaultLocale: true, redirectToDefaultLocale: true }, + }, + }, + }); + }); + + it('renders English start page at /en/start/', async () => { + const route = options.routesList.routes.find((r) => r.route === '/en/start'); + assert.ok(route); + const result = await renderAndAssertPath( + prerenderer, + '/en/start', + route, + options, + '/en/start/index.html', + ); + assert.ok(result.body.toString().includes('Start')); + }); + + it('renders Portuguese start page at /pt/start/', async () => { + const route = options.routesList.routes.find((r) => r.route === '/pt/start'); + assert.ok(route); + const result = await renderAndAssertPath( + prerenderer, + '/pt/start', + route, + options, + '/pt/start/index.html', + ); + assert.ok(result.body.toString().includes('Oi essa e start')); + }); + + it('renders Spanish start page at /spanish/start/', async () => { + const route = options.routesList.routes.find((r) => r.route === '/spanish/start'); + assert.ok(route); + const result = await renderAndAssertPath( + prerenderer, + '/spanish/start', + route, + options, + '/spanish/start/index.html', + ); + assert.ok(result.body.toString().includes('Espanol')); + }); + + it('does not write /it/start (no Italian pages)', async () => { + const result = await renderPath({ + prerenderer: createMockPrerenderer({ '/it/start': new Response(null, { status: 404 }) }), + pathname: '/it/start', + route: createRouteData({ route: '/it/start', type: 'page' }), + options, + logger: options.logger, + }); + assert.equal(result, null); + }); +}); + +describe('[SSG] i18n routing — prefix-other-locales', () => { + let options: StaticBuildOptions; + + const pages: Record = { + 'src/pages/start.astro': createMockAstroSource('

    Start

    '), + 'src/pages/pt/start.astro': createMockAstroSource('

    Oi essa e start

    '), + }; + + const prerenderer = createMockPrerenderer({ + '/start': '

    Start

    ', + '/pt/start': '

    Oi essa e start

    ', + }); + + before(async () => { + options = await createStaticBuildOptions({ + pages, + inlineConfig: { + base: '/new-site', + i18n: { + defaultLocale: 'en', + locales: ['en', 'pt', 'it'], + routing: { prefixDefaultLocale: false }, + }, + }, + }); + }); + + it('renders default locale (en) at root /start/', async () => { + const route = options.routesList.routes.find((r) => r.route === '/start'); + assert.ok(route); + const result = await renderAndAssertPath( + prerenderer, + '/start', + route, + options, + '/start/index.html', + ); + assert.ok(result.body.toString().includes('Start')); + }); + + it('renders Portuguese start page at /pt/start/', async () => { + const route = options.routesList.routes.find((r) => r.route === '/pt/start'); + assert.ok(route); + const result = await renderAndAssertPath( + prerenderer, + '/pt/start', + route, + options, + '/pt/start/index.html', + ); + assert.ok(result.body.toString().includes('Oi essa e start')); + }); + + it('does not create an /en/start route (default locale has no prefix)', () => { + const enStartRoute = options.routesList.routes.find((r) => r.route === '/en/start'); + assert.equal(enStartRoute, undefined); + }); +}); + +describe('[SSG] i18n routing — pathname-prefix-always, no redirect to default locale', () => { + let options: StaticBuildOptions; + + const pages: Record = { + 'src/pages/index.astro': createMockAstroSource('

    I am index

    '), + }; + + const prerenderer = createMockPrerenderer({ + '/': '

    I am index

    ', + }); + + before(async () => { + options = await createStaticBuildOptions({ + pages, + inlineConfig: { + base: '/new-site', + i18n: { + defaultLocale: 'en', + locales: ['en', 'pt'], + routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false }, + }, + }, + }); + }); + + it('renders / with page content, not a redirect', async () => { + const route = options.routesList.routes.find((r) => r.route === '/'); + assert.ok(route); + const result = await renderAndAssertPath(prerenderer, '/', route, options, '/index.html'); + assert.ok(result.body.toString().includes('I am index')); + }); +}); + +describe('[SSG] i18n routing — fallback (it → en, spanish → en)', () => { + let options: StaticBuildOptions; + + const pages: Record = { + 'src/pages/start.astro': createMockAstroSource('

    Start

    '), + 'src/pages/pt/start.astro': createMockAstroSource('

    Oi essa e start: pt

    '), + }; + + const prerenderer = createMockPrerenderer({ + '/start': '

    Start

    ', + '/pt/start': '

    Oi essa e start: pt

    ', + }); + + before(async () => { + options = await createStaticBuildOptions({ + pages, + inlineConfig: { + base: 'new-site', + i18n: { + defaultLocale: 'en', + locales: ['en', 'pt', 'it', { path: 'spanish', codes: ['es', 'es-AR'] }], + routing: { prefixDefaultLocale: false }, + fallback: { it: 'en', spanish: 'en' }, + }, + }, + }); + }); + + it('renders /start/ in English', async () => { + const route = options.routesList.routes.find((r) => r.route === '/start'); + assert.ok(route); + const result = await renderAndAssertPath( + prerenderer, + '/start', + route, + options, + '/start/index.html', + ); + assert.ok(result.body.toString().includes('Start')); + }); + + it('renders /pt/start/ in Portuguese', async () => { + const route = options.routesList.routes.find((r) => r.route === '/pt/start'); + assert.ok(route); + const result = await renderAndAssertPath( + prerenderer, + '/pt/start', + route, + options, + '/pt/start/index.html', + ); + assert.ok(result.body.toString().includes('Oi essa e start')); + }); + + it('renders /spanish/start/ as a redirect to /start (fallback)', async () => { + const startRoute = options.routesList.routes.find((r) => r.route === '/start'); + const fallbackRoute = startRoute?.fallbackRoutes.find((r) => r.route === '/spanish/start'); + assert.ok(fallbackRoute, 'expected fallback route for /spanish/start'); + + const result = await renderPath({ + prerenderer: createMockPrerenderer({ + '/spanish/start': new Response(null, { + status: 302, + headers: { location: '/new-site/start' }, + }), + }), + pathname: '/spanish/start', + route: fallbackRoute, + options, + logger: options.logger, + }); + assert.ok(result !== null); + assert.ok(result.body.toString().includes('http-equiv="refresh"')); + assert.ok(result.body.toString().includes('/new-site/start')); + }); + + it('renders /it/start/ as a redirect to /start (fallback)', async () => { + const startRoute = options.routesList.routes.find((r) => r.route === '/start'); + const fallbackRoute = startRoute?.fallbackRoutes.find((r) => r.route === '/it/start'); + assert.ok(fallbackRoute, 'expected fallback route for /it/start'); + + const result = await renderPath({ + prerenderer: createMockPrerenderer({ + '/it/start': new Response(null, { status: 302, headers: { location: '/new-site/start' } }), + }), + pathname: '/it/start', + route: fallbackRoute, + options, + logger: options.logger, + }); + assert.ok(result !== null); + assert.ok(result.body.toString().includes('http-equiv="refresh"')); + assert.ok(result.body.toString().includes('/new-site/start')); + }); + + it('does not write /fr/start (no French locale or fallback)', async () => { + const result = await renderPath({ + prerenderer: createMockPrerenderer({ '/fr/start': new Response(null, { status: 404 }) }), + pathname: '/fr/start', + route: createRouteData({ route: '/fr/start', type: 'page' }), + options, + logger: options.logger, + }); + assert.equal(result, null); + }); +}); + +describe('[SSG] i18n routing — fallback with prefix-always (it → en)', () => { + let options: StaticBuildOptions; + + const pages: Record = { + 'src/pages/en/start.astro': createMockAstroSource('

    Start

    '), + }; + + before(async () => { + options = await createStaticBuildOptions({ + pages, + inlineConfig: { + base: '/new-site', + i18n: { + defaultLocale: 'en', + locales: ['en', 'pt', 'it'], + routing: { prefixDefaultLocale: true }, + fallback: { it: 'en' }, + }, + }, + }); + }); + + it('renders /it/start/ as a redirect to /new-site/en/start (fallback)', async () => { + const enRoute = options.routesList.routes.find((r) => r.route === '/en/start'); + const fallbackRoute = enRoute?.fallbackRoutes.find((r) => r.route === '/it/start'); + assert.ok(fallbackRoute, 'expected fallback route for /it/start'); + + const result = await renderPath({ + prerenderer: createMockPrerenderer({ + '/it/start': new Response(null, { + status: 302, + headers: { location: '/new-site/en/start' }, + }), + }), + pathname: '/it/start', + route: fallbackRoute, + options, + logger: options.logger, + }); + assert.ok(result !== null); + assert.ok(result.body.toString().includes('/new-site/en/start')); + }); +}); + +describe('[SSG] i18n routing — fallback rewrite with dynamic routes (es → en)', () => { + let options: StaticBuildOptions; + + const pages: Record = { + 'src/pages/index.astro': createMockAstroSource('Index'), + 'src/pages/test.astro': createMockAstroSource('test'), + }; + + before(async () => { + options = await createStaticBuildOptions({ + pages, + inlineConfig: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'es'], + routing: { fallbackType: 'rewrite', prefixDefaultLocale: false }, + fallback: { es: 'en' }, + }, + }, + }); + }); + + it('renders /es/slug-1 via fallback rewrite', async () => { + const result = await renderPath({ + prerenderer: createMockPrerenderer({ + '/es/slug-1': 'slug-1', + }), + pathname: '/es/slug-1', + route: createRouteData({ route: '/es/slug-1', type: 'fallback' }), + options, + logger: options.logger, + }); + assert.ok(result !== null); + assert.ok(result.body.toString().includes('slug-1')); + }); + + it('renders /es/page-1 via fallback rewrite', async () => { + const result = await renderPath({ + prerenderer: createMockPrerenderer({ + '/es/page-1': 'page-1', + }), + pathname: '/es/page-1', + route: createRouteData({ route: '/es/page-1', type: 'fallback' }), + options, + logger: options.logger, + }); + assert.ok(result !== null); + assert.ok(result.body.toString().includes('page-1')); + }); + + it('renders /es/test via fallback rewrite', async () => { + const testRoute = options.routesList.routes.find((r) => r.route === '/test'); + const fallbackRoute = testRoute?.fallbackRoutes.find((r) => r.route === '/es/test'); + assert.ok(fallbackRoute, 'expected fallback route for /es/test'); + + const result = await renderPath({ + prerenderer: createMockPrerenderer({ + '/es/test': 'test', + }), + pathname: '/es/test', + route: fallbackRoute, + options, + logger: options.logger, + }); + assert.ok(result !== null); + assert.ok(result.body.toString().includes('test')); + }); +}); + +describe('[SSG] i18n routing — fallback rewrite with locale-like filenames (de → en)', () => { + let options: StaticBuildOptions; + + const pages: Record = { + 'src/pages/index.astro': createMockAstroSource('Index'), + 'src/pages/denmark.astro': createMockAstroSource('Denmark'), + 'src/pages/norway.astro': createMockAstroSource('Norway'), + 'src/pages/destinations/index.astro': createMockAstroSource( + 'Destination: Index', + ), + 'src/pages/destinations/denmark.astro': createMockAstroSource( + 'Destination: Denmark', + ), + 'src/pages/destinations/norway.astro': createMockAstroSource( + 'Destination: Norway', + ), + 'src/pages/trade/index.astro': createMockAstroSource('Trade: Index'), + 'src/pages/trade/denmark.astro': createMockAstroSource('Trade: Denmark'), + 'src/pages/trade/norway.astro': createMockAstroSource('Trade: Norway'), + }; + + const prerenderer = createMockPrerenderer({ + '/': 'Index', + '/norway': 'Norway', + '/denmark': 'Denmark', + '/destinations': 'Destination: Index', + '/destinations/denmark': + 'Destination: Denmark', + '/destinations/norway': 'Destination: Norway', + '/trade': 'Trade: Index', + '/trade/denmark': 'Trade: Denmark', + '/trade/norway': 'Trade: Norway', + '/de/norway': 'Norway', + '/de/denmark': 'Denmark', + '/de/destinations/denmark': + 'Destination: Denmark', + '/de/destinations/norway': + 'Destination: Norway', + '/de/trade/denmark': 'Trade: Denmark', + '/de/trade/norway': 'Trade: Norway', + }); + + before(async () => { + options = await createStaticBuildOptions({ + pages, + inlineConfig: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'de'], + routing: { fallbackType: 'rewrite', prefixDefaultLocale: false }, + fallback: { de: 'en' }, + }, + }, + }); + }); + + for (const [en, de, expected] of [ + ['/norway', '/de/norway', 'Norway'], + ['/denmark', '/de/denmark', 'Denmark'], + ['/destinations/denmark', '/de/destinations/denmark', 'Destination: Denmark'], + ['/destinations/norway', '/de/destinations/norway', 'Destination: Norway'], + ['/trade/denmark', '/de/trade/denmark', 'Trade: Denmark'], + ['/trade/norway', '/de/trade/norway', 'Trade: Norway'], + ] as const) { + it(`renders ${en} (EN)`, async () => { + const route = options.routesList.routes.find((r) => r.route === en); + assert.ok(route, `expected route ${en}`); + const result = await renderPath({ + prerenderer, + pathname: en, + route, + options, + logger: options.logger, + }); + assert.ok(result !== null); + assert.ok(result.body.toString().includes(expected)); + }); + + it(`renders ${de} via fallback rewrite`, async () => { + const enRoute = options.routesList.routes.find((r) => r.route === en); + const fallbackRoute = enRoute?.fallbackRoutes.find((r) => r.route === de); + assert.ok(fallbackRoute, `expected fallback route ${de}`); + const result = await renderPath({ + prerenderer, + pathname: de, + route: fallbackRoute, + options, + logger: options.logger, + }); + assert.ok(result !== null); + assert.ok(result.body.toString().includes(expected)); + }); + } +}); + +describe('[SSG] i18n routing — page starting with locale-like segment', () => { + let options: StaticBuildOptions; + + const pages: Record = { + 'src/pages/endurance.astro': createMockAstroSource('

    Endurance

    '), + }; + + const prerenderer = createMockPrerenderer({ + '/endurance': '

    Endurance

    ', + }); + + before(async () => { + options = await createStaticBuildOptions({ + pages, + inlineConfig: { + i18n: { + defaultLocale: 'spanish', + locales: ['en', 'pt', 'it', { path: 'spanish', codes: ['es', 'es-SP'] }], + routing: { prefixDefaultLocale: false }, + }, + }, + }); + }); + + it('renders /endurance/ (not treated as a locale prefix)', async () => { + const route = options.routesList.routes.find((r) => r.route === '/endurance'); + assert.ok(route); + const result = await renderPath({ + prerenderer, + pathname: '/endurance', + route, + options, + logger: options.logger, + }); + assert.ok(result !== null); + assert.ok(result.body.toString().includes('Endurance')); + }); +}); diff --git a/packages/astro/test/units/i18n/i18n-static-build.test.js b/packages/astro/test/units/i18n/i18n-static-build.test.js deleted file mode 100644 index 8f5dd3ec4a01..000000000000 --- a/packages/astro/test/units/i18n/i18n-static-build.test.js +++ /dev/null @@ -1,127 +0,0 @@ -// @ts-check - -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { renderPath } from '../../../dist/core/build/generate.js'; -import { createMockPrerenderer, createStaticBuildOptions } from '../build/test-helpers.js'; -import { createMockAstroSource, createRouteData } from '../mocks.js'; - -// Page sources — mirrors the structure of the deleted fixture. -// createStaticBuildOptions writes these into a temp directory and derives -// routesList from them using the same config, so routes and settings are in sync. -const pages = { - 'src/pages/index.astro': createMockAstroSource('

    Index

    '), - 'src/pages/es/test/item1.astro': createMockAstroSource('

    Test Item 1 (ES)

    '), - 'src/pages/test/item1.astro': createMockAstroSource('

    Test Item 1 (EN)

    '), - 'src/pages/test/item2.astro': createMockAstroSource( - '

    Test Item 2 (EN only)

    ', - ), -}; - -const prerenderer = createMockPrerenderer({ - '/es/test/item1': '

    Test Item 1 (ES)

    ', - '/test/item1': '

    Test Item 1 (EN)

    ', - '/test/item2': '

    Test Item 2 (EN only)

    ', -}); - -// A single shared options object is sufficient — none of these tests inspect the -// written files; they only assert on the `result` returned by renderPath(). -let sharedOpts; - -describe('i18n double-prefix prevention', () => { - before(async () => { - sharedOpts = await createStaticBuildOptions({ - pages, - inlineConfig: { - i18n: { - defaultLocale: 'en', - locales: ['en', { path: 'es', codes: ['es', 'es-ES', 'es-MX'] }], - routing: { prefixDefaultLocale: false }, - fallback: { es: 'en' }, - }, - }, - }); - }); - it('should not create double-prefixed redirect pages', async () => { - // The Spanish page exists as a real route in routesList - const esRoute = sharedOpts.routesList.routes.find( - (r) => r.route === '/es/test/item1' && r.type === 'page', - ); - assert.ok(esRoute, 'expected a real ES page route in routesList'); - - const esResult = await renderPath({ - prerenderer, - pathname: '/es/test/item1', - route: esRoute, - options: sharedOpts, - logger: sharedOpts.logger, - }); - assert.ok(esResult !== null); - assert.ok(esResult.body.toString().includes('

    Test Item 1 (ES)

    ')); - - // Double-prefixed path should NOT exist. - // createStaticBuildOptions already prevents generating a fallback for /es/test/item1 - // because the real ES page exists. renderPath provides a secondary safety net: if a - // fallback route were somehow passed for a pathname that already has a real page in - // routesList, it must return null (suppressed). - const fallbackEsItem1Route = createRouteData({ route: '/es/test/item1', type: 'fallback' }); - const fallbackResult = await renderPath({ - prerenderer, - pathname: '/es/test/item1', - route: fallbackEsItem1Route, - options: sharedOpts, - logger: sharedOpts.logger, - }); - assert.equal( - fallbackResult, - null, - 'Double-prefixed path /es/es/test/item1/index.html should not exist', - ); - - // The English page should be unaffected - const enRoute = sharedOpts.routesList.routes.find((r) => r.route === '/test/item1'); - assert.ok(enRoute); - const enResult = await renderPath({ - prerenderer, - pathname: '/test/item1', - route: enRoute, - options: sharedOpts, - logger: sharedOpts.logger, - }); - assert.ok(enResult !== null); - assert.ok(enResult.body.toString().includes('

    Test Item 1 (EN)

    ')); - }); - - it('should generate correct fallback redirects for missing Spanish pages', async () => { - // item2 only exists in English — createStaticBuildOptions generates a fallback for /es/test/item2 - const enItem2Route = sharedOpts.routesList.routes.find((r) => r.route === '/test/item2'); - const fallbackEsItem2Route = enItem2Route?.fallbackRoutes.find( - (r) => r.route === '/es/test/item2', - ); - assert.ok( - fallbackEsItem2Route, - 'expected routesList to contain a fallback route for /es/test/item2', - ); - - const prerendererWithFallback = createMockPrerenderer({ - '/es/test/item2': '

    Test Item 2 (EN only)

    ', - }); - - const result = await renderPath({ - prerenderer: prerendererWithFallback, - pathname: '/es/test/item2', - route: fallbackEsItem2Route, - options: sharedOpts, - logger: sharedOpts.logger, - }); - - // The Spanish fallback was generated — verify it would not be double-prefixed - assert.ok(result !== null); - assert.ok(result.body.toString().includes('

    Test Item 2 (EN only)

    ')); - assert.equal( - result.outFile.pathname.includes('/es/es/'), - false, - 'Double-prefixed path /es/es/test/item2/index.html should not exist', - ); - }); -}); diff --git a/packages/astro/test/units/i18n/i18n-static-build.test.ts b/packages/astro/test/units/i18n/i18n-static-build.test.ts new file mode 100644 index 000000000000..7f1ebd110989 --- /dev/null +++ b/packages/astro/test/units/i18n/i18n-static-build.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import type { StaticBuildOptions } from '../../../dist/core/build/types.js'; +import { renderPath } from '../../../dist/core/build/generate.js'; +import { createMockPrerenderer, createStaticBuildOptions } from '../build/test-helpers.ts'; +import { createMockAstroSource, createRouteData } from '../mocks.ts'; + +// Page sources — mirrors the structure of the deleted fixture. +// createStaticBuildOptions writes these into a temp directory and derives +// routesList from them using the same config, so routes and settings are in sync. +const pages: Record = { + 'src/pages/index.astro': createMockAstroSource('

    Index

    '), + 'src/pages/es/test/item1.astro': createMockAstroSource('

    Test Item 1 (ES)

    '), + 'src/pages/test/item1.astro': createMockAstroSource('

    Test Item 1 (EN)

    '), + 'src/pages/test/item2.astro': createMockAstroSource( + '

    Test Item 2 (EN only)

    ', + ), +}; + +const prerenderer = createMockPrerenderer({ + '/es/test/item1': '

    Test Item 1 (ES)

    ', + '/test/item1': '

    Test Item 1 (EN)

    ', + '/test/item2': '

    Test Item 2 (EN only)

    ', +}); + +// A single shared options object is sufficient — none of these tests inspect the +// written files; they only assert on the `result` returned by renderPath(). +let sharedOpts: StaticBuildOptions; + +describe('i18n double-prefix prevention', () => { + before(async () => { + sharedOpts = await createStaticBuildOptions({ + pages, + inlineConfig: { + i18n: { + defaultLocale: 'en', + locales: ['en', { path: 'es', codes: ['es', 'es-ES', 'es-MX'] }], + routing: { prefixDefaultLocale: false }, + fallback: { es: 'en' }, + }, + }, + }); + }); + it('should not create double-prefixed redirect pages', async () => { + // The Spanish page exists as a real route in routesList + const esRoute = sharedOpts.routesList.routes.find( + (r) => r.route === '/es/test/item1' && r.type === 'page', + ); + assert.ok(esRoute, 'expected a real ES page route in routesList'); + + const esResult = await renderPath({ + prerenderer, + pathname: '/es/test/item1', + route: esRoute, + options: sharedOpts, + logger: sharedOpts.logger, + }); + assert.ok(esResult !== null); + assert.ok(esResult.body.toString().includes('

    Test Item 1 (ES)

    ')); + + // Double-prefixed path should NOT exist. + // createStaticBuildOptions already prevents generating a fallback for /es/test/item1 + // because the real ES page exists. renderPath provides a secondary safety net: if a + // fallback route were somehow passed for a pathname that already has a real page in + // routesList, it must return null (suppressed). + const fallbackEsItem1Route = createRouteData({ route: '/es/test/item1', type: 'fallback' }); + const fallbackResult = await renderPath({ + prerenderer, + pathname: '/es/test/item1', + route: fallbackEsItem1Route, + options: sharedOpts, + logger: sharedOpts.logger, + }); + assert.equal( + fallbackResult, + null, + 'Double-prefixed path /es/es/test/item1/index.html should not exist', + ); + + // The English page should be unaffected + const enRoute = sharedOpts.routesList.routes.find((r) => r.route === '/test/item1'); + assert.ok(enRoute); + const enResult = await renderPath({ + prerenderer, + pathname: '/test/item1', + route: enRoute, + options: sharedOpts, + logger: sharedOpts.logger, + }); + assert.ok(enResult !== null); + assert.ok(enResult.body.toString().includes('

    Test Item 1 (EN)

    ')); + }); + + it('should generate correct fallback redirects for missing Spanish pages', async () => { + // item2 only exists in English — createStaticBuildOptions generates a fallback for /es/test/item2 + const enItem2Route = sharedOpts.routesList.routes.find((r) => r.route === '/test/item2'); + const fallbackEsItem2Route = enItem2Route?.fallbackRoutes.find( + (r) => r.route === '/es/test/item2', + ); + assert.ok( + fallbackEsItem2Route, + 'expected routesList to contain a fallback route for /es/test/item2', + ); + + const prerendererWithFallback = createMockPrerenderer({ + '/es/test/item2': '

    Test Item 2 (EN only)

    ', + }); + + const result = await renderPath({ + prerenderer: prerendererWithFallback, + pathname: '/es/test/item2', + route: fallbackEsItem2Route, + options: sharedOpts, + logger: sharedOpts.logger, + }); + + // The Spanish fallback was generated — verify it would not be double-prefixed + assert.ok(result !== null); + assert.ok(result.body.toString().includes('

    Test Item 2 (EN only)

    ')); + assert.equal( + result.outFile.pathname.includes('/es/es/'), + false, + 'Double-prefixed path /es/es/test/item2/index.html should not exist', + ); + }); +}); diff --git a/packages/astro/test/units/i18n/i18n-utils.test.js b/packages/astro/test/units/i18n/i18n-utils.test.js deleted file mode 100644 index ee31a1531061..000000000000 --- a/packages/astro/test/units/i18n/i18n-utils.test.js +++ /dev/null @@ -1,150 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - computeCurrentLocale, - computePreferredLocale, - computePreferredLocaleList, -} from '../../../dist/i18n/utils.js'; -import { - getPathByLocale, - getLocaleByPath, - getAllCodes, - toCodes, - toPaths, -} from '../../../dist/i18n/index.js'; - -describe('computeCurrentLocale', () => { - const stringLocales = ['en', 'fr', 'es']; - - it('detects locale in first path segment', () => { - assert.equal(computeCurrentLocale('/en/about', stringLocales, 'en'), 'en'); - }); - - it('detects non-default locale', () => { - assert.equal(computeCurrentLocale('/fr/about', stringLocales, 'en'), 'fr'); - }); - - it('returns default locale when no locale in path', () => { - assert.equal(computeCurrentLocale('/about', stringLocales, 'en'), 'en'); - }); - - it('returns default locale for root path', () => { - assert.equal(computeCurrentLocale('/', stringLocales, 'en'), 'en'); - }); - - it('handles .html extension in segments', () => { - assert.equal(computeCurrentLocale('/fr.html', stringLocales, 'en'), 'fr'); - }); - - it('handles case-insensitive locale matching', () => { - assert.equal(computeCurrentLocale('/EN/about', stringLocales, 'en'), 'en'); - }); - - it('handles object locales with path', () => { - const locales = [{ path: 'spanish', codes: ['es', 'es-ES'] }, 'en']; - assert.equal(computeCurrentLocale('/spanish/about', locales, 'en'), 'es'); - }); - - it('handles object locales with codes matching segment', () => { - const locales = [{ path: 'spanish', codes: ['es', 'es-ES'] }, 'en']; - assert.equal(computeCurrentLocale('/es/about', locales, 'en'), 'es'); - }); - - it('returns first code for object locale default', () => { - const locales = [{ path: 'english', codes: ['en', 'en-US'] }, 'fr']; - assert.equal(computeCurrentLocale('/about', locales, 'english'), 'en'); - }); -}); - -describe('computePreferredLocale', () => { - const locales = ['en', 'fr', 'es']; - - it('returns the best match from Accept-Language', () => { - const req = new Request('http://example.com/', { - headers: { 'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8' }, - }); - assert.equal(computePreferredLocale(req, locales), 'fr'); - }); - - it('returns undefined when no match', () => { - const req = new Request('http://example.com/', { - headers: { 'Accept-Language': 'de,ja' }, - }); - assert.equal(computePreferredLocale(req, locales), undefined); - }); - - it('returns undefined when no Accept-Language header', () => { - const req = new Request('http://example.com/'); - assert.equal(computePreferredLocale(req, locales), undefined); - }); -}); - -describe('computePreferredLocaleList', () => { - const locales = ['en', 'fr', 'es']; - - it('returns all matching locales sorted by quality', () => { - const req = new Request('http://example.com/', { - headers: { 'Accept-Language': 'es;q=1.0,en;q=0.8,fr;q=0.5' }, - }); - assert.deepEqual(computePreferredLocaleList(req, locales), ['es', 'en', 'fr']); - }); - - it('returns empty array when no match', () => { - const req = new Request('http://example.com/', { - headers: { 'Accept-Language': 'de' }, - }); - assert.deepEqual(computePreferredLocaleList(req, locales), []); - }); -}); - -describe('getPathByLocale', () => { - it('returns the locale itself for string locales', () => { - assert.equal(getPathByLocale('en', ['en', 'fr']), 'en'); - }); - - it('returns the path for object locales', () => { - const locales = [{ path: 'spanish', codes: ['es', 'es-ES'] }, 'en']; - assert.equal(getPathByLocale('es', locales), 'spanish'); - }); - - it('throws for unknown locale', () => { - assert.throws(() => getPathByLocale('de', ['en', 'fr'])); - }); -}); - -describe('getLocaleByPath', () => { - it('returns the locale for string locales', () => { - assert.equal(getLocaleByPath('en', ['en', 'fr']), 'en'); - }); - - it('returns the first code for object locales', () => { - const locales = [{ path: 'spanish', codes: ['es', 'es-ES'] }, 'en']; - assert.equal(getLocaleByPath('spanish', locales), 'es'); - }); -}); - -describe('getAllCodes', () => { - it('returns all codes from string and object locales', () => { - const locales = ['en', { path: 'spanish', codes: ['es', 'es-ES'] }]; - assert.deepEqual(getAllCodes(locales), ['en', 'es', 'es-ES']); - }); - - it('handles all string locales', () => { - assert.deepEqual(getAllCodes(['en', 'fr']), ['en', 'fr']); - }); -}); - -describe('toCodes', () => { - it('returns first code per locale entry', () => { - const locales = ['en', { path: 'spanish', codes: ['es', 'es-ES'] }]; - assert.deepEqual(toCodes(locales), ['en', 'es']); - }); -}); - -describe('toPaths', () => { - it('returns path strings for all locales', () => { - const locales = ['en', { path: 'spanish', codes: ['es'] }]; - assert.deepEqual(toPaths(locales), ['en', 'spanish']); - }); -}); diff --git a/packages/astro/test/units/i18n/i18n-utils.test.ts b/packages/astro/test/units/i18n/i18n-utils.test.ts new file mode 100644 index 000000000000..852cccae4fc8 --- /dev/null +++ b/packages/astro/test/units/i18n/i18n-utils.test.ts @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + computeCurrentLocale, + computePreferredLocale, + computePreferredLocaleList, +} from '../../../dist/i18n/utils.js'; +import { + getPathByLocale, + getLocaleByPath, + getAllCodes, + toCodes, + toPaths, +} from '../../../dist/i18n/index.js'; + +describe('computeCurrentLocale', () => { + const stringLocales = ['en', 'fr', 'es']; + + it('detects locale in first path segment', () => { + assert.equal(computeCurrentLocale('/en/about', stringLocales, 'en'), 'en'); + }); + + it('detects non-default locale', () => { + assert.equal(computeCurrentLocale('/fr/about', stringLocales, 'en'), 'fr'); + }); + + it('returns default locale when no locale in path', () => { + assert.equal(computeCurrentLocale('/about', stringLocales, 'en'), 'en'); + }); + + it('returns default locale for root path', () => { + assert.equal(computeCurrentLocale('/', stringLocales, 'en'), 'en'); + }); + + it('handles .html extension in segments', () => { + assert.equal(computeCurrentLocale('/fr.html', stringLocales, 'en'), 'fr'); + }); + + it('handles case-insensitive locale matching', () => { + assert.equal(computeCurrentLocale('/EN/about', stringLocales, 'en'), 'en'); + }); + + it('handles object locales with path', () => { + const locales = [{ path: 'spanish', codes: ['es', 'es-ES'] as [string, ...string[]] }, 'en']; + assert.equal(computeCurrentLocale('/spanish/about', locales, 'en'), 'es'); + }); + + it('handles object locales with codes matching segment', () => { + const locales = [{ path: 'spanish', codes: ['es', 'es-ES'] as [string, ...string[]] }, 'en']; + assert.equal(computeCurrentLocale('/es/about', locales, 'en'), 'es'); + }); + + it('returns first code for object locale default', () => { + const locales = [{ path: 'english', codes: ['en', 'en-US'] as [string, ...string[]] }, 'fr']; + assert.equal(computeCurrentLocale('/about', locales, 'english'), 'en'); + }); +}); + +describe('computePreferredLocale', () => { + const locales = ['en', 'fr', 'es']; + + it('returns the best match from Accept-Language', () => { + const req = new Request('http://example.com/', { + headers: { 'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8' }, + }); + assert.equal(computePreferredLocale(req, locales), 'fr'); + }); + + it('returns undefined when no match', () => { + const req = new Request('http://example.com/', { + headers: { 'Accept-Language': 'de,ja' }, + }); + assert.equal(computePreferredLocale(req, locales), undefined); + }); + + it('returns undefined when no Accept-Language header', () => { + const req = new Request('http://example.com/'); + assert.equal(computePreferredLocale(req, locales), undefined); + }); +}); + +describe('computePreferredLocaleList', () => { + const locales = ['en', 'fr', 'es']; + + it('returns all matching locales sorted by quality', () => { + const req = new Request('http://example.com/', { + headers: { 'Accept-Language': 'es;q=1.0,en;q=0.8,fr;q=0.5' }, + }); + assert.deepEqual(computePreferredLocaleList(req, locales), ['es', 'en', 'fr']); + }); + + it('returns empty array when no match', () => { + const req = new Request('http://example.com/', { + headers: { 'Accept-Language': 'de' }, + }); + assert.deepEqual(computePreferredLocaleList(req, locales), []); + }); +}); + +describe('getPathByLocale', () => { + it('returns the locale itself for string locales', () => { + assert.equal(getPathByLocale('en', ['en', 'fr']), 'en'); + }); + + it('returns the path for object locales', () => { + const locales = [{ path: 'spanish', codes: ['es', 'es-ES'] as [string, ...string[]] }, 'en']; + assert.equal(getPathByLocale('es', locales), 'spanish'); + }); + + it('throws for unknown locale', () => { + assert.throws(() => getPathByLocale('de', ['en', 'fr'])); + }); +}); + +describe('getLocaleByPath', () => { + it('returns the locale for string locales', () => { + assert.equal(getLocaleByPath('en', ['en', 'fr']), 'en'); + }); + + it('returns the first code for object locales', () => { + const locales = [{ path: 'spanish', codes: ['es', 'es-ES'] as [string, ...string[]] }, 'en']; + assert.equal(getLocaleByPath('spanish', locales), 'es'); + }); +}); + +describe('getAllCodes', () => { + it('returns all codes from string and object locales', () => { + const locales = ['en', { path: 'spanish', codes: ['es', 'es-ES'] as [string, ...string[]] }]; + assert.deepEqual(getAllCodes(locales), ['en', 'es', 'es-ES']); + }); + + it('handles all string locales', () => { + assert.deepEqual(getAllCodes(['en', 'fr']), ['en', 'fr']); + }); +}); + +describe('toCodes', () => { + it('returns first code per locale entry', () => { + const locales = ['en', { path: 'spanish', codes: ['es', 'es-ES'] as [string, ...string[]] }]; + assert.deepEqual(toCodes(locales), ['en', 'es']); + }); +}); + +describe('toPaths', () => { + it('returns path strings for all locales', () => { + const locales = ['en', { path: 'spanish', codes: ['es'] as [string, ...string[]] }]; + assert.deepEqual(toPaths(locales), ['en', 'spanish']); + }); +}); diff --git a/packages/astro/test/units/i18n/manual-middleware.test.js b/packages/astro/test/units/i18n/manual-middleware.test.js deleted file mode 100644 index 10e7ce163bd2..000000000000 --- a/packages/astro/test/units/i18n/manual-middleware.test.js +++ /dev/null @@ -1,546 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { requestHasLocale, redirectToDefaultLocale, notFound } from '../../../dist/i18n/index.js'; -import { createManualRoutingContext, createMiddlewarePayload } from './test-helpers.js'; -import { createMockNext } from '../test-utils.js'; - -describe('Custom Middleware with Allowlist Pattern', () => { - describe('allowlist bypasses i18n routing', () => { - it('should allow /help to bypass locale check', async () => { - const allowList = new Set(['/help', '/help/']); - const context = createManualRoutingContext({ pathname: '/help' }); - const next = createMockNext(new Response('Help page')); - - // Middleware logic: if allowlist matches, call next() - let response; - if (allowList.has(context.url.pathname)) { - response = await next(); - } - - assert.ok(next.called); - assert.equal(await response.text(), 'Help page'); - }); - - it('should allow /about if in allowlist', async () => { - const allowList = new Set(['/about']); - const context = createManualRoutingContext({ pathname: '/about' }); - const next = createMockNext(new Response('About page')); - - let response; - if (allowList.has(context.url.pathname)) { - response = await next(); - } - - assert.ok(next.called); - assert.equal(await response.text(), 'About page'); - }); - - it('should not call next() for non-allowlisted paths', async () => { - const allowList = new Set(['/help']); - const context = createManualRoutingContext({ pathname: '/blog' }); - const next = createMockNext(); - - let response = null; - if (!allowList.has(context.url.pathname)) { - // Path not in allowlist, don't call next - response = new Response(null, { status: 404 }); - } - - assert.equal(next.called, false); - assert.ok(response); - assert.equal(response.status, 404); - }); - }); - - describe('paths with locales proceed to next()', () => { - it('should call next() when requestHasLocale returns true', async () => { - const locales = ['en', 'es']; - const hasLocale = requestHasLocale(locales); - const context = createManualRoutingContext({ pathname: '/en/blog' }); - const next = createMockNext(new Response('Blog page')); - - let response; - if (hasLocale(context)) { - response = await next(); - } - - assert.ok(next.called); - assert.equal(await response.text(), 'Blog page'); - }); - - it('should call next() for /spanish with locale object', async () => { - const locales = ['en', { path: 'spanish', codes: ['es'] }]; - const hasLocale = requestHasLocale(locales); - const context = createManualRoutingContext({ pathname: '/spanish' }); - const next = createMockNext(new Response('Spanish page')); - - let response = null; - if (hasLocale(context)) { - response = await next(); - } - - assert.ok(next.called); - assert.ok(response); - }); - - it('should not call next() for paths without locale', async () => { - const locales = ['en', 'es']; - const hasLocale = requestHasLocale(locales); - const context = createManualRoutingContext({ pathname: '/blog' }); - const next = createMockNext(); - - let response = null; - if (hasLocale(context)) { - response = await next(); - } else { - response = new Response(null, { status: 404 }); - } - - assert.equal(next.called, false); - assert.ok(response); - assert.equal(response.status, 404); - }); - }); - - describe('root path redirects to default locale', () => { - it('should redirect / to default locale without calling next()', async () => { - const payload = createMiddlewarePayload({ defaultLocale: 'en' }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - const next = createMockNext(); - - let response; - if (context.url.pathname === '/') { - response = redirect(context); - } else { - response = await next(); - } - - assert.equal(next.called, false); // next() should NOT be called - assert.equal(response.status, 302); - assert.equal(response.headers.get('Location'), '/en/'); - }); - - it('should redirect with custom status code', async () => { - const payload = createMiddlewarePayload({ defaultLocale: 'en' }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context, 301); - - assert.equal(response.status, 301); - }); - }); - - describe('unknown paths return 404', () => { - it('should return 404 for unknown paths without calling next()', async () => { - const locales = ['en', 'es']; - const hasLocale = requestHasLocale(locales); - const context = createManualRoutingContext({ pathname: '/unknown' }); - const next = createMockNext(); - - let response = null; - if (hasLocale(context)) { - response = await next(); - } else if (context.url.pathname !== '/') { - response = new Response(null, { status: 404 }); - } - - assert.equal(next.called, false); - assert.ok(response); - assert.equal(response.status, 404); - }); - - it('should return 404 for /blog without locale', async () => { - const locales = ['en', 'es']; - const hasLocale = requestHasLocale(locales); - const context = createManualRoutingContext({ pathname: '/blog' }); - - let response = null; - if (!hasLocale(context) && context.url.pathname !== '/') { - response = new Response(null, { status: 404 }); - } - - assert.ok(response); - assert.equal(response.status, 404); - }); - }); - - describe('special 404 route handling', () => { - it('should redirect /redirect-me to default locale', async () => { - const payload = createMiddlewarePayload({ defaultLocale: 'en' }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/redirect-me' }); - - // Middleware logic from fixture - let response = null; - if (context.url.pathname === '/' || context.url.pathname === '/redirect-me') { - response = redirect(context); - } - - assert.ok(response); - assert.equal(response.status, 302); - assert.equal(response.headers.get('Location'), '/en/'); - }); - }); -}); - -describe('Middleware Flow Control', () => { - describe('decision tree execution order', () => { - it('should check allowlist first, then locale, then root, then 404', async () => { - const allowList = new Set(['/help']); - const locales = ['en', 'es']; - const hasLocale = requestHasLocale(locales); - const payload = createMiddlewarePayload({ defaultLocale: 'en' }); - const redirect = redirectToDefaultLocale(payload); - - // Test function that mimics the middleware from fixture - async function middleware(pathname) { - const context = createManualRoutingContext({ pathname }); - const next = createMockNext(new Response('Page content')); - - // Step 1: Check allowlist - if (allowList.has(context.url.pathname)) { - return { response: await next(), calledNext: true }; - } - - // Step 2: Check if has locale - if (hasLocale(context)) { - return { response: await next(), calledNext: true }; - } - - // Step 3: Check if root or special path - if (context.url.pathname === '/' || context.url.pathname === '/redirect-me') { - return { response: redirect(context), calledNext: false }; - } - - // Step 4: Return 404 - return { response: new Response(null, { status: 404 }), calledNext: false }; - } - - // Test allowlist path - const result1 = await middleware('/help'); - assert.equal(result1.calledNext, true); - assert.equal(await result1.response.text(), 'Page content'); - - // Test locale path - const result2 = await middleware('/en/blog'); - assert.equal(result2.calledNext, true); - - // Test root path - const result3 = await middleware('/'); - assert.equal(result3.calledNext, false); - assert.equal(result3.response.status, 302); - - // Test unknown path - const result4 = await middleware('/unknown'); - assert.equal(result4.calledNext, false); - assert.equal(result4.response.status, 404); - }); - - it('should short-circuit on allowlist match', async () => { - const allowList = new Set(['/help']); - const locales = ['en', 'es']; - const hasLocale = requestHasLocale(locales); - const context = createManualRoutingContext({ pathname: '/help' }); - const next = createMockNext(new Response('Help page')); - - // Middleware should return immediately after allowlist check - let response = null; - if (allowList.has(context.url.pathname)) { - response = await next(); - } else if (hasLocale(context)) { - // This should not execute - assert.fail('Should not check locale after allowlist match'); - } - - assert.ok(next.called); - assert.ok(response); - }); - - it('should short-circuit on locale match', async () => { - const locales = ['en', 'es']; - const hasLocale = requestHasLocale(locales); - const context = createManualRoutingContext({ pathname: '/en/blog' }); - const next = createMockNext(new Response('Blog')); - - let response = null; - if (hasLocale(context)) { - response = await next(); - } else if (context.url.pathname === '/') { - // This should not execute - assert.fail('Should not check root after locale match'); - } - - assert.ok(next.called); - assert.ok(response); - }); - }); - - describe('early return patterns', () => { - it('should return immediately when allowlist matches', async () => { - const allowList = new Set(['/help']); - const context = createManualRoutingContext({ pathname: '/help' }); - const next = createMockNext(new Response('Help')); - - let executedNext = false; - let executedOther = false; - - let response = null; - if (allowList.has(context.url.pathname)) { - executedNext = true; - response = await next(); - // Early return, nothing after should execute - } else { - executedOther = true; - } - - assert.equal(executedNext, true); - assert.equal(executedOther, false); - assert.ok(response); - }); - - it('should not call next() when redirecting', async () => { - const payload = createMiddlewarePayload({ defaultLocale: 'en' }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - const next = createMockNext(); - - let response; - if (context.url.pathname === '/') { - response = redirect(context); - // Should return here, not call next() - } else { - response = await next(); - } - - assert.equal(next.called, false); - assert.equal(response.status, 302); - }); - - it('should not call next() when returning 404', async () => { - const locales = ['en', 'es']; - const hasLocale = requestHasLocale(locales); - const context = createManualRoutingContext({ pathname: '/unknown' }); - const next = createMockNext(); - - let response = null; - if (hasLocale(context)) { - response = await next(); - } else { - response = new Response(null, { status: 404 }); - // Should return here, not call next() - } - - assert.equal(next.called, false); - assert.ok(response); - assert.equal(response.status, 404); - }); - }); - - describe('response propagation', () => { - it('should propagate response from next() when locale found', async () => { - const locales = ['en', 'es']; - const hasLocale = requestHasLocale(locales); - const context = createManualRoutingContext({ pathname: '/en/blog' }); - const expectedResponse = new Response('Blog content', { - status: 200, - headers: { 'X-Custom': 'value' }, - }); - const next = createMockNext(expectedResponse); - - let response; - if (hasLocale(context)) { - response = await next(); - } - - assert.equal(response, expectedResponse); - assert.equal(response.headers.get('X-Custom'), 'value'); - }); - - it('should propagate custom response from allowlist route', async () => { - const allowList = new Set(['/api/health']); - const context = createManualRoutingContext({ pathname: '/api/health' }); - const healthResponse = new Response(JSON.stringify({ status: 'ok' }), { - headers: { 'Content-Type': 'application/json' }, - }); - const next = createMockNext(healthResponse); - - let response; - if (allowList.has(context.url.pathname)) { - response = await next(); - } - - assert.equal(response.headers.get('Content-Type'), 'application/json'); - assert.equal(await response.text(), JSON.stringify({ status: 'ok' })); - }); - }); -}); - -describe('Complete Middleware Scenarios', () => { - describe('fixture middleware pattern', () => { - /** - * This replicates the exact middleware from the i18n-routing-manual fixture - */ - async function fixtureMiddleware(pathname) { - const allowList = new Set(['/help', '/help/']); - const locales = ['en', 'pt', 'it', { path: 'spanish', codes: ['es', 'es-ar'] }]; - const payload = createMiddlewarePayload({ - defaultLocale: 'en', - locales, - }); - - const hasLocale = requestHasLocale(locales); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname }); - const next = createMockNext(new Response('Page content')); - - // Replicate exact middleware logic - if (allowList.has(context.url.pathname)) { - return await next(); - } - if (hasLocale(context)) { - return await next(); - } - if (context.url.pathname === '/' || context.url.pathname === '/redirect-me') { - return redirect(context); - } - return new Response(null, { status: 404 }); - } - - it('should handle all fixture test cases correctly', async () => { - // Test case 1: Root redirects to /en/ - const response1 = await fixtureMiddleware('/'); - assert.equal(response1.status, 302); - assert.equal(response1.headers.get('Location'), '/en/'); - - // Test case 2: /help is allowed (not i18n) - const response2 = await fixtureMiddleware('/help'); - assert.equal(await response2.text(), 'Page content'); - - // Test case 3: /en/blog has locale - const response3 = await fixtureMiddleware('/en/blog'); - assert.equal(await response3.text(), 'Page content'); - - // Test case 4: /pt/start has locale - const response4 = await fixtureMiddleware('/pt/start'); - assert.equal(await response4.text(), 'Page content'); - - // Test case 5: /spanish has locale (object path) - const response5 = await fixtureMiddleware('/spanish'); - assert.equal(await response5.text(), 'Page content'); - - // Test case 6: /redirect-me redirects like root - const response6 = await fixtureMiddleware('/redirect-me'); - assert.equal(response6.status, 302); - assert.equal(response6.headers.get('Location'), '/en/'); - - // Test case 7: Unknown path returns 404 - const response7 = await fixtureMiddleware('/unknown'); - assert.equal(response7.status, 404); - - // Test case 8: /blog without locale returns 404 - const response8 = await fixtureMiddleware('/blog'); - assert.equal(response8.status, 404); - }); - - it('should not match locale codes for locale objects', async () => { - // /es should NOT match the spanish locale object (only /spanish matches) - const response = await fixtureMiddleware('/es'); - assert.equal(response.status, 404); - }); - - it('should handle trailing slash in allowlist', async () => { - const response = await fixtureMiddleware('/help/'); - assert.equal(await response.text(), 'Page content'); - }); - }); - - describe('middleware with base path', () => { - async function middlewareWithBase(pathname, base = '/blog') { - const locales = ['en', 'es']; - const payload = createMiddlewarePayload({ - base, - defaultLocale: 'en', - locales, - }); - - const hasLocale = requestHasLocale(locales); - const redirect = redirectToDefaultLocale(payload); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname }); - const next = createMockNext(new Response('Page')); - - if (hasLocale(context)) { - return await next(); - } - if (context.url.pathname === base || context.url.pathname === base + '/') { - return redirect(context); - } - const result = notFoundFn(context); - return result || new Response(null, { status: 404 }); - } - - it('should redirect base path to base + locale', async () => { - const response = await middlewareWithBase('/blog'); - assert.equal(response.status, 302); - assert.equal(response.headers.get('Location'), '/blog/en/'); - }); - - it('should allow paths with locale under base', async () => { - const response = await middlewareWithBase('/blog/en/post'); - assert.equal(await response.text(), 'Page'); - }); - - it('should return 404 for paths without locale under base', async () => { - const response = await middlewareWithBase('/blog/about'); - assert.equal(response.status, 404); - }); - }); - - describe('middleware with custom responses', () => { - it('should allow custom response from middleware before calling next()', async () => { - const allowList = new Set(['/api/status']); - const context = createManualRoutingContext({ pathname: '/api/status' }); - const next = createMockNext(); - - let response; - if (allowList.has(context.url.pathname)) { - // Return custom JSON response without calling next() - response = new Response(JSON.stringify({ status: 'healthy' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } else { - response = await next(); - } - - assert.equal(next.called, false); - assert.equal(response.status, 200); - assert.equal(await response.text(), JSON.stringify({ status: 'healthy' })); - }); - - it('should modify response after next() call', async () => { - const locales = ['en']; - const hasLocale = requestHasLocale(locales); - const context = createManualRoutingContext({ pathname: '/en/api' }); - const next = createMockNext(new Response('Data')); - - let response; - if (hasLocale(context)) { - const originalResponse = await next(); - // Add custom header to response from next() - response = new Response(originalResponse.body, { - status: originalResponse.status, - headers: { - ...Object.fromEntries(originalResponse.headers), - 'X-Custom-Header': 'added-by-middleware', - }, - }); - } - - assert.ok(next.called); - assert.equal(response.headers.get('X-Custom-Header'), 'added-by-middleware'); - }); - }); -}); diff --git a/packages/astro/test/units/i18n/manual-middleware.test.ts b/packages/astro/test/units/i18n/manual-middleware.test.ts new file mode 100644 index 000000000000..8c2130c1dcf6 --- /dev/null +++ b/packages/astro/test/units/i18n/manual-middleware.test.ts @@ -0,0 +1,547 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { requestHasLocale, redirectToDefaultLocale, notFound } from '../../../dist/i18n/index.js'; +import type { Locales } from '../../../dist/types/public/config.js'; +import { createManualRoutingContext, createMiddlewarePayload } from './test-helpers.ts'; +import { createMockNext } from '../test-utils.ts'; + +describe('Custom Middleware with Allowlist Pattern', () => { + describe('allowlist bypasses i18n routing', () => { + it('should allow /help to bypass locale check', async () => { + const allowList = new Set(['/help', '/help/']); + const context = createManualRoutingContext({ pathname: '/help' }); + const next = createMockNext(new Response('Help page')); + + // Middleware logic: if allowlist matches, call next() + let response: Response | undefined; + if (allowList.has(context.url.pathname)) { + response = await next(); + } + + assert.ok(next.called); + assert.equal(await response!.text(), 'Help page'); + }); + + it('should allow /about if in allowlist', async () => { + const allowList = new Set(['/about']); + const context = createManualRoutingContext({ pathname: '/about' }); + const next = createMockNext(new Response('About page')); + + let response: Response | undefined; + if (allowList.has(context.url.pathname)) { + response = await next(); + } + + assert.ok(next.called); + assert.equal(await response!.text(), 'About page'); + }); + + it('should not call next() for non-allowlisted paths', async () => { + const allowList = new Set(['/help']); + const context = createManualRoutingContext({ pathname: '/blog' }); + const next = createMockNext(); + + let response: Response | null = null; + if (!allowList.has(context.url.pathname)) { + // Path not in allowlist, don't call next + response = new Response(null, { status: 404 }); + } + + assert.equal(next.called, false); + assert.ok(response); + assert.equal(response.status, 404); + }); + }); + + describe('paths with locales proceed to next()', () => { + it('should call next() when requestHasLocale returns true', async () => { + const locales: Locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + const next = createMockNext(new Response('Blog page')); + + let response: Response | undefined; + if (hasLocale(context)) { + response = await next(); + } + + assert.ok(next.called); + assert.equal(await response!.text(), 'Blog page'); + }); + + it('should call next() for /spanish with locale object', async () => { + const locales: Locales = ['en', { path: 'spanish', codes: ['es'] }]; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/spanish' }); + const next = createMockNext(new Response('Spanish page')); + + let response: Response | null = null; + if (hasLocale(context)) { + response = await next(); + } + + assert.ok(next.called); + assert.ok(response); + }); + + it('should not call next() for paths without locale', async () => { + const locales: Locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/blog' }); + const next = createMockNext(); + + let response: Response | null = null; + if (hasLocale(context)) { + response = await next(); + } else { + response = new Response(null, { status: 404 }); + } + + assert.equal(next.called, false); + assert.ok(response); + assert.equal(response.status, 404); + }); + }); + + describe('root path redirects to default locale', () => { + it('should redirect / to default locale without calling next()', async () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + const next = createMockNext(); + + let response: Response; + if (context.url.pathname === '/') { + response = redirect(context); + } else { + response = await next(); + } + + assert.equal(next.called, false); // next() should NOT be called + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should redirect with custom status code', async () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 301); + + assert.equal(response.status, 301); + }); + }); + + describe('unknown paths return 404', () => { + it('should return 404 for unknown paths without calling next()', async () => { + const locales: Locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/unknown' }); + const next = createMockNext(); + + let response: Response | null = null; + if (hasLocale(context)) { + response = await next(); + } else if (context.url.pathname !== '/') { + response = new Response(null, { status: 404 }); + } + + assert.equal(next.called, false); + assert.ok(response); + assert.equal(response.status, 404); + }); + + it('should return 404 for /blog without locale', async () => { + const locales: Locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/blog' }); + + let response: Response | null = null; + if (!hasLocale(context) && context.url.pathname !== '/') { + response = new Response(null, { status: 404 }); + } + + assert.ok(response); + assert.equal(response.status, 404); + }); + }); + + describe('special 404 route handling', () => { + it('should redirect /redirect-me to default locale', async () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/redirect-me' }); + + // Middleware logic from fixture + let response: Response | null = null; + if (context.url.pathname === '/' || context.url.pathname === '/redirect-me') { + response = redirect(context); + } + + assert.ok(response); + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/en/'); + }); + }); +}); + +describe('Middleware Flow Control', () => { + describe('decision tree execution order', () => { + it('should check allowlist first, then locale, then root, then 404', async () => { + const allowList = new Set(['/help']); + const locales: Locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + + // Test function that mimics the middleware from fixture + async function middleware(pathname: string) { + const context = createManualRoutingContext({ pathname }); + const next = createMockNext(new Response('Page content')); + + // Step 1: Check allowlist + if (allowList.has(context.url.pathname)) { + return { response: await next(), calledNext: true }; + } + + // Step 2: Check if has locale + if (hasLocale(context)) { + return { response: await next(), calledNext: true }; + } + + // Step 3: Check if root or special path + if (context.url.pathname === '/' || context.url.pathname === '/redirect-me') { + return { response: redirect(context), calledNext: false }; + } + + // Step 4: Return 404 + return { response: new Response(null, { status: 404 }), calledNext: false }; + } + + // Test allowlist path + const result1 = await middleware('/help'); + assert.equal(result1.calledNext, true); + assert.equal(await result1.response.text(), 'Page content'); + + // Test locale path + const result2 = await middleware('/en/blog'); + assert.equal(result2.calledNext, true); + + // Test root path + const result3 = await middleware('/'); + assert.equal(result3.calledNext, false); + assert.equal(result3.response.status, 302); + + // Test unknown path + const result4 = await middleware('/unknown'); + assert.equal(result4.calledNext, false); + assert.equal(result4.response.status, 404); + }); + + it('should short-circuit on allowlist match', async () => { + const allowList = new Set(['/help']); + const locales: Locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/help' }); + const next = createMockNext(new Response('Help page')); + + // Middleware should return immediately after allowlist check + let response: Response | null = null; + if (allowList.has(context.url.pathname)) { + response = await next(); + } else if (hasLocale(context)) { + // This should not execute + assert.fail('Should not check locale after allowlist match'); + } + + assert.ok(next.called); + assert.ok(response); + }); + + it('should short-circuit on locale match', async () => { + const locales: Locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + const next = createMockNext(new Response('Blog')); + + let response: Response | null = null; + if (hasLocale(context)) { + response = await next(); + } else if (context.url.pathname === '/') { + // This should not execute + assert.fail('Should not check root after locale match'); + } + + assert.ok(next.called); + assert.ok(response); + }); + }); + + describe('early return patterns', () => { + it('should return immediately when allowlist matches', async () => { + const allowList = new Set(['/help']); + const context = createManualRoutingContext({ pathname: '/help' }); + const next = createMockNext(new Response('Help')); + + let executedNext = false; + let executedOther = false; + + let response: Response | null = null; + if (allowList.has(context.url.pathname)) { + executedNext = true; + response = await next(); + // Early return, nothing after should execute + } else { + executedOther = true; + } + + assert.equal(executedNext, true); + assert.equal(executedOther, false); + assert.ok(response); + }); + + it('should not call next() when redirecting', async () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + const next = createMockNext(); + + let response: Response; + if (context.url.pathname === '/') { + response = redirect(context); + // Should return here, not call next() + } else { + response = await next(); + } + + assert.equal(next.called, false); + assert.equal(response.status, 302); + }); + + it('should not call next() when returning 404', async () => { + const locales: Locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/unknown' }); + const next = createMockNext(); + + let response: Response | null = null; + if (hasLocale(context)) { + response = await next(); + } else { + response = new Response(null, { status: 404 }); + // Should return here, not call next() + } + + assert.equal(next.called, false); + assert.ok(response); + assert.equal(response.status, 404); + }); + }); + + describe('response propagation', () => { + it('should propagate response from next() when locale found', async () => { + const locales: Locales = ['en', 'es']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + const expectedResponse = new Response('Blog content', { + status: 200, + headers: { 'X-Custom': 'value' }, + }); + const next = createMockNext(expectedResponse); + + let response: Response | undefined; + if (hasLocale(context)) { + response = await next(); + } + + assert.equal(response, expectedResponse); + assert.equal(response!.headers.get('X-Custom'), 'value'); + }); + + it('should propagate custom response from allowlist route', async () => { + const allowList = new Set(['/api/health']); + const context = createManualRoutingContext({ pathname: '/api/health' }); + const healthResponse = new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + const next = createMockNext(healthResponse); + + let response: Response | undefined; + if (allowList.has(context.url.pathname)) { + response = await next(); + } + + assert.equal(response!.headers.get('Content-Type'), 'application/json'); + assert.equal(await response!.text(), JSON.stringify({ status: 'ok' })); + }); + }); +}); + +describe('Complete Middleware Scenarios', () => { + describe('fixture middleware pattern', () => { + /** + * This replicates the exact middleware from the i18n-routing-manual fixture + */ + async function fixtureMiddleware(pathname: string): Promise { + const allowList = new Set(['/help', '/help/']); + const locales: Locales = ['en', 'pt', 'it', { path: 'spanish', codes: ['es', 'es-ar'] }]; + const payload = createMiddlewarePayload({ + defaultLocale: 'en', + locales, + }); + + const hasLocale = requestHasLocale(locales); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname }); + const next = createMockNext(new Response('Page content')); + + // Replicate exact middleware logic + if (allowList.has(context.url.pathname)) { + return await next(); + } + if (hasLocale(context)) { + return await next(); + } + if (context.url.pathname === '/' || context.url.pathname === '/redirect-me') { + return redirect(context); + } + return new Response(null, { status: 404 }); + } + + it('should handle all fixture test cases correctly', async () => { + // Test case 1: Root redirects to /en/ + const response1 = await fixtureMiddleware('/'); + assert.equal(response1.status, 302); + assert.equal(response1.headers.get('Location'), '/en/'); + + // Test case 2: /help is allowed (not i18n) + const response2 = await fixtureMiddleware('/help'); + assert.equal(await response2.text(), 'Page content'); + + // Test case 3: /en/blog has locale + const response3 = await fixtureMiddleware('/en/blog'); + assert.equal(await response3.text(), 'Page content'); + + // Test case 4: /pt/start has locale + const response4 = await fixtureMiddleware('/pt/start'); + assert.equal(await response4.text(), 'Page content'); + + // Test case 5: /spanish has locale (object path) + const response5 = await fixtureMiddleware('/spanish'); + assert.equal(await response5.text(), 'Page content'); + + // Test case 6: /redirect-me redirects like root + const response6 = await fixtureMiddleware('/redirect-me'); + assert.equal(response6.status, 302); + assert.equal(response6.headers.get('Location'), '/en/'); + + // Test case 7: Unknown path returns 404 + const response7 = await fixtureMiddleware('/unknown'); + assert.equal(response7.status, 404); + + // Test case 8: /blog without locale returns 404 + const response8 = await fixtureMiddleware('/blog'); + assert.equal(response8.status, 404); + }); + + it('should not match locale codes for locale objects', async () => { + // /es should NOT match the spanish locale object (only /spanish matches) + const response = await fixtureMiddleware('/es'); + assert.equal(response.status, 404); + }); + + it('should handle trailing slash in allowlist', async () => { + const response = await fixtureMiddleware('/help/'); + assert.equal(await response.text(), 'Page content'); + }); + }); + + describe('middleware with base path', () => { + async function middlewareWithBase(pathname: string, base = '/blog'): Promise { + const locales: Locales = ['en', 'es']; + const payload = createMiddlewarePayload({ + base, + defaultLocale: 'en', + locales, + }); + + const hasLocale = requestHasLocale(locales); + const redirect = redirectToDefaultLocale(payload); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname }); + const next = createMockNext(new Response('Page')); + + if (hasLocale(context)) { + return await next(); + } + if (context.url.pathname === base || context.url.pathname === base + '/') { + return redirect(context); + } + const result = notFoundFn(context); + return result || new Response(null, { status: 404 }); + } + + it('should redirect base path to base + locale', async () => { + const response = await middlewareWithBase('/blog'); + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/blog/en/'); + }); + + it('should allow paths with locale under base', async () => { + const response = await middlewareWithBase('/blog/en/post'); + assert.equal(await response.text(), 'Page'); + }); + + it('should return 404 for paths without locale under base', async () => { + const response = await middlewareWithBase('/blog/about'); + assert.equal(response.status, 404); + }); + }); + + describe('middleware with custom responses', () => { + it('should allow custom response from middleware before calling next()', async () => { + const allowList = new Set(['/api/status']); + const context = createManualRoutingContext({ pathname: '/api/status' }); + const next = createMockNext(); + + let response: Response; + if (allowList.has(context.url.pathname)) { + // Return custom JSON response without calling next() + response = new Response(JSON.stringify({ status: 'healthy' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } else { + response = await next(); + } + + assert.equal(next.called, false); + assert.equal(response.status, 200); + assert.equal(await response.text(), JSON.stringify({ status: 'healthy' })); + }); + + it('should modify response after next() call', async () => { + const locales: Locales = ['en']; + const hasLocale = requestHasLocale(locales); + const context = createManualRoutingContext({ pathname: '/en/api' }); + const next = createMockNext(new Response('Data')); + + let response: Response | undefined; + if (hasLocale(context)) { + const originalResponse = await next(); + // Add custom header to response from next() + response = new Response(originalResponse.body, { + status: originalResponse.status, + headers: { + ...Object.fromEntries(originalResponse.headers), + 'X-Custom-Header': 'added-by-middleware', + }, + }); + } + + assert.ok(next.called); + assert.equal(response!.headers.get('X-Custom-Header'), 'added-by-middleware'); + }); + }); +}); diff --git a/packages/astro/test/units/i18n/manual-routing.test.js b/packages/astro/test/units/i18n/manual-routing.test.js deleted file mode 100644 index e8038cd6f2eb..000000000000 --- a/packages/astro/test/units/i18n/manual-routing.test.js +++ /dev/null @@ -1,1349 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - normalizeTheLocale, - normalizeThePath, - pathHasLocale, - requestHasLocale, - redirectToDefaultLocale, - notFound, - redirectToFallback, -} from '../../../dist/i18n/index.js'; -import { REROUTE_DIRECTIVE_HEADER } from '../../../dist/core/constants.js'; -import { createManualRoutingContext, createMiddlewarePayload } from './test-helpers.js'; - -describe('normalizeTheLocale', () => { - it('should convert underscores to dashes', () => { - assert.equal(normalizeTheLocale('en_US'), 'en-us'); - assert.equal(normalizeTheLocale('pt_BR'), 'pt-br'); - assert.equal(normalizeTheLocale('zh_Hans_CN'), 'zh-hans-cn'); - }); - - it('should convert to lowercase', () => { - assert.equal(normalizeTheLocale('EN'), 'en'); - assert.equal(normalizeTheLocale('ES'), 'es'); - assert.equal(normalizeTheLocale('PT'), 'pt'); - }); - - it('should convert both underscores and case', () => { - assert.equal(normalizeTheLocale('EN_US'), 'en-us'); - assert.equal(normalizeTheLocale('Es_AR'), 'es-ar'); - }); - - it('should handle already normalized locales', () => { - assert.equal(normalizeTheLocale('en-us'), 'en-us'); - assert.equal(normalizeTheLocale('en'), 'en'); - assert.equal(normalizeTheLocale('pt-br'), 'pt-br'); - }); - - it('should handle edge cases', () => { - assert.equal(normalizeTheLocale(''), ''); - assert.equal(normalizeTheLocale('a'), 'a'); - }); -}); - -describe('normalizeThePath', () => { - it('should remove .html extension', () => { - assert.equal(normalizeThePath('/en/blog.html'), '/en/blog'); - assert.equal(normalizeThePath('/spanish.html'), '/spanish'); - assert.equal(normalizeThePath('en.html'), 'en'); - }); - - it('should not modify paths without .html', () => { - assert.equal(normalizeThePath('/en/blog'), '/en/blog'); - assert.equal(normalizeThePath('/spanish'), '/spanish'); - assert.equal(normalizeThePath('/'), '/'); - }); - - it('should not remove other extensions', () => { - assert.equal(normalizeThePath('/en/blog.php'), '/en/blog.php'); - assert.equal(normalizeThePath('/api.json'), '/api.json'); - assert.equal(normalizeThePath('/file.txt'), '/file.txt'); - }); - - it('should handle edge cases', () => { - assert.equal(normalizeThePath(''), ''); - assert.equal(normalizeThePath('.html'), ''); - assert.equal(normalizeThePath('a.html'), 'a'); - }); -}); - -describe('pathHasLocale', () => { - describe('string locales - basic matching', () => { - it('should return true when path contains string locale', () => { - assert.equal(pathHasLocale('/en', ['en', 'es']), true); - assert.equal(pathHasLocale('/es', ['en', 'es']), true); - assert.equal(pathHasLocale('/pt', ['en', 'es', 'pt']), true); - }); - - it('should return true when path contains locale in nested path', () => { - assert.equal(pathHasLocale('/en/about', ['en', 'es']), true); - assert.equal(pathHasLocale('/es/blog/post', ['en', 'es']), true); - assert.equal(pathHasLocale('/pt/nested/deep/path', ['pt']), true); - }); - - it('should return false when path does not contain locale', () => { - assert.equal(pathHasLocale('/fr', ['en', 'es']), false); - assert.equal(pathHasLocale('/about', ['en', 'es']), false); - assert.equal(pathHasLocale('/blog/post', ['en', 'es']), false); - }); - - it('should return false for root path', () => { - assert.equal(pathHasLocale('/', ['en', 'es']), false); - }); - }); - - describe('string locales - case insensitive matching', () => { - it('should match locale regardless of case in path', () => { - assert.equal(pathHasLocale('/EN', ['en']), true); - assert.equal(pathHasLocale('/En', ['en']), true); - assert.equal(pathHasLocale('/eN', ['en']), true); - }); - - it('should match locale regardless of case in config', () => { - assert.equal(pathHasLocale('/en', ['EN']), true); - assert.equal(pathHasLocale('/en', ['En']), true); - }); - - it('should handle underscore to dash normalization in path', () => { - assert.equal(pathHasLocale('/en_US', ['en-us']), true); - assert.equal(pathHasLocale('/pt_BR', ['pt-br']), true); - }); - - it('should handle dash to underscore normalization in config', () => { - assert.equal(pathHasLocale('/en-us', ['en_US']), true); - assert.equal(pathHasLocale('/pt-br', ['pt_BR']), true); - }); - - it('should handle mixed case and separators', () => { - assert.equal(pathHasLocale('/EN_us', ['en-US']), true); - assert.equal(pathHasLocale('/pt-BR', ['PT_br']), true); - }); - }); - - describe('object locales - path matching', () => { - it('should match locale object by path', () => { - const locales = [{ path: 'spanish', codes: ['es', 'es-ar'] }]; - assert.equal(pathHasLocale('/spanish', locales), true); - }); - - it('should match locale object in nested path', () => { - const locales = [{ path: 'spanish', codes: ['es'] }]; - assert.equal(pathHasLocale('/spanish/blog', locales), true); - assert.equal(pathHasLocale('/spanish/blog/post', locales), true); - }); - - it('should not match locale codes, only path', () => { - const locales = [{ path: 'spanish', codes: ['es', 'es-ar'] }]; - assert.equal(pathHasLocale('/es', locales), false); - assert.equal(pathHasLocale('/es-ar', locales), false); - }); - - it('should match multiple locale objects', () => { - const locales = [ - { path: 'spanish', codes: ['es'] }, - { path: 'portuguese', codes: ['pt'] }, - ]; - assert.equal(pathHasLocale('/spanish', locales), true); - assert.equal(pathHasLocale('/portuguese', locales), true); - }); - }); - - describe('mixed locales', () => { - it('should match string locale in mixed array', () => { - const locales = ['en', { path: 'spanish', codes: ['es'] }]; - assert.equal(pathHasLocale('/en/blog', locales), true); - }); - - it('should match object locale in mixed array', () => { - const locales = ['en', { path: 'spanish', codes: ['es'] }]; - assert.equal(pathHasLocale('/spanish/blog', locales), true); - }); - - it('should not match undefined locale', () => { - const locales = ['en', { path: 'spanish', codes: ['es'] }]; - assert.equal(pathHasLocale('/pt', locales), false); - assert.equal(pathHasLocale('/fr/blog', locales), false); - }); - - it('should work with complex mixed config', () => { - const locales = [ - 'en', - 'fr', - { path: 'spanish', codes: ['es', 'es-ar'] }, - 'pt', - { path: 'italiano', codes: ['it', 'it-va'] }, - ]; - assert.equal(pathHasLocale('/en', locales), true); - assert.equal(pathHasLocale('/fr/about', locales), true); - assert.equal(pathHasLocale('/spanish', locales), true); - assert.equal(pathHasLocale('/pt/blog', locales), true); - assert.equal(pathHasLocale('/italiano', locales), true); - assert.equal(pathHasLocale('/de', locales), false); - }); - }); - - describe('HTML extension handling (SSG)', () => { - it('should match locale with .html extension', () => { - assert.equal(pathHasLocale('/en.html', ['en']), true); - assert.equal(pathHasLocale('/es.html', ['en', 'es']), true); - }); - - it('should match locale object path with .html', () => { - const locales = [{ path: 'spanish', codes: ['es'] }]; - assert.equal(pathHasLocale('/spanish.html', locales), true); - }); - - it('should match nested paths with .html', () => { - assert.equal(pathHasLocale('/en/blog.html', ['en']), true); - assert.equal(pathHasLocale('/es/about/us.html', ['es']), true); - }); - - it('should strip .html before checking locale', () => { - const locales = [{ path: 'spanish', codes: ['es'] }]; - assert.equal(pathHasLocale('/spanish.html', locales), true); - // But not match the code - assert.equal(pathHasLocale('/es.html', locales), false); - }); - }); - - describe('edge cases', () => { - it('should handle root path', () => { - assert.equal(pathHasLocale('/', ['en', 'es']), false); - }); - - it('should handle empty path', () => { - assert.equal(pathHasLocale('', ['en', 'es']), false); - }); - - it('should handle trailing slash', () => { - assert.equal(pathHasLocale('/en/', ['en']), true); - assert.equal(pathHasLocale('/es/blog/', ['es']), true); - }); - - it('should handle path with only locale and trailing slash', () => { - const locales = [{ path: 'spanish', codes: ['es'] }]; - assert.equal(pathHasLocale('/spanish/', locales), true); - }); - - it('should handle multiple consecutive slashes', () => { - assert.equal(pathHasLocale('/en//blog', ['en']), true); - assert.equal(pathHasLocale('//en/blog', ['en']), true); - }); - - it('should not match partial locale segments', () => { - assert.equal(pathHasLocale('/english', ['en']), false); - assert.equal(pathHasLocale('/item', ['it']), false); - assert.equal(pathHasLocale('/open', ['en']), false); - }); - - it('should handle empty locales array', () => { - assert.equal(pathHasLocale('/en', []), false); - assert.equal(pathHasLocale('/', []), false); - }); - - it('should handle single character locales', () => { - assert.equal(pathHasLocale('/a', ['a', 'b']), true); - assert.equal(pathHasLocale('/b/page', ['a', 'b']), true); - }); - }); -}); - -describe('requestHasLocale', () => { - it('should return a function', () => { - const hasLocale = requestHasLocale(['en', 'es']); - assert.equal(typeof hasLocale, 'function'); - }); - - it('should check context.url.pathname for locale', () => { - const hasLocale = requestHasLocale(['en', 'es']); - const context = createManualRoutingContext({ pathname: '/en/blog' }); - assert.equal(hasLocale(context), true); - }); - - it('should return true for paths with configured locales', () => { - const hasLocale = requestHasLocale(['en', 'es', 'pt']); - - assert.equal(hasLocale(createManualRoutingContext({ pathname: '/en' })), true); - assert.equal(hasLocale(createManualRoutingContext({ pathname: '/es/about' })), true); - assert.equal(hasLocale(createManualRoutingContext({ pathname: '/pt/blog/post' })), true); - }); - - it('should return false for paths without locales', () => { - const hasLocale = requestHasLocale(['en', 'es']); - - assert.equal(hasLocale(createManualRoutingContext({ pathname: '/blog' })), false); - assert.equal(hasLocale(createManualRoutingContext({ pathname: '/about' })), false); - assert.equal(hasLocale(createManualRoutingContext({ pathname: '/' })), false); - }); - - it('should work with locale objects', () => { - const hasLocale = requestHasLocale(['en', { path: 'spanish', codes: ['es', 'es-ar'] }]); - - assert.equal(hasLocale(createManualRoutingContext({ pathname: '/en' })), true); - assert.equal(hasLocale(createManualRoutingContext({ pathname: '/spanish' })), true); - assert.equal(hasLocale(createManualRoutingContext({ pathname: '/es' })), false); - }); - - it('should not modify context', () => { - const hasLocale = requestHasLocale(['en']); - const context = createManualRoutingContext({ pathname: '/en/blog' }); - const originalPathname = context.url.pathname; - - hasLocale(context); - - assert.equal(context.url.pathname, originalPathname); - }); - - it('should handle different hostnames', () => { - const hasLocale = requestHasLocale(['en', 'es']); - - const context1 = createManualRoutingContext({ pathname: '/en', hostname: 'localhost' }); - const context2 = createManualRoutingContext({ pathname: '/en', hostname: '127.0.0.1' }); - - assert.equal(hasLocale(context1), true); - assert.equal(hasLocale(context2), true); - }); - - it('should work consistently across multiple calls', () => { - const hasLocale = requestHasLocale(['en', 'es']); - const context = createManualRoutingContext({ pathname: '/en/blog' }); - - assert.equal(hasLocale(context), true); - assert.equal(hasLocale(context), true); - assert.equal(hasLocale(context), true); - }); -}); - -describe('redirectToDefaultLocale', () => { - describe('basic redirect generation', () => { - it('should create a function that returns a Response', () => { - const payload = createMiddlewarePayload({ - defaultLocale: 'en', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - assert.ok(response instanceof Response); - }); - - it('should redirect to default locale with no base', () => { - const payload = createMiddlewarePayload({ - base: '', - defaultLocale: 'en', - trailingSlash: 'ignore', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - assert.equal(response.status, 302); - // trailingSlash: 'ignore' + format: 'directory' adds trailing slash - assert.equal(response.headers.get('Location'), '/en/'); - }); - - it('should use default status 302', () => { - const payload = createMiddlewarePayload({ defaultLocale: 'en' }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - assert.equal(response.status, 302); - }); - }); - - describe('custom status codes', () => { - it('should accept custom status code 301', () => { - const payload = createMiddlewarePayload({ defaultLocale: 'en' }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context, 301); - - assert.equal(response.status, 301); - // Default payload has trailingSlash: 'ignore' + format: 'directory' - assert.equal(response.headers.get('Location'), '/en/'); - }); - - it('should accept custom status code 307', () => { - const payload = createMiddlewarePayload({ defaultLocale: 'en' }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context, 307); - - assert.equal(response.status, 307); - }); - - it('should accept custom status code 308', () => { - const payload = createMiddlewarePayload({ defaultLocale: 'en' }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context, 308); - - assert.equal(response.status, 308); - }); - }); - - describe('base path handling', () => { - it('should redirect to base + locale', () => { - const payload = createMiddlewarePayload({ - base: '/blog', - defaultLocale: 'en', - trailingSlash: 'ignore', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - // trailingSlash: 'ignore' + format: 'directory' adds trailing slash - assert.equal(response.headers.get('Location'), '/blog/en/'); - }); - - it('should handle base with leading slash', () => { - const payload = createMiddlewarePayload({ - base: '/my-site', - defaultLocale: 'pt', - trailingSlash: 'ignore', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - // trailingSlash: 'ignore' + format: 'directory' adds trailing slash - assert.equal(response.headers.get('Location'), '/my-site/pt/'); - }); - - it('should handle base with trailing slash', () => { - const payload = createMiddlewarePayload({ - base: '/blog/', - defaultLocale: 'en', - trailingSlash: 'ignore', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - // joinPaths normalizes, then trailingSlash: 'ignore' + format: 'directory' adds / - assert.equal(response.headers.get('Location'), '/blog/en/'); - }); - - it('should handle complex base paths', () => { - const payload = createMiddlewarePayload({ - base: '/sites/my-app', - defaultLocale: 'es', - trailingSlash: 'ignore', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - // trailingSlash: 'ignore' + format: 'directory' adds trailing slash - assert.equal(response.headers.get('Location'), '/sites/my-app/es/'); - }); - }); - - describe('trailing slash behavior', () => { - it('should add trailing slash with trailingSlash: always and format: directory', () => { - const payload = createMiddlewarePayload({ - base: '', - defaultLocale: 'en', - trailingSlash: 'always', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - assert.equal(response.headers.get('Location'), '/en/'); - }); - - it('should not add trailing slash with trailingSlash: never', () => { - const payload = createMiddlewarePayload({ - base: '', - defaultLocale: 'en', - trailingSlash: 'never', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - assert.equal(response.headers.get('Location'), '/en'); - }); - - it('should add trailing slash with trailingSlash: ignore and format: directory', () => { - const payload = createMiddlewarePayload({ - base: '', - defaultLocale: 'en', - trailingSlash: 'ignore', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - // trailingSlash: 'ignore' + format: 'directory' adds trailing slash - assert.equal(response.headers.get('Location'), '/en/'); - }); - - it('should add trailing slash with trailingSlash: always and format: file', () => { - const payload = createMiddlewarePayload({ - base: '', - defaultLocale: 'en', - trailingSlash: 'always', - format: 'file', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - assert.equal(response.headers.get('Location'), '/en/'); - }); - - it('should not add trailing slash with trailingSlash: never and format: file', () => { - const payload = createMiddlewarePayload({ - base: '', - defaultLocale: 'en', - trailingSlash: 'never', - format: 'file', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - assert.equal(response.headers.get('Location'), '/en'); - }); - }); - - describe('combined scenarios', () => { - it('should handle base + trailing slash + status code', () => { - const payload = createMiddlewarePayload({ - base: '/blog', - defaultLocale: 'pt', - trailingSlash: 'always', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context, 301); - - assert.equal(response.status, 301); - assert.equal(response.headers.get('Location'), '/blog/pt/'); - }); - - it('should handle complex locale codes', () => { - const payload = createMiddlewarePayload({ - base: '', - defaultLocale: 'es-AR', - trailingSlash: 'ignore', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - // trailingSlash: 'ignore' + format: 'directory' adds trailing slash - assert.equal(response.headers.get('Location'), '/es-AR/'); - }); - - it('should work with underscore locales', () => { - const payload = createMiddlewarePayload({ - base: '', - defaultLocale: 'en_US', - trailingSlash: 'ignore', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context); - - // trailingSlash: 'ignore' + format: 'directory' adds trailing slash - assert.equal(response.headers.get('Location'), '/en_US/'); - }); - - it('should handle all parameters combined', () => { - const payload = createMiddlewarePayload({ - base: '/sites/app', - defaultLocale: 'pt-BR', - trailingSlash: 'always', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = redirect(context, 307); - - assert.equal(response.status, 307); - assert.equal(response.headers.get('Location'), '/sites/app/pt-BR/'); - }); - }); - - describe('context independence', () => { - it('should work regardless of context pathname', () => { - const payload = createMiddlewarePayload({ - base: '', - defaultLocale: 'en', - trailingSlash: 'ignore', - format: 'directory', - }); - const redirect = redirectToDefaultLocale(payload); - - // All should redirect to the same place - const response1 = redirect(createManualRoutingContext({ pathname: '/' })); - const response2 = redirect(createManualRoutingContext({ pathname: '/about' })); - const response3 = redirect(createManualRoutingContext({ pathname: '/blog/post' })); - - // trailingSlash: 'ignore' + format: 'directory' adds trailing slash - assert.equal(response1.headers.get('Location'), '/en/'); - assert.equal(response2.headers.get('Location'), '/en/'); - assert.equal(response3.headers.get('Location'), '/en/'); - }); - }); -}); - -describe('notFound', () => { - describe('basic 404 for non-locale paths', () => { - it('should return 404 Response for paths without locale', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog' }); - - const response = notFoundFn(context); - - assert.ok(response instanceof Response); - assert.equal(response.status, 404); - }); - - it('should return 404 for /about with configured locales', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/about' }); - - const response = notFoundFn(context); - - assert.equal(response.status, 404); - }); - - it('should set REROUTE_DIRECTIVE_HEADER to no', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog' }); - - const response = notFoundFn(context); - - assert.equal(response.headers.get(REROUTE_DIRECTIVE_HEADER), 'no'); - }); - }); - - describe('root path handling', () => { - it('should return undefined for / (root)', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/' }); - - const response = notFoundFn(context); - - assert.equal(response, undefined); - }); - - it('should return undefined for base path as root', () => { - const payload = createMiddlewarePayload({ - base: '/blog', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog' }); - - const response = notFoundFn(context); - - assert.equal(response, undefined); - }); - - it('should return undefined for base path with trailing slash', () => { - const payload = createMiddlewarePayload({ - base: '/blog', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog/' }); - - const response = notFoundFn(context); - - assert.equal(response, undefined); - }); - }); - - describe('locale paths allowed', () => { - it('should return undefined for valid locale paths', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - - assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/en/blog' })), undefined); - assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/es/about' })), undefined); - }); - - it('should return undefined for locale object paths', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: [{ path: 'spanish', codes: ['es'] }], - }); - const notFoundFn = notFound(payload); - - assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/spanish' })), undefined); - assert.equal( - notFoundFn(createManualRoutingContext({ pathname: '/spanish/blog' })), - undefined, - ); - }); - - it('should return undefined for mixed locale config', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', { path: 'spanish', codes: ['es'] }], - }); - const notFoundFn = notFound(payload); - - assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/en' })), undefined); - assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/spanish' })), undefined); - }); - }); - - describe('response parameter handling', () => { - it('should preserve body when Response is passed', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog' }); - const originalResponse = new Response('Original body', { status: 200 }); - - const response = notFoundFn(context, originalResponse); - - assert.equal(response.status, 404); - assert.equal(response.body, originalResponse.body); - }); - - it('should copy headers when Response is passed', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog' }); - const originalResponse = new Response('body', { - status: 200, - headers: { 'X-Custom': 'value' }, - }); - - const response = notFoundFn(context, originalResponse); - - assert.equal(response.status, 404); - assert.equal(response.headers.get('X-Custom'), 'value'); - }); - - it('should override status to 404 when Response is passed', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog' }); - const originalResponse = new Response('body', { status: 200 }); - - const response = notFoundFn(context, originalResponse); - - assert.equal(response.status, 404); - }); - - it('should set REROUTE_DIRECTIVE_HEADER on passed Response', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog' }); - const originalResponse = new Response('body'); - - const response = notFoundFn(context, originalResponse); - - assert.equal(response.headers.get(REROUTE_DIRECTIVE_HEADER), 'no'); - }); - - it('should return original response when REROUTE_DIRECTIVE_HEADER is no and no fallback', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - fallback: undefined, - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog' }); - const originalResponse = new Response('body', { - headers: { [REROUTE_DIRECTIVE_HEADER]: 'no' }, - }); - - const response = notFoundFn(context, originalResponse); - - assert.equal(response, originalResponse); - }); - }); - - describe('fallback configuration', () => { - it('should still return 404 for non-locale paths with fallback configured', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - fallback: { es: 'en' }, - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog' }); - - const response = notFoundFn(context); - - assert.equal(response.status, 404); - }); - - it('should not return original response with fallback when REROUTE_DIRECTIVE_HEADER is no', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - fallback: { es: 'en' }, - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog' }); - const originalResponse = new Response('body', { - headers: { [REROUTE_DIRECTIVE_HEADER]: 'no' }, - }); - - const response = notFoundFn(context, originalResponse); - - // With fallback defined, it should not return the original - assert.notEqual(response, originalResponse); - assert.equal(response.status, 404); - }); - }); - - describe('base path handling', () => { - it('should return 404 for non-locale paths with base', () => { - const payload = createMiddlewarePayload({ - base: '/blog', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog/about' }); - - const response = notFoundFn(context); - - assert.equal(response.status, 404); - }); - - it('should allow locale paths with base', () => { - const payload = createMiddlewarePayload({ - base: '/blog', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - - assert.equal( - notFoundFn(createManualRoutingContext({ pathname: '/blog/en/about' })), - undefined, - ); - assert.equal( - notFoundFn(createManualRoutingContext({ pathname: '/blog/es/post' })), - undefined, - ); - }); - - it('should return 404 for paths without locale under base', () => { - const payload = createMiddlewarePayload({ - base: '/site', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - - const response = notFoundFn(createManualRoutingContext({ pathname: '/site/contact' })); - - assert.equal(response.status, 404); - }); - }); - - describe('edge cases', () => { - it('should handle empty pathname', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '' }); - - // Empty pathname is treated as root - const response = notFoundFn(context); - - // Based on implementation, empty string might be treated as root - assert.ok(response === undefined || response.status === 404); - }); - - it('should handle case sensitivity in locale matching', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - - // Normalized matching - assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/EN' })), undefined); - assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/Es' })), undefined); - }); - - it('should work with single locale', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en'], - }); - const notFoundFn = notFound(payload); - - assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/en' })), undefined); - assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/es' })).status, 404); - }); - - it('should return null body for 404 without passed Response', () => { - const payload = createMiddlewarePayload({ - base: '', - locales: ['en', 'es'], - }); - const notFoundFn = notFound(payload); - const context = createManualRoutingContext({ pathname: '/blog' }); - - const response = notFoundFn(context); - - assert.equal(response.body, null); - }); - }); -}); - -describe('redirectToFallback', () => { - describe('basic fallback behavior', () => { - it('should return original response when status is not 404', async () => { - const payload = createMiddlewarePayload({ - fallback: { es: 'en' }, - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/about' }); - const originalResponse = new Response('Content', { status: 200 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response, originalResponse); - }); - - it('should redirect when status is 404 and locale has fallback', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es', 'fr'], - defaultLocale: 'en', - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/about' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.status, 302); - assert.equal(response.headers.get('Location'), '/about'); - }); - - it('should return original response when no fallback configured', async () => { - const payload = createMiddlewarePayload({ - fallback: undefined, - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/about' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response, originalResponse); - }); - - it('should return original response when locale not in fallback config', async () => { - const payload = createMiddlewarePayload({ - fallback: { es: 'en' }, - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/fr/about' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response, originalResponse); - }); - }); - - describe('fallbackType: redirect', () => { - it('should redirect to fallback locale', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es', 'fr'], - defaultLocale: 'en', - fallback: { es: 'fr' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/blog/post' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.status, 302); - assert.equal(response.headers.get('Location'), '/fr/blog/post'); - }); - - it('should remove default locale prefix with prefix-other-locales strategy', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - defaultLocale: 'en', - strategy: 'pathname-prefix-other-locales', - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/about' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.headers.get('Location'), '/about'); - }); - - it('should handle base path correctly', async () => { - const payload = createMiddlewarePayload({ - base: '/blog', - locales: ['en', 'es'], - defaultLocale: 'en', - strategy: 'pathname-prefix-other-locales', - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/blog/es/post' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.headers.get('Location'), '/blog/post'); - }); - - it('should preserve query string', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - defaultLocale: 'en', - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/search?q=test' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.headers.get('Location'), '/search?q=test'); - }); - - it('should not redirect for 3xx status codes', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/page' }); - const originalResponse = new Response(null, { status: 301 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response, originalResponse); - }); - - it('should not redirect for non-404 4xx status codes', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/page' }); - const originalResponse = new Response(null, { status: 403 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response, originalResponse); - }); - - it('should not redirect for 5xx status codes', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/page' }); - const originalResponse = new Response(null, { status: 500 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response, originalResponse); - }); - }); - - describe('fallbackType: rewrite', () => { - it('should rewrite to fallback locale', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es', 'fr'], - defaultLocale: 'en', - fallback: { es: 'fr' }, - fallbackType: 'rewrite', - }); - const fallbackFn = redirectToFallback(payload); - - // Mock context.rewrite - const context = { - ...createManualRoutingContext({ pathname: '/es/blog/post' }), - rewrite: async (path) => { - return new Response(null, { - status: 200, - headers: { 'X-Rewrite-Path': path }, - }); - }, - }; - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.status, 200); - assert.equal(response.headers.get('X-Rewrite-Path'), '/fr/blog/post'); - }); - - it('should preserve query string in rewrite', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - defaultLocale: 'en', - fallback: { es: 'en' }, - fallbackType: 'rewrite', - }); - const fallbackFn = redirectToFallback(payload); - - const context = { - ...createManualRoutingContext({ pathname: '/es/search?q=test&lang=es' }), - rewrite: async (path) => { - return new Response(null, { - headers: { 'X-Rewrite-Path': path }, - }); - }, - }; - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.headers.get('X-Rewrite-Path'), '/search?q=test&lang=es'); - }); - - it('should remove default locale prefix with prefix-other-locales strategy', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - defaultLocale: 'en', - strategy: 'pathname-prefix-other-locales', - fallback: { es: 'en' }, - fallbackType: 'rewrite', - }); - const fallbackFn = redirectToFallback(payload); - - const context = { - ...createManualRoutingContext({ pathname: '/es/about' }), - rewrite: async (path) => { - return new Response(null, { - headers: { 'X-Rewrite-Path': path }, - }); - }, - }; - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.headers.get('X-Rewrite-Path'), '/about'); - }); - }); - - describe('locale extraction from pathname', () => { - it('should find locale in first segment', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/blog' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.notEqual(response, originalResponse); - assert.equal(response.status, 302); - }); - - it('should handle locale objects with path', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', { path: 'spanish', codes: ['es'] }], - defaultLocale: 'en', - fallback: { spanish: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/spanish/blog' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.status, 302); - assert.equal(response.headers.get('Location'), '/blog'); - }); - - it('should handle fallback to non-default locale', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es', 'fr'], - defaultLocale: 'en', - strategy: 'pathname-prefix-always', - fallback: { es: 'fr' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/page' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.headers.get('Location'), '/fr/page'); - }); - }); - - describe('edge cases', () => { - it('should handle root path with locale', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - defaultLocale: 'en', - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - // When replacing /es with empty string, we get empty path - assert.equal(response.headers.get('Location'), ''); - }); - - it('should handle deep nested paths', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - defaultLocale: 'en', - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/blog/2024/post/title' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.headers.get('Location'), '/blog/2024/post/title'); - }); - - it('should handle base path without trailing slash', async () => { - const payload = createMiddlewarePayload({ - base: '/site', - locales: ['en', 'es'], - defaultLocale: 'en', - strategy: 'pathname-prefix-other-locales', - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/site/es/page' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response.headers.get('Location'), '/site/page'); - }); - - it('should not fallback when locale is not found in path', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/blog/post' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - assert.equal(response, originalResponse); - }); - - it('should handle empty query string', async () => { - const payload = createMiddlewarePayload({ - locales: ['en', 'es'], - fallback: { es: 'en' }, - fallbackType: 'redirect', - }); - const fallbackFn = redirectToFallback(payload); - const context = createManualRoutingContext({ pathname: '/es/page?' }); - const originalResponse = new Response(null, { status: 404 }); - - const response = await fallbackFn(context, originalResponse); - - // context.url.search is empty for '?', so query string is not preserved - assert.equal(response.headers.get('Location'), '/page'); - }); - }); -}); diff --git a/packages/astro/test/units/i18n/manual-routing.test.ts b/packages/astro/test/units/i18n/manual-routing.test.ts new file mode 100644 index 000000000000..26664b357721 --- /dev/null +++ b/packages/astro/test/units/i18n/manual-routing.test.ts @@ -0,0 +1,1350 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + normalizeTheLocale, + normalizeThePath, + pathHasLocale, + requestHasLocale, + redirectToDefaultLocale, + notFound, + redirectToFallback, +} from '../../../dist/i18n/index.js'; +import { REROUTE_DIRECTIVE_HEADER } from '../../../dist/core/constants.js'; +import type { Locales } from '../../../dist/types/public/config.js'; +import { createManualRoutingContext, createMiddlewarePayload } from './test-helpers.ts'; + +describe('normalizeTheLocale', () => { + it('should convert underscores to dashes', () => { + assert.equal(normalizeTheLocale('en_US'), 'en-us'); + assert.equal(normalizeTheLocale('pt_BR'), 'pt-br'); + assert.equal(normalizeTheLocale('zh_Hans_CN'), 'zh-hans-cn'); + }); + + it('should convert to lowercase', () => { + assert.equal(normalizeTheLocale('EN'), 'en'); + assert.equal(normalizeTheLocale('ES'), 'es'); + assert.equal(normalizeTheLocale('PT'), 'pt'); + }); + + it('should convert both underscores and case', () => { + assert.equal(normalizeTheLocale('EN_US'), 'en-us'); + assert.equal(normalizeTheLocale('Es_AR'), 'es-ar'); + }); + + it('should handle already normalized locales', () => { + assert.equal(normalizeTheLocale('en-us'), 'en-us'); + assert.equal(normalizeTheLocale('en'), 'en'); + assert.equal(normalizeTheLocale('pt-br'), 'pt-br'); + }); + + it('should handle edge cases', () => { + assert.equal(normalizeTheLocale(''), ''); + assert.equal(normalizeTheLocale('a'), 'a'); + }); +}); + +describe('normalizeThePath', () => { + it('should remove .html extension', () => { + assert.equal(normalizeThePath('/en/blog.html'), '/en/blog'); + assert.equal(normalizeThePath('/spanish.html'), '/spanish'); + assert.equal(normalizeThePath('en.html'), 'en'); + }); + + it('should not modify paths without .html', () => { + assert.equal(normalizeThePath('/en/blog'), '/en/blog'); + assert.equal(normalizeThePath('/spanish'), '/spanish'); + assert.equal(normalizeThePath('/'), '/'); + }); + + it('should not remove other extensions', () => { + assert.equal(normalizeThePath('/en/blog.php'), '/en/blog.php'); + assert.equal(normalizeThePath('/api.json'), '/api.json'); + assert.equal(normalizeThePath('/file.txt'), '/file.txt'); + }); + + it('should handle edge cases', () => { + assert.equal(normalizeThePath(''), ''); + assert.equal(normalizeThePath('.html'), ''); + assert.equal(normalizeThePath('a.html'), 'a'); + }); +}); + +describe('pathHasLocale', () => { + describe('string locales - basic matching', () => { + it('should return true when path contains string locale', () => { + assert.equal(pathHasLocale('/en', ['en', 'es']), true); + assert.equal(pathHasLocale('/es', ['en', 'es']), true); + assert.equal(pathHasLocale('/pt', ['en', 'es', 'pt']), true); + }); + + it('should return true when path contains locale in nested path', () => { + assert.equal(pathHasLocale('/en/about', ['en', 'es']), true); + assert.equal(pathHasLocale('/es/blog/post', ['en', 'es']), true); + assert.equal(pathHasLocale('/pt/nested/deep/path', ['pt']), true); + }); + + it('should return false when path does not contain locale', () => { + assert.equal(pathHasLocale('/fr', ['en', 'es']), false); + assert.equal(pathHasLocale('/about', ['en', 'es']), false); + assert.equal(pathHasLocale('/blog/post', ['en', 'es']), false); + }); + + it('should return false for root path', () => { + assert.equal(pathHasLocale('/', ['en', 'es']), false); + }); + }); + + describe('string locales - case insensitive matching', () => { + it('should match locale regardless of case in path', () => { + assert.equal(pathHasLocale('/EN', ['en']), true); + assert.equal(pathHasLocale('/En', ['en']), true); + assert.equal(pathHasLocale('/eN', ['en']), true); + }); + + it('should match locale regardless of case in config', () => { + assert.equal(pathHasLocale('/en', ['EN']), true); + assert.equal(pathHasLocale('/en', ['En']), true); + }); + + it('should handle underscore to dash normalization in path', () => { + assert.equal(pathHasLocale('/en_US', ['en-us']), true); + assert.equal(pathHasLocale('/pt_BR', ['pt-br']), true); + }); + + it('should handle dash to underscore normalization in config', () => { + assert.equal(pathHasLocale('/en-us', ['en_US']), true); + assert.equal(pathHasLocale('/pt-br', ['pt_BR']), true); + }); + + it('should handle mixed case and separators', () => { + assert.equal(pathHasLocale('/EN_us', ['en-US']), true); + assert.equal(pathHasLocale('/pt-BR', ['PT_br']), true); + }); + }); + + describe('object locales - path matching', () => { + it('should match locale object by path', () => { + const locales: Locales = [{ path: 'spanish', codes: ['es', 'es-ar'] }]; + assert.equal(pathHasLocale('/spanish', locales), true); + }); + + it('should match locale object in nested path', () => { + const locales: Locales = [{ path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/spanish/blog', locales), true); + assert.equal(pathHasLocale('/spanish/blog/post', locales), true); + }); + + it('should not match locale codes, only path', () => { + const locales: Locales = [{ path: 'spanish', codes: ['es', 'es-ar'] }]; + assert.equal(pathHasLocale('/es', locales), false); + assert.equal(pathHasLocale('/es-ar', locales), false); + }); + + it('should match multiple locale objects', () => { + const locales: Locales = [ + { path: 'spanish', codes: ['es'] }, + { path: 'portuguese', codes: ['pt'] }, + ]; + assert.equal(pathHasLocale('/spanish', locales), true); + assert.equal(pathHasLocale('/portuguese', locales), true); + }); + }); + + describe('mixed locales', () => { + it('should match string locale in mixed array', () => { + const locales: Locales = ['en', { path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/en/blog', locales), true); + }); + + it('should match object locale in mixed array', () => { + const locales: Locales = ['en', { path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/spanish/blog', locales), true); + }); + + it('should not match undefined locale', () => { + const locales: Locales = ['en', { path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/pt', locales), false); + assert.equal(pathHasLocale('/fr/blog', locales), false); + }); + + it('should work with complex mixed config', () => { + const locales: Locales = [ + 'en', + 'fr', + { path: 'spanish', codes: ['es', 'es-ar'] }, + 'pt', + { path: 'italiano', codes: ['it', 'it-va'] }, + ]; + assert.equal(pathHasLocale('/en', locales), true); + assert.equal(pathHasLocale('/fr/about', locales), true); + assert.equal(pathHasLocale('/spanish', locales), true); + assert.equal(pathHasLocale('/pt/blog', locales), true); + assert.equal(pathHasLocale('/italiano', locales), true); + assert.equal(pathHasLocale('/de', locales), false); + }); + }); + + describe('HTML extension handling (SSG)', () => { + it('should match locale with .html extension', () => { + assert.equal(pathHasLocale('/en.html', ['en']), true); + assert.equal(pathHasLocale('/es.html', ['en', 'es']), true); + }); + + it('should match locale object path with .html', () => { + const locales: Locales = [{ path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/spanish.html', locales), true); + }); + + it('should match nested paths with .html', () => { + assert.equal(pathHasLocale('/en/blog.html', ['en']), true); + assert.equal(pathHasLocale('/es/about/us.html', ['es']), true); + }); + + it('should strip .html before checking locale', () => { + const locales: Locales = [{ path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/spanish.html', locales), true); + // But not match the code + assert.equal(pathHasLocale('/es.html', locales), false); + }); + }); + + describe('edge cases', () => { + it('should handle root path', () => { + assert.equal(pathHasLocale('/', ['en', 'es']), false); + }); + + it('should handle empty path', () => { + assert.equal(pathHasLocale('', ['en', 'es']), false); + }); + + it('should handle trailing slash', () => { + assert.equal(pathHasLocale('/en/', ['en']), true); + assert.equal(pathHasLocale('/es/blog/', ['es']), true); + }); + + it('should handle path with only locale and trailing slash', () => { + const locales: Locales = [{ path: 'spanish', codes: ['es'] }]; + assert.equal(pathHasLocale('/spanish/', locales), true); + }); + + it('should handle multiple consecutive slashes', () => { + assert.equal(pathHasLocale('/en//blog', ['en']), true); + assert.equal(pathHasLocale('//en/blog', ['en']), true); + }); + + it('should not match partial locale segments', () => { + assert.equal(pathHasLocale('/english', ['en']), false); + assert.equal(pathHasLocale('/item', ['it']), false); + assert.equal(pathHasLocale('/open', ['en']), false); + }); + + it('should handle empty locales array', () => { + assert.equal(pathHasLocale('/en', []), false); + assert.equal(pathHasLocale('/', []), false); + }); + + it('should handle single character locales', () => { + assert.equal(pathHasLocale('/a', ['a', 'b']), true); + assert.equal(pathHasLocale('/b/page', ['a', 'b']), true); + }); + }); +}); + +describe('requestHasLocale', () => { + it('should return a function', () => { + const hasLocale = requestHasLocale(['en', 'es']); + assert.equal(typeof hasLocale, 'function'); + }); + + it('should check context.url.pathname for locale', () => { + const hasLocale = requestHasLocale(['en', 'es']); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + assert.equal(hasLocale(context), true); + }); + + it('should return true for paths with configured locales', () => { + const hasLocale = requestHasLocale(['en', 'es', 'pt']); + + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/en' })), true); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/es/about' })), true); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/pt/blog/post' })), true); + }); + + it('should return false for paths without locales', () => { + const hasLocale = requestHasLocale(['en', 'es']); + + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/blog' })), false); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/about' })), false); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/' })), false); + }); + + it('should work with locale objects', () => { + const hasLocale = requestHasLocale(['en', { path: 'spanish', codes: ['es', 'es-ar'] }]); + + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/en' })), true); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/spanish' })), true); + assert.equal(hasLocale(createManualRoutingContext({ pathname: '/es' })), false); + }); + + it('should not modify context', () => { + const hasLocale = requestHasLocale(['en']); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + const originalPathname = context.url.pathname; + + hasLocale(context); + + assert.equal(context.url.pathname, originalPathname); + }); + + it('should handle different hostnames', () => { + const hasLocale = requestHasLocale(['en', 'es']); + + const context1 = createManualRoutingContext({ pathname: '/en', hostname: 'localhost' }); + const context2 = createManualRoutingContext({ pathname: '/en', hostname: '127.0.0.1' }); + + assert.equal(hasLocale(context1), true); + assert.equal(hasLocale(context2), true); + }); + + it('should work consistently across multiple calls', () => { + const hasLocale = requestHasLocale(['en', 'es']); + const context = createManualRoutingContext({ pathname: '/en/blog' }); + + assert.equal(hasLocale(context), true); + assert.equal(hasLocale(context), true); + assert.equal(hasLocale(context), true); + }); +}); + +describe('redirectToDefaultLocale', () => { + describe('basic redirect generation', () => { + it('should create a function that returns a Response', () => { + const payload = createMiddlewarePayload({ + defaultLocale: 'en', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.ok(response instanceof Response); + }); + + it('should redirect to default locale with no base', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.status, 302); + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should use default status 302', () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.status, 302); + }); + }); + + describe('custom status codes', () => { + it('should accept custom status code 301', () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 301); + + assert.equal(response.status, 301); + // Default payload has trailingSlash: 'ignore' + format: 'directory' + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should accept custom status code 307', () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 307); + + assert.equal(response.status, 307); + }); + + it('should accept custom status code 308', () => { + const payload = createMiddlewarePayload({ defaultLocale: 'en' }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 308); + + assert.equal(response.status, 308); + }); + }); + + describe('base path handling', () => { + it('should redirect to base + locale', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + defaultLocale: 'en', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/blog/en/'); + }); + + it('should handle base with leading slash', () => { + const payload = createMiddlewarePayload({ + base: '/my-site', + defaultLocale: 'pt', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/my-site/pt/'); + }); + + it('should handle base with trailing slash', () => { + const payload = createMiddlewarePayload({ + base: '/blog/', + defaultLocale: 'en', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // joinPaths normalizes, then trailingSlash: 'ignore' + format: 'directory' adds / + assert.equal(response.headers.get('Location'), '/blog/en/'); + }); + + it('should handle complex base paths', () => { + const payload = createMiddlewarePayload({ + base: '/sites/my-app', + defaultLocale: 'es', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/sites/my-app/es/'); + }); + }); + + describe('trailing slash behavior', () => { + it('should add trailing slash with trailingSlash: always and format: directory', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'always', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should not add trailing slash with trailingSlash: never', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'never', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.headers.get('Location'), '/en'); + }); + + it('should add trailing slash with trailingSlash: ignore and format: directory', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should add trailing slash with trailingSlash: always and format: file', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'always', + format: 'file', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.headers.get('Location'), '/en/'); + }); + + it('should not add trailing slash with trailingSlash: never and format: file', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'never', + format: 'file', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + assert.equal(response.headers.get('Location'), '/en'); + }); + }); + + describe('combined scenarios', () => { + it('should handle base + trailing slash + status code', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + defaultLocale: 'pt', + trailingSlash: 'always', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 301); + + assert.equal(response.status, 301); + assert.equal(response.headers.get('Location'), '/blog/pt/'); + }); + + it('should handle complex locale codes', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'es-AR', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/es-AR/'); + }); + + it('should work with underscore locales', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en_US', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response.headers.get('Location'), '/en_US/'); + }); + + it('should handle all parameters combined', () => { + const payload = createMiddlewarePayload({ + base: '/sites/app', + defaultLocale: 'pt-BR', + trailingSlash: 'always', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = redirect(context, 307); + + assert.equal(response.status, 307); + assert.equal(response.headers.get('Location'), '/sites/app/pt-BR/'); + }); + }); + + describe('context independence', () => { + it('should work regardless of context pathname', () => { + const payload = createMiddlewarePayload({ + base: '', + defaultLocale: 'en', + trailingSlash: 'ignore', + format: 'directory', + }); + const redirect = redirectToDefaultLocale(payload); + + // All should redirect to the same place + const response1 = redirect(createManualRoutingContext({ pathname: '/' })); + const response2 = redirect(createManualRoutingContext({ pathname: '/about' })); + const response3 = redirect(createManualRoutingContext({ pathname: '/blog/post' })); + + // trailingSlash: 'ignore' + format: 'directory' adds trailing slash + assert.equal(response1.headers.get('Location'), '/en/'); + assert.equal(response2.headers.get('Location'), '/en/'); + assert.equal(response3.headers.get('Location'), '/en/'); + }); + }); +}); + +describe('notFound', () => { + describe('basic 404 for non-locale paths', () => { + it('should return 404 Response for paths without locale', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + + const response = notFoundFn(context); + + assert.ok(response instanceof Response); + assert.equal(response!.status, 404); + }); + + it('should return 404 for /about with configured locales', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/about' }); + + const response = notFoundFn(context); + + assert.equal(response!.status, 404); + }); + + it('should set REROUTE_DIRECTIVE_HEADER to no', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + + const response = notFoundFn(context); + + assert.equal(response!.headers.get(REROUTE_DIRECTIVE_HEADER), 'no'); + }); + }); + + describe('root path handling', () => { + it('should return undefined for / (root)', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/' }); + + const response = notFoundFn(context); + + assert.equal(response, undefined); + }); + + it('should return undefined for base path as root', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + + const response = notFoundFn(context); + + assert.equal(response, undefined); + }); + + it('should return undefined for base path with trailing slash', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog/' }); + + const response = notFoundFn(context); + + assert.equal(response, undefined); + }); + }); + + describe('locale paths allowed', () => { + it('should return undefined for valid locale paths', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/en/blog' })), undefined); + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/es/about' })), undefined); + }); + + it('should return undefined for locale object paths', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: [{ path: 'spanish', codes: ['es'] }], + }); + const notFoundFn = notFound(payload); + + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/spanish' })), undefined); + assert.equal( + notFoundFn(createManualRoutingContext({ pathname: '/spanish/blog' })), + undefined, + ); + }); + + it('should return undefined for mixed locale config', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', { path: 'spanish', codes: ['es'] }], + }); + const notFoundFn = notFound(payload); + + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/en' })), undefined); + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/spanish' })), undefined); + }); + }); + + describe('response parameter handling', () => { + it('should preserve body when Response is passed', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('Original body', { status: 200 }); + + const response = notFoundFn(context, originalResponse); + + assert.equal(response!.status, 404); + assert.equal(response!.body, originalResponse.body); + }); + + it('should copy headers when Response is passed', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('body', { + status: 200, + headers: { 'X-Custom': 'value' }, + }); + + const response = notFoundFn(context, originalResponse); + + assert.equal(response!.status, 404); + assert.equal(response!.headers.get('X-Custom'), 'value'); + }); + + it('should override status to 404 when Response is passed', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('body', { status: 200 }); + + const response = notFoundFn(context, originalResponse); + + assert.equal(response!.status, 404); + }); + + it('should set REROUTE_DIRECTIVE_HEADER on passed Response', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('body'); + + const response = notFoundFn(context, originalResponse); + + assert.equal(response!.headers.get(REROUTE_DIRECTIVE_HEADER), 'no'); + }); + + it('should return original response when REROUTE_DIRECTIVE_HEADER is no and no fallback', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + fallback: undefined, + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('body', { + headers: { [REROUTE_DIRECTIVE_HEADER]: 'no' }, + }); + + const response = notFoundFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + }); + + describe('fallback configuration', () => { + it('should still return 404 for non-locale paths with fallback configured', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + fallback: { es: 'en' }, + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + + const response = notFoundFn(context); + + assert.equal(response!.status, 404); + }); + + it('should not return original response with fallback when REROUTE_DIRECTIVE_HEADER is no', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + fallback: { es: 'en' }, + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + const originalResponse = new Response('body', { + headers: { [REROUTE_DIRECTIVE_HEADER]: 'no' }, + }); + + const response = notFoundFn(context, originalResponse); + + // With fallback defined, it should not return the original + assert.notEqual(response, originalResponse); + assert.equal(response!.status, 404); + }); + }); + + describe('base path handling', () => { + it('should return 404 for non-locale paths with base', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog/about' }); + + const response = notFoundFn(context); + + assert.equal(response!.status, 404); + }); + + it('should allow locale paths with base', () => { + const payload = createMiddlewarePayload({ + base: '/blog', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + + assert.equal( + notFoundFn(createManualRoutingContext({ pathname: '/blog/en/about' })), + undefined, + ); + assert.equal( + notFoundFn(createManualRoutingContext({ pathname: '/blog/es/post' })), + undefined, + ); + }); + + it('should return 404 for paths without locale under base', () => { + const payload = createMiddlewarePayload({ + base: '/site', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + + const response = notFoundFn(createManualRoutingContext({ pathname: '/site/contact' })); + + assert.equal(response!.status, 404); + }); + }); + + describe('edge cases', () => { + it('should handle empty pathname', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '' }); + + // Empty pathname is treated as root + const response = notFoundFn(context); + + // Based on implementation, empty string might be treated as root + assert.ok(response === undefined || response.status === 404); + }); + + it('should handle case sensitivity in locale matching', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + + // Normalized matching + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/EN' })), undefined); + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/Es' })), undefined); + }); + + it('should work with single locale', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en'], + }); + const notFoundFn = notFound(payload); + + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/en' })), undefined); + assert.equal(notFoundFn(createManualRoutingContext({ pathname: '/es' }))!.status, 404); + }); + + it('should return null body for 404 without passed Response', () => { + const payload = createMiddlewarePayload({ + base: '', + locales: ['en', 'es'], + }); + const notFoundFn = notFound(payload); + const context = createManualRoutingContext({ pathname: '/blog' }); + + const response = notFoundFn(context); + + assert.equal(response!.body, null); + }); + }); +}); + +describe('redirectToFallback', () => { + describe('basic fallback behavior', () => { + it('should return original response when status is not 404', async () => { + const payload = createMiddlewarePayload({ + fallback: { es: 'en' }, + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/about' }); + const originalResponse = new Response('Content', { status: 200 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + + it('should redirect when status is 404 and locale has fallback', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es', 'fr'], + defaultLocale: 'en', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/about' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/about'); + }); + + it('should return original response when no fallback configured', async () => { + const payload = createMiddlewarePayload({ + fallback: undefined, + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/about' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + + it('should return original response when locale not in fallback config', async () => { + const payload = createMiddlewarePayload({ + fallback: { es: 'en' }, + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/fr/about' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + }); + + describe('fallbackType: redirect', () => { + it('should redirect to fallback locale', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es', 'fr'], + defaultLocale: 'en', + fallback: { es: 'fr' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/blog/post' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/fr/blog/post'); + }); + + it('should remove default locale prefix with prefix-other-locales strategy', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + strategy: 'pathname-prefix-other-locales', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/about' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/about'); + }); + + it('should handle base path correctly', async () => { + const payload = createMiddlewarePayload({ + base: '/blog', + locales: ['en', 'es'], + defaultLocale: 'en', + strategy: 'pathname-prefix-other-locales', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/blog/es/post' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/blog/post'); + }); + + it('should preserve query string', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/search?q=test' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/search?q=test'); + }); + + it('should not redirect for 3xx status codes', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/page' }); + const originalResponse = new Response(null, { status: 301 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + + it('should not redirect for non-404 4xx status codes', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/page' }); + const originalResponse = new Response(null, { status: 403 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + + it('should not redirect for 5xx status codes', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/page' }); + const originalResponse = new Response(null, { status: 500 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + }); + + describe('fallbackType: rewrite', () => { + it('should rewrite to fallback locale', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es', 'fr'], + defaultLocale: 'en', + fallback: { es: 'fr' }, + fallbackType: 'rewrite', + }); + const fallbackFn = redirectToFallback(payload); + + // Mock context.rewrite + const context = { + ...createManualRoutingContext({ pathname: '/es/blog/post' }), + rewrite: async (path: string) => { + return new Response(null, { + status: 200, + headers: { 'X-Rewrite-Path': path }, + }); + }, + }; + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('X-Rewrite-Path'), '/fr/blog/post'); + }); + + it('should preserve query string in rewrite', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + fallback: { es: 'en' }, + fallbackType: 'rewrite', + }); + const fallbackFn = redirectToFallback(payload); + + const context = { + ...createManualRoutingContext({ pathname: '/es/search?q=test&lang=es' }), + rewrite: async (path: string) => { + return new Response(null, { + headers: { 'X-Rewrite-Path': path }, + }); + }, + }; + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('X-Rewrite-Path'), '/search?q=test&lang=es'); + }); + + it('should remove default locale prefix with prefix-other-locales strategy', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + strategy: 'pathname-prefix-other-locales', + fallback: { es: 'en' }, + fallbackType: 'rewrite', + }); + const fallbackFn = redirectToFallback(payload); + + const context = { + ...createManualRoutingContext({ pathname: '/es/about' }), + rewrite: async (path: string) => { + return new Response(null, { + headers: { 'X-Rewrite-Path': path }, + }); + }, + }; + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('X-Rewrite-Path'), '/about'); + }); + }); + + describe('locale extraction from pathname', () => { + it('should find locale in first segment', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/blog' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.notEqual(response, originalResponse); + assert.equal(response.status, 302); + }); + + it('should handle locale objects with path', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', { path: 'spanish', codes: ['es'] }], + defaultLocale: 'en', + fallback: { spanish: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/spanish/blog' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/blog'); + }); + + it('should handle fallback to non-default locale', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es', 'fr'], + defaultLocale: 'en', + strategy: 'pathname-prefix-always', + fallback: { es: 'fr' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/page' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/fr/page'); + }); + }); + + describe('edge cases', () => { + it('should handle root path with locale', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + // When replacing /es with empty string, we get empty path + assert.equal(response.headers.get('Location'), ''); + }); + + it('should handle deep nested paths', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + defaultLocale: 'en', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/blog/2024/post/title' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/blog/2024/post/title'); + }); + + it('should handle base path without trailing slash', async () => { + const payload = createMiddlewarePayload({ + base: '/site', + locales: ['en', 'es'], + defaultLocale: 'en', + strategy: 'pathname-prefix-other-locales', + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/site/es/page' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response.headers.get('Location'), '/site/page'); + }); + + it('should not fallback when locale is not found in path', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/blog/post' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + assert.equal(response, originalResponse); + }); + + it('should handle empty query string', async () => { + const payload = createMiddlewarePayload({ + locales: ['en', 'es'], + fallback: { es: 'en' }, + fallbackType: 'redirect', + }); + const fallbackFn = redirectToFallback(payload); + const context = createManualRoutingContext({ pathname: '/es/page?' }); + const originalResponse = new Response(null, { status: 404 }); + + const response = await fallbackFn(context, originalResponse); + + // context.url.search is empty for '?', so query string is not preserved + assert.equal(response.headers.get('Location'), '/page'); + }); + }); +}); diff --git a/packages/astro/test/units/i18n/router.test.js b/packages/astro/test/units/i18n/router.test.js deleted file mode 100644 index f95743f5c666..000000000000 --- a/packages/astro/test/units/i18n/router.test.js +++ /dev/null @@ -1,537 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { I18nRouter } from '../../../dist/i18n/router.js'; -import { makeI18nRouterConfig, makeRouterContext } from './test-helpers.js'; - -describe('I18nRouter', () => { - describe('strategy: pathname-prefix-always', () => { - let router; - - before(() => { - const config = makeI18nRouterConfig({ - strategy: 'pathname-prefix-always', - defaultLocale: 'en', - locales: ['en', 'es', 'pt'], - }); - router = new I18nRouter(config); - }); - - it('redirects root path to default locale', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = router.match('/', context); - - assert.equal(result.type, 'redirect'); - assert.equal(result.location, '/en'); - }); - - it('returns 404 for paths without locale prefix', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = router.match('/about', context); - - assert.equal(result.type, 'notFound'); - }); - - it('continues for paths with valid locale prefix', () => { - const context = makeRouterContext({ currentLocale: 'es' }); - - const result = router.match('/es/about', context); - - assert.equal(result.type, 'continue'); - }); - - it('continues for default locale with prefix', () => { - const context = makeRouterContext({ currentLocale: 'en' }); - - const result = router.match('/en/about', context); - - assert.equal(result.type, 'continue'); - }); - - describe('with base path', () => { - let routerWithBase; - - before(() => { - const configWithBase = makeI18nRouterConfig({ - strategy: 'pathname-prefix-always', - defaultLocale: 'en', - locales: ['en', 'es'], - base: '/new-site', - }); - routerWithBase = new I18nRouter(configWithBase); - }); - - it('handles base path - redirects base root to base + default locale', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = routerWithBase.match('/new-site/', context); - - assert.equal(result.type, 'redirect'); - assert.equal(result.location, '/new-site/en'); - }); - - it('handles base path without trailing slash', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = routerWithBase.match('/new-site', context); - - assert.equal(result.type, 'redirect'); - assert.equal(result.location, '/new-site/en'); - }); - - it('returns 404 for path without locale under base', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = routerWithBase.match('/new-site/about', context); - - assert.equal(result.type, 'notFound'); - }); - }); - - describe('with base "/" (root base path)', () => { - let routerWithSlashBase; - - before(() => { - const config = makeI18nRouterConfig({ - strategy: 'pathname-prefix-always', - defaultLocale: 'en', - locales: ['en', 'es'], - base: '/', - }); - routerWithSlashBase = new I18nRouter(config); - }); - - it('redirects root to /defaultLocale, not //defaultLocale (#15844)', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = routerWithSlashBase.match('/', context); - - assert.equal(result.type, 'redirect'); - assert.equal(result.location, '/en'); - }); - - it('continues for paths with valid locale prefix', () => { - const context = makeRouterContext({ currentLocale: 'es' }); - - const result = routerWithSlashBase.match('/es/about', context); - - assert.equal(result.type, 'continue'); - }); - - it('returns 404 for paths without locale prefix', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = routerWithSlashBase.match('/about', context); - - assert.equal(result.type, 'notFound'); - }); - }); - }); - - describe('strategy: pathname-prefix-other-locales', () => { - let router; - - before(() => { - const config = makeI18nRouterConfig({ - strategy: 'pathname-prefix-other-locales', - defaultLocale: 'en', - locales: ['en', 'es', 'pt'], - }); - router = new I18nRouter(config); - }); - - it('returns 404 with Location header for default locale with prefix', () => { - const context = makeRouterContext({ currentLocale: 'en' }); - - const result = router.match('/en/about', context); - - assert.equal(result.type, 'notFound'); - assert.equal(result.location, '/about'); - }); - - it('continues for non-default locale with prefix', () => { - const context = makeRouterContext({ currentLocale: 'es' }); - - const result = router.match('/es/about', context); - - assert.equal(result.type, 'continue'); - }); - - it('continues for default locale without prefix', () => { - const context = makeRouterContext({ currentLocale: 'en' }); - - const result = router.match('/about', context); - - assert.equal(result.type, 'continue'); - }); - - it('continues for root path (default locale)', () => { - const context = makeRouterContext({ currentLocale: 'en' }); - - const result = router.match('/', context); - - assert.equal(result.type, 'continue'); - }); - - it('handles default locale in middle of path', () => { - const context = makeRouterContext({ currentLocale: 'en' }); - - const result = router.match('/blog/en/post', context); - - assert.equal(result.type, 'notFound'); - assert.equal(result.location, '/blog/post'); - }); - - it('handles base path with default locale prefix', () => { - const configWithBase = makeI18nRouterConfig({ - strategy: 'pathname-prefix-other-locales', - defaultLocale: 'en', - locales: ['en', 'es'], - base: '/new-site', - }); - const routerWithBase = new I18nRouter(configWithBase); - const context = makeRouterContext({ currentLocale: 'en' }); - - const result = routerWithBase.match('/new-site/en/about', context); - - assert.equal(result.type, 'notFound'); - assert.equal(result.location, '/new-site/about'); - }); - }); - - describe('strategy: pathname-prefix-always-no-redirect', () => { - let router; - - before(() => { - const config = makeI18nRouterConfig({ - strategy: 'pathname-prefix-always-no-redirect', - defaultLocale: 'en', - locales: ['en', 'es', 'pt'], - }); - router = new I18nRouter(config); - }); - - it('continues for root path (allows serving, no redirect)', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = router.match('/', context); - - assert.equal(result.type, 'continue'); - }); - - it('returns 404 for non-root paths without locale prefix', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = router.match('/about', context); - - assert.equal(result.type, 'notFound'); - }); - - it('continues for paths with valid locale prefix', () => { - const context = makeRouterContext({ currentLocale: 'es' }); - - const result = router.match('/es/about', context); - - assert.equal(result.type, 'continue'); - }); - - it('continues for base root path', () => { - const configWithBase = makeI18nRouterConfig({ - strategy: 'pathname-prefix-always-no-redirect', - defaultLocale: 'en', - locales: ['en', 'es'], - base: '/new-site', - }); - const routerWithBase = new I18nRouter(configWithBase); - const context = makeRouterContext({ currentLocale: undefined }); - - const result = routerWithBase.match('/new-site', context); - - assert.equal(result.type, 'continue'); - }); - - it('continues for root path when base is "/" (#15844)', () => { - const routerWithSlashBase = new I18nRouter( - makeI18nRouterConfig({ - strategy: 'pathname-prefix-always-no-redirect', - defaultLocale: 'en', - locales: ['en', 'es'], - base: '/', - }), - ); - const context = makeRouterContext({ currentLocale: undefined }); - - const result = routerWithSlashBase.match('/', context); - - assert.equal(result.type, 'continue'); - }); - }); - - describe('strategy: domains-prefix-always', () => { - let router; - - before(() => { - const config = makeI18nRouterConfig({ - strategy: 'domains-prefix-always', - defaultLocale: 'en', - locales: ['en', 'es', 'fr'], - domains: { - 'en.example.com': ['en'], - 'es.example.com': ['es'], - 'fr.example.com': ['fr'], - }, - }); - router = new I18nRouter(config); - }); - - it('redirects root when locale matches domain', () => { - const context = makeRouterContext({ - currentLocale: 'en', - currentDomain: 'en.example.com', - }); - - const result = router.match('/', context); - - assert.equal(result.type, 'redirect'); - assert.equal(result.location, '/en'); - }); - - it('continues when locale does not match domain (fallback to pathname logic)', () => { - const context = makeRouterContext({ - currentLocale: 'es', - currentDomain: 'en.example.com', - }); - - const result = router.match('/es/about', context); - - assert.equal(result.type, 'continue'); - }); - - it('returns 404 for path without locale when locale matches domain', () => { - const context = makeRouterContext({ - currentLocale: undefined, - currentDomain: 'en.example.com', - }); - - const result = router.match('/about', context); - - assert.equal(result.type, 'notFound'); - }); - - it('redirects root to /locale, not //locale, when base is "/" (#15844)', () => { - const routerWithSlashBase = new I18nRouter( - makeI18nRouterConfig({ - strategy: 'domains-prefix-always', - defaultLocale: 'en', - locales: ['en', 'es'], - base: '/', - domains: { - 'en.example.com': ['en'], - 'es.example.com': ['es'], - }, - }), - ); - const context = makeRouterContext({ - currentLocale: 'en', - currentDomain: 'en.example.com', - }); - - const result = routerWithSlashBase.match('/', context); - - assert.equal(result.type, 'redirect'); - assert.equal(result.location, '/en'); - }); - }); - - describe('strategy: domains-prefix-other-locales', () => { - let router; - - before(() => { - const config = makeI18nRouterConfig({ - strategy: 'domains-prefix-other-locales', - defaultLocale: 'en', - locales: ['en', 'es', 'fr'], - domains: { - 'en.example.com': ['en'], - 'es.example.com': ['es'], - 'fr.example.com': ['fr'], - }, - }); - router = new I18nRouter(config); - }); - - it('returns 404 with Location for default locale prefix when locale matches domain', () => { - const context = makeRouterContext({ - currentLocale: 'en', - currentDomain: 'en.example.com', - }); - - const result = router.match('/en/about', context); - - assert.equal(result.type, 'notFound'); - assert.equal(result.location, '/about'); - }); - - it('continues for non-default locale when locale matches domain', () => { - const context = makeRouterContext({ - currentLocale: 'es', - currentDomain: 'es.example.com', - }); - - const result = router.match('/es/about', context); - - assert.equal(result.type, 'continue'); - }); - - it('continues when locale does not match domain', () => { - const context = makeRouterContext({ - currentLocale: 'es', - currentDomain: 'en.example.com', - }); - - const result = router.match('/es/about', context); - - assert.equal(result.type, 'continue'); - }); - }); - - describe('strategy: domains-prefix-always-no-redirect', () => { - let router; - - before(() => { - const config = makeI18nRouterConfig({ - strategy: 'domains-prefix-always-no-redirect', - defaultLocale: 'en', - locales: ['en', 'es', 'fr'], - domains: { - 'en.example.com': ['en'], - 'es.example.com': ['es'], - 'fr.example.com': ['fr'], - }, - }); - router = new I18nRouter(config); - }); - - it('continues for root when locale matches domain (allows serving, no redirect)', () => { - const context = makeRouterContext({ - currentLocale: undefined, - currentDomain: 'en.example.com', - }); - - const result = router.match('/', context); - - assert.equal(result.type, 'continue'); - }); - - it('continues for path with locale when locale matches domain', () => { - const context = makeRouterContext({ - currentLocale: 'en', - currentDomain: 'en.example.com', - }); - - const result = router.match('/en/about', context); - - assert.equal(result.type, 'continue'); - }); - }); - - describe('route filtering - skips i18n processing', () => { - let router; - - before(() => { - const config = makeI18nRouterConfig({ - strategy: 'pathname-prefix-always', - defaultLocale: 'en', - locales: ['en', 'es'], - }); - router = new I18nRouter(config); - }); - - it('skips 404 pages', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = router.match('/404', context); - - assert.equal(result.type, 'continue'); - }); - - it('skips 500 pages', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = router.match('/500', context); - - assert.equal(result.type, 'continue'); - }); - - it('skips server islands', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = router.match('/_server-islands/Counter', context); - - assert.equal(result.type, 'continue'); - }); - - it('skips non-page routes (endpoint)', () => { - const context = makeRouterContext({ - currentLocale: undefined, - routeType: 'endpoint', - }); - - const result = router.match('/api/data', context); - - assert.equal(result.type, 'continue'); - }); - - it('skips reroutes', () => { - const context = makeRouterContext({ - currentLocale: undefined, - isReroute: true, - }); - - const result = router.match('/about', context); - - assert.equal(result.type, 'continue'); - }); - - it('processes fallback routes', () => { - const context = makeRouterContext({ - currentLocale: undefined, - routeType: 'fallback', - }); - - const result = router.match('/about', context); - - assert.equal(result.type, 'notFound'); - }); - }); - - describe('strategy: manual', () => { - let router; - - before(() => { - const config = makeI18nRouterConfig({ - strategy: 'manual', - defaultLocale: 'en', - locales: ['en', 'es'], - }); - router = new I18nRouter(config); - }); - - it('always continues (no automatic routing)', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = router.match('/', context); - - assert.equal(result.type, 'continue'); - }); - - it('continues for any path', () => { - const context = makeRouterContext({ currentLocale: undefined }); - - const result = router.match('/any/path', context); - - assert.equal(result.type, 'continue'); - }); - }); -}); diff --git a/packages/astro/test/units/i18n/router.test.ts b/packages/astro/test/units/i18n/router.test.ts new file mode 100644 index 000000000000..f1454eb014b8 --- /dev/null +++ b/packages/astro/test/units/i18n/router.test.ts @@ -0,0 +1,550 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { I18nRouter } from '../../../dist/i18n/router.js'; +import type { I18nRouterMatch } from '../../../dist/i18n/router.js'; +import { makeI18nRouterConfig, makeRouterContext } from './test-helpers.ts'; + +describe('I18nRouter', () => { + describe('strategy: pathname-prefix-always', () => { + let router: I18nRouter; + + before(() => { + const config = makeI18nRouterConfig({ + strategy: 'pathname-prefix-always', + defaultLocale: 'en', + locales: ['en', 'es', 'pt'], + }); + router = new I18nRouter(config); + }); + + it('redirects root path to default locale', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = router.match('/', context); + + assert.equal(result.type, 'redirect'); + assert.equal((result as Extract).location, '/en'); + }); + + it('returns 404 for paths without locale prefix', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = router.match('/about', context); + + assert.equal(result.type, 'notFound'); + }); + + it('continues for paths with valid locale prefix', () => { + const context = makeRouterContext({ currentLocale: 'es' }); + + const result: I18nRouterMatch = router.match('/es/about', context); + + assert.equal(result.type, 'continue'); + }); + + it('continues for default locale with prefix', () => { + const context = makeRouterContext({ currentLocale: 'en' }); + + const result: I18nRouterMatch = router.match('/en/about', context); + + assert.equal(result.type, 'continue'); + }); + + describe('with base path', () => { + let routerWithBase: I18nRouter; + + before(() => { + const configWithBase = makeI18nRouterConfig({ + strategy: 'pathname-prefix-always', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/new-site', + }); + routerWithBase = new I18nRouter(configWithBase); + }); + + it('handles base path - redirects base root to base + default locale', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = routerWithBase.match('/new-site/', context); + + assert.equal(result.type, 'redirect'); + assert.equal( + (result as Extract).location, + '/new-site/en', + ); + }); + + it('handles base path without trailing slash', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = routerWithBase.match('/new-site', context); + + assert.equal(result.type, 'redirect'); + assert.equal( + (result as Extract).location, + '/new-site/en', + ); + }); + + it('returns 404 for path without locale under base', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = routerWithBase.match('/new-site/about', context); + + assert.equal(result.type, 'notFound'); + }); + }); + + describe('with base "/" (root base path)', () => { + let routerWithSlashBase: I18nRouter; + + before(() => { + const config = makeI18nRouterConfig({ + strategy: 'pathname-prefix-always', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/', + }); + routerWithSlashBase = new I18nRouter(config); + }); + + it('redirects root to /defaultLocale, not //defaultLocale (#15844)', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = routerWithSlashBase.match('/', context); + + assert.equal(result.type, 'redirect'); + assert.equal((result as Extract).location, '/en'); + }); + + it('continues for paths with valid locale prefix', () => { + const context = makeRouterContext({ currentLocale: 'es' }); + + const result: I18nRouterMatch = routerWithSlashBase.match('/es/about', context); + + assert.equal(result.type, 'continue'); + }); + + it('returns 404 for paths without locale prefix', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = routerWithSlashBase.match('/about', context); + + assert.equal(result.type, 'notFound'); + }); + }); + }); + + describe('strategy: pathname-prefix-other-locales', () => { + let router: I18nRouter; + + before(() => { + const config = makeI18nRouterConfig({ + strategy: 'pathname-prefix-other-locales', + defaultLocale: 'en', + locales: ['en', 'es', 'pt'], + }); + router = new I18nRouter(config); + }); + + it('returns 404 with Location header for default locale with prefix', () => { + const context = makeRouterContext({ currentLocale: 'en' }); + + const result: I18nRouterMatch = router.match('/en/about', context); + + assert.equal(result.type, 'notFound'); + assert.equal((result as Extract).location, '/about'); + }); + + it('continues for non-default locale with prefix', () => { + const context = makeRouterContext({ currentLocale: 'es' }); + + const result: I18nRouterMatch = router.match('/es/about', context); + + assert.equal(result.type, 'continue'); + }); + + it('continues for default locale without prefix', () => { + const context = makeRouterContext({ currentLocale: 'en' }); + + const result: I18nRouterMatch = router.match('/about', context); + + assert.equal(result.type, 'continue'); + }); + + it('continues for root path (default locale)', () => { + const context = makeRouterContext({ currentLocale: 'en' }); + + const result: I18nRouterMatch = router.match('/', context); + + assert.equal(result.type, 'continue'); + }); + + it('handles default locale in middle of path', () => { + const context = makeRouterContext({ currentLocale: 'en' }); + + const result: I18nRouterMatch = router.match('/blog/en/post', context); + + assert.equal(result.type, 'notFound'); + assert.equal( + (result as Extract).location, + '/blog/post', + ); + }); + + it('handles base path with default locale prefix', () => { + const configWithBase = makeI18nRouterConfig({ + strategy: 'pathname-prefix-other-locales', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/new-site', + }); + const routerWithBase = new I18nRouter(configWithBase); + const context = makeRouterContext({ currentLocale: 'en' }); + + const result: I18nRouterMatch = routerWithBase.match('/new-site/en/about', context); + + assert.equal(result.type, 'notFound'); + assert.equal( + (result as Extract).location, + '/new-site/about', + ); + }); + }); + + describe('strategy: pathname-prefix-always-no-redirect', () => { + let router: I18nRouter; + + before(() => { + const config = makeI18nRouterConfig({ + strategy: 'pathname-prefix-always-no-redirect', + defaultLocale: 'en', + locales: ['en', 'es', 'pt'], + }); + router = new I18nRouter(config); + }); + + it('continues for root path (allows serving, no redirect)', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = router.match('/', context); + + assert.equal(result.type, 'continue'); + }); + + it('returns 404 for non-root paths without locale prefix', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = router.match('/about', context); + + assert.equal(result.type, 'notFound'); + }); + + it('continues for paths with valid locale prefix', () => { + const context = makeRouterContext({ currentLocale: 'es' }); + + const result: I18nRouterMatch = router.match('/es/about', context); + + assert.equal(result.type, 'continue'); + }); + + it('continues for base root path', () => { + const configWithBase = makeI18nRouterConfig({ + strategy: 'pathname-prefix-always-no-redirect', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/new-site', + }); + const routerWithBase = new I18nRouter(configWithBase); + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = routerWithBase.match('/new-site', context); + + assert.equal(result.type, 'continue'); + }); + + it('continues for root path when base is "/" (#15844)', () => { + const routerWithSlashBase = new I18nRouter( + makeI18nRouterConfig({ + strategy: 'pathname-prefix-always-no-redirect', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/', + }), + ); + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = routerWithSlashBase.match('/', context); + + assert.equal(result.type, 'continue'); + }); + }); + + describe('strategy: domains-prefix-always', () => { + let router: I18nRouter; + + before(() => { + const config = makeI18nRouterConfig({ + strategy: 'domains-prefix-always', + defaultLocale: 'en', + locales: ['en', 'es', 'fr'], + domains: { + 'en.example.com': ['en'], + 'es.example.com': ['es'], + 'fr.example.com': ['fr'], + }, + }); + router = new I18nRouter(config); + }); + + it('redirects root when locale matches domain', () => { + const context = makeRouterContext({ + currentLocale: 'en', + currentDomain: 'en.example.com', + }); + + const result: I18nRouterMatch = router.match('/', context); + + assert.equal(result.type, 'redirect'); + assert.equal((result as Extract).location, '/en'); + }); + + it('continues when locale does not match domain (fallback to pathname logic)', () => { + const context = makeRouterContext({ + currentLocale: 'es', + currentDomain: 'en.example.com', + }); + + const result: I18nRouterMatch = router.match('/es/about', context); + + assert.equal(result.type, 'continue'); + }); + + it('returns 404 for path without locale when locale matches domain', () => { + const context = makeRouterContext({ + currentLocale: undefined, + currentDomain: 'en.example.com', + }); + + const result: I18nRouterMatch = router.match('/about', context); + + assert.equal(result.type, 'notFound'); + }); + + it('redirects root to /locale, not //locale, when base is "/" (#15844)', () => { + const routerWithSlashBase = new I18nRouter( + makeI18nRouterConfig({ + strategy: 'domains-prefix-always', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/', + domains: { + 'en.example.com': ['en'], + 'es.example.com': ['es'], + }, + }), + ); + const context = makeRouterContext({ + currentLocale: 'en', + currentDomain: 'en.example.com', + }); + + const result: I18nRouterMatch = routerWithSlashBase.match('/', context); + + assert.equal(result.type, 'redirect'); + assert.equal((result as Extract).location, '/en'); + }); + }); + + describe('strategy: domains-prefix-other-locales', () => { + let router: I18nRouter; + + before(() => { + const config = makeI18nRouterConfig({ + strategy: 'domains-prefix-other-locales', + defaultLocale: 'en', + locales: ['en', 'es', 'fr'], + domains: { + 'en.example.com': ['en'], + 'es.example.com': ['es'], + 'fr.example.com': ['fr'], + }, + }); + router = new I18nRouter(config); + }); + + it('returns 404 with Location for default locale prefix when locale matches domain', () => { + const context = makeRouterContext({ + currentLocale: 'en', + currentDomain: 'en.example.com', + }); + + const result: I18nRouterMatch = router.match('/en/about', context); + + assert.equal(result.type, 'notFound'); + assert.equal((result as Extract).location, '/about'); + }); + + it('continues for non-default locale when locale matches domain', () => { + const context = makeRouterContext({ + currentLocale: 'es', + currentDomain: 'es.example.com', + }); + + const result: I18nRouterMatch = router.match('/es/about', context); + + assert.equal(result.type, 'continue'); + }); + + it('continues when locale does not match domain', () => { + const context = makeRouterContext({ + currentLocale: 'es', + currentDomain: 'en.example.com', + }); + + const result: I18nRouterMatch = router.match('/es/about', context); + + assert.equal(result.type, 'continue'); + }); + }); + + describe('strategy: domains-prefix-always-no-redirect', () => { + let router: I18nRouter; + + before(() => { + const config = makeI18nRouterConfig({ + strategy: 'domains-prefix-always-no-redirect', + defaultLocale: 'en', + locales: ['en', 'es', 'fr'], + domains: { + 'en.example.com': ['en'], + 'es.example.com': ['es'], + 'fr.example.com': ['fr'], + }, + }); + router = new I18nRouter(config); + }); + + it('continues for root when locale matches domain (allows serving, no redirect)', () => { + const context = makeRouterContext({ + currentLocale: undefined, + currentDomain: 'en.example.com', + }); + + const result: I18nRouterMatch = router.match('/', context); + + assert.equal(result.type, 'continue'); + }); + + it('continues for path with locale when locale matches domain', () => { + const context = makeRouterContext({ + currentLocale: 'en', + currentDomain: 'en.example.com', + }); + + const result: I18nRouterMatch = router.match('/en/about', context); + + assert.equal(result.type, 'continue'); + }); + }); + + describe('route filtering - skips i18n processing', () => { + let router: I18nRouter; + + before(() => { + const config = makeI18nRouterConfig({ + strategy: 'pathname-prefix-always', + defaultLocale: 'en', + locales: ['en', 'es'], + }); + router = new I18nRouter(config); + }); + + it('skips 404 pages', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = router.match('/404', context); + + assert.equal(result.type, 'continue'); + }); + + it('skips 500 pages', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = router.match('/500', context); + + assert.equal(result.type, 'continue'); + }); + + it('skips server islands', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = router.match('/_server-islands/Counter', context); + + assert.equal(result.type, 'continue'); + }); + + it('skips non-page routes (endpoint)', () => { + const context = makeRouterContext({ + currentLocale: undefined, + routeType: 'endpoint', + }); + + const result: I18nRouterMatch = router.match('/api/data', context); + + assert.equal(result.type, 'continue'); + }); + + it('skips reroutes', () => { + const context = makeRouterContext({ + currentLocale: undefined, + isReroute: true, + }); + + const result: I18nRouterMatch = router.match('/about', context); + + assert.equal(result.type, 'continue'); + }); + + it('processes fallback routes', () => { + const context = makeRouterContext({ + currentLocale: undefined, + routeType: 'fallback', + }); + + const result: I18nRouterMatch = router.match('/about', context); + + assert.equal(result.type, 'notFound'); + }); + }); + + describe('strategy: manual', () => { + let router: I18nRouter; + + before(() => { + const config = makeI18nRouterConfig({ + strategy: 'manual', + defaultLocale: 'en', + locales: ['en', 'es'], + }); + router = new I18nRouter(config); + }); + + it('always continues (no automatic routing)', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = router.match('/', context); + + assert.equal(result.type, 'continue'); + }); + + it('continues for any path', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result: I18nRouterMatch = router.match('/any/path', context); + + assert.equal(result.type, 'continue'); + }); + }); +}); diff --git a/packages/astro/test/units/i18n/test-helpers.js b/packages/astro/test/units/i18n/test-helpers.js deleted file mode 100644 index a6c6d197b82e..000000000000 --- a/packages/astro/test/units/i18n/test-helpers.js +++ /dev/null @@ -1,168 +0,0 @@ -// @ts-check - -/** - * Creates an i18n router config for testing - * @param {object} [options] - * @param {import('../../../dist/core/app/common.js').RoutingStrategies} [options.strategy] - * @param {string} [options.defaultLocale] - * @param {import('../../../src/types/public/config.js').Locales} [options.locales] - * @param {string} [options.base] - * @param {Record} [options.domains] - */ -export function makeI18nRouterConfig({ - strategy = 'pathname-prefix-other-locales', - defaultLocale = 'en', - locales = ['en', 'es', 'pt'], - base = '', - domains, -} = {}) { - return { strategy, defaultLocale, locales, base, domains }; -} - -/** - * Creates router context for testing - * @param {object} [options] - * @param {string | undefined} [options.currentLocale] - * @param {string} [options.currentDomain] - * @param {string} [options.routeType] - * @param {boolean} [options.isReroute] - */ -export function makeRouterContext({ - currentLocale, - currentDomain = 'example.com', - routeType = 'page', - isReroute = false, -} = {}) { - return { currentLocale, currentDomain, routeType, isReroute }; -} - -/** - * Creates fallback options for testing - * @param {object} options - * @param {string} options.pathname - * @param {number} [options.responseStatus] - * @param {string | undefined} [options.currentLocale] - * @param {Record} [options.fallback] - * @param {'redirect' | 'rewrite'} [options.fallbackType] - * @param {import('../../../src/types/public/config.js').Locales} [options.locales] - * @param {string} [options.defaultLocale] - * @param {import('../../../dist/core/app/common.js').RoutingStrategies} [options.strategy] - * @param {string} [options.base] - */ -export function makeFallbackOptions({ - pathname, - responseStatus = 404, - currentLocale, - fallback = {}, - fallbackType = 'redirect', - locales = ['en', 'es', 'pt'], - defaultLocale = 'en', - strategy = 'pathname-prefix-other-locales', - base = '', -}) { - return { - pathname, - responseStatus, - currentLocale, - fallback, - fallbackType, - locales, - defaultLocale, - strategy, - base, - }; -} - -/** - * Creates a minimal mock APIContext for manual routing tests. - * - * This helper creates a mock context object that mimics Astro's APIContext - * with the essential properties needed for testing i18n manual routing functions - * like requestHasLocale, redirectToDefaultLocale, and notFound. - * - * @param {object} [options] - Configuration options for the mock context - * @param {string} [options.pathname='/'] - The pathname for the URL (e.g., '/en/blog') - * @param {string} [options.hostname='localhost'] - The hostname for the URL - * @param {string} [options.method='GET'] - The HTTP method for the request - * @param {string | undefined} [options.currentLocale] - The current locale from the context - * @returns {object} A mock APIContext object with url, request, currentLocale, and redirect method - * - * @example - * const context = createManualRoutingContext({ pathname: '/en/blog' }); - * const hasLocale = requestHasLocale(['en', 'es']); - * hasLocale(context); // true - */ -export function createManualRoutingContext({ - pathname = '/', - hostname = 'localhost', - method = 'GET', - currentLocale = undefined, - ...options -} = {}) { - const url = new URL(`http://${hostname}${pathname}`); - const request = new Request(url.toString(), { method }); - - return { - url, - request, - currentLocale, - redirect(path, status = 302) { - return new Response(null, { - status, - headers: { Location: path }, - }); - }, - ...options, - }; -} - -/** - * Creates a MiddlewarePayload for testing manual routing functions. - * - * This helper creates a payload object that matches the MiddlewarePayload type - * used by i18n manual routing functions like redirectToDefaultLocale and notFound. - * It provides sensible defaults for all required fields. - * - * @param {object} [options] - Configuration options for the middleware payload - * @param {string} [options.base=''] - The base path for the site (e.g., '/blog') - * @param {import('../../../src/types/public/config.js').Locales} [options.locales=['en', 'es']] - Array of locale strings or locale objects - * @param {'always' | 'never' | 'ignore'} [options.trailingSlash='ignore'] - Trailing slash behavior - * @param {'directory' | 'file'} [options.format='directory'] - Build output format - * @param {import('../../../dist/core/app/common.js').RoutingStrategies} [options.strategy='pathname-prefix-other-locales'] - i18n routing strategy - * @param {string} [options.defaultLocale='en'] - The default locale - * @param {Record | undefined} [options.domains] - Domain-to-locale mapping - * @param {Record | undefined} [options.fallback] - Fallback locale configuration - * @param {'redirect' | 'rewrite'} [options.fallbackType='redirect'] - Type of fallback behavior - * @returns {object} A MiddlewarePayload object - * - * @example - * const payload = createMiddlewarePayload({ - * base: '/blog', - * defaultLocale: 'en', - * locales: ['en', 'es', 'pt'] - * }); - * const redirect = redirectToDefaultLocale(payload); - */ -export function createMiddlewarePayload({ - base = '', - locales = ['en', 'es'], - trailingSlash = 'ignore', - format = 'directory', - strategy = 'pathname-prefix-other-locales', - defaultLocale = 'en', - domains = undefined, - fallback = undefined, - fallbackType = 'redirect', -} = {}) { - return { - base, - locales, - trailingSlash, - format, - strategy, - defaultLocale, - domains, - fallback, - fallbackType, - }; -} diff --git a/packages/astro/test/units/i18n/test-helpers.ts b/packages/astro/test/units/i18n/test-helpers.ts new file mode 100644 index 000000000000..fe910ad044d3 --- /dev/null +++ b/packages/astro/test/units/i18n/test-helpers.ts @@ -0,0 +1,119 @@ +import type { RoutingStrategies } from '../../../dist/core/app/common.js'; +import type { Locales } from '../../../dist/types/public/config.js'; +import type { MiddlewarePayload } from '../../../dist/i18n/index.js'; + +export function makeI18nRouterConfig({ + strategy = 'pathname-prefix-other-locales', + defaultLocale = 'en', + locales = ['en', 'es', 'pt'], + base = '', + domains, +}: { + strategy?: RoutingStrategies; + defaultLocale?: string; + locales?: Locales; + base?: string; + domains?: Record; +} = {}) { + return { strategy, defaultLocale, locales, base, domains }; +} + +export function makeRouterContext({ + currentLocale, + currentDomain = 'example.com', + routeType = 'page', + isReroute = false, +}: { + currentLocale?: string; + currentDomain?: string; + routeType?: string; + isReroute?: boolean; +} = {}) { + return { currentLocale, currentDomain, routeType: routeType as 'page' | 'fallback', isReroute }; +} + +export function makeFallbackOptions({ + pathname, + responseStatus = 404, + currentLocale, + fallback = {}, + fallbackType = 'redirect', + locales = ['en', 'es', 'pt'], + defaultLocale = 'en', + strategy = 'pathname-prefix-other-locales', + base = '', +}: { + pathname: string; + responseStatus?: number; + currentLocale?: string; + fallback?: Record; + fallbackType?: 'redirect' | 'rewrite'; + locales?: Locales; + defaultLocale?: string; + strategy?: RoutingStrategies; + base?: string; +}) { + return { + pathname, + responseStatus, + currentLocale, + fallback, + fallbackType, + locales, + defaultLocale, + strategy, + base, + }; +} + +export function createManualRoutingContext({ + pathname = '/', + hostname = 'localhost', + method = 'GET', + currentLocale = undefined as string | undefined, +}: { + pathname?: string; + hostname?: string; + method?: string; + currentLocale?: string; +} = {}) { + const url = new URL(`http://${hostname}${pathname}`); + const request = new Request(url.toString(), { method }); + + // Cast to any — this is a partial mock of APIContext for unit tests + return { + url, + request, + currentLocale, + redirect(path: string, status = 302) { + return new Response(null, { + status, + headers: { Location: path }, + }); + }, + } as any; +} + +export function createMiddlewarePayload({ + base = '', + locales = ['en', 'es'] as Locales, + trailingSlash = 'ignore' as 'always' | 'never' | 'ignore', + format = 'directory' as 'directory' | 'file', + strategy = 'pathname-prefix-other-locales' as RoutingStrategies, + defaultLocale = 'en', + domains = undefined as Record | undefined, + fallback = undefined as Record | undefined, + fallbackType = 'redirect' as 'redirect' | 'rewrite', +}: Partial = {}): MiddlewarePayload { + return { + base, + locales, + trailingSlash, + format, + strategy, + defaultLocale, + domains, + fallback, + fallbackType, + }; +} diff --git a/packages/astro/test/units/integrations/api.test.js b/packages/astro/test/units/integrations/api.test.js deleted file mode 100644 index c625fce65af8..000000000000 --- a/packages/astro/test/units/integrations/api.test.js +++ /dev/null @@ -1,560 +0,0 @@ -import { deepEqual } from 'node:assert'; -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { validateSupportedFeatures } from '../../../dist/integrations/features-validation.js'; -import { - normalizeCodegenDir, - normalizeInjectedTypeFilename, - runHookBuildSetup, - runHookConfigSetup, -} from '../../../dist/integrations/hooks.js'; -import { createFixture, defaultLogger, runInContainer } from '../test-utils.js'; - -const defaultConfig = { - root: new URL('./', import.meta.url), - srcDir: new URL('src/', import.meta.url), - build: {}, - image: { - remotePatterns: [], - }, - outDir: new URL('./dist/', import.meta.url), - publicDir: new URL('./public/', import.meta.url), - experimental: {}, -}; -const dotAstroDir = new URL('./.astro/', defaultConfig.root); - -describe('Integration API', () => { - it('runHookBuildSetup should work', async () => { - const updatedViteConfig = await runHookBuildSetup({ - config: { - ...defaultConfig, - integrations: [ - { - name: 'test', - hooks: { - 'astro:build:setup'({ updateConfig }) { - updateConfig({ - define: { - foo: 'bar', - }, - }); - }, - }, - }, - ], - }, - vite: {}, - logger: defaultLogger, - pages: new Map(), - target: 'server', - }); - assert.equal(updatedViteConfig.hasOwnProperty('define'), true); - }); - - it('runHookBuildSetup should return updated config', async () => { - let updatedInternalConfig; - const updatedViteConfig = await runHookBuildSetup({ - config: { - ...defaultConfig, - integrations: [ - { - name: 'test', - hooks: { - 'astro:build:setup'({ updateConfig }) { - updatedInternalConfig = updateConfig({ - define: { - foo: 'bar', - }, - }); - }, - }, - }, - ], - }, - vite: {}, - logger: defaultLogger, - pages: new Map(), - target: 'server', - }); - deepEqual(updatedViteConfig, updatedInternalConfig); - }); - - it('runHookConfigSetup can update Astro config', async () => { - const site = 'https://test.com/'; - const updatedSettings = await runHookConfigSetup({ - logger: defaultLogger, - settings: { - config: { - ...defaultConfig, - integrations: [ - { - name: 'test', - hooks: { - 'astro:config:setup': ({ updateConfig }) => { - updateConfig({ site }); - }, - }, - }, - ], - }, - dotAstroDir, - }, - }); - assert.equal(updatedSettings.config.site, site); - }); - - it('runHookConfigSetup runs integrations added by another integration', async () => { - const site = 'https://test.com/'; - const updatedSettings = await runHookConfigSetup({ - logger: defaultLogger, - settings: { - config: { - ...defaultConfig, - integrations: [ - { - name: 'test', - hooks: { - 'astro:config:setup': ({ updateConfig }) => { - updateConfig({ - integrations: [ - { - name: 'dynamically-added', - hooks: { - // eslint-disable-next-line @typescript-eslint/no-shadow - 'astro:config:setup': ({ updateConfig }) => { - updateConfig({ site }); - }, - }, - }, - ], - }); - }, - }, - }, - ], - }, - dotAstroDir, - }, - }); - assert.equal(updatedSettings.config.site, site); - assert.equal(updatedSettings.config.integrations.length, 2); - }); - - describe('Routes resolved hooks', () => { - it.skip( - 'should work in dev', - { todo: "[p2] Understand why routes aren't deep equal anymore" }, - async () => { - let routes = []; - const fixture = await createFixture({ - '/src/pages/about.astro': '', - '/src/actions.ts': 'export const server = {}', - '/src/foo.astro': '', - }); - - await runInContainer( - { - inlineConfig: { - root: fixture.path, - integrations: [ - { - name: 'test', - hooks: { - 'astro:config:setup': (params) => { - params.injectRoute({ - entrypoint: './src/foo.astro', - pattern: '/foo', - }); - }, - 'astro:routes:resolved': (params) => { - routes = params.routes.map((r) => ({ - isPrerendered: r.isPrerendered, - entrypoint: r.entrypoint, - pattern: r.pattern, - params: r.params, - origin: r.origin, - })); - routes.sort((a, b) => a.pattern.localeCompare(b.pattern)); - }, - }, - }, - ], - }, - }, - async (container) => { - assert.equal(routes.length, 6); - assert.deepEqual( - routes, - [ - { - isPrerendered: false, - entrypoint: '_server-islands.astro', - pattern: '/_server-islands/[name]', - params: ['name'], - origin: 'internal', - }, - { - isPrerendered: false, - entrypoint: '../../../../dist/actions/runtime/entrypoints/route.js', - pattern: '/_actions/[...path]', - params: ['...path'], - origin: 'internal', - }, - { - isPrerendered: true, - entrypoint: 'src/pages/about.astro', - pattern: '/about', - params: [], - origin: 'project', - }, - { - isPrerendered: true, - entrypoint: 'src/foo.astro', - pattern: '/foo', - params: [], - origin: 'external', - }, - { - isPrerendered: false, - entrypoint: '../../../../dist/assets/endpoint/dev.js', - pattern: '/_image', - params: [], - origin: 'internal', - }, - { - isPrerendered: false, - entrypoint: 'astro-default-404.astro', - pattern: '/404', - params: [], - origin: 'internal', - }, - ].sort((a, b) => a.pattern.localeCompare(b.pattern)), - ); - - await fixture.writeFile('/src/pages/bar.astro', ''); - container.viteServer.watcher.emit( - 'add', - fixture.getPath('/src/pages/bar.astro').replace(/\\/g, '/'), - ); - await new Promise((r) => setTimeout(r, 100)); - - deepEqual( - routes, - [ - { - isPrerendered: false, - entrypoint: '_server-islands.astro', - pattern: '/_server-islands/[name]', - params: ['name'], - origin: 'internal', - }, - { - isPrerendered: false, - entrypoint: '../../../../dist/actions/runtime/entrypoints/route.js', - pattern: '/_actions/[...path]', - params: ['...path'], - origin: 'internal', - }, - { - isPrerendered: true, - entrypoint: 'src/pages/about.astro', - pattern: '/about', - params: [], - origin: 'project', - }, - { - isPrerendered: true, - entrypoint: 'src/pages/bar.astro', - pattern: '/bar', - params: [], - origin: 'project', - }, - { - isPrerendered: true, - entrypoint: 'src/foo.astro', - pattern: '/foo', - params: [], - origin: 'external', - }, - { - isPrerendered: false, - entrypoint: '../../../../dist/assets/endpoint/dev.js', - pattern: '/_image', - params: [], - origin: 'internal', - }, - { - isPrerendered: false, - entrypoint: 'astro-default-404.astro', - pattern: '/404', - params: [], - origin: 'internal', - }, - ].sort((a, b) => a.pattern.localeCompare(b.pattern)), - ); - - await fixture.writeFile( - '/src/pages/about.astro', - '---\nexport const prerender=false\n', - ); - container.viteServer.watcher.emit( - 'change', - fixture.getPath('/src/pages/about.astro').replace(/\\/g, '/'), - ); - await new Promise((r) => setTimeout(r, 100)); - - deepEqual( - routes, - [ - { - isPrerendered: false, - entrypoint: '_server-islands.astro', - pattern: '/_server-islands/[name]', - params: ['name'], - origin: 'internal', - }, - { - isPrerendered: false, - entrypoint: '../../../../dist/actions/runtime/entrypoints/route.js', - pattern: '/_actions/[...path]', - params: ['...path'], - origin: 'internal', - }, - { - isPrerendered: false, - entrypoint: 'src/pages/about.astro', - pattern: '/about', - params: [], - origin: 'project', - }, - { - isPrerendered: true, - entrypoint: 'src/pages/bar.astro', - pattern: '/bar', - params: [], - origin: 'project', - }, - { - isPrerendered: true, - entrypoint: 'src/foo.astro', - pattern: '/foo', - params: [], - origin: 'external', - }, - { - isPrerendered: false, - entrypoint: '../../../../dist/assets/endpoint/dev.js', - pattern: '/_image', - params: [], - origin: 'internal', - }, - { - isPrerendered: false, - entrypoint: 'astro-default-404.astro', - pattern: '/404', - params: [], - origin: 'internal', - }, - ].sort((a, b) => a.pattern.localeCompare(b.pattern)), - ); - }, - ); - }, - ); - }); - - describe('Routes setup hook', () => { - it('should work in dev', async () => { - let routes = []; - const fixture = await createFixture({ - '/src/pages/no-prerender.astro': '---\nexport const prerender = false\n---', - '/src/pages/prerender.astro': '---\nexport const prerender = true\n---', - '/src/pages/unknown-prerender.astro': '', - }); - - await runInContainer( - { - inlineConfig: { - root: fixture.path, - integrations: [ - { - name: 'test', - hooks: { - 'astro:route:setup': (params) => { - routes.push({ - component: params.route.component, - prerender: params.route.prerender, - }); - }, - }, - }, - ], - }, - }, - async () => { - routes.sort((a, b) => a.component.localeCompare(b.component)); - deepEqual(routes, [ - { - component: 'src/pages/no-prerender.astro', - prerender: false, - }, - { - component: 'src/pages/prerender.astro', - prerender: true, - }, - { - component: 'src/pages/unknown-prerender.astro', - prerender: true, - }, - ]); - }, - ); - }); - }); -}); - -describe('Astro feature map', function () { - it('should support the feature when stable', () => { - let result = validateSupportedFeatures( - 'test', - { - hybridOutput: 'stable', - }, - { - config: { output: 'static' }, - }, - {}, - defaultLogger, - ); - assert.equal(result['hybridOutput'], true); - }); - - it('should not support the feature when not provided', () => { - let result = validateSupportedFeatures( - 'test', - {}, - { - buildOutput: 'server', - config: { output: 'static' }, - }, - defaultLogger, - ); - assert.equal(result['hybridOutput'], false); - }); - - it('should not support the feature when an empty object is provided', () => { - let result = validateSupportedFeatures( - 'test', - {}, - { - buildOutput: 'server', - config: { output: 'static' }, - }, - defaultLogger, - ); - assert.equal(result['hybridOutput'], false); - }); - - describe('static output', function () { - it('should be supported with the correct config', () => { - let result = validateSupportedFeatures( - 'test', - { staticOutput: 'stable' }, - { - config: { output: 'static' }, - }, - defaultLogger, - ); - assert.equal(result['staticOutput'], true); - }); - - it("should not be valid if the config is correct, but the it's unsupported", () => { - let result = validateSupportedFeatures( - 'test', - { staticOutput: 'unsupported' }, - { - buildOutput: 'static', - config: { output: 'static' }, - }, - defaultLogger, - ); - assert.equal(result['staticOutput'], false); - }); - }); - describe('hybrid output', function () { - it('should be supported with the correct config', () => { - let result = validateSupportedFeatures( - 'test', - { hybridOutput: 'stable' }, - { - config: { output: 'static' }, - }, - defaultLogger, - ); - assert.equal(result['hybridOutput'], true); - }); - - it("should not be valid if the config is correct, but the it's unsupported", () => { - let result = validateSupportedFeatures( - 'test', - { - hybridOutput: 'unsupported', - }, - { - buildOutput: 'server', - config: { output: 'static' }, - }, - defaultLogger, - ); - assert.equal(result['hybridOutput'], false); - }); - }); - describe('server output', function () { - it('should be supported with the correct config', () => { - let result = validateSupportedFeatures( - 'test', - { serverOutput: 'stable' }, - { - config: { output: 'server' }, - }, - defaultLogger, - ); - assert.equal(result['serverOutput'], true); - }); - - it("should not be valid if the config is correct, but the it's unsupported", () => { - let result = validateSupportedFeatures( - 'test', - { - serverOutput: 'unsupported', - }, - { - config: { output: 'server' }, - }, - defaultLogger, - ); - assert.equal(result['serverOutput'], false); - }); - }); -}); - -describe('normalizeInjectedTypeFilename', () => { - // invalid filename - assert.throws(() => normalizeInjectedTypeFilename('types', 'integration')); - // valid filename - assert.doesNotThrow(() => normalizeInjectedTypeFilename('types.d.ts', 'integration')); - // filename normalization - assert.equal( - normalizeInjectedTypeFilename('aA1-*/_"~.d.ts', 'integration'), - './integrations/integration/aA1-_____.d.ts', - ); - // integration name normalization - assert.equal( - normalizeInjectedTypeFilename('types.d.ts', 'aA1-*/_"~.'), - './integrations/aA1-_____./types.d.ts', - ); -}); - -describe('normalizeCodegenDir', () => { - assert.equal(normalizeCodegenDir('aA1-*/_"~.'), './integrations/aA1-_____./'); -}); diff --git a/packages/astro/test/units/integrations/api.test.ts b/packages/astro/test/units/integrations/api.test.ts new file mode 100644 index 000000000000..6094d132c222 --- /dev/null +++ b/packages/astro/test/units/integrations/api.test.ts @@ -0,0 +1,302 @@ +import { deepEqual } from 'node:assert'; +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { validateSupportedFeatures } from '../../../dist/integrations/features-validation.js'; +import { + normalizeCodegenDir, + normalizeInjectedTypeFilename, + runHookBuildSetup, + runHookConfigSetup, +} from '../../../dist/integrations/hooks.js'; +import { defaultLogger } from '../test-utils.ts'; + +import type { AstroConfig } from '../../../dist/types/public/config.js'; +import type { AstroSettings } from '../../../dist/types/astro.js'; + +const defaultConfig: Record = { + root: new URL('./', import.meta.url), + srcDir: new URL('src/', import.meta.url), + build: {}, + image: { + remotePatterns: [], + }, + outDir: new URL('./dist/', import.meta.url), + publicDir: new URL('./public/', import.meta.url), + experimental: {}, +}; + +const dotAstroDir = new URL('./.astro/', defaultConfig.root as URL); + +describe('Integration API', () => { + it('runHookBuildSetup should work', async () => { + const updatedViteConfig = await runHookBuildSetup({ + config: { + ...defaultConfig, + integrations: [ + { + name: 'test', + hooks: { + 'astro:build:setup'({ updateConfig }: { updateConfig: (cfg: object) => object }) { + updateConfig({ + define: { + foo: 'bar', + }, + }); + }, + }, + }, + ], + } as unknown as AstroConfig, + vite: {}, + logger: defaultLogger, + pages: new Map(), + target: 'server', + }); + assert.equal(updatedViteConfig.hasOwnProperty('define'), true); + }); + + it('runHookBuildSetup should return updated config', async () => { + let updatedInternalConfig: unknown; + const updatedViteConfig = await runHookBuildSetup({ + config: { + ...defaultConfig, + integrations: [ + { + name: 'test', + hooks: { + 'astro:build:setup'({ updateConfig }: { updateConfig: (cfg: object) => object }) { + updatedInternalConfig = updateConfig({ + define: { + foo: 'bar', + }, + }); + }, + }, + }, + ], + } as unknown as AstroConfig, + vite: {}, + logger: defaultLogger, + pages: new Map(), + target: 'server', + }); + deepEqual(updatedViteConfig, updatedInternalConfig); + }); + + it('runHookConfigSetup can update Astro config', async () => { + const site = 'https://test.com/'; + const updatedSettings = await runHookConfigSetup({ + logger: defaultLogger, + settings: { + config: { + ...defaultConfig, + integrations: [ + { + name: 'test', + hooks: { + 'astro:config:setup': ({ + updateConfig, + }: { + updateConfig: (cfg: object) => void; + }) => { + updateConfig({ site }); + }, + }, + }, + ], + }, + dotAstroDir, + } as unknown as AstroSettings, + } as Parameters[0]); + assert.equal(updatedSettings.config.site, site); + }); + + it('runHookConfigSetup runs integrations added by another integration', async () => { + const site = 'https://test.com/'; + const updatedSettings = await runHookConfigSetup({ + logger: defaultLogger, + settings: { + config: { + ...defaultConfig, + integrations: [ + { + name: 'test', + hooks: { + 'astro:config:setup': ({ + updateConfig, + }: { + updateConfig: (cfg: object) => void; + }) => { + updateConfig({ + integrations: [ + { + name: 'dynamically-added', + hooks: { + 'astro:config:setup': ({ + updateConfig: innerUpdateConfig, + }: { + updateConfig: (cfg: object) => void; + }) => { + innerUpdateConfig({ site }); + }, + }, + }, + ], + }); + }, + }, + }, + ], + }, + dotAstroDir, + } as unknown as AstroSettings, + } as Parameters[0]); + assert.equal(updatedSettings.config.site, site); + assert.equal(updatedSettings.config.integrations.length, 2); + }); +}); + +describe('Astro feature map', function () { + it('should support the feature when stable', () => { + const result = validateSupportedFeatures( + 'test', + { + hybridOutput: 'stable', + }, + { + config: { output: 'static' }, + } as unknown as AstroSettings, + defaultLogger, + ); + assert.equal(result['hybridOutput'], true); + }); + + it('should not support the feature when not provided', () => { + const result = validateSupportedFeatures( + 'test', + {}, + { + buildOutput: 'server', + config: { output: 'static' }, + } as unknown as AstroSettings, + defaultLogger, + ); + assert.equal(result['hybridOutput'], false); + }); + + it('should not support the feature when an empty object is provided', () => { + const result = validateSupportedFeatures( + 'test', + {}, + { + buildOutput: 'server', + config: { output: 'static' }, + } as unknown as AstroSettings, + defaultLogger, + ); + assert.equal(result['hybridOutput'], false); + }); + + describe('static output', function () { + it('should be supported with the correct config', () => { + const result = validateSupportedFeatures( + 'test', + { staticOutput: 'stable' }, + { + config: { output: 'static' }, + } as unknown as AstroSettings, + defaultLogger, + ); + assert.equal(result['staticOutput'], true); + }); + + it("should not be valid if the config is correct, but the it's unsupported", () => { + const result = validateSupportedFeatures( + 'test', + { staticOutput: 'unsupported' }, + { + buildOutput: 'static', + config: { output: 'static' }, + } as unknown as AstroSettings, + defaultLogger, + ); + assert.equal(result['staticOutput'], false); + }); + }); + describe('hybrid output', function () { + it('should be supported with the correct config', () => { + const result = validateSupportedFeatures( + 'test', + { hybridOutput: 'stable' }, + { + config: { output: 'static' }, + } as unknown as AstroSettings, + defaultLogger, + ); + assert.equal(result['hybridOutput'], true); + }); + + it("should not be valid if the config is correct, but the it's unsupported", () => { + const result = validateSupportedFeatures( + 'test', + { + hybridOutput: 'unsupported', + }, + { + buildOutput: 'server', + config: { output: 'static' }, + } as unknown as AstroSettings, + defaultLogger, + ); + assert.equal(result['hybridOutput'], false); + }); + }); + describe('server output', function () { + it('should be supported with the correct config', () => { + const result = validateSupportedFeatures( + 'test', + { serverOutput: 'stable' }, + { + config: { output: 'server' }, + } as unknown as AstroSettings, + defaultLogger, + ); + assert.equal(result['serverOutput'], true); + }); + + it("should not be valid if the config is correct, but the it's unsupported", () => { + const result = validateSupportedFeatures( + 'test', + { + serverOutput: 'unsupported', + }, + { + config: { output: 'server' }, + } as unknown as AstroSettings, + defaultLogger, + ); + assert.equal(result['serverOutput'], false); + }); + }); +}); + +describe('normalizeInjectedTypeFilename', () => { + // invalid filename + assert.throws(() => normalizeInjectedTypeFilename('types', 'integration')); + // valid filename + assert.doesNotThrow(() => normalizeInjectedTypeFilename('types.d.ts', 'integration')); + // filename normalization + assert.equal( + normalizeInjectedTypeFilename('aA1-*/_"~.d.ts', 'integration'), + './integrations/integration/aA1-_____.d.ts', + ); + // integration name normalization + assert.equal( + normalizeInjectedTypeFilename('types.d.ts', 'aA1-*/_"~.'), + './integrations/aA1-_____./types.d.ts', + ); +}); + +describe('normalizeCodegenDir', () => { + assert.equal(normalizeCodegenDir('aA1-*/_"~.'), './integrations/aA1-_____./'); +}); diff --git a/packages/astro/test/units/integrations/hooks.test.ts b/packages/astro/test/units/integrations/hooks.test.ts new file mode 100644 index 000000000000..43aeb07c6cea --- /dev/null +++ b/packages/astro/test/units/integrations/hooks.test.ts @@ -0,0 +1,319 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + normalizeCodegenDir, + normalizeInjectedTypeFilename, + toIntegrationResolvedRoute, +} from '../../../dist/integrations/hooks.js'; +import { + getAdapterStaticRecommendation, + getSupportMessage, + unwrapSupportKind, +} from '../../../dist/integrations/features-validation.js'; +import { resolveMiddlewareMode } from '../../../dist/integrations/adapter-utils.js'; +import { createRouteData } from '../mocks.ts'; +import { dynamicPart, makeRoute, spreadPart, staticPart } from '../routing/test-helpers.ts'; + +import type { + AdapterSupport, + AstroAdapterFeatures, +} from '../../../dist/types/public/integrations.js'; + +// #region normalizeCodegenDir +describe('normalizeCodegenDir', () => { + it('preserves alphanumeric, dots, and hyphens', () => { + assert.equal(normalizeCodegenDir('my-integration'), './integrations/my-integration/'); + }); + + it('replaces slashes', () => { + assert.equal(normalizeCodegenDir('@scope/plugin'), './integrations/_scope_plugin/'); + }); + + it('replaces spaces and special characters', () => { + assert.equal(normalizeCodegenDir('has space!@#$'), './integrations/has_space____/'); + }); + + it('preserves dots in name', () => { + assert.equal(normalizeCodegenDir('my.integration.v2'), './integrations/my.integration.v2/'); + }); + + it('handles empty string', () => { + assert.equal(normalizeCodegenDir(''), './integrations//'); + }); + + it('replaces unicode characters', () => { + assert.equal(normalizeCodegenDir('cafe\u0301'), './integrations/cafe_/'); + }); +}); +// #endregion + +// #region normalizeInjectedTypeFilename +describe('normalizeInjectedTypeFilename', () => { + it('throws when filename does not end with .d.ts', () => { + assert.throws( + () => normalizeInjectedTypeFilename('types.ts', 'my-integration'), + /does not end with/, + ); + }); + + it('throws for plain filename without extension', () => { + assert.throws( + () => normalizeInjectedTypeFilename('types', 'my-integration'), + /does not end with/, + ); + }); + + it('does not throw for valid .d.ts filename', () => { + assert.doesNotThrow(() => normalizeInjectedTypeFilename('types.d.ts', 'my-integration')); + }); + + it('returns normalized path with integration dir prefix', () => { + assert.equal( + normalizeInjectedTypeFilename('types.d.ts', 'my-integration'), + './integrations/my-integration/types.d.ts', + ); + }); + + it('sanitizes special characters in filename', () => { + assert.equal( + normalizeInjectedTypeFilename('my types!.d.ts', 'my-integration'), + './integrations/my-integration/my_types_.d.ts', + ); + }); + + it('sanitizes special characters in integration name', () => { + assert.equal( + normalizeInjectedTypeFilename('types.d.ts', '@scope/pkg'), + './integrations/_scope_pkg/types.d.ts', + ); + }); + + it('handles both filename and integration name with special chars', () => { + assert.equal( + normalizeInjectedTypeFilename('aA1-*/_"~.d.ts', 'aA1-*/_"~.'), + './integrations/aA1-_____./aA1-_____.d.ts', + ); + }); +}); +// #endregion + +// #region toIntegrationResolvedRoute +describe('toIntegrationResolvedRoute', () => { + it('maps RouteData fields to IntegrationResolvedRoute fields', () => { + const route = makeRoute({ + route: '/blog/[slug]', + segments: [[staticPart('blog')], [dynamicPart('slug')]], + trailingSlash: 'ignore', + pathname: undefined, + }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + + assert.equal(result.isPrerendered, false); + assert.equal(result.entrypoint, route.component); + assert.equal(result.pattern, '/blog/[slug]'); + assert.deepEqual(result.params, ['slug']); + assert.equal(result.origin, 'project'); + assert.equal(result.patternRegex, route.pattern); + assert.deepEqual(result.segments, route.segments); + assert.equal(result.type, 'page'); + assert.equal(result.pathname, undefined); + assert.equal(result.redirect, undefined); + assert.equal(result.redirectRoute, undefined); + assert.deepEqual(result.fallbackRoutes, []); + }); + + it('generate function produces correct path from params', () => { + const route = makeRoute({ + route: '/blog/[slug]', + segments: [[staticPart('blog')], [dynamicPart('slug')]], + trailingSlash: 'ignore', + pathname: undefined, + }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + + assert.equal(result.generate({ slug: 'hello-world' }), '/blog/hello-world'); + }); + + it('handles static routes with pathname', () => { + const route = createRouteData({ route: '/about' }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + + assert.equal(result.pathname, '/about'); + assert.equal(result.pattern, '/about'); + assert.deepEqual(result.params, []); + }); + + it('maps prerendered routes correctly', () => { + const route = createRouteData({ route: '/page', prerender: true }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + assert.equal(result.isPrerendered, true); + }); + + it('recursively maps redirectRoute', () => { + const targetRoute = createRouteData({ route: '/new-blog' }); + const route = createRouteData({ route: '/old-blog', type: 'redirect' }); + route.redirect = '/new-blog'; + route.redirectRoute = targetRoute; + + const result = toIntegrationResolvedRoute(route, 'ignore'); + assert.equal(result.type, 'redirect'); + assert.ok(result.redirectRoute); + assert.equal(result.redirectRoute.pattern, '/new-blog'); + }); + + it('recursively maps fallbackRoutes', () => { + const fallback = createRouteData({ route: '/en/blog' }); + fallback.origin = 'internal'; + const route = createRouteData({ route: '/blog' }); + route.fallbackRoutes = [fallback]; + + const result = toIntegrationResolvedRoute(route, 'ignore'); + assert.equal(result.fallbackRoutes.length, 1); + assert.equal(result.fallbackRoutes[0].pattern, '/en/blog'); + assert.equal(result.fallbackRoutes[0].origin, 'internal'); + }); + + it('applies trailingSlash "always" to generate function', () => { + const route = createRouteData({ route: '/about' }); + const result = toIntegrationResolvedRoute(route, 'always'); + assert.equal(result.generate({}), '/about/'); + }); + + it('applies trailingSlash "never" to generate function', () => { + const route = createRouteData({ route: '/about' }); + const result = toIntegrationResolvedRoute(route, 'never'); + const generated = result.generate({}); + assert.ok(!generated.endsWith('/') || generated === '/'); + }); + + it('handles endpoint route type', () => { + const route = createRouteData({ route: '/api/data', type: 'endpoint' }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + assert.equal(result.type, 'endpoint'); + }); + + it('handles spread params in generate', () => { + const route = makeRoute({ + route: '/blog/[...slug]', + segments: [[staticPart('blog')], [spreadPart('...slug')]], + trailingSlash: 'ignore', + pathname: undefined, + }); + const result = toIntegrationResolvedRoute(route, 'ignore'); + assert.equal(result.generate({ slug: 'a/b/c' }), '/blog/a/b/c'); + }); +}); +// #endregion + +// #region resolveMiddlewareMode +describe('resolveMiddlewareMode', () => { + it('returns "classic" when features is undefined', () => { + assert.equal(resolveMiddlewareMode(undefined), 'classic'); + }); + + it('returns "classic" when features is empty object', () => { + assert.equal(resolveMiddlewareMode({}), 'classic'); + }); + + it('returns the middlewareMode value when explicitly set', () => { + assert.equal(resolveMiddlewareMode({ middlewareMode: 'edge' }), 'edge'); + }); + + it('returns "classic" when middlewareMode is "classic"', () => { + assert.equal(resolveMiddlewareMode({ middlewareMode: 'classic' }), 'classic'); + }); + + it('returns "edge" for deprecated edgeMiddleware: true', () => { + assert.equal(resolveMiddlewareMode({ edgeMiddleware: true } as AstroAdapterFeatures), 'edge'); + }); + + it('returns "classic" for deprecated edgeMiddleware: false', () => { + assert.equal( + resolveMiddlewareMode({ edgeMiddleware: false } as AstroAdapterFeatures), + 'classic', + ); + }); + + it('middlewareMode takes precedence over edgeMiddleware', () => { + assert.equal( + resolveMiddlewareMode({ + middlewareMode: 'classic', + edgeMiddleware: true, + } as AstroAdapterFeatures), + 'classic', + ); + }); +}); +// #endregion + +// #region getAdapterStaticRecommendation +describe('getAdapterStaticRecommendation', () => { + it('returns recommendation for @astrojs/vercel/static', () => { + const result = getAdapterStaticRecommendation('@astrojs/vercel/static'); + assert.ok(result); + assert.ok(result.includes('@astrojs/vercel/serverless')); + }); + + it('returns undefined for unknown adapter', () => { + assert.equal(getAdapterStaticRecommendation('unknown-adapter'), undefined); + }); + + it('returns undefined for empty string', () => { + assert.equal(getAdapterStaticRecommendation(''), undefined); + }); + + it('returns undefined for similar but non-matching adapter name', () => { + assert.equal(getAdapterStaticRecommendation('@astrojs/vercel'), undefined); + }); +}); +// #endregion + +// #region unwrapSupportKind +describe('unwrapSupportKind', () => { + it('returns undefined when supportKind is undefined', () => { + assert.equal(unwrapSupportKind(undefined), undefined); + }); + + it('returns the string directly when supportKind is a string', () => { + assert.equal(unwrapSupportKind('stable'), 'stable'); + }); + + it('returns support from object when supportKind is an object', () => { + assert.equal( + unwrapSupportKind({ support: 'experimental', message: 'Beta feature' }), + 'experimental', + ); + }); + + it('handles all stability levels as strings', () => { + assert.equal(unwrapSupportKind('stable'), 'stable'); + assert.equal(unwrapSupportKind('deprecated'), 'deprecated'); + assert.equal(unwrapSupportKind('unsupported'), 'unsupported'); + assert.equal(unwrapSupportKind('experimental'), 'experimental'); + assert.equal(unwrapSupportKind('limited'), 'limited'); + }); + + it('returns undefined for falsy values', () => { + assert.equal(unwrapSupportKind(undefined), undefined); + }); +}); +// #endregion + +// #region getSupportMessage +describe('getSupportMessage', () => { + it('returns undefined when supportKind is a string', () => { + assert.equal(getSupportMessage('stable'), undefined); + }); + + it('returns the message when supportKind is an object with message', () => { + assert.equal( + getSupportMessage({ support: 'experimental', message: 'Beta feature' }), + 'Beta feature', + ); + }); + + it('returns undefined when supportKind is an object without message', () => { + assert.equal(getSupportMessage({ support: 'stable' } as unknown as AdapterSupport), undefined); + }); +}); +// #endregion diff --git a/packages/astro/test/units/logger/destination.test.ts b/packages/astro/test/units/logger/destination.test.ts new file mode 100644 index 000000000000..f5bd057bbfc8 --- /dev/null +++ b/packages/astro/test/units/logger/destination.test.ts @@ -0,0 +1,172 @@ +import * as assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import type { AstroLogMessage, AstroLoggerDestination } from '../../../dist/core/logger/core.js'; +import { AstroLogger } from '../../../dist/core/logger/core.js'; + +let logs: AstroLogMessage[] = []; +let jsonLogs: string[] = []; + +const testDestination: AstroLoggerDestination = { + write(event: AstroLogMessage) { + logs.push(event); + return true; + }, +}; + +const jsonDestination: AstroLoggerDestination = { + write(event: AstroLogMessage) { + if ((event as any)._format === 'json') { + jsonLogs.push(JSON.stringify({ message: event.message, label: event.label })); + } + return true; + }, +}; + +describe('log destination', () => { + beforeEach(() => { + logs = []; + jsonLogs = []; + }); + + describe('event shape', () => { + const logger = new AstroLogger({ + destination: testDestination, + level: 'info', + _format: 'default', + }); + + it('info() pushes an event with level info', () => { + logger.info('build', 'server started'); + assert.equal(logs.length, 1); + assert.equal(logs[0].level, 'info'); + assert.equal(logs[0].label, 'build'); + assert.equal(logs[0].message, 'server started'); + assert.equal(logs[0].newLine, true); + }); + + it('warn() pushes an event with level warn', () => { + logger.warn('build', 'deprecation notice'); + assert.equal(logs.length, 1); + assert.equal(logs[0].level, 'warn'); + assert.equal(logs[0].label, 'build'); + assert.equal(logs[0].message, 'deprecation notice'); + }); + + it('error() pushes an event with level error', () => { + logger.error('build', 'build failed'); + assert.equal(logs.length, 1); + assert.equal(logs[0].level, 'error'); + assert.equal(logs[0].message, 'build failed'); + }); + + it('supports null label', () => { + logger.info(null, 'no label'); + assert.equal(logs[0].label, null); + }); + + it('respects newLine parameter', () => { + logger.info('build', 'no trailing newline', false); + assert.equal(logs[0].newLine, false); + }); + }); + + describe('format propagation', () => { + it('propagates default format to events', () => { + const logger = new AstroLogger({ + destination: testDestination, + level: 'info', + _format: 'default', + }); + logger.info('build', 'test'); + assert.equal((logs[0] as any)._format, 'default'); + }); + + it('propagates json format to events', () => { + const logger = new AstroLogger({ + destination: testDestination, + level: 'info', + _format: 'json', + }); + logger.info('build', 'test'); + assert.equal((logs[0] as any)._format, 'json'); + }); + }); + + describe('json formatting', () => { + const logger = new AstroLogger({ + destination: jsonDestination, + level: 'info', + _format: 'json', + }); + + it('serializes message and label as JSON', () => { + logger.info('build', 'compiled successfully'); + assert.equal(jsonLogs.length, 1); + assert.equal(jsonLogs[0], '{"message":"compiled successfully","label":"build"}'); + }); + + it('serializes null label', () => { + logger.info(null, 'no label message'); + assert.equal(jsonLogs[0], '{"message":"no label message","label":null}'); + }); + + it('only includes message and label', () => { + logger.warn('build', 'a warning'); + assert.equal(jsonLogs[0], '{"message":"a warning","label":"build"}'); + }); + + it('does not write when format is not json', () => { + const defaultLogger = new AstroLogger({ + destination: jsonDestination, + level: 'info', + _format: 'default', + }); + defaultLogger.info('build', 'should not appear'); + assert.equal(jsonLogs.length, 0); + }); + }); + + describe('level filtering', () => { + it('filters out info when level is warn', () => { + const logger = new AstroLogger({ + destination: testDestination, + level: 'warn', + _format: 'default', + }); + logger.info('build', 'should be filtered'); + assert.equal(logs.length, 0); + }); + + it('allows warn when level is warn', () => { + const logger = new AstroLogger({ + destination: testDestination, + level: 'warn', + _format: 'default', + }); + logger.warn('build', 'should pass'); + assert.equal(logs.length, 1); + }); + + it('allows error when level is warn', () => { + const logger = new AstroLogger({ + destination: testDestination, + level: 'warn', + _format: 'default', + }); + logger.error('build', 'should pass'); + assert.equal(logs.length, 1); + }); + + it('filters everything when level is silent', () => { + const logger = new AstroLogger({ + destination: testDestination, + level: 'silent', + _format: 'default', + }); + logger.info('build', 'nope'); + logger.warn('build', 'nope'); + logger.error('build', 'nope'); + assert.equal(logs.length, 0); + }); + }); +}); diff --git a/packages/astro/test/units/logger/locale.test.js b/packages/astro/test/units/logger/locale.test.ts similarity index 100% rename from packages/astro/test/units/logger/locale.test.js rename to packages/astro/test/units/logger/locale.test.ts diff --git a/packages/astro/test/units/manifest/serialized.test.js b/packages/astro/test/units/manifest/serialized.test.js new file mode 100644 index 000000000000..90411ecdf9f1 --- /dev/null +++ b/packages/astro/test/units/manifest/serialized.test.js @@ -0,0 +1,227 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + serializedManifestPlugin, + SERIALIZED_MANIFEST_RESOLVED_ID, +} from '../../../dist/manifest/serialized.js'; +import { createBasicSettings } from '../test-utils.js'; + +/** + * Invoke the plugin's load handler (as it runs in dev mode) and return the + * parsed SerializedSSRManifest that is embedded in the generated module code. + */ +async function getManifest(settings) { + const plugin = serializedManifestPlugin({ settings, command: 'dev', sync: false }); + const load = plugin.load; + const result = await load.handler.call({}, SERIALIZED_MANIFEST_RESOLVED_ID); + // The generated code contains: _deserializeManifest(()) + const match = /_deserializeManifest\(\((.+)\)\)/s.exec(result.code); + assert.ok(match, 'Could not find manifest JSON in plugin output'); + return JSON.parse(match[1]); +} + +describe('serializedManifestPlugin - dev mode', () => { + describe('allowedDomains', () => { + it('defaults to an empty array when not configured', async () => { + const settings = await createBasicSettings({}); + const manifest = await getManifest(settings); + assert.deepEqual(manifest.allowedDomains, []); + }); + + it('is an empty array when configured as []', async () => { + const settings = await createBasicSettings({ + security: { allowedDomains: [] }, + }); + const manifest = await getManifest(settings); + assert.deepEqual(manifest.allowedDomains, []); + }); + + it('preserves a single hostname pattern', async () => { + const pattern = [{ hostname: 'example.com' }]; + const settings = await createBasicSettings({ + security: { allowedDomains: pattern }, + }); + const manifest = await getManifest(settings); + assert.deepEqual(manifest.allowedDomains, pattern); + }); + + it('preserves multiple patterns with protocol and port', async () => { + const patterns = [ + { hostname: '*.example.com', protocol: 'https' }, + { hostname: 'cdn.example.com', port: '443' }, + ]; + const settings = await createBasicSettings({ + security: { allowedDomains: patterns }, + }); + const manifest = await getManifest(settings); + assert.deepEqual(manifest.allowedDomains, patterns); + }); + }); + + describe('checkOrigin', () => { + it('is false by default', async () => { + const settings = await createBasicSettings({}); + const manifest = await getManifest(settings); + assert.equal(manifest.checkOrigin, false); + }); + + it('is false when checkOrigin=true but buildOutput is not server', async () => { + const settings = await createBasicSettings({ + security: { checkOrigin: true }, + }); + settings.buildOutput = 'static'; + const manifest = await getManifest(settings); + assert.equal(manifest.checkOrigin, false); + }); + + it('is true when checkOrigin=true and buildOutput is server', async () => { + const settings = await createBasicSettings({ + security: { checkOrigin: true }, + }); + settings.buildOutput = 'server'; + const manifest = await getManifest(settings); + assert.equal(manifest.checkOrigin, true); + }); + }); + + describe('actionBodySizeLimit', () => { + it('defaults to 1 MB when not configured', async () => { + const settings = await createBasicSettings({}); + const manifest = await getManifest(settings); + assert.equal(manifest.actionBodySizeLimit, 1024 * 1024); + }); + + it('uses the configured value', async () => { + const settings = await createBasicSettings({ + security: { actionBodySizeLimit: 2097152 }, + }); + const manifest = await getManifest(settings); + assert.equal(manifest.actionBodySizeLimit, 2097152); + }); + }); + + describe('serverIslandBodySizeLimit', () => { + it('defaults to 1 MB when not configured', async () => { + const settings = await createBasicSettings({}); + const manifest = await getManifest(settings); + assert.equal(manifest.serverIslandBodySizeLimit, 1024 * 1024); + }); + + it('uses the configured value', async () => { + const settings = await createBasicSettings({ + security: { serverIslandBodySizeLimit: 512 }, + }); + const manifest = await getManifest(settings); + assert.equal(manifest.serverIslandBodySizeLimit, 512); + }); + }); + + describe('serverLike', () => { + it('is true when buildOutput is server', async () => { + const settings = await createBasicSettings({}); + settings.buildOutput = 'server'; + const manifest = await getManifest(settings); + assert.equal(manifest.serverLike, true); + }); + + it('is false when buildOutput is static', async () => { + const settings = await createBasicSettings({}); + settings.buildOutput = 'static'; + const manifest = await getManifest(settings); + assert.equal(manifest.serverLike, false); + }); + + it('is false when buildOutput is undefined', async () => { + const settings = await createBasicSettings({}); + settings.buildOutput = undefined; + const manifest = await getManifest(settings); + assert.equal(manifest.serverLike, false); + }); + }); + + describe('trailingSlash', () => { + for (const value of ['always', 'never', 'ignore']) { + it(`preserves trailingSlash="${value}"`, async () => { + const settings = await createBasicSettings({ trailingSlash: value }); + const manifest = await getManifest(settings); + assert.equal(manifest.trailingSlash, value); + }); + } + }); + + describe('base', () => { + it('preserves base="/"', async () => { + const settings = await createBasicSettings({ base: '/' }); + const manifest = await getManifest(settings); + assert.equal(manifest.base, '/'); + }); + + it('preserves base="/subpath/"', async () => { + const settings = await createBasicSettings({ base: '/subpath/' }); + const manifest = await getManifest(settings); + assert.equal(manifest.base, '/subpath/'); + }); + }); + + describe('compressHTML', () => { + it('is true by default', async () => { + const settings = await createBasicSettings({}); + const manifest = await getManifest(settings); + assert.equal(manifest.compressHTML, true); + }); + + it('is false when explicitly disabled', async () => { + const settings = await createBasicSettings({ compressHTML: false }); + const manifest = await getManifest(settings); + assert.equal(manifest.compressHTML, false); + }); + }); + + describe('i18n', () => { + it('is undefined when not configured', async () => { + const settings = await createBasicSettings({}); + const manifest = await getManifest(settings); + assert.equal(manifest.i18n, undefined); + }); + + it('includes expected fields when configured', async () => { + const settings = await createBasicSettings({ + i18n: { + defaultLocale: 'en', + locales: ['en', 'fr'], + fallback: { fr: 'en' }, + }, + }); + const manifest = await getManifest(settings); + assert.ok(manifest.i18n, 'i18n should be defined'); + assert.equal(manifest.i18n.defaultLocale, 'en'); + assert.deepEqual(manifest.i18n.locales, ['en', 'fr']); + assert.deepEqual(manifest.i18n.fallback, { fr: 'en' }); + assert.ok('strategy' in manifest.i18n, 'strategy should be present'); + assert.ok('fallbackType' in manifest.i18n, 'fallbackType should be present'); + assert.ok('domainLookupTable' in manifest.i18n, 'domainLookupTable should be present'); + }); + }); + + describe('key', () => { + it('embeds a non-empty encoded key string', async () => { + const settings = await createBasicSettings({}); + const manifest = await getManifest(settings); + assert.ok(typeof manifest.key === 'string' && manifest.key.length > 0); + }); + }); + + describe('directory paths', () => { + it('serializes directory URLs to strings', async () => { + const settings = await createBasicSettings({}); + const manifest = await getManifest(settings); + assert.equal(typeof manifest.rootDir, 'string'); + assert.equal(typeof manifest.srcDir, 'string'); + assert.equal(typeof manifest.outDir, 'string'); + assert.equal(typeof manifest.cacheDir, 'string'); + assert.equal(typeof manifest.publicDir, 'string'); + assert.equal(typeof manifest.buildClientDir, 'string'); + assert.equal(typeof manifest.buildServerDir, 'string'); + }); + }); +}); diff --git a/packages/astro/test/units/middleware/call-middleware.test.js b/packages/astro/test/units/middleware/call-middleware.test.js deleted file mode 100644 index f73d92962bfc..000000000000 --- a/packages/astro/test/units/middleware/call-middleware.test.js +++ /dev/null @@ -1,196 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it, beforeEach } from 'node:test'; -import { callMiddleware } from '../../../dist/core/middleware/callMiddleware.js'; -import { createMockAPIContext, createResponseFunction } from '../mocks.js'; - -describe('callMiddleware', () => { - /** @type {import('astro').APIContext} */ - let ctx; - const defaultResponseFn = createResponseFunction(); - - beforeEach(() => { - ctx = createMockAPIContext(); - }); - - describe('next() called', () => { - it('returns the middleware return value when next() is called and a Response is returned', async () => { - const middleware = async (_ctx, next) => { - const response = await next(); - return new Response('modified', { status: 200, headers: response.headers }); - }; - - const response = await callMiddleware(middleware, ctx, createResponseFunction('original')); - - assert.equal(await response.text(), 'modified'); - }); - - it('returns the responseFunction result when next() is called but middleware returns undefined', async () => { - const middleware = async (_ctx, next) => { - await next(); - // deliberately returns undefined - }; - - const response = await callMiddleware(middleware, ctx, createResponseFunction('from page')); - - assert.equal(await response.text(), 'from page'); - }); - - it('throws MiddlewareNotAResponse when next() is called but middleware returns a non-Response', async () => { - const middleware = async (_ctx, next) => { - await next(); - return 'not a response'; - }; - - await assert.rejects( - () => callMiddleware(middleware, ctx, defaultResponseFn), - (err) => { - assert.equal(err.name, 'MiddlewareNotAResponse'); - return true; - }, - ); - }); - }); - - describe('next() not called', () => { - it('returns the Response when middleware short-circuits without calling next()', async () => { - const middleware = async () => { - return new Response('short-circuit', { status: 200 }); - }; - - const response = await callMiddleware(middleware, ctx, defaultResponseFn); - - assert.equal(await response.text(), 'short-circuit'); - assert.equal(response.status, 200); - }); - - it('returns a 500 Response when middleware short-circuits with an error status', async () => { - const middleware = async () => { - return new Response(null, { status: 500 }); - }; - - const response = await callMiddleware(middleware, ctx, defaultResponseFn); - - assert.equal(response.status, 500); - }); - - it('throws MiddlewareNoDataOrNextCalled when middleware returns undefined without calling next()', async () => { - const middleware = async () => { - // returns undefined, never calls next - }; - - await assert.rejects( - () => callMiddleware(middleware, ctx, defaultResponseFn), - (err) => { - assert.equal(err.name, 'MiddlewareNoDataOrNextCalled'); - return true; - }, - ); - }); - - it('throws MiddlewareNotAResponse when middleware returns a non-Response without calling next()', async () => { - const middleware = async () => { - return 'not a response'; - }; - - await assert.rejects( - () => callMiddleware(middleware, ctx, defaultResponseFn), - (err) => { - assert.equal(err.name, 'MiddlewareNotAResponse'); - return true; - }, - ); - }); - }); - - describe('context mutation', () => { - it('locals mutations are visible in the response function', async () => { - const middleware = async (context, next) => { - context.locals.name = 'bar'; - return next(); - }; - const responseFn = async (apiCtx) => { - return new Response(`name=${apiCtx.locals.name}`); - }; - - const response = await callMiddleware(middleware, ctx, responseFn); - - assert.equal(await response.text(), 'name=bar'); - }); - - it('middleware can set response headers after calling next()', async () => { - const middleware = async (_context, next) => { - const response = await next(); - response.headers.set('X-Custom', 'value'); - return response; - }; - - const response = await callMiddleware(middleware, ctx, createResponseFunction('OK')); - - assert.equal(response.headers.get('X-Custom'), 'value'); - }); - - it('middleware can clone the response, modify body, and return a new Response', async () => { - const middleware = async (_context, next) => { - const response = await next(); - const cloned = response.clone(); - const html = await cloned.text(); - const modified = html.replace('testing', 'it works'); - return new Response(modified, { status: 200, headers: response.headers }); - }; - - const response = await callMiddleware( - middleware, - ctx, - createResponseFunction('

    testing

    '), - ); - - assert.equal(await response.text(), '

    it works

    '); - }); - - it('middleware can intercept a JSON response, modify it, and return a new Response', async () => { - const middleware = async (_context, next) => { - const response = await next(); - const data = await response.json(); - data.name = 'REDACTED'; - return new Response(JSON.stringify(data), { - headers: { 'Content-Type': 'application/json' }, - }); - }; - - const response = await callMiddleware( - middleware, - ctx, - createResponseFunction(JSON.stringify({ name: 'secret', value: 42 }), { - headers: { 'Content-Type': 'application/json' }, - }), - ); - const body = await response.json(); - - assert.equal(body.name, 'REDACTED'); - assert.equal(body.value, 42); - }); - }); - - describe('synchronous middleware', () => { - it('works with a synchronous middleware that calls next()', async () => { - const middleware = (_context, next) => { - return next(); - }; - - const response = await callMiddleware(middleware, ctx, createResponseFunction('sync OK')); - - assert.equal(await response.text(), 'sync OK'); - }); - - it('works with a synchronous middleware that returns a Response', async () => { - const middleware = () => { - return new Response('sync short-circuit'); - }; - - const response = await callMiddleware(middleware, ctx, defaultResponseFn); - - assert.equal(await response.text(), 'sync short-circuit'); - }); - }); -}); diff --git a/packages/astro/test/units/middleware/call-middleware.test.ts b/packages/astro/test/units/middleware/call-middleware.test.ts new file mode 100644 index 000000000000..9ebb18d81e75 --- /dev/null +++ b/packages/astro/test/units/middleware/call-middleware.test.ts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import { describe, it, beforeEach } from 'node:test'; +import { callMiddleware } from '../../../dist/core/middleware/callMiddleware.js'; +import { createMockAPIContext, createResponseFunction } from '../mocks.ts'; + +import type { APIContext, MiddlewareHandler } from 'astro'; + +describe('callMiddleware', () => { + let ctx: APIContext; + const defaultResponseFn = createResponseFunction(); + + beforeEach(() => { + ctx = createMockAPIContext(); + }); + + describe('next() called', () => { + it('returns the middleware return value when next() is called and a Response is returned', async () => { + const middleware: MiddlewareHandler = async (_ctx, next) => { + const response = await next(); + return new Response('modified', { status: 200, headers: response.headers }); + }; + + const response = await callMiddleware(middleware, ctx, createResponseFunction('original')); + + assert.equal(await response.text(), 'modified'); + }); + + it('returns the responseFunction result when next() is called but middleware returns undefined', async () => { + const middleware: MiddlewareHandler = async (_ctx, next) => { + await next(); + // deliberately returns undefined + }; + + const response = await callMiddleware(middleware, ctx, createResponseFunction('from page')); + + assert.equal(await response.text(), 'from page'); + }); + + it('throws MiddlewareNotAResponse when next() is called but middleware returns a non-Response', async () => { + const middleware: MiddlewareHandler = async (_ctx, next) => { + await next(); + return 'not a response' as unknown as Response; + }; + + await assert.rejects( + () => callMiddleware(middleware, ctx, defaultResponseFn), + (err: Error) => { + assert.equal(err.name, 'MiddlewareNotAResponse'); + return true; + }, + ); + }); + }); + + describe('next() not called', () => { + it('returns the Response when middleware short-circuits without calling next()', async () => { + const middleware: MiddlewareHandler = async () => { + return new Response('short-circuit', { status: 200 }); + }; + + const response = await callMiddleware(middleware, ctx, defaultResponseFn); + + assert.equal(await response.text(), 'short-circuit'); + assert.equal(response.status, 200); + }); + + it('returns a 500 Response when middleware short-circuits with an error status', async () => { + const middleware: MiddlewareHandler = async () => { + return new Response(null, { status: 500 }); + }; + + const response = await callMiddleware(middleware, ctx, defaultResponseFn); + + assert.equal(response.status, 500); + }); + + it('throws MiddlewareNoDataOrNextCalled when middleware returns undefined without calling next()', async () => { + const middleware: MiddlewareHandler = async () => { + // returns undefined, never calls next + }; + + await assert.rejects( + () => callMiddleware(middleware, ctx, defaultResponseFn), + (err: Error) => { + assert.equal(err.name, 'MiddlewareNoDataOrNextCalled'); + return true; + }, + ); + }); + + it('throws MiddlewareNotAResponse when middleware returns a non-Response without calling next()', async () => { + const middleware: MiddlewareHandler = async () => { + return 'not a response' as unknown as Response; + }; + + await assert.rejects( + () => callMiddleware(middleware, ctx, defaultResponseFn), + (err: Error) => { + assert.equal(err.name, 'MiddlewareNotAResponse'); + return true; + }, + ); + }); + }); + + describe('context mutation', () => { + it('locals mutations are visible in the response function', async () => { + const middleware: MiddlewareHandler = async (context, next) => { + (context.locals as Record).name = 'bar'; + return next(); + }; + const responseFn = async (apiCtx: APIContext) => { + return new Response(`name=${(apiCtx.locals as Record).name}`); + }; + + const response = await callMiddleware(middleware, ctx, responseFn); + + assert.equal(await response.text(), 'name=bar'); + }); + + it('middleware can set response headers after calling next()', async () => { + const middleware: MiddlewareHandler = async (_context, next) => { + const response = await next(); + response.headers.set('X-Custom', 'value'); + return response; + }; + + const response = await callMiddleware(middleware, ctx, createResponseFunction('OK')); + + assert.equal(response.headers.get('X-Custom'), 'value'); + }); + + it('middleware can clone the response, modify body, and return a new Response', async () => { + const middleware: MiddlewareHandler = async (_context, next) => { + const response = await next(); + const cloned = response.clone(); + const html = await cloned.text(); + const modified = html.replace('testing', 'it works'); + return new Response(modified, { status: 200, headers: response.headers }); + }; + + const response = await callMiddleware( + middleware, + ctx, + createResponseFunction('

    testing

    '), + ); + + assert.equal(await response.text(), '

    it works

    '); + }); + + it('middleware can intercept a JSON response, modify it, and return a new Response', async () => { + const middleware: MiddlewareHandler = async (_context, next) => { + const response = await next(); + const data = await response.json(); + data.name = 'REDACTED'; + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const response = await callMiddleware( + middleware, + ctx, + createResponseFunction(JSON.stringify({ name: 'secret', value: 42 }), { + headers: { 'Content-Type': 'application/json' }, + }), + ); + const body = await response.json(); + + assert.equal(body.name, 'REDACTED'); + assert.equal(body.value, 42); + }); + }); + + describe('synchronous middleware', () => { + it('works with a synchronous middleware that calls next()', async () => { + const middleware: MiddlewareHandler = (_context, next) => { + return next(); + }; + + const response = await callMiddleware(middleware, ctx, createResponseFunction('sync OK')); + + assert.equal(await response.text(), 'sync OK'); + }); + + it('works with a synchronous middleware that returns a Response', async () => { + const middleware: MiddlewareHandler = () => { + return new Response('sync short-circuit'); + }; + + const response = await callMiddleware(middleware, ctx, defaultResponseFn); + + assert.equal(await response.text(), 'sync short-circuit'); + }); + }); +}); diff --git a/packages/astro/test/units/middleware/locals.test.js b/packages/astro/test/units/middleware/locals.test.js deleted file mode 100644 index eada9afbadbb..000000000000 --- a/packages/astro/test/units/middleware/locals.test.js +++ /dev/null @@ -1,80 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { isLocalsSerializable, trySerializeLocals } from '../../../dist/core/middleware/index.js'; - -describe('isLocalsSerializable', () => { - it('returns true for null', () => { - assert.equal(isLocalsSerializable(null), true); - }); - - it('returns true for string', () => { - assert.equal(isLocalsSerializable('hello'), true); - }); - - it('returns true for number', () => { - assert.equal(isLocalsSerializable(42), true); - }); - - it('returns true for boolean', () => { - assert.equal(isLocalsSerializable(true), true); - assert.equal(isLocalsSerializable(false), true); - }); - - it('returns true for a plain object', () => { - assert.equal(isLocalsSerializable({ a: 1, b: 'two' }), true); - }); - - it('returns true for a nested plain object', () => { - assert.equal(isLocalsSerializable({ a: { b: { c: 3 } } }), true); - }); - - it('returns true for an array', () => { - assert.equal(isLocalsSerializable([1, 'two', null]), true); - }); - - it('returns false for a Date', () => { - assert.equal(isLocalsSerializable(new Date()), false); - }); - - it('returns false for a Map', () => { - assert.equal(isLocalsSerializable(new Map()), false); - }); - - it('returns false for a Set', () => { - assert.equal(isLocalsSerializable(new Set()), false); - }); - - it('returns false for a class instance', () => { - class Foo {} - assert.equal(isLocalsSerializable(new Foo()), false); - }); - - it('returns false for a plain object containing a non-serializable value', () => { - assert.equal(isLocalsSerializable({ date: new Date() }), false); - }); - - it('handles deeply nested objects without stack overflow (iterative implementation)', () => { - // Build a 10,000-level deep object — would overflow the call stack with recursion - let deep = /** @type {any} */ ({}); - let current = deep; - for (let i = 0; i < 10_000; i++) { - current.child = {}; - current = current.child; - } - current.value = 'leaf'; - assert.equal(isLocalsSerializable(deep), true); - }); -}); - -describe('trySerializeLocals', () => { - it('returns a JSON string for a serializable object', () => { - const result = trySerializeLocals({ user: 'alice', count: 3 }); - assert.equal(typeof result, 'string'); - assert.deepEqual(JSON.parse(result), { user: 'alice', count: 3 }); - }); - - it('throws for a non-serializable value', () => { - assert.throws(() => trySerializeLocals({ date: new Date() }), /serialized/i); - }); -}); diff --git a/packages/astro/test/units/middleware/locals.test.ts b/packages/astro/test/units/middleware/locals.test.ts new file mode 100644 index 000000000000..1a896a18f4db --- /dev/null +++ b/packages/astro/test/units/middleware/locals.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isLocalsSerializable, trySerializeLocals } from '../../../dist/core/middleware/index.js'; + +describe('isLocalsSerializable', () => { + it('returns true for null', () => { + assert.equal(isLocalsSerializable(null), true); + }); + + it('returns true for string', () => { + assert.equal(isLocalsSerializable('hello'), true); + }); + + it('returns true for number', () => { + assert.equal(isLocalsSerializable(42), true); + }); + + it('returns true for boolean', () => { + assert.equal(isLocalsSerializable(true), true); + assert.equal(isLocalsSerializable(false), true); + }); + + it('returns true for a plain object', () => { + assert.equal(isLocalsSerializable({ a: 1, b: 'two' }), true); + }); + + it('returns true for a nested plain object', () => { + assert.equal(isLocalsSerializable({ a: { b: { c: 3 } } }), true); + }); + + it('returns true for an array', () => { + assert.equal(isLocalsSerializable([1, 'two', null]), true); + }); + + it('returns false for a Date', () => { + assert.equal(isLocalsSerializable(new Date()), false); + }); + + it('returns false for a Map', () => { + assert.equal(isLocalsSerializable(new Map()), false); + }); + + it('returns false for a Set', () => { + assert.equal(isLocalsSerializable(new Set()), false); + }); + + it('returns false for a class instance', () => { + class Foo {} + assert.equal(isLocalsSerializable(new Foo()), false); + }); + + it('returns false for a plain object containing a non-serializable value', () => { + assert.equal(isLocalsSerializable({ date: new Date() }), false); + }); + + it('handles deeply nested objects without stack overflow (iterative implementation)', () => { + // Build a 10,000-level deep object — would overflow the call stack with recursion + type DeepObject = { child?: DeepObject; value?: string }; + const deep: DeepObject = {}; + let current: DeepObject = deep; + for (let i = 0; i < 10_000; i++) { + current.child = {}; + current = current.child; + } + current.value = 'leaf'; + assert.equal(isLocalsSerializable(deep), true); + }); +}); + +describe('trySerializeLocals', () => { + it('returns a JSON string for a serializable object', () => { + const result = trySerializeLocals({ user: 'alice', count: 3 }); + assert.equal(typeof result, 'string'); + assert.deepEqual(JSON.parse(result), { user: 'alice', count: 3 }); + }); + + it('throws for a non-serializable value', () => { + assert.throws(() => trySerializeLocals({ date: new Date() }), /serialized/i); + }); +}); diff --git a/packages/astro/test/units/middleware/middleware-app.test.js b/packages/astro/test/units/middleware/middleware-app.test.js deleted file mode 100644 index 02b5d968d30e..000000000000 --- a/packages/astro/test/units/middleware/middleware-app.test.js +++ /dev/null @@ -1,914 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { App } from '../../../dist/core/app/app.js'; -import { createComponent, render } from '../../../dist/runtime/server/index.js'; -import { createRouteData } from '../mocks.js'; -import { createManifest } from '../app/test-helpers.js'; - -/** - * Helper: creates an App with the given middleware and routes. - * @param {object} opts - * @param {import('astro').MiddlewareHandler} opts.onRequest - The middleware handler - * @param {Array<{ routeData: any; component?: any }>} opts.routes - Route definitions - * @param {Map} opts.pageMap - Component map - * @param {string} [opts.base] - */ -function createAppWithMiddleware({ onRequest, routes, pageMap, base }) { - const manifest = createManifest({ - routes: routes.map((r) => ({ routeData: r.routeData })), - pageMap, - base, - }); - // Override the middleware field — createManifest sets it to undefined, - // but the pipeline reads it from manifest.middleware - manifest.middleware = () => ({ onRequest }); - return new App(manifest); -} - -// ----- Shared route data ----- - -const indexRouteData = createRouteData({ route: '/' }); -const loremRouteData = createRouteData({ route: '/lorem' }); -const secondRouteData = createRouteData({ route: '/second' }); -const redirectRouteData = createRouteData({ route: '/redirect' }); -const rewriteRouteData = createRouteData({ route: '/rewrite' }); -const adminRouteData = createRouteData({ route: '/admin' }); -const apiRouteData = createRouteData({ route: '/api/endpoint', type: 'endpoint' }); -const throwRouteData = createRouteData({ route: '/throw' }); -const notFoundRouteData = createRouteData({ route: '/404' }); -const serverErrorRouteData = createRouteData({ route: '/500' }); -const spacesRouteData = createRouteData({ - route: '/path with spaces', - pathname: '/path with spaces', -}); - -// ----- Shared page components ----- - -const simplePage = (localKey = 'name') => - createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - return render`

    ${Astro.locals[localKey]}

    `; - }); - -const notFoundPage = createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - return render`Error

    ${Astro.locals.name}

    `; -}); - -const serverErrorPage = createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - return render`500

    ${Astro.locals.name}

    `; -}); - -const throwingPage = createComponent(() => { - throw new Error('page threw an error'); -}); - -const cookiePage = createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - Astro.cookies.set('from-component', 'component-value'); - return render`

    cookies

    `; -}); - -// ----- Tests ----- - -describe('Middleware via App.render()', () => { - describe('locals', () => { - it('should render locals data set by middleware', async () => { - const onRequest = async (ctx, next) => { - ctx.locals.name = 'bar'; - return next(); - }; - const pageMap = new Map([ - [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: indexRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/')); - const html = await response.text(); - - assert.match(html, /

    bar<\/p>/); - }); - - it('should change locals data based on URL', async () => { - const onRequest = async (ctx, next) => { - if (ctx.url.pathname === '/lorem') { - ctx.locals.name = 'ipsum'; - } else { - ctx.locals.name = 'bar'; - } - return next(); - }; - const page = simplePage(); - const pageMap = new Map([ - [indexRouteData.component, async () => ({ page: async () => ({ default: page }) })], - [loremRouteData.component, async () => ({ page: async () => ({ default: page }) })], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: indexRouteData }, { routeData: loremRouteData }], - pageMap, - }); - - const indexRes = await app.render(new Request('http://localhost/')); - assert.match(await indexRes.text(), /

    bar<\/p>/); - - const loremRes = await app.render(new Request('http://localhost/lorem')); - assert.match(await loremRes.text(), /

    ipsum<\/p>/); - }); - }); - - describe('sequence', () => { - it('should call a second middleware in a sequence via manifest', async () => { - // We test sequence by making the manifest middleware itself a sequence. - // sequence() is already tested in sequence.test.js; here we verify it works - // when wired through the App pipeline. - const { sequence } = await import('../../../dist/core/middleware/sequence.js'); - - const first = async (ctx, next) => { - ctx.locals.name = 'first'; - return next(); - }; - const second = async (ctx, next) => { - if (ctx.url.pathname === '/second') { - ctx.locals.name = 'second'; - } - return next(); - }; - const combined = sequence(first, second); - const page = simplePage(); - const pageMap = new Map([ - [indexRouteData.component, async () => ({ page: async () => ({ default: page }) })], - [secondRouteData.component, async () => ({ page: async () => ({ default: page }) })], - ]); - const app = createAppWithMiddleware({ - onRequest: combined, - routes: [{ routeData: indexRouteData }, { routeData: secondRouteData }], - pageMap, - }); - - const indexRes = await app.render(new Request('http://localhost/')); - assert.match(await indexRes.text(), /

    first<\/p>/); - - const secondRes = await app.render(new Request('http://localhost/second')); - assert.match(await secondRes.text(), /

    second<\/p>/); - }); - }); - - describe('short-circuit responses', () => { - it('should successfully create a new response bypassing the page', async () => { - const onRequest = async (ctx, next) => { - if (ctx.url.pathname === '/rewrite') { - return new Response('New content!!', { status: 200 }); - } - return next(); - }; - const pageMap = new Map([ - [ - rewriteRouteData.component, - async () => ({ page: async () => ({ default: simplePage() }) }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: rewriteRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/rewrite')); - - assert.equal(response.status, 200); - assert.equal(await response.text(), 'New content!!'); - }); - - it('should return a new response that is a 500', async () => { - const onRequest = async (ctx, next) => { - if (ctx.url.pathname === '/broken-500') { - return new Response(null, { status: 500 }); - } - return next(); - }; - // We need a route that matches /broken-500 - const brokenRoute = createRouteData({ route: '/broken-500' }); - const pageMap = new Map([ - [brokenRoute.component, async () => ({ page: async () => ({ default: simplePage() }) })], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: brokenRoute }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/broken-500')); - - assert.equal(response.status, 500); - }); - - it('should return 200 if middleware returns a 200 Response for a non-existent route', async () => { - const onRequest = async (ctx, next) => { - if (ctx.url.pathname === '/no-route-but-200') { - return new Response("It's OK!", { status: 200 }); - } - return next(); - }; - // No route matches /no-route-but-200, but middleware short-circuits - const pageMap = new Map([ - [ - notFoundRouteData.component, - async () => ({ page: async () => ({ default: notFoundPage }) }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: notFoundRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/no-route-but-200')); - - assert.equal(response.status, 200); - assert.equal(await response.text(), "It's OK!"); - }); - }); - - describe('pass-through middleware', () => { - it('should render the page normally if middleware only calls next()', async () => { - const onRequest = async (_ctx, next) => { - return next(); - }; - const pageMap = new Map([ - [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: indexRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/'), { - locals: { name: 'passthrough' }, - }); - const html = await response.text(); - - assert.equal(response.status, 200); - assert.match(html, /

    passthrough<\/p>/); - }); - }); - - describe('error handling', () => { - it('should throw when middleware returns undefined without calling next()', async () => { - const onRequest = async () => { - return undefined; - }; - const pageMap = new Map([ - [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: indexRouteData }], - pageMap, - }); - - // In the App pipeline, errors in middleware result in a 500 response - const response = await app.render(new Request('http://localhost/')); - assert.equal(response.status, 500); - }); - - it('should render 500.astro when middleware throws an error', async () => { - const onRequest = async (ctx, next) => { - if (ctx.url.pathname === '/throw') { - throw new Error('middleware error'); - } - return next(); - }; - const pageMap = new Map([ - [throwRouteData.component, async () => ({ page: async () => ({ default: throwingPage }) })], - [ - serverErrorRouteData.component, - async () => ({ page: async () => ({ default: serverErrorPage }) }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: throwRouteData }, { routeData: serverErrorRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/throw')); - - assert.equal(response.status, 500); - }); - }); - - describe('redirect', () => { - it('should successfully redirect to another page', async () => { - const onRequest = async (ctx, next) => { - if (ctx.url.pathname === '/redirect') { - return ctx.redirect('/', 302); - } - return next(); - }; - const pageMap = new Map([ - [ - redirectRouteData.component, - async () => ({ page: async () => ({ default: simplePage() }) }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: redirectRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/redirect')); - - assert.equal(response.status, 302); - assert.equal(response.headers.get('Location'), '/'); - }); - }); - - describe('cookies', () => { - it('should allow middleware to set cookies', async () => { - const onRequest = async (ctx, next) => { - ctx.cookies.set('foo', 'bar'); - return next(); - }; - const pageMap = new Map([ - [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: indexRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/'), { - locals: { name: 'test' }, - addCookieHeader: true, - }); - - const setCookie = response.headers.get('set-cookie'); - assert.ok(setCookie); - assert.match(setCookie, /foo=bar/); - }); - - it('should forward cookies set in a component when middleware returns a new response', async () => { - const onRequest = async (_ctx, next) => { - const response = await next(); - const html = await response.text(); - return new Response(html, { status: 200, headers: response.headers }); - }; - const pageMap = new Map([ - [indexRouteData.component, async () => ({ page: async () => ({ default: cookiePage }) })], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: indexRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/'), { - addCookieHeader: true, - }); - const setCookie = response.headers.get('set-cookie'); - - assert.ok(setCookie); - assert.match(setCookie, /from-component=component-value/); - }); - }); - - describe('response modification', () => { - it('should be able to clone the response and modify it', async () => { - const onRequest = async (_ctx, next) => { - const response = await next(); - const cloned = response.clone(); - const html = await cloned.text(); - const modified = html.replace('testing', 'it works'); - return new Response(modified, { status: 200, headers: response.headers }); - }; - const testPage = createComponent(() => { - return render`

    testing

    `; - }); - const pageMap = new Map([ - [indexRouteData.component, async () => ({ page: async () => ({ default: testPage }) })], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: indexRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/')); - const html = await response.text(); - - assert.match(html, /it works/); - assert.ok(!html.includes('testing')); - }); - }); - - describe('API endpoints', () => { - it('should correctly work for API endpoints that return a Response object', async () => { - const onRequest = async (_ctx, next) => { - return next(); - }; - const pageMap = new Map([ - [ - apiRouteData.component, - async () => ({ - page: async () => ({ - GET: async () => - new Response(JSON.stringify({ name: 'test' }), { - headers: { 'Content-Type': 'application/json' }, - }), - }), - }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: apiRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/api/endpoint')); - - assert.equal(response.status, 200); - assert.equal(response.headers.get('Content-Type'), 'application/json'); - const body = await response.json(); - assert.equal(body.name, 'test'); - }); - - it('should correctly manipulate the response coming from API endpoints', async () => { - const onRequest = async (ctx, next) => { - if (ctx.url.pathname === '/api/endpoint') { - const response = await next(); - const data = await response.json(); - data.name = 'REDACTED'; - return new Response(JSON.stringify(data), { - headers: response.headers, - }); - } - return next(); - }; - const pageMap = new Map([ - [ - apiRouteData.component, - async () => ({ - page: async () => ({ - GET: async () => - new Response(JSON.stringify({ name: 'secret', value: 42 }), { - headers: { 'Content-Type': 'application/json' }, - }), - }), - }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: apiRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/api/endpoint')); - const body = await response.json(); - - assert.equal(body.name, 'REDACTED'); - assert.equal(body.value, 42); - }); - }); - - describe('404 handling', () => { - it('should correctly call middleware for 404 routes', async () => { - const onRequest = async (ctx, next) => { - ctx.locals.name = 'bar'; - return next(); - }; - const pageMap = new Map([ - [ - notFoundRouteData.component, - async () => ({ page: async () => ({ default: notFoundPage }) }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: notFoundRouteData }], - pageMap, - }); - - // Request a URL that doesn't match any route — falls back to 404 - const response = await app.render(new Request('http://localhost/unknown-page')); - - assert.equal(response.status, 404); - const html = await response.text(); - assert.match(html, /bar/); - }); - }); - - describe('path encoding and auth', () => { - /** - * Auth middleware that protects /admin - */ - const authMiddleware = async (ctx, next) => { - if (ctx.url.pathname === '/admin') { - const authToken = ctx.request.headers.get('Authorization'); - if (!authToken) { - return ctx.redirect('/'); - } - } - return next(); - }; - - function createAuthApp() { - const page = simplePage(); - const pageMap = new Map([ - [adminRouteData.component, async () => ({ page: async () => ({ default: page }) })], - [indexRouteData.component, async () => ({ page: async () => ({ default: page }) })], - [ - notFoundRouteData.component, - async () => ({ page: async () => ({ default: notFoundPage }) }), - ], - ]); - return createAppWithMiddleware({ - onRequest: authMiddleware, - routes: [ - { routeData: adminRouteData }, - { routeData: indexRouteData }, - { routeData: notFoundRouteData }, - ], - pageMap, - }); - } - - it('should allow accessing /admin with valid auth header', async () => { - const app = createAuthApp(); - const response = await app.render( - new Request('http://localhost/admin', { - headers: { Authorization: 'Bearer token123' }, - }), - { locals: { name: 'admin-content' } }, - ); - - assert.equal(response.status, 200); - }); - - it('should redirect /admin without auth header', async () => { - const app = createAuthApp(); - const response = await app.render(new Request('http://localhost/admin')); - - assert.equal(response.status, 302); - assert.equal(response.headers.get('Location'), '/'); - }); - - it('should handle requests with spaces in path correctly', async () => { - const onRequest = async (_ctx, next) => { - return next(); - }; - const spacesPage = createComponent(() => { - return render`

    spaces page

    `; - }); - const pageMap = new Map([ - [spacesRouteData.component, async () => ({ page: async () => ({ default: spacesPage }) })], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: spacesRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/path%20with%20spaces')); - - assert.equal(response.status, 200); - }); - }); - - describe('cookies on error pages', () => { - it('should preserve cookies set by middleware when returning Response(null, { status: 404 })', async () => { - // Middleware sets a cookie and returns 404 with null body (common auth guard pattern) - const onRequest = async (ctx, next) => { - ctx.cookies.set('session', 'abc123', { path: '/' }); - if (ctx.url.pathname.startsWith('/api/guarded')) { - return new Response(null, { status: 404 }); - } - return next(); - }; - - const guardedRouteData = createRouteData({ - route: '/api/guarded/[...path]', - pathname: undefined, - segments: undefined, - }); - // Override for spread route - guardedRouteData.params = ['...path']; - guardedRouteData.pattern = /^\/api\/guarded(?:\/(.*))?$/; - guardedRouteData.pathname = undefined; - guardedRouteData.segments = [ - [{ content: 'api', dynamic: false, spread: false }], - [{ content: 'guarded', dynamic: false, spread: false }], - [{ content: '...path', dynamic: true, spread: true }], - ]; - - const pageMap = new Map([ - [ - guardedRouteData.component, - async () => ({ - page: async () => ({ - default: simplePage(), - }), - }), - ], - [ - notFoundRouteData.component, - async () => ({ page: async () => ({ default: notFoundPage }) }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: guardedRouteData }, { routeData: notFoundRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/api/guarded/secret'), { - addCookieHeader: true, - }); - - assert.equal(response.status, 404); - const setCookie = response.headers.get('set-cookie'); - assert.ok(setCookie, 'Expected Set-Cookie header to be present on 404 error page response'); - assert.match(setCookie, /session=abc123/); - }); - - it('should preserve cookies set by middleware when returning Response(null, { status: 500 })', async () => { - const onRequest = async (ctx, next) => { - ctx.cookies.set('csrf', 'token456', { path: '/' }); - if (ctx.url.pathname.startsWith('/api/error')) { - return new Response(null, { status: 500 }); - } - return next(); - }; - - const errorRouteData = createRouteData({ - route: '/api/error/[...path]', - pathname: undefined, - segments: undefined, - }); - errorRouteData.params = ['...path']; - errorRouteData.pattern = /^\/api\/error(?:\/(.*))?$/; - errorRouteData.pathname = undefined; - errorRouteData.segments = [ - [{ content: 'api', dynamic: false, spread: false }], - [{ content: 'error', dynamic: false, spread: false }], - [{ content: '...path', dynamic: true, spread: true }], - ]; - - const pageMap = new Map([ - [ - errorRouteData.component, - async () => ({ - page: async () => ({ - default: simplePage(), - }), - }), - ], - [ - serverErrorRouteData.component, - async () => ({ page: async () => ({ default: serverErrorPage }) }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: errorRouteData }, { routeData: serverErrorRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/api/error/test'), { - addCookieHeader: true, - }); - - assert.equal(response.status, 500); - const setCookie = response.headers.get('set-cookie'); - assert.ok(setCookie, 'Expected Set-Cookie header to be present on 500 error page response'); - assert.match(setCookie, /csrf=token456/); - }); - - it('should preserve multiple cookies from sequenced middleware during error page rerouting', async () => { - const onRequest = async (ctx, next) => { - ctx.cookies.set('session', 'abc123', { path: '/' }); - ctx.cookies.set('csrf', 'token456', { path: '/' }); - if (ctx.url.pathname.startsWith('/api/guarded')) { - ctx.cookies.set('auth_attempt', 'failed', { path: '/' }); - return new Response(null, { status: 404 }); - } - return next(); - }; - - const guardedRouteData = createRouteData({ - route: '/api/guarded/[...path]', - pathname: undefined, - segments: undefined, - }); - guardedRouteData.params = ['...path']; - guardedRouteData.pattern = /^\/api\/guarded(?:\/(.*))?$/; - guardedRouteData.pathname = undefined; - guardedRouteData.segments = [ - [{ content: 'api', dynamic: false, spread: false }], - [{ content: 'guarded', dynamic: false, spread: false }], - [{ content: '...path', dynamic: true, spread: true }], - ]; - - const pageMap = new Map([ - [ - guardedRouteData.component, - async () => ({ - page: async () => ({ - default: simplePage(), - }), - }), - ], - [ - notFoundRouteData.component, - async () => ({ page: async () => ({ default: notFoundPage }) }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: guardedRouteData }, { routeData: notFoundRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/api/guarded/secret'), { - addCookieHeader: true, - }); - - assert.equal(response.status, 404); - const setCookies = response.headers.getSetCookie(); - const cookieValues = setCookies.join(', '); - assert.ok( - cookieValues.includes('session=abc123'), - 'Expected session cookie in Set-Cookie headers', - ); - assert.ok( - cookieValues.includes('csrf=token456'), - 'Expected csrf cookie in Set-Cookie headers', - ); - assert.ok( - cookieValues.includes('auth_attempt=failed'), - 'Expected auth_attempt cookie in Set-Cookie headers', - ); - }); - }); - - describe('framing headers on error pages', () => { - it('should not preserve Content-Length from middleware when rendering 404 error page', async () => { - // Middleware calls next(), then decides to return 404 with a stale Content-Length header. - // On re-render for the error page, middleware passes the response through unchanged. - let callCount = 0; - const onRequest = async (ctx, next) => { - callCount++; - const response = await next(); - if (callCount === 1 && ctx.url.pathname.startsWith('/api/guarded')) { - return new Response(null, { - status: 404, - headers: { 'Content-Length': '999', 'X-Custom': 'keep-me' }, - }); - } - return response; - }; - - const guardedRouteData = createRouteData({ - route: '/api/guarded/[...path]', - pathname: undefined, - segments: undefined, - }); - guardedRouteData.params = ['...path']; - guardedRouteData.pattern = /^\/api\/guarded(?:\/(.*))?$/; - guardedRouteData.pathname = undefined; - guardedRouteData.segments = [ - [{ content: 'api', dynamic: false, spread: false }], - [{ content: 'guarded', dynamic: false, spread: false }], - [{ content: '...path', dynamic: true, spread: true }], - ]; - - const pageMap = new Map([ - [ - guardedRouteData.component, - async () => ({ - page: async () => ({ - default: simplePage(), - }), - }), - ], - [ - notFoundRouteData.component, - async () => ({ page: async () => ({ default: notFoundPage }) }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: guardedRouteData }, { routeData: notFoundRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/api/guarded/secret')); - - assert.equal(response.status, 404); - // Content-Length from middleware's original response must not leak into the error page response - assert.equal( - response.headers.get('Content-Length'), - null, - 'Content-Length from middleware should be stripped during error page merge', - ); - // Non-framing custom headers should still be preserved - assert.equal(response.headers.get('X-Custom'), 'keep-me'); - }); - - it('should not preserve Transfer-Encoding from middleware when rendering 500 error page', async () => { - let callCount = 0; - const onRequest = async (ctx, next) => { - callCount++; - const response = await next(); - if (callCount === 1 && ctx.url.pathname.startsWith('/api/error')) { - return new Response(null, { - status: 500, - headers: { 'Transfer-Encoding': 'chunked', 'X-Error-Source': 'middleware' }, - }); - } - return response; - }; - - const errorRouteData = createRouteData({ - route: '/api/error/[...path]', - pathname: undefined, - segments: undefined, - }); - errorRouteData.params = ['...path']; - errorRouteData.pattern = /^\/api\/error(?:\/(.*))?$/; - errorRouteData.pathname = undefined; - errorRouteData.segments = [ - [{ content: 'api', dynamic: false, spread: false }], - [{ content: 'error', dynamic: false, spread: false }], - [{ content: '...path', dynamic: true, spread: true }], - ]; - - const pageMap = new Map([ - [ - errorRouteData.component, - async () => ({ - page: async () => ({ - default: simplePage(), - }), - }), - ], - [ - serverErrorRouteData.component, - async () => ({ page: async () => ({ default: serverErrorPage }) }), - ], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: errorRouteData }, { routeData: serverErrorRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/api/error/test')); - - assert.equal(response.status, 500); - // Transfer-Encoding from middleware's original response must not leak into the error page response - assert.equal( - response.headers.get('Transfer-Encoding'), - null, - 'Transfer-Encoding from middleware should be stripped during error page merge', - ); - // Non-framing custom headers should still be preserved - assert.equal(response.headers.get('X-Error-Source'), 'middleware'); - }); - }); - - describe('middleware with custom headers', () => { - it('should correctly set custom headers in middleware', async () => { - const onRequest = async (_ctx, next) => { - const response = await next(); - response.headers.set('X-Custom-Header', 'custom-value'); - return response; - }; - const pageMap = new Map([ - [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], - ]); - const app = createAppWithMiddleware({ - onRequest, - routes: [{ routeData: indexRouteData }], - pageMap, - }); - - const response = await app.render(new Request('http://localhost/'), { - locals: { name: 'test' }, - }); - - assert.equal(response.headers.get('X-Custom-Header'), 'custom-value'); - }); - }); -}); diff --git a/packages/astro/test/units/middleware/middleware-app.test.ts b/packages/astro/test/units/middleware/middleware-app.test.ts new file mode 100644 index 000000000000..790498eb3994 --- /dev/null +++ b/packages/astro/test/units/middleware/middleware-app.test.ts @@ -0,0 +1,921 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { App } from '../../../dist/core/app/app.js'; +import { createComponent, render } from '../../../dist/runtime/server/index.js'; +import { createRouteData } from '../mocks.ts'; +import { createManifest } from '../app/test-helpers.ts'; + +import type { MiddlewareHandler } from 'astro'; +import type { RouteData } from '../../../dist/types/public/internal.js'; + +/** + * Helper: creates an App with the given middleware and routes. + */ +function createAppWithMiddleware({ + onRequest, + routes, + pageMap, + base, +}: { + onRequest: MiddlewareHandler; + routes: Array<{ routeData: RouteData; component?: unknown }>; + pageMap: Map Promise>>; + base?: string; +}): App { + const manifest = createManifest({ + routes: routes.map((r) => ({ routeData: r.routeData })) as any, + pageMap: pageMap as any, + base, + }); + // Override the middleware field — createManifest sets it to undefined, + // but the pipeline reads it from manifest.middleware + (manifest as any).middleware = () => ({ onRequest }); + return new App(manifest as any); +} + +// ----- Shared route data ----- + +const indexRouteData = createRouteData({ route: '/' }); +const loremRouteData = createRouteData({ route: '/lorem' }); +const secondRouteData = createRouteData({ route: '/second' }); +const redirectRouteData = createRouteData({ route: '/redirect' }); +const rewriteRouteData = createRouteData({ route: '/rewrite' }); +const adminRouteData = createRouteData({ route: '/admin' }); +const apiRouteData = createRouteData({ route: '/api/endpoint', type: 'endpoint' }); +const throwRouteData = createRouteData({ route: '/throw' }); +const notFoundRouteData = createRouteData({ route: '/404' }); +const serverErrorRouteData = createRouteData({ route: '/500' }); +const spacesRouteData = createRouteData({ + route: '/path with spaces', + pathname: '/path with spaces', +}); + +// ----- Shared page components ----- + +const simplePage = (localKey = 'name') => + createComponent((result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + return render`

    ${Astro.locals[localKey]}

    `; + }); + +const notFoundPage = createComponent((result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + return render`Error

    ${Astro.locals.name}

    `; +}); + +const serverErrorPage = createComponent((result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + return render`500

    ${Astro.locals.name}

    `; +}); + +const throwingPage = createComponent(() => { + throw new Error('page threw an error'); +}); + +const cookiePage = createComponent((result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + Astro.cookies.set('from-component', 'component-value'); + return render`

    cookies

    `; +}); + +// ----- Tests ----- + +describe('Middleware via App.render()', () => { + describe('locals', () => { + it('should render locals data set by middleware', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + (ctx.locals as Record).name = 'bar'; + return next(); + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/')); + const html = await response.text(); + + assert.match(html, /

    bar<\/p>/); + }); + + it('should change locals data based on URL', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + if (ctx.url.pathname === '/lorem') { + (ctx.locals as Record).name = 'ipsum'; + } else { + (ctx.locals as Record).name = 'bar'; + } + return next(); + }; + const page = simplePage(); + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: page }) })], + [loremRouteData.component, async () => ({ page: async () => ({ default: page }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }, { routeData: loremRouteData }], + pageMap, + }); + + const indexRes = await app.render(new Request('http://localhost/')); + assert.match(await indexRes.text(), /

    bar<\/p>/); + + const loremRes = await app.render(new Request('http://localhost/lorem')); + assert.match(await loremRes.text(), /

    ipsum<\/p>/); + }); + }); + + describe('sequence', () => { + it('should call a second middleware in a sequence via manifest', async () => { + // We test sequence by making the manifest middleware itself a sequence. + // sequence() is already tested in sequence.test.ts; here we verify it works + // when wired through the App pipeline. + const { sequence } = await import('../../../dist/core/middleware/sequence.js'); + + const first: MiddlewareHandler = async (ctx, next) => { + (ctx.locals as Record).name = 'first'; + return next(); + }; + const second: MiddlewareHandler = async (ctx, next) => { + if (ctx.url.pathname === '/second') { + (ctx.locals as Record).name = 'second'; + } + return next(); + }; + const combined = sequence(first, second); + const page = simplePage(); + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: page }) })], + [secondRouteData.component, async () => ({ page: async () => ({ default: page }) })], + ]); + const app = createAppWithMiddleware({ + onRequest: combined, + routes: [{ routeData: indexRouteData }, { routeData: secondRouteData }], + pageMap, + }); + + const indexRes = await app.render(new Request('http://localhost/')); + assert.match(await indexRes.text(), /

    first<\/p>/); + + const secondRes = await app.render(new Request('http://localhost/second')); + assert.match(await secondRes.text(), /

    second<\/p>/); + }); + }); + + describe('short-circuit responses', () => { + it('should successfully create a new response bypassing the page', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + if (ctx.url.pathname === '/rewrite') { + return new Response('New content!!', { status: 200 }); + } + return next(); + }; + const pageMap = new Map([ + [ + rewriteRouteData.component, + async () => ({ page: async () => ({ default: simplePage() }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: rewriteRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/rewrite')); + + assert.equal(response.status, 200); + assert.equal(await response.text(), 'New content!!'); + }); + + it('should return a new response that is a 500', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + if (ctx.url.pathname === '/broken-500') { + return new Response(null, { status: 500 }); + } + return next(); + }; + // We need a route that matches /broken-500 + const brokenRoute = createRouteData({ route: '/broken-500' }); + const pageMap = new Map([ + [brokenRoute.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: brokenRoute }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/broken-500')); + + assert.equal(response.status, 500); + }); + + it('should return 200 if middleware returns a 200 Response for a non-existent route', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + if (ctx.url.pathname === '/no-route-but-200') { + return new Response("It's OK!", { status: 200 }); + } + return next(); + }; + // No route matches /no-route-but-200, but middleware short-circuits + const pageMap = new Map([ + [ + notFoundRouteData.component, + async () => ({ page: async () => ({ default: notFoundPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: notFoundRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/no-route-but-200')); + + assert.equal(response.status, 200); + assert.equal(await response.text(), "It's OK!"); + }); + }); + + describe('pass-through middleware', () => { + it('should render the page normally if middleware only calls next()', async () => { + const onRequest: MiddlewareHandler = async (_ctx, next) => { + return next(); + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/'), { + locals: { name: 'passthrough' }, + }); + const html = await response.text(); + + assert.equal(response.status, 200); + assert.match(html, /

    passthrough<\/p>/); + }); + }); + + describe('error handling', () => { + it('should throw when middleware returns undefined without calling next()', async () => { + const onRequest: MiddlewareHandler = async () => { + return undefined as unknown as Response; + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + // In the App pipeline, errors in middleware result in a 500 response + const response = await app.render(new Request('http://localhost/')); + assert.equal(response.status, 500); + }); + + it('should render 500.astro when middleware throws an error', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + if (ctx.url.pathname === '/throw') { + throw new Error('middleware error'); + } + return next(); + }; + const pageMap = new Map([ + [throwRouteData.component, async () => ({ page: async () => ({ default: throwingPage }) })], + [ + serverErrorRouteData.component, + async () => ({ page: async () => ({ default: serverErrorPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: throwRouteData }, { routeData: serverErrorRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/throw')); + + assert.equal(response.status, 500); + }); + }); + + describe('redirect', () => { + it('should successfully redirect to another page', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + if (ctx.url.pathname === '/redirect') { + return ctx.redirect('/', 302); + } + return next(); + }; + const pageMap = new Map([ + [ + redirectRouteData.component, + async () => ({ page: async () => ({ default: simplePage() }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: redirectRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/redirect')); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/'); + }); + }); + + describe('cookies', () => { + it('should allow middleware to set cookies', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + ctx.cookies.set('foo', 'bar'); + return next(); + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/'), { + locals: { name: 'test' }, + addCookieHeader: true, + }); + + const setCookie = response.headers.get('set-cookie'); + assert.ok(setCookie); + assert.match(setCookie, /foo=bar/); + }); + + it('should forward cookies set in a component when middleware returns a new response', async () => { + const onRequest: MiddlewareHandler = async (_ctx, next) => { + const response = await next(); + const html = await response.text(); + return new Response(html, { status: 200, headers: response.headers }); + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: cookiePage }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/'), { + addCookieHeader: true, + }); + const setCookie = response.headers.get('set-cookie'); + + assert.ok(setCookie); + assert.match(setCookie, /from-component=component-value/); + }); + }); + + describe('response modification', () => { + it('should be able to clone the response and modify it', async () => { + const onRequest: MiddlewareHandler = async (_ctx, next) => { + const response = await next(); + const cloned = response.clone(); + const html = await cloned.text(); + const modified = html.replace('testing', 'it works'); + return new Response(modified, { status: 200, headers: response.headers }); + }; + const testPage = createComponent(() => { + return render`

    testing

    `; + }); + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: testPage }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/')); + const html = await response.text(); + + assert.match(html, /it works/); + assert.ok(!html.includes('testing')); + }); + }); + + describe('API endpoints', () => { + it('should correctly work for API endpoints that return a Response object', async () => { + const onRequest: MiddlewareHandler = async (_ctx, next) => { + return next(); + }; + const pageMap = new Map([ + [ + apiRouteData.component, + async () => ({ + page: async () => ({ + GET: async () => + new Response(JSON.stringify({ name: 'test' }), { + headers: { 'Content-Type': 'application/json' }, + }), + }), + }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: apiRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/api/endpoint')); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('Content-Type'), 'application/json'); + const body = await response.json(); + assert.equal(body.name, 'test'); + }); + + it('should correctly manipulate the response coming from API endpoints', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + if (ctx.url.pathname === '/api/endpoint') { + const response = await next(); + const data = await response.json(); + data.name = 'REDACTED'; + return new Response(JSON.stringify(data), { + headers: response.headers, + }); + } + return next(); + }; + const pageMap = new Map([ + [ + apiRouteData.component, + async () => ({ + page: async () => ({ + GET: async () => + new Response(JSON.stringify({ name: 'secret', value: 42 }), { + headers: { 'Content-Type': 'application/json' }, + }), + }), + }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: apiRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/api/endpoint')); + const body = await response.json(); + + assert.equal(body.name, 'REDACTED'); + assert.equal(body.value, 42); + }); + }); + + describe('404 handling', () => { + it('should correctly call middleware for 404 routes', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + (ctx.locals as Record).name = 'bar'; + return next(); + }; + const pageMap = new Map([ + [ + notFoundRouteData.component, + async () => ({ page: async () => ({ default: notFoundPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: notFoundRouteData }], + pageMap, + }); + + // Request a URL that doesn't match any route — falls back to 404 + const response = await app.render(new Request('http://localhost/unknown-page')); + + assert.equal(response.status, 404); + const html = await response.text(); + assert.match(html, /bar/); + }); + }); + + describe('path encoding and auth', () => { + /** + * Auth middleware that protects /admin + */ + const authMiddleware: MiddlewareHandler = async (ctx, next) => { + if (ctx.url.pathname === '/admin') { + const authToken = ctx.request.headers.get('Authorization'); + if (!authToken) { + return ctx.redirect('/'); + } + } + return next(); + }; + + function createAuthApp(): App { + const page = simplePage(); + const pageMap = new Map([ + [adminRouteData.component, async () => ({ page: async () => ({ default: page }) })], + [indexRouteData.component, async () => ({ page: async () => ({ default: page }) })], + [ + notFoundRouteData.component, + async () => ({ page: async () => ({ default: notFoundPage }) }), + ], + ]); + return createAppWithMiddleware({ + onRequest: authMiddleware, + routes: [ + { routeData: adminRouteData }, + { routeData: indexRouteData }, + { routeData: notFoundRouteData }, + ], + pageMap, + }); + } + + it('should allow accessing /admin with valid auth header', async () => { + const app = createAuthApp(); + const response = await app.render( + new Request('http://localhost/admin', { + headers: { Authorization: 'Bearer token123' }, + }), + { locals: { name: 'admin-content' } }, + ); + + assert.equal(response.status, 200); + }); + + it('should redirect /admin without auth header', async () => { + const app = createAuthApp(); + const response = await app.render(new Request('http://localhost/admin')); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/'); + }); + + it('should handle requests with spaces in path correctly', async () => { + const onRequest: MiddlewareHandler = async (_ctx, next) => { + return next(); + }; + const spacesPage = createComponent(() => { + return render`

    spaces page

    `; + }); + const pageMap = new Map([ + [spacesRouteData.component, async () => ({ page: async () => ({ default: spacesPage }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: spacesRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/path%20with%20spaces')); + + assert.equal(response.status, 200); + }); + }); + + describe('cookies on error pages', () => { + it('should preserve cookies set by middleware when returning Response(null, { status: 404 })', async () => { + // Middleware sets a cookie and returns 404 with null body (common auth guard pattern) + const onRequest: MiddlewareHandler = async (ctx, next) => { + ctx.cookies.set('session', 'abc123', { path: '/' }); + if (ctx.url.pathname.startsWith('/api/guarded')) { + return new Response(null, { status: 404 }); + } + return next(); + }; + + const guardedRouteData = createRouteData({ + route: '/api/guarded/[...path]', + pathname: undefined as unknown as string, + segments: undefined as unknown as undefined, + }); + // Override for spread route + guardedRouteData.params = ['...path']; + guardedRouteData.pattern = /^\/api\/guarded(?:\/(.*))?$/; + guardedRouteData.pathname = undefined; + guardedRouteData.segments = [ + [{ content: 'api', dynamic: false, spread: false }], + [{ content: 'guarded', dynamic: false, spread: false }], + [{ content: '...path', dynamic: true, spread: true }], + ]; + + const pageMap = new Map([ + [ + guardedRouteData.component, + async () => ({ + page: async () => ({ + default: simplePage(), + }), + }), + ], + [ + notFoundRouteData.component, + async () => ({ page: async () => ({ default: notFoundPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: guardedRouteData }, { routeData: notFoundRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/api/guarded/secret'), { + addCookieHeader: true, + }); + + assert.equal(response.status, 404); + const setCookie = response.headers.get('set-cookie'); + assert.ok(setCookie, 'Expected Set-Cookie header to be present on 404 error page response'); + assert.match(setCookie, /session=abc123/); + }); + + it('should preserve cookies set by middleware when returning Response(null, { status: 500 })', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + ctx.cookies.set('csrf', 'token456', { path: '/' }); + if (ctx.url.pathname.startsWith('/api/error')) { + return new Response(null, { status: 500 }); + } + return next(); + }; + + const errorRouteData = createRouteData({ + route: '/api/error/[...path]', + pathname: undefined as unknown as string, + segments: undefined as unknown as undefined, + }); + errorRouteData.params = ['...path']; + errorRouteData.pattern = /^\/api\/error(?:\/(.*))?$/; + errorRouteData.pathname = undefined; + errorRouteData.segments = [ + [{ content: 'api', dynamic: false, spread: false }], + [{ content: 'error', dynamic: false, spread: false }], + [{ content: '...path', dynamic: true, spread: true }], + ]; + + const pageMap = new Map([ + [ + errorRouteData.component, + async () => ({ + page: async () => ({ + default: simplePage(), + }), + }), + ], + [ + serverErrorRouteData.component, + async () => ({ page: async () => ({ default: serverErrorPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: errorRouteData }, { routeData: serverErrorRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/api/error/test'), { + addCookieHeader: true, + }); + + assert.equal(response.status, 500); + const setCookie = response.headers.get('set-cookie'); + assert.ok(setCookie, 'Expected Set-Cookie header to be present on 500 error page response'); + assert.match(setCookie, /csrf=token456/); + }); + + it('should preserve multiple cookies from sequenced middleware during error page rerouting', async () => { + const onRequest: MiddlewareHandler = async (ctx, next) => { + ctx.cookies.set('session', 'abc123', { path: '/' }); + ctx.cookies.set('csrf', 'token456', { path: '/' }); + if (ctx.url.pathname.startsWith('/api/guarded')) { + ctx.cookies.set('auth_attempt', 'failed', { path: '/' }); + return new Response(null, { status: 404 }); + } + return next(); + }; + + const guardedRouteData = createRouteData({ + route: '/api/guarded/[...path]', + pathname: undefined as unknown as string, + segments: undefined as unknown as undefined, + }); + guardedRouteData.params = ['...path']; + guardedRouteData.pattern = /^\/api\/guarded(?:\/(.*))?$/; + guardedRouteData.pathname = undefined; + guardedRouteData.segments = [ + [{ content: 'api', dynamic: false, spread: false }], + [{ content: 'guarded', dynamic: false, spread: false }], + [{ content: '...path', dynamic: true, spread: true }], + ]; + + const pageMap = new Map([ + [ + guardedRouteData.component, + async () => ({ + page: async () => ({ + default: simplePage(), + }), + }), + ], + [ + notFoundRouteData.component, + async () => ({ page: async () => ({ default: notFoundPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: guardedRouteData }, { routeData: notFoundRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/api/guarded/secret'), { + addCookieHeader: true, + }); + + assert.equal(response.status, 404); + const setCookies = response.headers.getSetCookie(); + const cookieValues = setCookies.join(', '); + assert.ok( + cookieValues.includes('session=abc123'), + 'Expected session cookie in Set-Cookie headers', + ); + assert.ok( + cookieValues.includes('csrf=token456'), + 'Expected csrf cookie in Set-Cookie headers', + ); + assert.ok( + cookieValues.includes('auth_attempt=failed'), + 'Expected auth_attempt cookie in Set-Cookie headers', + ); + }); + }); + + describe('framing headers on error pages', () => { + it('should not preserve Content-Length from middleware when rendering 404 error page', async () => { + // Middleware calls next(), then decides to return 404 with a stale Content-Length header. + // On re-render for the error page, middleware passes the response through unchanged. + let callCount = 0; + const onRequest: MiddlewareHandler = async (ctx, next) => { + callCount++; + const response = await next(); + if (callCount === 1 && ctx.url.pathname.startsWith('/api/guarded')) { + return new Response(null, { + status: 404, + headers: { 'Content-Length': '999', 'X-Custom': 'keep-me' }, + }); + } + return response; + }; + + const guardedRouteData = createRouteData({ + route: '/api/guarded/[...path]', + pathname: undefined as unknown as string, + segments: undefined as unknown as undefined, + }); + guardedRouteData.params = ['...path']; + guardedRouteData.pattern = /^\/api\/guarded(?:\/(.*))?$/; + guardedRouteData.pathname = undefined; + guardedRouteData.segments = [ + [{ content: 'api', dynamic: false, spread: false }], + [{ content: 'guarded', dynamic: false, spread: false }], + [{ content: '...path', dynamic: true, spread: true }], + ]; + + const pageMap = new Map([ + [ + guardedRouteData.component, + async () => ({ + page: async () => ({ + default: simplePage(), + }), + }), + ], + [ + notFoundRouteData.component, + async () => ({ page: async () => ({ default: notFoundPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: guardedRouteData }, { routeData: notFoundRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/api/guarded/secret')); + + assert.equal(response.status, 404); + // Content-Length from middleware's original response must not leak into the error page response + assert.equal( + response.headers.get('Content-Length'), + null, + 'Content-Length from middleware should be stripped during error page merge', + ); + // Non-framing custom headers should still be preserved + assert.equal(response.headers.get('X-Custom'), 'keep-me'); + }); + + it('should not preserve Transfer-Encoding from middleware when rendering 500 error page', async () => { + let callCount = 0; + const onRequest: MiddlewareHandler = async (ctx, next) => { + callCount++; + const response = await next(); + if (callCount === 1 && ctx.url.pathname.startsWith('/api/error')) { + return new Response(null, { + status: 500, + headers: { 'Transfer-Encoding': 'chunked', 'X-Error-Source': 'middleware' }, + }); + } + return response; + }; + + const errorRouteData = createRouteData({ + route: '/api/error/[...path]', + pathname: undefined as unknown as string, + segments: undefined as unknown as undefined, + }); + errorRouteData.params = ['...path']; + errorRouteData.pattern = /^\/api\/error(?:\/(.*))?$/; + errorRouteData.pathname = undefined; + errorRouteData.segments = [ + [{ content: 'api', dynamic: false, spread: false }], + [{ content: 'error', dynamic: false, spread: false }], + [{ content: '...path', dynamic: true, spread: true }], + ]; + + const pageMap = new Map([ + [ + errorRouteData.component, + async () => ({ + page: async () => ({ + default: simplePage(), + }), + }), + ], + [ + serverErrorRouteData.component, + async () => ({ page: async () => ({ default: serverErrorPage }) }), + ], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: errorRouteData }, { routeData: serverErrorRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/api/error/test')); + + assert.equal(response.status, 500); + // Transfer-Encoding from middleware's original response must not leak into the error page response + assert.equal( + response.headers.get('Transfer-Encoding'), + null, + 'Transfer-Encoding from middleware should be stripped during error page merge', + ); + // Non-framing custom headers should still be preserved + assert.equal(response.headers.get('X-Error-Source'), 'middleware'); + }); + }); + + describe('middleware with custom headers', () => { + it('should correctly set custom headers in middleware', async () => { + const onRequest: MiddlewareHandler = async (_ctx, next) => { + const response = await next(); + response.headers.set('X-Custom-Header', 'custom-value'); + return response; + }; + const pageMap = new Map([ + [indexRouteData.component, async () => ({ page: async () => ({ default: simplePage() }) })], + ]); + const app = createAppWithMiddleware({ + onRequest, + routes: [{ routeData: indexRouteData }], + pageMap, + }); + + const response = await app.render(new Request('http://localhost/'), { + locals: { name: 'test' }, + }); + + assert.equal(response.headers.get('X-Custom-Header'), 'custom-value'); + }); + }); +}); diff --git a/packages/astro/test/units/middleware/sequence.test.js b/packages/astro/test/units/middleware/sequence.test.js deleted file mode 100644 index 452881c1273d..000000000000 --- a/packages/astro/test/units/middleware/sequence.test.js +++ /dev/null @@ -1,191 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { beforeEach, describe, it } from 'node:test'; -import { callMiddleware } from '../../../dist/core/middleware/callMiddleware.js'; -import { sequence } from '../../../dist/core/middleware/sequence.js'; -import { createMockAPIContext, createResponseFunction } from '../mocks.js'; - -describe('sequence', () => { - /** @type {import('astro').APIContext} */ - let globaCtx; - - beforeEach(() => { - globaCtx = createMockAPIContext(); - }); - - it('returns a passthrough middleware when called with no handlers', async () => { - const combined = sequence(); - const responseFn = createResponseFunction('passthrough'); - - const response = await callMiddleware(combined, globaCtx, responseFn); - - assert.equal(await response.text(), 'passthrough'); - }); - - it('works with a single handler', async () => { - const handler = async (ctx, next) => { - ctx.locals.touched = true; - return next(); - }; - const combined = sequence(handler); - const responseFn = createResponseFunction('single'); - - const response = await callMiddleware(combined, globaCtx, responseFn); - - assert.equal(await response.text(), 'single'); - assert.equal(globaCtx.locals.touched, true); - }); - - it('executes handlers in order', async () => { - const order = []; - const handler1 = async (_ctx, next) => { - order.push(1); - return next(); - }; - const handler2 = async (_ctx, next) => { - order.push(2); - return next(); - }; - const handler3 = async (_ctx, next) => { - order.push(3); - return next(); - }; - const combined = sequence(handler1, handler2, handler3); - const responseFn = createResponseFunction(); - - await callMiddleware(combined, globaCtx, responseFn); - - assert.deepEqual(order, [1, 2, 3]); - }); - - it('propagates context mutations across handlers', async () => { - const first = async (ctx, next) => { - ctx.locals.first = 'a'; - return next(); - }; - const second = async (ctx, next) => { - // should see mutation from first - ctx.locals.second = ctx.locals.first + 'b'; - return next(); - }; - const combined = sequence(first, second); - const responseFn = async (apiCtx) => { - return new Response(`${apiCtx.locals.first}-${apiCtx.locals.second}`); - }; - - const response = await callMiddleware(combined, globaCtx, responseFn); - - assert.equal(await response.text(), 'a-ab'); - }); - - it('allows the last handler to modify the response from the page', async () => { - const handler1 = async (_ctx, next) => { - return next(); - }; - const handler2 = async (_ctx, next) => { - const response = await next(); - const text = await response.text(); - return new Response(text.toUpperCase()); - }; - const combined = sequence(handler1, handler2); - const responseFn = createResponseFunction('hello world'); - - const response = await callMiddleware(combined, globaCtx, responseFn); - - assert.equal(await response.text(), 'HELLO WORLD'); - }); - - it('supports mixed sync and async handlers', async () => { - const syncHandler = (_ctx, next) => { - return next(); - }; - const asyncHandler = async (ctx, next) => { - ctx.locals.async = true; - return await next(); - }; - const combined = sequence(syncHandler, asyncHandler); - const responseFn = createResponseFunction('mixed'); - - const response = await callMiddleware(combined, globaCtx, responseFn); - - assert.equal(await response.text(), 'mixed'); - assert.equal(globaCtx.locals.async, true); - }); - - it('filters out falsy handlers', async () => { - const order = []; - const handler1 = async (_ctx, next) => { - order.push(1); - return next(); - }; - const handler2 = async (_ctx, next) => { - order.push(2); - return next(); - }; - const combined = sequence(handler1, null, undefined, handler2); - const responseFn = createResponseFunction(); - - await callMiddleware(combined, globaCtx, responseFn); - - assert.deepEqual(order, [1, 2]); - }); - - it('allows earlier handlers to short-circuit the chain', async () => { - const order = []; - const handler1 = async () => { - order.push(1); - return new Response('short-circuit'); - }; - const handler2 = async (_ctx, next) => { - order.push(2); - return next(); - }; - const combined = sequence(handler1, handler2); - const responseFn = createResponseFunction('should not reach'); - - const response = await callMiddleware(combined, globaCtx, responseFn); - - assert.equal(await response.text(), 'short-circuit'); - assert.deepEqual(order, [1]); // handler2 was never called - }); - - it('accumulates cookies set by multiple handlers', async () => { - const handler1 = async (ctx, next) => { - ctx.cookies.set('cookie1', 'value1'); - return next(); - }; - const handler2 = async (ctx, next) => { - ctx.cookies.set('cookie2', 'value2'); - return next(); - }; - const combined = sequence(handler1, handler2); - const responseFn = createResponseFunction('OK'); - - await callMiddleware(combined, globaCtx, responseFn); - - assert.equal(globaCtx.cookies.get('cookie1')?.value, 'value1'); - assert.equal(globaCtx.cookies.get('cookie2')?.value, 'value2'); - }); - - it('handles a chain where middle handler returns a redirect', async () => { - const handler1 = async (ctx, next) => { - ctx.locals.beforeRedirect = true; - return next(); - }; - const handler2 = async (ctx) => { - return ctx.redirect('/login'); - }; - const handler3 = async (_ctx, next) => { - // should never be called - return next(); - }; - const combined = sequence(handler1, handler2, handler3); - const responseFn = createResponseFunction(); - - const response = await callMiddleware(combined, globaCtx, responseFn); - - assert.equal(response.status, 302); - assert.equal(response.headers.get('Location'), '/login'); - assert.equal(globaCtx.locals.beforeRedirect, true); - }); -}); diff --git a/packages/astro/test/units/middleware/sequence.test.ts b/packages/astro/test/units/middleware/sequence.test.ts new file mode 100644 index 000000000000..58c1e81bbba9 --- /dev/null +++ b/packages/astro/test/units/middleware/sequence.test.ts @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import { callMiddleware } from '../../../dist/core/middleware/callMiddleware.js'; +import { sequence } from '../../../dist/core/middleware/sequence.js'; +import { createMockAPIContext, createResponseFunction } from '../mocks.ts'; + +import type { APIContext, MiddlewareHandler } from 'astro'; + +describe('sequence', () => { + let globaCtx: APIContext; + + beforeEach(() => { + globaCtx = createMockAPIContext(); + }); + + it('returns a passthrough middleware when called with no handlers', async () => { + const combined = sequence(); + const responseFn = createResponseFunction('passthrough'); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'passthrough'); + }); + + it('works with a single handler', async () => { + const handler: MiddlewareHandler = async (ctx, next) => { + (ctx.locals as Record).touched = true; + return next(); + }; + const combined = sequence(handler); + const responseFn = createResponseFunction('single'); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'single'); + assert.equal((globaCtx.locals as Record).touched, true); + }); + + it('executes handlers in order', async () => { + const order: number[] = []; + const handler1: MiddlewareHandler = async (_ctx, next) => { + order.push(1); + return next(); + }; + const handler2: MiddlewareHandler = async (_ctx, next) => { + order.push(2); + return next(); + }; + const handler3: MiddlewareHandler = async (_ctx, next) => { + order.push(3); + return next(); + }; + const combined = sequence(handler1, handler2, handler3); + const responseFn = createResponseFunction(); + + await callMiddleware(combined, globaCtx, responseFn); + + assert.deepEqual(order, [1, 2, 3]); + }); + + it('propagates context mutations across handlers', async () => { + const first: MiddlewareHandler = async (ctx, next) => { + (ctx.locals as Record).first = 'a'; + return next(); + }; + const second: MiddlewareHandler = async (ctx, next) => { + const locals = ctx.locals as Record; + // should see mutation from first + locals.second = (locals.first as string) + 'b'; + return next(); + }; + const combined = sequence(first, second); + const responseFn = async (apiCtx: APIContext) => { + const locals = apiCtx.locals as Record; + return new Response(`${locals.first}-${locals.second}`); + }; + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'a-ab'); + }); + + it('allows the last handler to modify the response from the page', async () => { + const handler1: MiddlewareHandler = async (_ctx, next) => { + return next(); + }; + const handler2: MiddlewareHandler = async (_ctx, next) => { + const response = await next(); + const text = await response.text(); + return new Response(text.toUpperCase()); + }; + const combined = sequence(handler1, handler2); + const responseFn = createResponseFunction('hello world'); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'HELLO WORLD'); + }); + + it('supports mixed sync and async handlers', async () => { + const syncHandler: MiddlewareHandler = (_ctx, next) => { + return next(); + }; + const asyncHandler: MiddlewareHandler = async (ctx, next) => { + (ctx.locals as Record).async = true; + return await next(); + }; + const combined = sequence(syncHandler, asyncHandler); + const responseFn = createResponseFunction('mixed'); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'mixed'); + assert.equal((globaCtx.locals as Record).async, true); + }); + + it('filters out falsy handlers', async () => { + const order: number[] = []; + const handler1: MiddlewareHandler = async (_ctx, next) => { + order.push(1); + return next(); + }; + const handler2: MiddlewareHandler = async (_ctx, next) => { + order.push(2); + return next(); + }; + const combined = sequence(handler1, null as any, undefined as any, handler2); + const responseFn = createResponseFunction(); + + await callMiddleware(combined, globaCtx, responseFn); + + assert.deepEqual(order, [1, 2]); + }); + + it('allows earlier handlers to short-circuit the chain', async () => { + const order: number[] = []; + const handler1: MiddlewareHandler = async () => { + order.push(1); + return new Response('short-circuit'); + }; + const handler2: MiddlewareHandler = async (_ctx, next) => { + order.push(2); + return next(); + }; + const combined = sequence(handler1, handler2); + const responseFn = createResponseFunction('should not reach'); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(await response.text(), 'short-circuit'); + assert.deepEqual(order, [1]); // handler2 was never called + }); + + it('accumulates cookies set by multiple handlers', async () => { + const handler1: MiddlewareHandler = async (ctx, next) => { + ctx.cookies.set('cookie1', 'value1'); + return next(); + }; + const handler2: MiddlewareHandler = async (ctx, next) => { + ctx.cookies.set('cookie2', 'value2'); + return next(); + }; + const combined = sequence(handler1, handler2); + const responseFn = createResponseFunction('OK'); + + await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(globaCtx.cookies.get('cookie1')?.value, 'value1'); + assert.equal(globaCtx.cookies.get('cookie2')?.value, 'value2'); + }); + + it('handles a chain where middle handler returns a redirect', async () => { + const handler1: MiddlewareHandler = async (ctx, next) => { + (ctx.locals as Record).beforeRedirect = true; + return next(); + }; + const handler2: MiddlewareHandler = async (ctx) => { + return ctx.redirect('/login'); + }; + const handler3: MiddlewareHandler = async (_ctx, next) => { + // should never be called + return next(); + }; + const combined = sequence(handler1, handler2, handler3); + const responseFn = createResponseFunction(); + + const response = await callMiddleware(combined, globaCtx, responseFn); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('Location'), '/login'); + assert.equal((globaCtx.locals as Record).beforeRedirect, true); + }); +}); diff --git a/packages/astro/test/units/mocks.js b/packages/astro/test/units/mocks.js deleted file mode 100644 index 8aaf50cfe89a..000000000000 --- a/packages/astro/test/units/mocks.js +++ /dev/null @@ -1,296 +0,0 @@ -import { createBasicPipeline } from './test-utils.js'; -import { makeRoute, staticPart } from './routing/test-helpers.js'; -import { AstroCookies } from '../../dist/core/cookies/index.js'; -import { App } from '../../dist/core/app/app.js'; -import { baseService } from '../../dist/assets/services/service.js'; -import { isRemoteAllowed } from '@astrojs/internal-helpers/remote'; -import { - createComponent, - render, - renderComponent, - spreadAttributes, -} from '../../dist/runtime/server/index.js'; -import { createManifest, createRouteInfo } from './app/test-helpers.js'; - -/** - * Mock utilities for unit tests. - * - * This file contains lightweight mock functions for unit testing Astro internals. - * For integration tests that need full structures, use the test-helpers.js files - * in their respective directories. - */ - -/** - * Creates a minimal RenderContext mock for unit testing redirect functions. - * - * This is a lightweight mock that provides only what renderRedirect() needs, - * without the overhead of creating a full RenderContext instance. - * - * @param {object} overrides - Properties to override - * @param {Request} [overrides.request] - The request object - * @param {object} [overrides.routeData] - Route data including redirect config - * @param {Record} [overrides.params] - Route parameters - * @param {object} [overrides.pipeline] - Pipeline instance - * @returns {object} A mock render context suitable for testing renderRedirect - * - * @example - * const context = createMockRenderContext({ - * request: new Request('http://localhost/source'), - * routeData: { type: 'redirect', redirect: '/target' }, - * params: { slug: 'my-post' } - * }); - */ -export function createMockRenderContext(overrides = {}) { - const pipeline = - overrides.pipeline || - createBasicPipeline({ - manifest: { - rootDir: import.meta.url, - experimentalQueuedRendering: { enabled: true }, - trailingSlash: 'never', - }, - }); - - return { - request: overrides.request || new Request('http://localhost/'), - routeData: overrides.routeData || {}, - params: overrides.params || {}, - pipeline, - ...overrides, - }; -} - -/** - * Creates a mock APIContext suitable for calling middleware directly via `callMiddleware()`. - * - * All fields can be overridden. The `cookies` field uses the real `AstroCookies` class - * by default to avoid mock drift. - * - * @param {Partial & { url?: string | URL }} overrides - * @returns {import('astro').APIContext} - */ -export function createMockAPIContext(overrides = {}) { - const url = - overrides.url instanceof URL ? overrides.url : new URL(overrides.url ?? 'http://localhost/'); - const request = overrides.request ?? new Request(url); - const cookies = overrides.cookies ?? new AstroCookies(request); - - return /** @type {import('astro').APIContext} */ ({ - url, - request, - locals: overrides.locals ?? {}, - params: overrides.params ?? {}, - cookies, - redirect: - overrides.redirect ?? - ((path, status = 302) => new Response(null, { status, headers: { Location: String(path) } })), - rewrite: - overrides.rewrite ?? - (() => { - throw new Error( - 'rewrite() is not mocked -- provide a mock if your middleware uses rewrite', - ); - }), - props: overrides.props ?? {}, - routePattern: overrides.routePattern ?? '', - isPrerendered: overrides.isPrerendered ?? false, - site: overrides.site, - generator: overrides.generator ?? 'astro-test', - clientAddress: overrides.clientAddress ?? '127.0.0.1', - originPathname: overrides.originPathname ?? url.pathname, - }); -} - -/** - * Creates a response function compatible with callMiddleware's third argument. - * This simulates what "rendering the page" would return. - * - * @param {string} body - The response body - * @param {ResponseInit} [init] - Optional response init (status, headers, etc.) - * @returns {(ctx: import('astro').APIContext, payload?: unknown) => Promise} - */ -export function createResponseFunction(body = 'OK', init = {}) { - return async (_ctx, _payload) => new Response(body, init); -} - -/** - * Converts a component + route config into the shape expected by createTestApp. - * - * @param {Function} component - A component created via `createComponent()` - * @param {object} routeConfig - Fields passed to createRouteData() - * @param {string} routeConfig.route - The route pattern (e.g. '/about', '/[slug]') - * @returns {{ routeData: object, module: Function }} - */ -export function createPage(component, routeConfig) { - const routeData = createRouteData(routeConfig); - return { - routeData, - module: async () => ({ page: async () => ({ default: component }) }), - }; -} - -/** - * Creates an App instance with one or more pages. - * - * @param {Array<{ routeData: object, module: Function }>} pages - Pages created via createPage() - * @param {object} [manifestOverrides] - Extra fields passed to createManifest() - * @returns {import('../../dist/core/app/app.js').App} - * - * @example - * const app = createTestApp([ - * createPage(myComponent, { route: '/about' }), - * createPage(indexComponent, { route: '/', isIndex: true }), - * ]); - * const response = await app.render(new Request('http://example.com/about')); - */ -export function createTestApp(pages, manifestOverrides = {}) { - const routes = []; - const pageMap = new Map(); - for (const { routeData, module } of pages) { - routes.push(createRouteInfo(routeData)); - pageMap.set(routeData.component, module); - } - - return new App( - createManifest({ - routes, - pageMap, - ...manifestOverrides, - }), - ); -} - -/** - * Creates a component that spreads all props onto a `` and renders - * `Astro.props.class` as text content. Useful for testing prop forwarding. - * - * Equivalent to: `{Astro.props.class}` - */ -export const spreadPropsSpan = createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - return render`${Astro.props.class ?? ''}`; -}); - -/** - * Creates a page component that renders the given child component once for each - * props object in the array. - * - * @param {Function} childComponent - The component to render - * @param {Record[]} propsArray - Array of props objects - * @returns {Function} A page component - * - * @example - * const page = createMultiChildPage(spreadPropsSpan, [ - * { 'class:list': ['foo', 'bar'] }, - * { style: { color: 'red' } }, - * ]); - * const app = createTestApp([createPage(page, { route: '/test' })]); - */ -export function createMultiChildPage(childComponent, propsArray) { - return createComponent((result) => { - const renders = propsArray.map( - (props) => render`${renderComponent(result, 'Child', childComponent, props)}`, - ); - return render`${renders}`; - }); -} - -/** - * Convenience wrapper around `makeRoute` from routing test-helpers. - * Auto-generates segments from the route string for simple static routes, - * while using the real `getPattern()` for regex generation. - * - * @param {object} overrides - * @param {string} overrides.route - The route pattern (e.g. '/foo', '/api/endpoint') - * @param {'page' | 'endpoint' | 'redirect' | 'fallback'} [overrides.type] - * @param {string} [overrides.component] - * @param {boolean} [overrides.prerender] - * @param {boolean} [overrides.isIndex] - * @param {string} [overrides.pathname] - * @param {import('../../dist/types/public/internal.js').RoutePart[][]} [overrides.segments] - * @param {'always' | 'never' | 'ignore'} [overrides.trailingSlash] - */ -export function createRouteData(overrides) { - const route = overrides.route; - const segments = - overrides.segments ?? - (route === '/' - ? [[]] - : route - .split('/') - .filter(Boolean) - .map((s) => [staticPart(s)])); - - return makeRoute({ - route, - segments, - trailingSlash: overrides.trailingSlash ?? 'ignore', - pathname: overrides.pathname ?? route, - type: overrides.type ?? 'page', - component: overrides.component ?? `src/pages${route === '/' ? '/index' : route}.astro`, - isIndex: overrides.isIndex ?? route === '/', - prerender: overrides.prerender ?? false, - }); -} - -/** - * An image service for unit tests that extends baseService with a getURL - * that doesn't depend on import.meta.env.BASE_URL. - */ -const unitTestImageService = { - ...baseService, - getURL(options, imageConfig) { - const src = typeof options.src === 'string' ? options.src : options.src.src; - // Replicate baseService's allowlist check without import.meta.env.BASE_URL - if (typeof options.src === 'string' && !isRemoteAllowed(options.src, imageConfig)) { - return options.src; - } - const params = new URLSearchParams(); - params.set('href', src); - if (options.width) params.set('w', String(options.width)); - if (options.height) params.set('h', String(options.height)); - if (options.format) params.set('f', options.format); - if (options.fit) params.set('fit', options.fit); - if (options.position) params.set('pos', options.position); - return '/_image?' + params.toString(); - }, -}; - -/** - * Installs the unit test image service on globalThis so that getImage() - * can resolve it without the virtual:image-service Vite module. - * Returns the imageConfig object to pass to getImage(), and a cleanup function. - * - * Use the cleanup function inside the after testing hook. - * - * @param {object} [overrides] - * @param {string[]} [overrides.domains] - * @param {object[]} [overrides.remotePatterns] - * @returns {{ imageConfig: object, cleanup: () => void }} - */ -export function installImageService(overrides = {}) { - globalThis.astroAsset = { imageService: unitTestImageService }; - - const imageConfig = { - service: { entrypoint: 'test', config: {} }, - domains: overrides.domains ?? [], - remotePatterns: overrides.remotePatterns ?? [], - endpoint: { route: '/_image' }, - }; - - return { - imageConfig, - cleanup() { - globalThis.astroAsset = undefined; - }, - }; -} - -/** - * Creates a small Astro source component with an empty frontmatter - * @param html - * @returns {string} - */ -export function createMockAstroSource(html) { - return `---\n---\n${html}`; -} diff --git a/packages/astro/test/units/mocks.ts b/packages/astro/test/units/mocks.ts new file mode 100644 index 000000000000..870e022a2a5b --- /dev/null +++ b/packages/astro/test/units/mocks.ts @@ -0,0 +1,299 @@ +import { createBasicPipeline } from './test-utils.ts'; +import { makeRoute, staticPart } from './routing/test-helpers.ts'; +import { AstroCookies } from '../../dist/core/cookies/index.js'; +import { App } from '../../dist/core/app/app.js'; +import { baseService } from '../../dist/assets/services/service.js'; +import { isRemoteAllowed } from '@astrojs/internal-helpers/remote'; +import { + createComponent, + render, + renderComponent, + spreadAttributes, +} from '../../dist/runtime/server/index.js'; +import { createManifest, createRouteInfo } from './app/test-helpers.ts'; + +import type { Pipeline } from '../../dist/core/render/index.js'; +import type { RouteData, RoutePart, RouteType } from '../../dist/types/public/internal.js'; +import type { APIContext } from '../../dist/types/public/context.js'; +import type { SSRManifest, RouteInfo } from '../../dist/core/app/types.js'; +import type { AstroComponentFactory } from '../../dist/runtime/server/render/index.js'; +import type { ImageTransform } from '../../dist/assets/types.js'; + +/** + * Mock utilities for unit tests. + * + * This file contains lightweight mock functions for unit testing Astro internals. + * For integration tests that need full structures, use the test-helpers.js files + * in their respective directories. + */ + +interface MockRenderContextOverrides { + request?: Request; + routeData?: Partial; + params?: Record; + pipeline?: Pipeline; + [key: string]: unknown; +} + +/** + * Creates a minimal RenderContext mock for unit testing redirect functions. + * + * This is a lightweight mock that provides only what renderRedirect() needs, + * without the overhead of creating a full RenderContext instance. + */ +export function createMockRenderContext(overrides: MockRenderContextOverrides = {}) { + const pipeline = + overrides.pipeline || + createBasicPipeline({ + manifest: { + rootDir: new URL(import.meta.url), + experimentalQueuedRendering: { enabled: true }, + trailingSlash: 'never', + } as unknown as SSRManifest, + }); + + return { + request: overrides.request || new Request('http://localhost/'), + routeData: overrides.routeData || {}, + params: overrides.params || {}, + pipeline, + ...overrides, + }; +} + +interface MockAPIContextOverrides extends Partial> { + url?: string | URL; +} + +/** + * Creates a mock APIContext suitable for calling middleware directly via `callMiddleware()`. + * + * All fields can be overridden. The `cookies` field uses the real `AstroCookies` class + * by default to avoid mock drift. + */ +export function createMockAPIContext(overrides: MockAPIContextOverrides = {}): APIContext { + const url = + overrides.url instanceof URL ? overrides.url : new URL(overrides.url ?? 'http://localhost/'); + const request = overrides.request ?? new Request(url); + const cookies = overrides.cookies ?? new AstroCookies(request); + + return { + url, + request, + locals: overrides.locals ?? {}, + params: overrides.params ?? {}, + cookies, + redirect: + overrides.redirect ?? + ((path, status = 302) => new Response(null, { status, headers: { Location: String(path) } })), + rewrite: + overrides.rewrite ?? + (() => { + throw new Error( + 'rewrite() is not mocked -- provide a mock if your middleware uses rewrite', + ); + }), + props: overrides.props ?? {}, + routePattern: overrides.routePattern ?? '', + isPrerendered: overrides.isPrerendered ?? false, + site: overrides.site, + generator: overrides.generator ?? 'astro-test', + clientAddress: overrides.clientAddress ?? '127.0.0.1', + originPathname: overrides.originPathname ?? url.pathname, + } as APIContext; +} + +/** + * Creates a response function compatible with callMiddleware's third argument. + * This simulates what "rendering the page" would return. + */ +export function createResponseFunction( + body = 'OK', + init: ResponseInit = {}, +): (_ctx: APIContext, _payload?: unknown) => Promise { + return async (_ctx, _payload) => new Response(body, init); +} + +interface PageResult { + routeData: RouteData; + module: () => Promise<{ page: () => Promise<{ default: AstroComponentFactory }> }>; +} + +/** + * Converts a component + route config into the shape expected by createTestApp. + */ +export function createPage( + component: AstroComponentFactory, + routeConfig: CreateRouteDataOptions, +): PageResult { + const routeData = createRouteData(routeConfig); + return { + routeData, + module: async () => ({ page: async () => ({ default: component }) }), + }; +} + +/** + * Creates an App instance with one or more pages. + */ +export function createTestApp( + pages: PageResult[], + manifestOverrides: Record = {}, +): App { + const routes: RouteInfo[] = []; + const pageMap = new Map Promise>>(); + for (const { routeData, module } of pages) { + routes.push(createRouteInfo(routeData) as RouteInfo); + pageMap.set(routeData.component, module); + } + + const manifest = createManifest({ + routes, + pageMap: pageMap as unknown as ReturnType['pageMap'], + ...manifestOverrides, + }); + + return new App(manifest as unknown as SSRManifest); +} + +/** + * Creates a component that spreads all props onto a `` and renders + * `Astro.props.class` as text content. Useful for testing prop forwarding. + * + * Equivalent to: `{Astro.props.class}` + */ +export const spreadPropsSpan: AstroComponentFactory = createComponent( + (result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + return render`${Astro.props.class ?? ''}`; + }, +); + +/** + * Creates a page component that renders the given child component once for each + * props object in the array. + */ +export function createMultiChildPage( + childComponent: AstroComponentFactory, + propsArray: Record[], +): AstroComponentFactory { + return createComponent((result: any) => { + const renders = propsArray.map( + (props) => render`${renderComponent(result, 'Child', childComponent, props)}`, + ); + return render`${renders}`; + }); +} + +interface CreateRouteDataOptions { + route: string; + type?: RouteType; + component?: string; + prerender?: boolean; + isIndex?: boolean; + pathname?: string; + segments?: RoutePart[][]; + trailingSlash?: 'always' | 'never' | 'ignore'; +} + +/** + * Convenience wrapper around `makeRoute` from routing test-helpers. + * Auto-generates segments from the route string for simple static routes, + * while using the real `getPattern()` for regex generation. + */ +export function createRouteData(overrides: CreateRouteDataOptions): RouteData { + const route = overrides.route; + const segments: RoutePart[][] = + overrides.segments ?? + (route === '/' + ? [[]] + : route + .split('/') + .filter(Boolean) + .map((s) => [staticPart(s)])); + + return makeRoute({ + route, + segments, + trailingSlash: overrides.trailingSlash ?? 'ignore', + pathname: overrides.pathname ?? route, + type: overrides.type ?? 'page', + component: overrides.component ?? `src/pages${route === '/' ? '/index' : route}.astro`, + isIndex: overrides.isIndex ?? route === '/', + prerender: overrides.prerender ?? false, + }); +} + +/** + * An image service for unit tests that extends baseService with a getURL + * that doesn't depend on import.meta.env.BASE_URL. + */ +const unitTestImageService = { + ...baseService, + getURL( + options: ImageTransform, + imageConfig: { + domains: string[]; + remotePatterns: { hostname?: string; pathname?: string; protocol?: string; port?: string }[]; + }, + ) { + const src = typeof options.src === 'string' ? options.src : options.src.src; + // Replicate baseService's allowlist check without import.meta.env.BASE_URL + if (typeof options.src === 'string' && !isRemoteAllowed(options.src, imageConfig)) { + return options.src; + } + const params = new URLSearchParams(); + params.set('href', src); + if (options.width) params.set('w', String(options.width)); + if (options.height) params.set('h', String(options.height)); + if (options.format) params.set('f', options.format); + if (options.fit) params.set('fit', options.fit); + if (options.position) params.set('pos', options.position); + return '/_image?' + params.toString(); + }, +}; + +interface ImageServiceOverrides { + domains?: string[]; + remotePatterns?: { hostname?: string; pathname?: string; protocol?: string; port?: string }[]; +} + +/** + * Installs the unit test image service on globalThis so that getImage() + * can resolve it without the virtual:image-service Vite module. + * Returns the imageConfig object to pass to getImage(), and a cleanup function. + * + * Use the cleanup function inside the after testing hook. + */ +export function installImageService(overrides: ImageServiceOverrides = {}): { + imageConfig: { + service: { entrypoint: string; config: Record }; + domains: string[]; + remotePatterns: { hostname?: string; pathname?: string; protocol?: string; port?: string }[]; + endpoint: { route: string }; + }; + cleanup: () => void; +} { + (globalThis as any).astroAsset = { imageService: unitTestImageService }; + + const imageConfig = { + service: { entrypoint: 'test', config: {} as Record }, + domains: overrides.domains ?? [], + remotePatterns: overrides.remotePatterns ?? [], + endpoint: { route: '/_image' }, + }; + + return { + imageConfig, + cleanup() { + (globalThis as any).astroAsset = undefined; + }, + }; +} + +/** + * Creates a small Astro source component with an empty frontmatter + */ +export function createMockAstroSource(html: string): string { + return `---\n---\n${html}`; +} diff --git a/packages/astro/test/units/preferences/dlv.test.ts b/packages/astro/test/units/preferences/dlv.test.ts new file mode 100644 index 000000000000..508199d50873 --- /dev/null +++ b/packages/astro/test/units/preferences/dlv.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import dlv from '../../../dist/preferences/dlv.js'; + +describe('dlv', () => { + it('returns correct value', () => { + const data = { a: { b: { c: 42 } } }; + assert.equal(dlv(data, 'a.b.c'), 42); + }); + + it('returns undefined for missing keys', () => { + const data = { a: { b: 2 } }; + assert.equal(dlv(data, 'a.c'), undefined); + }); + + it('returns undefined for missing keys in the middle of the path', () => { + const data = { a: { b: 2 } }; + assert.equal(dlv(data, 'a.c.z'), undefined); + }); +}); diff --git a/packages/astro/test/units/redirects/open-redirect.test.js b/packages/astro/test/units/redirects/open-redirect.test.ts similarity index 100% rename from packages/astro/test/units/redirects/open-redirect.test.js rename to packages/astro/test/units/redirects/open-redirect.test.ts diff --git a/packages/astro/test/units/redirects/render.test.js b/packages/astro/test/units/redirects/render.test.js deleted file mode 100644 index 0b2785b4d4b6..000000000000 --- a/packages/astro/test/units/redirects/render.test.js +++ /dev/null @@ -1,263 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - computeRedirectStatus, - redirectIsExternal, - renderRedirect, - resolveRedirectTarget, -} from '../../../dist/core/redirects/render.js'; -import { createMockRenderContext } from '../mocks.js'; - -describe('redirects/render', () => { - describe('redirectIsExternal', () => { - it('returns true for http:// URLs', () => { - assert.equal(redirectIsExternal('http://example.com'), true); - assert.equal(redirectIsExternal('http://example.com/path'), true); - }); - - it('returns true for https:// URLs', () => { - assert.equal(redirectIsExternal('https://example.com'), true); - assert.equal(redirectIsExternal('https://example.com/path'), true); - }); - - it('returns false for relative URLs', () => { - assert.equal(redirectIsExternal('/path'), false); - assert.equal(redirectIsExternal('./path'), false); - assert.equal(redirectIsExternal('../path'), false); - assert.equal(redirectIsExternal('path'), false); - }); - - it('handles redirect objects with external destinations', () => { - assert.equal(redirectIsExternal({ destination: 'https://example.com', status: 301 }), true); - assert.equal(redirectIsExternal({ destination: 'http://example.com', status: 302 }), true); - }); - - it('handles redirect objects with relative destinations', () => { - assert.equal(redirectIsExternal({ destination: '/path', status: 301 }), false); - assert.equal(redirectIsExternal({ destination: './path', status: 302 }), false); - }); - }); - - describe('renderRedirect', () => { - it('returns 301 for GET requests', async () => { - const renderContext = createMockRenderContext({ - request: new Request('http://localhost/source'), - routeData: { - type: 'redirect', - redirect: '/target', - }, - }); - - const response = await renderRedirect(renderContext); - - assert.equal(response.status, 301); - assert.equal(response.headers.get('location'), '/target'); - }); - - it('returns 308 for non-GET requests', async () => { - const renderContext = createMockRenderContext({ - request: new Request('http://localhost/source', { method: 'POST' }), - routeData: { - type: 'redirect', - redirect: '/target', - }, - }); - - const response = await renderRedirect(renderContext); - - assert.equal(response.status, 308); - assert.equal(response.headers.get('location'), '/target'); - }); - - it('handles redirect object with custom status', async () => { - const renderContext = createMockRenderContext({ - routeData: { - type: 'redirect', - redirect: { destination: '/target', status: 302 }, - redirectRoute: { - segments: [[{ content: 'target', dynamic: false, spread: false }]], - }, - }, - }); - - const response = await renderRedirect(renderContext); - - assert.equal(response.status, 302); - }); - - it('encodes URIs properly', async () => { - const renderContext = createMockRenderContext({ - routeData: { - type: 'redirect', - redirect: '/target with spaces', - }, - }); - - const response = await renderRedirect(renderContext); - - assert.equal(response.headers.get('location'), '/target%20with%20spaces'); - }); - - it('handles external redirects', async () => { - const renderContext = createMockRenderContext({ - routeData: { - type: 'redirect', - redirect: 'https://example.com', - }, - }); - - const response = await renderRedirect(renderContext); - - assert.equal(response.status, 301); - // External redirects use Response.redirect which sets the Location header differently - assert.equal(response.headers.get('location'), 'https://example.com/'); - }); - - it('substitutes single dynamic parameter', async () => { - const renderContext = createMockRenderContext({ - routeData: { - type: 'redirect', - redirect: '/articles/[slug]', - }, - params: { slug: 'my-post' }, - }); - - const response = await renderRedirect(renderContext); - - assert.equal(response.headers.get('location'), '/articles/my-post'); - }); - - it('substitutes multiple dynamic parameters', async () => { - const renderContext = createMockRenderContext({ - routeData: { - type: 'redirect', - redirect: '/new/[param1]/[param2]', - }, - params: { param1: 'foo', param2: 'bar' }, - }); - - const response = await renderRedirect(renderContext); - - assert.equal(response.headers.get('location'), '/new/foo/bar'); - }); - - it('substitutes spread parameters', async () => { - const renderContext = createMockRenderContext({ - routeData: { - type: 'redirect', - redirect: '/new/[...rest]', - }, - params: { rest: 'a/b/c' }, - }); - - const response = await renderRedirect(renderContext); - - assert.equal(response.headers.get('location'), '/new/a/b/c'); - }); - - it('encodes special characters in parameters', async () => { - const renderContext = createMockRenderContext({ - routeData: { - type: 'redirect', - redirect: '/new/[city]', - }, - params: { city: 'Las Vegas\u2019' }, - }); - - const response = await renderRedirect(renderContext); - - assert.equal(response.headers.get('location'), '/new/Las%20Vegas%E2%80%99'); - }); - - it('uses redirectRoute when available', async () => { - const renderContext = createMockRenderContext({ - routeData: { - type: 'redirect', - redirect: '/not-used', - redirectRoute: { - segments: [[{ content: 'target', dynamic: false, spread: false }]], - pathname: '/target', - }, - }, - }); - - const response = await renderRedirect(renderContext); - - assert.equal(response.headers.get('location'), '/target'); - }); - - it('falls back to "/" when no redirect is defined', async () => { - const renderContext = createMockRenderContext({ - routeData: { - type: 'redirect', - redirect: undefined, - }, - }); - - const response = await renderRedirect(renderContext); - - assert.equal(response.headers.get('location'), '/'); - }); - }); -}); - -describe('computeRedirectStatus', () => { - it('returns 301 for GET without an explicit status', () => { - assert.equal(computeRedirectStatus('GET', '/destination', undefined), 301); - }); - - it('returns 308 for POST without an explicit status', () => { - assert.equal(computeRedirectStatus('POST', '/destination', undefined), 308); - }); - - it('returns the explicit status when redirectRoute is defined and redirect is an object', () => { - const redirectRoute = /** @type {any} */ ({}); - assert.equal( - computeRedirectStatus('GET', { status: 302, destination: '/dest' }, redirectRoute), - 302, - ); - }); - - it('falls back to method-based status when redirect is a string even with redirectRoute', () => { - const redirectRoute = /** @type {any} */ ({}); - assert.equal(computeRedirectStatus('POST', '/dest', redirectRoute), 308); - }); -}); - -describe('resolveRedirectTarget', () => { - it('substitutes a simple param into the redirect string', () => { - assert.equal( - resolveRedirectTarget({ slug: 'hello' }, '/[slug]/page', undefined, 'ignore'), - '/hello/page', - ); - }); - - it('substitutes a spread param', () => { - assert.equal( - resolveRedirectTarget({ rest: 'a/b/c' }, '/[...rest]', undefined, 'ignore'), - '/a/b/c', - ); - }); - - it('returns the string as-is when there are no params', () => { - assert.equal(resolveRedirectTarget({}, '/destination', undefined, 'ignore'), '/destination'); - }); - - it('returns / when redirect is undefined', () => { - assert.equal(resolveRedirectTarget({}, undefined, undefined, 'ignore'), '/'); - }); - - it('returns the destination from an object redirect', () => { - assert.equal( - resolveRedirectTarget({}, { status: 301, destination: '/new' }, undefined, 'ignore'), - '/new', - ); - }); - - it('returns an external URL unchanged', () => { - assert.equal( - resolveRedirectTarget({}, 'https://example.com/page', undefined, 'ignore'), - 'https://example.com/page', - ); - }); -}); diff --git a/packages/astro/test/units/redirects/render.test.ts b/packages/astro/test/units/redirects/render.test.ts new file mode 100644 index 000000000000..6ef6eff75c75 --- /dev/null +++ b/packages/astro/test/units/redirects/render.test.ts @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + computeRedirectStatus, + redirectIsExternal, + renderRedirect, + resolveRedirectTarget, +} from '../../../dist/core/redirects/render.js'; +import { createMockRenderContext } from '../mocks.ts'; + +import type { RenderContext } from '../../../dist/core/render-context.js'; +import type { RouteData } from '../../../dist/types/public/internal.js'; + +describe('redirects/render', () => { + describe('redirectIsExternal', () => { + it('returns true for http:// URLs', () => { + assert.equal(redirectIsExternal('http://example.com'), true); + assert.equal(redirectIsExternal('http://example.com/path'), true); + }); + + it('returns true for https:// URLs', () => { + assert.equal(redirectIsExternal('https://example.com'), true); + assert.equal(redirectIsExternal('https://example.com/path'), true); + }); + + it('returns false for relative URLs', () => { + assert.equal(redirectIsExternal('/path'), false); + assert.equal(redirectIsExternal('./path'), false); + assert.equal(redirectIsExternal('../path'), false); + assert.equal(redirectIsExternal('path'), false); + }); + + it('handles redirect objects with external destinations', () => { + assert.equal(redirectIsExternal({ destination: 'https://example.com', status: 301 }), true); + assert.equal(redirectIsExternal({ destination: 'http://example.com', status: 302 }), true); + }); + + it('handles redirect objects with relative destinations', () => { + assert.equal(redirectIsExternal({ destination: '/path', status: 301 }), false); + assert.equal(redirectIsExternal({ destination: './path', status: 302 }), false); + }); + }); + + describe('renderRedirect', () => { + it('returns 301 for GET requests', async () => { + const renderContext = createMockRenderContext({ + request: new Request('http://localhost/source'), + routeData: { + type: 'redirect', + redirect: '/target', + }, + }); + + const response = await renderRedirect(renderContext as unknown as RenderContext); + + assert.equal(response.status, 301); + assert.equal(response.headers.get('location'), '/target'); + }); + + it('returns 308 for non-GET requests', async () => { + const renderContext = createMockRenderContext({ + request: new Request('http://localhost/source', { method: 'POST' }), + routeData: { + type: 'redirect', + redirect: '/target', + }, + }); + + const response = await renderRedirect(renderContext as unknown as RenderContext); + + assert.equal(response.status, 308); + assert.equal(response.headers.get('location'), '/target'); + }); + + it('handles redirect object with custom status', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: { destination: '/target', status: 302 }, + redirectRoute: { + segments: [[{ content: 'target', dynamic: false, spread: false }]], + } as unknown as RouteData, + }, + }); + + const response = await renderRedirect(renderContext as unknown as RenderContext); + + assert.equal(response.status, 302); + }); + + it('encodes URIs properly', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/target with spaces', + }, + }); + + const response = await renderRedirect(renderContext as unknown as RenderContext); + + assert.equal(response.headers.get('location'), '/target%20with%20spaces'); + }); + + it('handles external redirects', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: 'https://example.com', + }, + }); + + const response = await renderRedirect(renderContext as unknown as RenderContext); + + assert.equal(response.status, 301); + // External redirects use Response.redirect which sets the Location header differently + assert.equal(response.headers.get('location'), 'https://example.com/'); + }); + + it('substitutes single dynamic parameter', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/articles/[slug]', + }, + params: { slug: 'my-post' }, + }); + + const response = await renderRedirect(renderContext as unknown as RenderContext); + + assert.equal(response.headers.get('location'), '/articles/my-post'); + }); + + it('substitutes multiple dynamic parameters', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/new/[param1]/[param2]', + }, + params: { param1: 'foo', param2: 'bar' }, + }); + + const response = await renderRedirect(renderContext as unknown as RenderContext); + + assert.equal(response.headers.get('location'), '/new/foo/bar'); + }); + + it('substitutes spread parameters', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/new/[...rest]', + }, + params: { rest: 'a/b/c' }, + }); + + const response = await renderRedirect(renderContext as unknown as RenderContext); + + assert.equal(response.headers.get('location'), '/new/a/b/c'); + }); + + it('encodes special characters in parameters', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/new/[city]', + }, + params: { city: 'Las Vegas\u2019' }, + }); + + const response = await renderRedirect(renderContext as unknown as RenderContext); + + assert.equal(response.headers.get('location'), '/new/Las%20Vegas%E2%80%99'); + }); + + it('uses redirectRoute when available', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: '/not-used', + redirectRoute: { + segments: [[{ content: 'target', dynamic: false, spread: false }]], + pathname: '/target', + } as unknown as RouteData, + }, + }); + + const response = await renderRedirect(renderContext as unknown as RenderContext); + + assert.equal(response.headers.get('location'), '/target'); + }); + + it('falls back to "/" when no redirect is defined', async () => { + const renderContext = createMockRenderContext({ + routeData: { + type: 'redirect', + redirect: undefined, + }, + }); + + const response = await renderRedirect(renderContext as unknown as RenderContext); + + assert.equal(response.headers.get('location'), '/'); + }); + }); +}); + +describe('computeRedirectStatus', () => { + it('returns 301 for GET without an explicit status', () => { + assert.equal(computeRedirectStatus('GET', '/destination', undefined), 301); + }); + + it('returns 308 for POST without an explicit status', () => { + assert.equal(computeRedirectStatus('POST', '/destination', undefined), 308); + }); + + it('returns the explicit status when redirectRoute is defined and redirect is an object', () => { + const redirectRoute = {} as RouteData; + assert.equal( + computeRedirectStatus('GET', { status: 302, destination: '/dest' }, redirectRoute), + 302, + ); + }); + + it('falls back to method-based status when redirect is a string even with redirectRoute', () => { + const redirectRoute = {} as RouteData; + assert.equal(computeRedirectStatus('POST', '/dest', redirectRoute), 308); + }); +}); + +describe('resolveRedirectTarget', () => { + it('substitutes a simple param into the redirect string', () => { + assert.equal( + resolveRedirectTarget({ slug: 'hello' }, '/[slug]/page', undefined, 'ignore'), + '/hello/page', + ); + }); + + it('substitutes a spread param', () => { + assert.equal( + resolveRedirectTarget({ rest: 'a/b/c' }, '/[...rest]', undefined, 'ignore'), + '/a/b/c', + ); + }); + + it('returns the string as-is when there are no params', () => { + assert.equal(resolveRedirectTarget({}, '/destination', undefined, 'ignore'), '/destination'); + }); + + it('returns / when redirect is undefined', () => { + assert.equal(resolveRedirectTarget({}, undefined, undefined, 'ignore'), '/'); + }); + + it('returns the destination from an object redirect', () => { + assert.equal( + resolveRedirectTarget({}, { status: 301, destination: '/new' }, undefined, 'ignore'), + '/new', + ); + }); + + it('returns an external URL unchanged', () => { + assert.equal( + resolveRedirectTarget({}, 'https://example.com/page', undefined, 'ignore'), + 'https://example.com/page', + ); + }); +}); diff --git a/packages/astro/test/units/redirects/static-build.test.js b/packages/astro/test/units/redirects/static-build.test.js deleted file mode 100644 index 9e00e1848c07..000000000000 --- a/packages/astro/test/units/redirects/static-build.test.js +++ /dev/null @@ -1,381 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { renderPath } from '../../../dist/core/build/generate.js'; -import { createMockPrerenderer, createStaticBuildOptions } from '../build/test-helpers.js'; -import { createTestApp, createPage } from '../mocks.js'; -import { createComponent, render, renderComponent } from '../../../dist/runtime/server/index.js'; - -// Minimal target page for redirect destination routes -const TARGET_PAGE = '---\n---\n

    Target

    '; - -describe('static redirects — meta refresh output', () => { - let options; - - before(async () => { - options = await createStaticBuildOptions({ - pages: { - 'src/pages/test.astro': TARGET_PAGE, - 'src/pages/articles/[...slug].astro': - '---\nexport function getStaticPaths(){return[{params:{slug:"one"}},{params:{slug:"two"}}]}\n---\n

    {Astro.params.slug}

    ', - 'src/pages/more/new/[...spread].astro': - '---\nexport function getStaticPaths(){return[{params:{spread:"welcome/world"}}]}\n---\n

    {Astro.params.spread}

    ', - }, - inlineConfig: { - redirects: { - '/old': '/test', - '/one': '/test', - '/two': '/test', - '/three': { status: 302, destination: '/test' }, - '/external/redirect': 'https://example.com/', - '/relative/redirect': '../../test', - '/blog/[...slug]': '/articles/[...slug]', - '/more/old/[...spread]': '/more/new/[...spread]', - }, - }, - }); - }); - - it('includes http-equiv refresh and target URL in redirect HTML', async () => { - const route = options.routesList.routes.find( - (r) => r.route === '/one' && r.type === 'redirect', - ); - assert.ok(route, 'expected /one redirect route'); - - const prerenderer = createMockPrerenderer({ - '/one': new Response(null, { status: 301, headers: { location: '/test' } }), - }); - const result = await renderPath({ - prerenderer, - pathname: '/one', - route, - options, - logger: options.logger, - }); - - assert.ok(result !== null); - assert.ok(result.body.toString().includes('http-equiv="refresh"')); - assert.ok(result.body.toString().includes('url=/test')); - }); - - it('generates redirect HTML for a 302 redirect', async () => { - const route = options.routesList.routes.find( - (r) => r.route === '/three' && r.type === 'redirect', - ); - assert.ok(route, 'expected /three redirect route'); - - const prerenderer = createMockPrerenderer({ - '/three': new Response(null, { status: 302, headers: { location: '/test' } }), - }); - const result = await renderPath({ - prerenderer, - pathname: '/three', - route, - options, - logger: options.logger, - }); - - assert.ok(result !== null); - assert.ok(result.body.toString().includes('http-equiv="refresh"')); - assert.ok(result.body.toString().includes('url=/test')); - }); - - it('generates redirect HTML for an external destination', async () => { - const route = options.routesList.routes.find( - (r) => r.route === '/external/redirect' && r.type === 'redirect', - ); - assert.ok(route, 'expected /external/redirect route'); - - const prerenderer = createMockPrerenderer({ - '/external/redirect': new Response(null, { - status: 301, - headers: { location: 'https://example.com/' }, - }), - }); - const result = await renderPath({ - prerenderer, - pathname: '/external/redirect', - route, - options, - logger: options.logger, - }); - - assert.ok(result !== null); - assert.ok(result.body.toString().includes('http-equiv="refresh"')); - assert.ok(result.body.toString().includes('url=https://example.com/')); - }); - - it('generates redirect HTML for a relative destination', async () => { - const route = options.routesList.routes.find( - (r) => r.route === '/relative/redirect' && r.type === 'redirect', - ); - assert.ok(route, 'expected /relative/redirect route'); - - const prerenderer = createMockPrerenderer({ - '/relative/redirect': new Response(null, { - status: 301, - headers: { location: '../../test' }, - }), - }); - const result = await renderPath({ - prerenderer, - pathname: '/relative/redirect', - route, - options, - logger: options.logger, - }); - - assert.ok(result !== null); - assert.ok(result.body.toString().includes('http-equiv="refresh"')); - assert.ok(result.body.toString().includes('url=../../test')); - }); - - it('generates redirect HTML for a dynamic slug redirect', async () => { - const route = options.routesList.routes.find( - (r) => r.route === '/blog/[...slug]' && r.type === 'redirect', - ); - assert.ok(route, 'expected /blog/[...slug] redirect route'); - - const prerenderer = createMockPrerenderer({ - '/blog/one': new Response(null, { status: 301, headers: { location: '/articles/one' } }), - }); - const result = await renderPath({ - prerenderer, - pathname: '/blog/one', - route, - options, - logger: options.logger, - }); - - assert.ok(result !== null); - assert.ok(result.body.toString().includes('http-equiv="refresh"')); - assert.ok(result.body.toString().includes('url=/articles/one')); - }); - - it('falls back to spread rule for multi-segment dynamic paths', async () => { - const route = options.routesList.routes.find( - (r) => r.route === '/more/old/[...spread]' && r.type === 'redirect', - ); - assert.ok(route, 'expected /more/old/[...spread] redirect route'); - - const prerenderer = createMockPrerenderer({ - '/more/old/welcome/world': new Response(null, { - status: 301, - headers: { location: '/more/new/welcome/world' }, - }), - }); - const result = await renderPath({ - prerenderer, - pathname: '/more/old/welcome/world', - route, - options, - logger: options.logger, - }); - - assert.ok(result !== null); - assert.ok(result.body.toString().includes('http-equiv="refresh"')); - assert.ok(result.body.toString().includes('url=/more/new/welcome/world')); - }); -}); - -describe('static redirects — config.build.redirects = false suppresses redirect pages', () => { - let options; - - before(async () => { - options = await createStaticBuildOptions({ - pages: { 'src/pages/index.astro': TARGET_PAGE }, - inlineConfig: { - redirects: { '/one': '/' }, - build: { redirects: false }, - }, - }); - }); - - it('returns null for a redirect route when build.redirects is false', async () => { - const route = options.routesList.routes.find( - (r) => r.route === '/one' && r.type === 'redirect', - ); - assert.ok(route, 'expected /one redirect route'); - - const prerenderer = createMockPrerenderer({ - '/one': new Response(null, { status: 301, headers: { location: '/' } }), - }); - const result = await renderPath({ - prerenderer, - pathname: '/one', - route, - options, - logger: options.logger, - }); - - assert.equal(result, null); - }); -}); - -describe('static redirects — site config does not affect redirect URL', () => { - let options; - - before(async () => { - options = await createStaticBuildOptions({ - pages: { 'src/pages/login.astro': TARGET_PAGE }, - inlineConfig: { - redirects: { '/one': '/login' }, - site: 'https://example.com', - }, - }); - }); - - it('uses relative URL in redirect HTML even when site is set', async () => { - const route = options.routesList.routes.find( - (r) => r.route === '/one' && r.type === 'redirect', - ); - assert.ok(route, 'expected /one redirect route'); - - const prerenderer = createMockPrerenderer({ - '/one': new Response(null, { status: 301, headers: { location: '/login' } }), - }); - const result = await renderPath({ - prerenderer, - pathname: '/one', - route, - options, - logger: options.logger, - }); - - assert.ok(result !== null); - assert.ok(!result.body.toString().includes('url=https://example.com/login')); - assert.ok(result.body.toString().includes('url=/login')); - }); -}); - -describe('static redirects — middleware-generated redirect', () => { - it('renders redirect HTML for a page that returns a redirect via middleware', async () => { - const indexPage = createComponent((_result, _props, _slots) => render`

    Index

    `); - const middleware = async (ctx, next) => { - if (new URL(ctx.request.url).pathname === '/middleware-redirect/') { - return new Response(null, { status: 301, headers: { Location: '/test' } }); - } - return next(); - }; - - const app = createTestApp([createPage(indexPage, { route: '/middleware-redirect' })], { - middleware: () => ({ onRequest: middleware }), - }); - - const response = await app.render(new Request('http://example.com/middleware-redirect/'), { - routeData: undefined, - }); - assert.equal(response.status, 301); - assert.equal(response.headers.get('Location'), '/test'); - }); -}); - -describe('static redirects — invalid redirect destination throws', () => { - it('throws InvalidRedirectDestination when dynamic destination does not match any route', async () => { - // Regression test for https://github.com/withastro/astro/issues/12036 - // where a redirect like /categories/[category] -> /categories/[category]/1 - // produced a misleading "getStaticPaths required" error instead of - // a clear error about the invalid redirect destination. - // The destination mixes a dynamic param [category] with a static segment "1" - // but the actual route is /categories/[category]/[page].astro so - // the routeMap won't find "/categories/[category]/1" as a key. - await assert.rejects( - () => - createStaticBuildOptions({ - pages: { - 'src/pages/categories/[category]/[page].astro': - '---\nexport function getStaticPaths() { return []; }\n---\n

    page

    ', - }, - inlineConfig: { - redirects: { - '/categories/[category]': '/categories/[category]/1', - }, - }, - }), - (err) => { - // Should NOT be the misleading getStaticPaths error - assert.ok(!err.message.includes('getStaticPaths()')); - // Should be our new clear error message - assert.ok(err.message.includes('does not match any existing route')); - assert.equal(err.name, 'InvalidRedirectDestination'); - return true; - }, - ); - }); -}); - -describe('Astro.redirect() in a page component — build.redirects = false', () => { - it('renders redirect HTML for a page that calls Astro.redirect() even when build.redirects is false', async () => { - // /secret calls Astro.redirect('/login') in frontmatter. - // build.redirects=false suppresses config-level redirect routes but must NOT - // suppress pages that explicitly return a redirect response via Astro.redirect(). - const secretPage = createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - return Astro.redirect('/login'); - }); - - const app = createTestApp([createPage(secretPage, { route: '/secret' })]); - const response = await app.render(new Request('http://example.com/secret/')); - - assert.equal(response.status, 302); - assert.equal(response.headers.get('location'), '/login'); - }); -}); - -describe('Astro.redirect() — site config does not inject absolute URL', () => { - it('uses relative URL in Location header even when site is set', async () => { - // The site config should not cause redirect URLs to become absolute. - const secretPage = createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - return Astro.redirect('/login'); - }); - - const app = createTestApp([createPage(secretPage, { route: '/secret' })]); - const response = await app.render(new Request('http://example.com/secret/')); - - const location = response.headers.get('location'); - assert.ok(!location.includes('https://example.com'), 'should not use absolute URL'); - assert.equal(location, '/login'); - }); -}); - -describe('output: "server"', () => { - // Routes intentionally use non-verbatim target names to ensure the redirect - // system resolves by route pattern, not by filename: - // '/source/[dynamic]' -> '/not-verbatim/target1/[dynamic]' - // (real file: not-verbatim/target1/[something-other-than-dynamic].astro) - // '/source/[dynamic]/[route]' -> '/not-verbatim/target2/[dynamic]/[route]' - // (real file: not-verbatim/target2/[abc]/[xyz].astro) - // '/source/[dynamic]/[prerender]' -> '/not-verbatim/target2/[dynamic]/[prerender]' - // (check for prerendered routes) - // '/source/[...spread]' -> '/not-verbatim/target3/[...spread]' - // (real file: not-verbatim/target3/[...rest].astro) - - it('Warns when used inside a component', async () => { - // A child component calls Astro.redirect() after the parent has already - // started streaming HTML — the same pattern as late.astro + redirect.astro. - const redirectChild = createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - return Astro.redirect('/login'); - }); - - const latePage = createComponent( - (result) => - render`

    Testing

    ${renderComponent(result, 'Redirect', redirectChild, {})}`, - ); - - const app = createTestApp([createPage(latePage, { route: '/late' })]); - const request = new Request('http://example.com/late'); - const response = await app.render(request); - - try { - await response.text(); - assert.equal(false, true); - } catch (e) { - assert.equal( - e.message, - 'The response has already been sent to the browser and cannot be altered.', - ); - } - }); -}); diff --git a/packages/astro/test/units/redirects/static-build.test.ts b/packages/astro/test/units/redirects/static-build.test.ts new file mode 100644 index 000000000000..9462215280f7 --- /dev/null +++ b/packages/astro/test/units/redirects/static-build.test.ts @@ -0,0 +1,387 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { renderPath } from '../../../dist/core/build/generate.js'; +import { createMockPrerenderer, createStaticBuildOptions } from '../build/test-helpers.ts'; +import { createTestApp, createPage } from '../mocks.ts'; +import { createComponent, render, renderComponent } from '../../../dist/runtime/server/index.js'; + +import type { StaticBuildOptions } from '../../../dist/core/build/types.js'; +import type { RouteData } from '../../../dist/types/public/internal.js'; +import type { MiddlewareHandler } from '../../../dist/types/public/common.js'; + +// Minimal target page for redirect destination routes +const TARGET_PAGE = '---\n---\n

    Target

    '; + +describe('static redirects — meta refresh output', () => { + let options: StaticBuildOptions; + + before(async () => { + options = await createStaticBuildOptions({ + pages: { + 'src/pages/test.astro': TARGET_PAGE, + 'src/pages/articles/[...slug].astro': + '---\nexport function getStaticPaths(){return[{params:{slug:"one"}},{params:{slug:"two"}}]}\n---\n

    {Astro.params.slug}

    ', + 'src/pages/more/new/[...spread].astro': + '---\nexport function getStaticPaths(){return[{params:{spread:"welcome/world"}}]}\n---\n

    {Astro.params.spread}

    ', + }, + inlineConfig: { + redirects: { + '/old': '/test', + '/one': '/test', + '/two': '/test', + '/three': { status: 302, destination: '/test' }, + '/external/redirect': 'https://example.com/', + '/relative/redirect': '../../test', + '/blog/[...slug]': '/articles/[...slug]', + '/more/old/[...spread]': '/more/new/[...spread]', + }, + }, + }); + }); + + it('includes http-equiv refresh and target URL in redirect HTML', async () => { + const route = (options.routesList as { routes: RouteData[] }).routes.find( + (r) => r.route === '/one' && r.type === 'redirect', + ); + assert.ok(route, 'expected /one redirect route'); + + const prerenderer = createMockPrerenderer({ + '/one': new Response(null, { status: 301, headers: { location: '/test' } }), + }); + const result = await renderPath({ + prerenderer, + pathname: '/one', + route, + options, + logger: options.logger, + }); + + assert.ok(result !== null); + assert.ok(result.body.toString().includes('http-equiv="refresh"')); + assert.ok(result.body.toString().includes('url=/test')); + }); + + it('generates redirect HTML for a 302 redirect', async () => { + const route = (options.routesList as { routes: RouteData[] }).routes.find( + (r) => r.route === '/three' && r.type === 'redirect', + ); + assert.ok(route, 'expected /three redirect route'); + + const prerenderer = createMockPrerenderer({ + '/three': new Response(null, { status: 302, headers: { location: '/test' } }), + }); + const result = await renderPath({ + prerenderer, + pathname: '/three', + route, + options, + logger: options.logger, + }); + + assert.ok(result !== null); + assert.ok(result.body.toString().includes('http-equiv="refresh"')); + assert.ok(result.body.toString().includes('url=/test')); + }); + + it('generates redirect HTML for an external destination', async () => { + const route = (options.routesList as { routes: RouteData[] }).routes.find( + (r) => r.route === '/external/redirect' && r.type === 'redirect', + ); + assert.ok(route, 'expected /external/redirect route'); + + const prerenderer = createMockPrerenderer({ + '/external/redirect': new Response(null, { + status: 301, + headers: { location: 'https://example.com/' }, + }), + }); + const result = await renderPath({ + prerenderer, + pathname: '/external/redirect', + route, + options, + logger: options.logger, + }); + + assert.ok(result !== null); + assert.ok(result.body.toString().includes('http-equiv="refresh"')); + assert.ok(result.body.toString().includes('url=https://example.com/')); + }); + + it('generates redirect HTML for a relative destination', async () => { + const route = (options.routesList as { routes: RouteData[] }).routes.find( + (r) => r.route === '/relative/redirect' && r.type === 'redirect', + ); + assert.ok(route, 'expected /relative/redirect route'); + + const prerenderer = createMockPrerenderer({ + '/relative/redirect': new Response(null, { + status: 301, + headers: { location: '../../test' }, + }), + }); + const result = await renderPath({ + prerenderer, + pathname: '/relative/redirect', + route, + options, + logger: options.logger, + }); + + assert.ok(result !== null); + assert.ok(result.body.toString().includes('http-equiv="refresh"')); + assert.ok(result.body.toString().includes('url=../../test')); + }); + + it('generates redirect HTML for a dynamic slug redirect', async () => { + const route = (options.routesList as { routes: RouteData[] }).routes.find( + (r) => r.route === '/blog/[...slug]' && r.type === 'redirect', + ); + assert.ok(route, 'expected /blog/[...slug] redirect route'); + + const prerenderer = createMockPrerenderer({ + '/blog/one': new Response(null, { status: 301, headers: { location: '/articles/one' } }), + }); + const result = await renderPath({ + prerenderer, + pathname: '/blog/one', + route, + options, + logger: options.logger, + }); + + assert.ok(result !== null); + assert.ok(result.body.toString().includes('http-equiv="refresh"')); + assert.ok(result.body.toString().includes('url=/articles/one')); + }); + + it('falls back to spread rule for multi-segment dynamic paths', async () => { + const route = (options.routesList as { routes: RouteData[] }).routes.find( + (r) => r.route === '/more/old/[...spread]' && r.type === 'redirect', + ); + assert.ok(route, 'expected /more/old/[...spread] redirect route'); + + const prerenderer = createMockPrerenderer({ + '/more/old/welcome/world': new Response(null, { + status: 301, + headers: { location: '/more/new/welcome/world' }, + }), + }); + const result = await renderPath({ + prerenderer, + pathname: '/more/old/welcome/world', + route, + options, + logger: options.logger, + }); + + assert.ok(result !== null); + assert.ok(result.body.toString().includes('http-equiv="refresh"')); + assert.ok(result.body.toString().includes('url=/more/new/welcome/world')); + }); +}); + +describe('static redirects — config.build.redirects = false suppresses redirect pages', () => { + let options: StaticBuildOptions; + + before(async () => { + options = await createStaticBuildOptions({ + pages: { 'src/pages/index.astro': TARGET_PAGE }, + inlineConfig: { + redirects: { '/one': '/' }, + build: { redirects: false }, + }, + }); + }); + + it('returns null for a redirect route when build.redirects is false', async () => { + const route = (options.routesList as { routes: RouteData[] }).routes.find( + (r) => r.route === '/one' && r.type === 'redirect', + ); + assert.ok(route, 'expected /one redirect route'); + + const prerenderer = createMockPrerenderer({ + '/one': new Response(null, { status: 301, headers: { location: '/' } }), + }); + const result = await renderPath({ + prerenderer, + pathname: '/one', + route, + options, + logger: options.logger, + }); + + assert.equal(result, null); + }); +}); + +describe('static redirects — site config does not affect redirect URL', () => { + let options: StaticBuildOptions; + + before(async () => { + options = await createStaticBuildOptions({ + pages: { 'src/pages/login.astro': TARGET_PAGE }, + inlineConfig: { + redirects: { '/one': '/login' }, + site: 'https://example.com', + }, + }); + }); + + it('uses relative URL in redirect HTML even when site is set', async () => { + const route = (options.routesList as { routes: RouteData[] }).routes.find( + (r) => r.route === '/one' && r.type === 'redirect', + ); + assert.ok(route, 'expected /one redirect route'); + + const prerenderer = createMockPrerenderer({ + '/one': new Response(null, { status: 301, headers: { location: '/login' } }), + }); + const result = await renderPath({ + prerenderer, + pathname: '/one', + route, + options, + logger: options.logger, + }); + + assert.ok(result !== null); + assert.ok(!result.body.toString().includes('url=https://example.com/login')); + assert.ok(result.body.toString().includes('url=/login')); + }); +}); + +describe('static redirects — middleware-generated redirect', () => { + it('renders redirect HTML for a page that returns a redirect via middleware', async () => { + const indexPage = createComponent( + (_result: any, _props: any, _slots: any) => render`

    Index

    `, + ); + const middleware: MiddlewareHandler = async (ctx, next) => { + if (new URL(ctx.request.url).pathname === '/middleware-redirect/') { + return new Response(null, { status: 301, headers: { Location: '/test' } }); + } + return next(); + }; + + const app = createTestApp([createPage(indexPage, { route: '/middleware-redirect' })], { + middleware: () => ({ onRequest: middleware }), + }); + + const response = await app.render(new Request('http://example.com/middleware-redirect/'), { + routeData: undefined, + } as any); + assert.equal(response.status, 301); + assert.equal(response.headers.get('Location'), '/test'); + }); +}); + +describe('static redirects — invalid redirect destination throws', () => { + it('throws InvalidRedirectDestination when dynamic destination does not match any route', async () => { + // Regression test for https://github.com/withastro/astro/issues/12036 + // where a redirect like /categories/[category] -> /categories/[category]/1 + // produced a misleading "getStaticPaths required" error instead of + // a clear error about the invalid redirect destination. + // The destination mixes a dynamic param [category] with a static segment "1" + // but the actual route is /categories/[category]/[page].astro so + // the routeMap won't find "/categories/[category]/1" as a key. + await assert.rejects( + () => + createStaticBuildOptions({ + pages: { + 'src/pages/categories/[category]/[page].astro': + '---\nexport function getStaticPaths() { return []; }\n---\n

    page

    ', + }, + inlineConfig: { + redirects: { + '/categories/[category]': '/categories/[category]/1', + }, + }, + }), + (err: Error & { name: string }) => { + // Should NOT be the misleading getStaticPaths error + assert.ok(!err.message.includes('getStaticPaths()')); + // Should be our new clear error message + assert.ok(err.message.includes('does not match any existing route')); + assert.equal(err.name, 'InvalidRedirectDestination'); + return true; + }, + ); + }); +}); + +describe('Astro.redirect() in a page component — build.redirects = false', () => { + it('renders redirect HTML for a page that calls Astro.redirect() even when build.redirects is false', async () => { + // /secret calls Astro.redirect('/login') in frontmatter. + // build.redirects=false suppresses config-level redirect routes but must NOT + // suppress pages that explicitly return a redirect response via Astro.redirect(). + const secretPage = createComponent((result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + return Astro.redirect('/login'); + }); + + const app = createTestApp([createPage(secretPage, { route: '/secret' })]); + const response = await app.render(new Request('http://example.com/secret/')); + + assert.equal(response.status, 302); + assert.equal(response.headers.get('location'), '/login'); + }); +}); + +describe('Astro.redirect() — site config does not inject absolute URL', () => { + it('uses relative URL in Location header even when site is set', async () => { + // The site config should not cause redirect URLs to become absolute. + const secretPage = createComponent((result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + return Astro.redirect('/login'); + }); + + const app = createTestApp([createPage(secretPage, { route: '/secret' })]); + const response = await app.render(new Request('http://example.com/secret/')); + + const location = response.headers.get('location')!; + assert.ok(!location.includes('https://example.com'), 'should not use absolute URL'); + assert.equal(location, '/login'); + }); +}); + +describe('output: "server"', () => { + // Routes intentionally use non-verbatim target names to ensure the redirect + // system resolves by route pattern, not by filename: + // '/source/[dynamic]' -> '/not-verbatim/target1/[dynamic]' + // (real file: not-verbatim/target1/[something-other-than-dynamic].astro) + // '/source/[dynamic]/[route]' -> '/not-verbatim/target2/[dynamic]/[route]' + // (real file: not-verbatim/target2/[abc]/[xyz].astro) + // '/source/[dynamic]/[prerender]' -> '/not-verbatim/target2/[dynamic]/[prerender]' + // (check for prerendered routes) + // '/source/[...spread]' -> '/not-verbatim/target3/[...spread]' + // (real file: not-verbatim/target3/[...rest].astro) + + it('Warns when used inside a component', async () => { + // A child component calls Astro.redirect() after the parent has already + // started streaming HTML — the same pattern as late.astro + redirect.astro. + const redirectChild = createComponent((result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + return Astro.redirect('/login'); + }); + + const latePage = createComponent( + (result: any) => + render`

    Testing

    ${renderComponent(result, 'Redirect', redirectChild, {})}`, + ); + + const app = createTestApp([createPage(latePage, { route: '/late' })]); + const request = new Request('http://example.com/late'); + const response = await app.render(request); + + try { + await response.text(); + assert.equal(false, true); + } catch (e: unknown) { + assert.ok(e instanceof Error); + assert.equal( + e.message, + 'The response has already been sent to the browser and cannot be altered.', + ); + } + }); +}); diff --git a/packages/astro/test/units/redirects/template.test.js b/packages/astro/test/units/redirects/template.test.js deleted file mode 100644 index 26da2255a157..000000000000 --- a/packages/astro/test/units/redirects/template.test.js +++ /dev/null @@ -1,157 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { redirectTemplate } from '../../../dist/core/routing/3xx.js'; - -describe('redirects/template', () => { - it('generates correct HTML structure', () => { - const html = redirectTemplate({ - status: 301, - absoluteLocation: 'https://example.com/new-page', - relativeLocation: '/new-page', - }); - - const $ = cheerio.load(html); - - // Check DOCTYPE - assert.ok(html.startsWith('')); - - // Check title - assert.equal($('title').text(), 'Redirecting to: /new-page'); - - // Check meta refresh tag - const metaRefresh = $('meta[http-equiv="refresh"]'); - assert.equal(metaRefresh.length, 1); - assert.equal(metaRefresh.attr('content'), '0;url=/new-page'); - - // Check robots meta tag - const metaRobots = $('meta[name="robots"]'); - assert.equal(metaRobots.length, 1); - assert.equal(metaRobots.attr('content'), 'noindex'); - - // Check canonical link - const canonical = $('link[rel="canonical"]'); - assert.equal(canonical.length, 1); - assert.equal(canonical.attr('href'), 'https://example.com/new-page'); - - // Check body content - const link = $('body a'); - assert.equal(link.length, 1); - assert.equal(link.attr('href'), '/new-page'); - assert.ok(link.html().includes('Redirecting')); - assert.ok(link.html().includes('/new-page')); - }); - - it('uses 2 second delay for 302 redirects', () => { - const html = redirectTemplate({ - status: 302, - absoluteLocation: 'https://example.com/temp', - relativeLocation: '/temp', - }); - - const $ = cheerio.load(html); - const metaRefresh = $('meta[http-equiv="refresh"]'); - assert.equal(metaRefresh.attr('content'), '2;url=/temp'); - }); - - it('uses 0 second delay for non-302 redirects', () => { - // Test 301 - let html = redirectTemplate({ - status: 301, - absoluteLocation: 'https://example.com/perm', - relativeLocation: '/perm', - }); - - let $ = cheerio.load(html); - let metaRefresh = $('meta[http-equiv="refresh"]'); - assert.equal(metaRefresh.attr('content'), '0;url=/perm'); - - // Test 308 - html = redirectTemplate({ - status: 308, - absoluteLocation: 'https://example.com/perm', - relativeLocation: '/perm', - }); - - $ = cheerio.load(html); - metaRefresh = $('meta[http-equiv="refresh"]'); - assert.equal(metaRefresh.attr('content'), '0;url=/perm'); - }); - - it('includes "from" information when provided', () => { - const html = redirectTemplate({ - status: 301, - absoluteLocation: 'https://example.com/new', - relativeLocation: '/new', - from: '/old', - }); - - const $ = cheerio.load(html); - const bodyText = $('body').html(); - assert.ok(bodyText.includes('from /old')); - assert.ok(bodyText.includes('to /new')); - }); - - it('omits "from" text when not provided', () => { - const html = redirectTemplate({ - status: 301, - absoluteLocation: 'https://example.com/new', - relativeLocation: '/new', - }); - - const $ = cheerio.load(html); - const bodyText = $('body').html(); - assert.ok(!bodyText.includes('from ')); - assert.ok(bodyText.includes('to /new')); - }); - - it('handles special characters in URLs', () => { - const html = redirectTemplate({ - status: 301, - absoluteLocation: 'https://example.com/page?foo=bar&baz=qux', - relativeLocation: '/page?foo=bar&baz=qux', - }); - - const $ = cheerio.load(html); - - // Title should show the URL as-is - assert.equal($('title').text(), 'Redirecting to: /page?foo=bar&baz=qux'); - - // Meta refresh should preserve the URL structure - const metaRefresh = $('meta[http-equiv="refresh"]'); - assert.equal(metaRefresh.attr('content'), '0;url=/page?foo=bar&baz=qux'); - - // Link href should be properly escaped - const link = $('body a'); - assert.equal(link.attr('href'), '/page?foo=bar&baz=qux'); - }); - - it('handles external URLs in relative location', () => { - const html = redirectTemplate({ - status: 301, - absoluteLocation: 'https://external.com/', - relativeLocation: 'https://external.com/', - }); - - const $ = cheerio.load(html); - - // Should use the external URL in all places - assert.equal($('title').text(), 'Redirecting to: https://external.com/'); - assert.equal($('meta[http-equiv="refresh"]').attr('content'), '0;url=https://external.com/'); - assert.equal($('link[rel="canonical"]').attr('href'), 'https://external.com/'); - assert.equal($('body a').attr('href'), 'https://external.com/'); - }); - - it('handles URL object for absoluteLocation', () => { - const html = redirectTemplate({ - status: 301, - absoluteLocation: new URL('https://example.com/page'), - relativeLocation: '/page', - }); - - const $ = cheerio.load(html); - - // Should convert URL object to string - assert.equal($('link[rel="canonical"]').attr('href'), 'https://example.com/page'); - }); -}); diff --git a/packages/astro/test/units/redirects/template.test.ts b/packages/astro/test/units/redirects/template.test.ts new file mode 100644 index 000000000000..05a4786bca7b --- /dev/null +++ b/packages/astro/test/units/redirects/template.test.ts @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { redirectTemplate } from '../../../dist/core/routing/3xx.js'; + +describe('redirects/template', () => { + it('generates correct HTML structure', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://example.com/new-page', + relativeLocation: '/new-page', + }); + + const $ = cheerio.load(html); + + // Check DOCTYPE + assert.ok(html.startsWith('')); + + // Check title + assert.equal($('title').text(), 'Redirecting to: /new-page'); + + // Check meta refresh tag + const metaRefresh = $('meta[http-equiv="refresh"]'); + assert.equal(metaRefresh.length, 1); + assert.equal(metaRefresh.attr('content'), '0;url=/new-page'); + + // Check robots meta tag + const metaRobots = $('meta[name="robots"]'); + assert.equal(metaRobots.length, 1); + assert.equal(metaRobots.attr('content'), 'noindex'); + + // Check canonical link + const canonical = $('link[rel="canonical"]'); + assert.equal(canonical.length, 1); + assert.equal(canonical.attr('href'), 'https://example.com/new-page'); + + // Check body content + const link = $('body a'); + assert.equal(link.length, 1); + assert.equal(link.attr('href'), '/new-page'); + assert.ok(link.html()?.includes('Redirecting')); + assert.ok(link.html()?.includes('/new-page')); + }); + + it('uses 2 second delay for 302 redirects', () => { + const html = redirectTemplate({ + status: 302, + absoluteLocation: 'https://example.com/temp', + relativeLocation: '/temp', + }); + + const $ = cheerio.load(html); + const metaRefresh = $('meta[http-equiv="refresh"]'); + assert.equal(metaRefresh.attr('content'), '2;url=/temp'); + }); + + it('uses 0 second delay for non-302 redirects', () => { + // Test 301 + let html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://example.com/perm', + relativeLocation: '/perm', + }); + + let $ = cheerio.load(html); + let metaRefresh = $('meta[http-equiv="refresh"]'); + assert.equal(metaRefresh.attr('content'), '0;url=/perm'); + + // Test 308 + html = redirectTemplate({ + status: 308, + absoluteLocation: 'https://example.com/perm', + relativeLocation: '/perm', + }); + + $ = cheerio.load(html); + metaRefresh = $('meta[http-equiv="refresh"]'); + assert.equal(metaRefresh.attr('content'), '0;url=/perm'); + }); + + it('includes "from" information when provided', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://example.com/new', + relativeLocation: '/new', + from: '/old', + }); + + const $ = cheerio.load(html); + const bodyText = $('body').html(); + assert.ok(bodyText?.includes('from /old')); + assert.ok(bodyText?.includes('to /new')); + }); + + it('omits "from" text when not provided', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://example.com/new', + relativeLocation: '/new', + }); + + const $ = cheerio.load(html); + const bodyText = $('body').html(); + assert.ok(!bodyText?.includes('from ')); + assert.ok(bodyText?.includes('to /new')); + }); + + it('handles special characters in URLs', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://example.com/page?foo=bar&baz=qux', + relativeLocation: '/page?foo=bar&baz=qux', + }); + + const $ = cheerio.load(html); + + // Title should show the URL as-is + assert.equal($('title').text(), 'Redirecting to: /page?foo=bar&baz=qux'); + + // Meta refresh should preserve the URL structure + const metaRefresh = $('meta[http-equiv="refresh"]'); + assert.equal(metaRefresh.attr('content'), '0;url=/page?foo=bar&baz=qux'); + + // Link href should be properly escaped + const link = $('body a'); + assert.equal(link.attr('href'), '/page?foo=bar&baz=qux'); + }); + + it('handles external URLs in relative location', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: 'https://external.com/', + relativeLocation: 'https://external.com/', + }); + + const $ = cheerio.load(html); + + // Should use the external URL in all places + assert.equal($('title').text(), 'Redirecting to: https://external.com/'); + assert.equal($('meta[http-equiv="refresh"]').attr('content'), '0;url=https://external.com/'); + assert.equal($('link[rel="canonical"]').attr('href'), 'https://external.com/'); + assert.equal($('body a').attr('href'), 'https://external.com/'); + }); + + it('handles URL object for absoluteLocation', () => { + const html = redirectTemplate({ + status: 301, + absoluteLocation: new URL('https://example.com/page'), + relativeLocation: '/page', + }); + + const $ = cheerio.load(html); + + // Should convert URL object to string + assert.equal($('link[rel="canonical"]').attr('href'), 'https://example.com/page'); + }); +}); diff --git a/packages/astro/test/units/remote-pattern.test.js b/packages/astro/test/units/remote-pattern.test.js deleted file mode 100644 index 7c9f7c74850a..000000000000 --- a/packages/astro/test/units/remote-pattern.test.js +++ /dev/null @@ -1,170 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - isRemoteAllowed, - matchHostname, - matchPathname, - matchPattern, - matchPort, - matchProtocol, -} from '@astrojs/internal-helpers/remote'; - -describe('remote-pattern', () => { - const url1 = new URL('https://docs.astro.build/en/getting-started'); - const url2 = new URL('http://preview.docs.astro.build:8080/'); - const url3 = new URL('https://astro.build/'); - const url4 = new URL('https://example.co/'); - const url5 = new URL('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=='); - - describe('remote pattern matchers', () => { - it('matches protocol', async () => { - // undefined - assert.equal(matchProtocol(url1), true); - - // defined, true/false - assert.equal(matchProtocol(url1, 'http'), false); - assert.equal(matchProtocol(url1, 'https'), true); - assert.equal(matchProtocol(url5, 'data'), true); - }); - - it('matches port', async () => { - // undefined - assert.equal(matchPort(url1), true); - - // defined, but port is empty (default port used in URL) - assert.equal(matchPort(url1, ''), true); - - // defined and port is custom - assert.equal(matchPort(url2, '8080'), true); - }); - - it('matches hostname (no wildcards)', async () => { - // undefined - assert.equal(matchHostname(url1), true); - - // defined, true/false - assert.equal(matchHostname(url1, 'astro.build'), false); - assert.equal(matchHostname(url1, 'docs.astro.build'), true); - }); - - it('matches hostname (with wildcards)', async () => { - // defined, true/false - assert.equal(matchHostname(url1, 'docs.astro.build', true), true); - assert.equal(matchHostname(url1, '**.astro.build', true), true); - assert.equal(matchHostname(url1, '*.astro.build', true), true); - - assert.equal(matchHostname(url2, '*.astro.build', true), false); - assert.equal(matchHostname(url2, '**.astro.build', true), true); - - assert.equal(matchHostname(url3, 'astro.build', true), true); - assert.equal(matchHostname(url3, '*.astro.build', true), false); - assert.equal(matchHostname(url3, '**.astro.build', true), false); - }); - - it('rejects hostname without dots when using single wildcard (*.domain.com)', async () => { - // hostnames without dots (like localhost) should not match *.astro.build - const localhostUrl = new URL('http://localhost/'); - assert.equal(matchHostname(localhostUrl, '*.astro.build', true), false); - - const bareHostnameUrl = new URL('http://example/'); - assert.equal(matchHostname(bareHostnameUrl, '*.victim.com', true), false); - - const internalUrl = new URL('http://internal/'); - assert.equal(matchHostname(internalUrl, '*.astro.build', true), false); - }); - - it('matches pathname (no wildcards)', async () => { - // undefined - assert.equal(matchPathname(url1), true); - - // defined, true/false - assert.equal(matchPathname(url1, '/'), false); - assert.equal(matchPathname(url1, '/en/getting-started'), true); - }); - - it('matches pathname (with wildcards)', async () => { - // defined, true/false - assert.equal(matchPathname(url1, '/en/**', true), true); - assert.equal(matchPathname(url1, '/en/*', true), true); - assert.equal(matchPathname(url1, '/**', true), true); - - assert.equal(matchPathname(url2, '/**', true), false); - assert.equal(matchPathname(url2, '/*', true), false); - }); - - it('does not match pathname when prefix appears mid-path', async () => { - // /en/* should NOT match /evil/en/getting-started - const evilUrl = new URL('https://docs.astro.build/evil/en/getting-started'); - assert.equal(matchPathname(evilUrl, '/en/*', true), false); - }); - - it('matches patterns', async () => { - assert.equal(matchPattern(url1, {}), true); - - assert.equal( - matchPattern(url1, { - protocol: 'https', - }), - true, - ); - - assert.equal( - matchPattern(url1, { - protocol: 'https', - hostname: '**.astro.build', - }), - true, - ); - - assert.equal( - matchPattern(url1, { - protocol: 'https', - hostname: '**.astro.build', - pathname: '/en/**', - }), - true, - ); - - assert.equal( - matchPattern(url4, { - protocol: 'https', - hostname: 'example.com', - }), - false, - ); - - assert.equal( - matchPattern(url5, { - protocol: 'data', - }), - true, - ); - }); - }); - - describe('remote is allowed', () => { - it('allows remote URLs based on patterns', async () => { - const patterns = { - domains: [], - remotePatterns: [ - { - protocol: 'https', - hostname: '**.astro.build', - pathname: '/en/**', - }, - { - protocol: 'http', - hostname: 'preview.docs.astro.build', - port: '8080', - }, - ], - }; - - assert.equal(isRemoteAllowed(url1, patterns), true); - assert.equal(isRemoteAllowed(url2, patterns), true); - assert.equal(isRemoteAllowed(url3, patterns), false); - assert.equal(isRemoteAllowed(url4, patterns), false); - assert.equal(isRemoteAllowed(url5, patterns), false); - }); - }); -}); diff --git a/packages/astro/test/units/remote-pattern.test.ts b/packages/astro/test/units/remote-pattern.test.ts new file mode 100644 index 000000000000..8cf73f69ac2a --- /dev/null +++ b/packages/astro/test/units/remote-pattern.test.ts @@ -0,0 +1,170 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + isRemoteAllowed, + matchHostname, + matchPathname, + matchPattern, + matchPort, + matchProtocol, +} from '@astrojs/internal-helpers/remote'; + +describe('remote-pattern', () => { + const url1 = new URL('https://docs.astro.build/en/getting-started'); + const url2 = new URL('http://preview.docs.astro.build:8080/'); + const url3 = new URL('https://astro.build/'); + const url4 = new URL('https://example.co/'); + const url5 = new URL('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=='); + + describe('remote pattern matchers', () => { + it('matches protocol', async () => { + // undefined + assert.equal(matchProtocol(url1), true); + + // defined, true/false + assert.equal(matchProtocol(url1, 'http'), false); + assert.equal(matchProtocol(url1, 'https'), true); + assert.equal(matchProtocol(url5, 'data'), true); + }); + + it('matches port', async () => { + // undefined + assert.equal(matchPort(url1), true); + + // defined, but port is empty (default port used in URL) + assert.equal(matchPort(url1, ''), true); + + // defined and port is custom + assert.equal(matchPort(url2, '8080'), true); + }); + + it('matches hostname (no wildcards)', async () => { + // undefined + assert.equal(matchHostname(url1), true); + + // defined, true/false + assert.equal(matchHostname(url1, 'astro.build'), false); + assert.equal(matchHostname(url1, 'docs.astro.build'), true); + }); + + it('matches hostname (with wildcards)', async () => { + // defined, true/false + assert.equal(matchHostname(url1, 'docs.astro.build', true), true); + assert.equal(matchHostname(url1, '**.astro.build', true), true); + assert.equal(matchHostname(url1, '*.astro.build', true), true); + + assert.equal(matchHostname(url2, '*.astro.build', true), false); + assert.equal(matchHostname(url2, '**.astro.build', true), true); + + assert.equal(matchHostname(url3, 'astro.build', true), true); + assert.equal(matchHostname(url3, '*.astro.build', true), false); + assert.equal(matchHostname(url3, '**.astro.build', true), false); + }); + + it('rejects hostname without dots when using single wildcard (*.domain.com)', async () => { + // hostnames without dots (like localhost) should not match *.astro.build + const localhostUrl = new URL('http://localhost/'); + assert.equal(matchHostname(localhostUrl, '*.astro.build', true), false); + + const bareHostnameUrl = new URL('http://example/'); + assert.equal(matchHostname(bareHostnameUrl, '*.victim.com', true), false); + + const internalUrl = new URL('http://internal/'); + assert.equal(matchHostname(internalUrl, '*.astro.build', true), false); + }); + + it('matches pathname (no wildcards)', async () => { + // undefined + assert.equal(matchPathname(url1), true); + + // defined, true/false + assert.equal(matchPathname(url1, '/'), false); + assert.equal(matchPathname(url1, '/en/getting-started'), true); + }); + + it('matches pathname (with wildcards)', async () => { + // defined, true/false + assert.equal(matchPathname(url1, '/en/**', true), true); + assert.equal(matchPathname(url1, '/en/*', true), true); + assert.equal(matchPathname(url1, '/**', true), true); + + assert.equal(matchPathname(url2, '/**', true), false); + assert.equal(matchPathname(url2, '/*', true), false); + }); + + it('does not match pathname when prefix appears mid-path', async () => { + // /en/* should NOT match /evil/en/getting-started + const evilUrl = new URL('https://docs.astro.build/evil/en/getting-started'); + assert.equal(matchPathname(evilUrl, '/en/*', true), false); + }); + + it('matches patterns', async () => { + assert.equal(matchPattern(url1, {}), true); + + assert.equal( + matchPattern(url1, { + protocol: 'https', + }), + true, + ); + + assert.equal( + matchPattern(url1, { + protocol: 'https', + hostname: '**.astro.build', + }), + true, + ); + + assert.equal( + matchPattern(url1, { + protocol: 'https', + hostname: '**.astro.build', + pathname: '/en/**', + }), + true, + ); + + assert.equal( + matchPattern(url4, { + protocol: 'https', + hostname: 'example.com', + }), + false, + ); + + assert.equal( + matchPattern(url5, { + protocol: 'data', + }), + true, + ); + }); + }); + + describe('remote is allowed', () => { + it('allows remote URLs based on patterns', async () => { + const patterns = { + domains: [] as string[], + remotePatterns: [ + { + protocol: 'https' as const, + hostname: '**.astro.build', + pathname: '/en/**', + }, + { + protocol: 'http' as const, + hostname: 'preview.docs.astro.build', + port: '8080', + }, + ], + }; + + assert.equal(isRemoteAllowed(url1.href, patterns), true); + assert.equal(isRemoteAllowed(url2.href, patterns), true); + assert.equal(isRemoteAllowed(url3.href, patterns), false); + assert.equal(isRemoteAllowed(url4.href, patterns), false); + assert.equal(isRemoteAllowed(url5.href, patterns), false); + }); + }); +}); diff --git a/packages/astro/test/units/render/chunk.test.js b/packages/astro/test/units/render/chunk.test.js deleted file mode 100644 index 57ab743261a1..000000000000 --- a/packages/astro/test/units/render/chunk.test.js +++ /dev/null @@ -1,46 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { createFixture, createRequestAndResponse, runInContainer } from '../test-utils.js'; - -describe('core/render chunk', () => { - it('does not throw on user object with type', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': `\ - --- - const value = { type: 'foobar' } - --- -
    {value}
    - `, - }); - - await runInContainer( - { - inlineConfig: { - root: fixture.path, - logLevel: 'silent', - integrations: [], - }, - }, - async (container) => { - const { req, res, done, text } = createRequestAndResponse({ - method: 'GET', - url: '/', - }); - container.handle(req, res); - - await done; - try { - const html = await text(); - const $ = cheerio.load(html); - const target = $('#chunk'); - - assert.ok(target); - assert.equal(target.text(), '[object Object]'); - } catch { - assert.fail(); - } - }, - ); - }); -}); diff --git a/packages/astro/test/units/render/class-list-and-style.test.js b/packages/astro/test/units/render/class-list-and-style.test.js deleted file mode 100644 index f59c21a744bf..000000000000 --- a/packages/astro/test/units/render/class-list-and-style.test.js +++ /dev/null @@ -1,200 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { addAttribute } from '../../../dist/runtime/server/index.js'; -import { toStyleString } from '../../../dist/runtime/server/render/util.js'; -import { createTestApp, createPage, createMultiChildPage, spreadPropsSpan } from '../mocks.js'; - -describe('class:list', () => { - it('handles a plain string', () => { - const result = addAttribute('test expression', 'class:list'); - assert.equal(result.toString(), ' class="test expression"'); - }); - - it('handles an array of strings', () => { - const result = addAttribute(['test', 'set'], 'class:list'); - assert.equal(result.toString(), ' class="test set"'); - }); - - it('handles an object with boolean values', () => { - const result = addAttribute({ test: true, true: true, false: false }, 'class:list'); - assert.equal(result.toString(), ' class="test true"'); - }); - - it('handles an object with truthy/falsy values', () => { - const result = addAttribute( - { test: 1, truthy: '0', noshow1: 0, noshow2: '', noshow3: null, noshow4: undefined }, - 'class:list', - ); - assert.equal(result.toString(), ' class="test truthy"'); - }); - - it('handles nested arrays and objects', () => { - const result = addAttribute( - ['hello goodbye', { hello: true, world: true }, ['hello', 'friend']], - 'class:list', - ); - assert.equal(result.toString(), ' class="hello goodbye hello world hello friend"'); - }); - - it('handles conditional expressions in arrays', () => { - const result = addAttribute(['foo', false && 'bar', true && 'baz'], 'class:list'); - assert.equal(result.toString(), ' class="foo baz"'); - }); - - it('returns empty string when all values are falsy', () => { - const result = addAttribute([false && 'empty'], 'class:list'); - assert.equal(result.toString(), ''); - }); - - it('returns empty string for null', () => { - const result = addAttribute(null, 'class:list'); - assert.equal(result.toString(), ''); - }); - - it('returns empty string for undefined', () => { - const result = addAttribute(undefined, 'class:list'); - assert.equal(result.toString(), ''); - }); -}); - -describe('toStyleString', () => { - it('converts camelCase to kebab-case', () => { - assert.equal(toStyleString({ backgroundColor: 'green' }), 'background-color:green'); - }); - - it('preserves CSS custom properties', () => { - assert.equal(toStyleString({ '--my-var': 'red' }), '--my-var:red'); - }); - - it('joins multiple properties with semicolons', () => { - assert.equal( - toStyleString({ backgroundColor: 'green', color: 'red' }), - 'background-color:green;color:red', - ); - }); - - it('handles numeric values', () => { - assert.equal(toStyleString({ zIndex: 10 }), 'z-index:10'); - }); - - it('handles zero as a value', () => { - assert.equal(toStyleString({ margin: 0 }), 'margin:0'); - }); - - it('filters out empty string values', () => { - assert.equal(toStyleString({ color: '', background: 'red' }), 'background:red'); - }); - - it('filters out whitespace-only string values', () => { - assert.equal(toStyleString({ color: ' ', background: 'red' }), 'background:red'); - }); - - it('filters out null/undefined/boolean values', () => { - assert.equal( - toStyleString({ color: null, background: undefined, display: false, margin: 'auto' }), - 'margin:auto', - ); - }); - - it('preserves quoted values', () => { - assert.equal(toStyleString({ backgroundImage: 'url("a")' }), 'background-image:url("a")'); - }); -}); - -describe('style object via addAttribute', () => { - it('converts style object to attribute string', () => { - const result = addAttribute({ backgroundColor: 'green' }, 'style'); - assert.equal(result.toString(), ' style="background-color:green"'); - }); - - it('converts style object with multiple properties', () => { - const result = addAttribute({ backgroundColor: 'blue', color: 'white' }, 'style'); - assert.equal(result.toString(), ' style="background-color:blue;color:white"'); - }); - - it('handles url() with quotes in style object', () => { - const result = addAttribute({ backgroundImage: 'url("a")' }, 'style'); - assert.equal(result.toString(), ' style="background-image:url("a")"'); - }); - - it('passes through string style values unchanged', () => { - const result = addAttribute('background-color:red', 'style'); - assert.equal(result.toString(), ' style="background-color:red"'); - }); - - it('handles style array [object, string] syntax', () => { - const result = addAttribute([{ color: 'red' }, 'font-size:16px'], 'style'); - assert.equal(result.toString(), ' style="color:red;font-size:16px"'); - }); -}); - -describe('class:list forwarded to components', () => { - it('forwards class:list values through component props', async () => { - const page = createMultiChildPage(spreadPropsSpan, [ - { class: 'test control' }, - { 'class:list': 'test expression' }, - { 'class:list': { test: true, true: true, false: false } }, - { - 'class:list': { - test: 1, - truthy: '0', - noshow1: 0, - noshow2: '', - noshow3: null, - noshow4: undefined, - }, - }, - { 'class:list': ['test', 'set'] }, - { 'class:list': ['hello goodbye', { hello: true, world: true }, ['hello', 'friend']] }, - { 'class:list': ['foo', false && 'bar', true && 'baz'] }, - { 'class:list': [false && 'empty'] }, - ]); - const app = createTestApp([createPage(page, { route: '/test' })]); - const response = await app.render(new Request('http://example.com/test')); - const html = await response.text(); - const $ = cheerio.load(html); - - assert.equal($('[class="test control"]').length, 1); - assert.equal($('[class="test expression"]').length, 1); - assert.equal($('[class="test true"]').length, 1); - assert.equal($('[class="test truthy"]').length, 1); - assert.equal($('[class="test set"]').length, 1); - assert.equal($('[class="hello goodbye hello world hello friend"]').length, 1); - assert.equal($('[class="foo baz"]').length, 1); - assert.equal($('span:not([class])').length, 1); - - assert.equal($('[class="test control"]').text(), 'test control'); - assert.equal($('[class="test expression"]').text(), 'test expression'); - assert.equal($('[class="test true"]').text(), 'test true'); - assert.equal($('[class="test truthy"]').text(), 'test truthy'); - assert.equal($('[class="test set"]').text(), 'test set'); - assert.equal( - $('[class="hello goodbye hello world hello friend"]').text(), - 'hello goodbye hello world hello friend', - ); - assert.equal($('[class="foo baz"]').text(), 'foo baz'); - assert.equal($('span:not([class])').text(), ''); - }); -}); - -describe('style object forwarded to components', () => { - it('forwards style values through component props', async () => { - const page = createMultiChildPage(spreadPropsSpan, [ - { style: 'background-color:green' }, - { style: 'background-color:red' }, - { style: { backgroundColor: 'blue' } }, - { style: { backgroundImage: 'url("a")' } }, - ]); - const app = createTestApp([createPage(page, { route: '/test' })]); - const response = await app.render(new Request('http://example.com/test')); - const html = await response.text(); - const $ = cheerio.load(html); - - assert.equal($('[style="background-color:green"]').length, 1); - assert.equal($('[style="background-color:red"]').length, 1); - assert.equal($('[style="background-color:blue"]').length, 1); - assert.equal($(`[style='background-image:url("a")']`).length, 1); - }); -}); diff --git a/packages/astro/test/units/render/class-list-and-style.test.ts b/packages/astro/test/units/render/class-list-and-style.test.ts new file mode 100644 index 000000000000..0a4ada6ce71f --- /dev/null +++ b/packages/astro/test/units/render/class-list-and-style.test.ts @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { addAttribute } from '../../../dist/runtime/server/index.js'; +import { toStyleString } from '../../../dist/runtime/server/render/util.js'; +import { createTestApp, createPage, createMultiChildPage, spreadPropsSpan } from '../mocks.ts'; + +describe('class:list', () => { + it('handles a plain string', () => { + const result = addAttribute('test expression', 'class:list'); + assert.equal(result.toString(), ' class="test expression"'); + }); + + it('handles an array of strings', () => { + const result = addAttribute(['test', 'set'], 'class:list'); + assert.equal(result.toString(), ' class="test set"'); + }); + + it('handles an object with boolean values', () => { + const result = addAttribute({ test: true, true: true, false: false }, 'class:list'); + assert.equal(result.toString(), ' class="test true"'); + }); + + it('handles an object with truthy/falsy values', () => { + const result = addAttribute( + { test: 1, truthy: '0', noshow1: 0, noshow2: '', noshow3: null, noshow4: undefined }, + 'class:list', + ); + assert.equal(result.toString(), ' class="test truthy"'); + }); + + it('handles nested arrays and objects', () => { + const result = addAttribute( + ['hello goodbye', { hello: true, world: true }, ['hello', 'friend']], + 'class:list', + ); + assert.equal(result.toString(), ' class="hello goodbye hello world hello friend"'); + }); + + it('handles conditional expressions in arrays', () => { + const result = addAttribute(['foo', false && 'bar', true && 'baz'], 'class:list'); + assert.equal(result.toString(), ' class="foo baz"'); + }); + + it('returns empty string when all values are falsy', () => { + const result = addAttribute([false && 'empty'], 'class:list'); + assert.equal(result.toString(), ''); + }); + + it('returns empty string for null', () => { + const result = addAttribute(null, 'class:list'); + assert.equal(result.toString(), ''); + }); + + it('returns empty string for undefined', () => { + const result = addAttribute(undefined, 'class:list'); + assert.equal(result.toString(), ''); + }); +}); + +describe('toStyleString', () => { + it('converts camelCase to kebab-case', () => { + assert.equal(toStyleString({ backgroundColor: 'green' }), 'background-color:green'); + }); + + it('preserves CSS custom properties', () => { + assert.equal(toStyleString({ '--my-var': 'red' }), '--my-var:red'); + }); + + it('joins multiple properties with semicolons', () => { + assert.equal( + toStyleString({ backgroundColor: 'green', color: 'red' }), + 'background-color:green;color:red', + ); + }); + + it('handles numeric values', () => { + assert.equal(toStyleString({ zIndex: 10 }), 'z-index:10'); + }); + + it('handles zero as a value', () => { + assert.equal(toStyleString({ margin: 0 }), 'margin:0'); + }); + + it('filters out empty string values', () => { + assert.equal(toStyleString({ color: '', background: 'red' }), 'background:red'); + }); + + it('filters out whitespace-only string values', () => { + assert.equal(toStyleString({ color: ' ', background: 'red' }), 'background:red'); + }); + + it('filters out null/undefined/boolean values', () => { + assert.equal( + toStyleString({ color: null, background: undefined, display: false, margin: 'auto' }), + 'margin:auto', + ); + }); + + it('preserves quoted values', () => { + assert.equal(toStyleString({ backgroundImage: 'url("a")' }), 'background-image:url("a")'); + }); +}); + +describe('style object via addAttribute', () => { + it('converts style object to attribute string', () => { + const result = addAttribute({ backgroundColor: 'green' }, 'style'); + assert.equal(result.toString(), ' style="background-color:green"'); + }); + + it('converts style object with multiple properties', () => { + const result = addAttribute({ backgroundColor: 'blue', color: 'white' }, 'style'); + assert.equal(result.toString(), ' style="background-color:blue;color:white"'); + }); + + it('handles url() with quotes in style object', () => { + const result = addAttribute({ backgroundImage: 'url("a")' }, 'style'); + assert.equal(result.toString(), ' style="background-image:url("a")"'); + }); + + it('passes through string style values unchanged', () => { + const result = addAttribute('background-color:red', 'style'); + assert.equal(result.toString(), ' style="background-color:red"'); + }); + + it('handles style array [object, string] syntax', () => { + const result = addAttribute([{ color: 'red' }, 'font-size:16px'], 'style'); + assert.equal(result.toString(), ' style="color:red;font-size:16px"'); + }); +}); + +describe('class:list forwarded to components', () => { + it('forwards class:list values through component props', async () => { + const page = createMultiChildPage(spreadPropsSpan, [ + { class: 'test control' }, + { 'class:list': 'test expression' }, + { 'class:list': { test: true, true: true, false: false } }, + { + 'class:list': { + test: 1, + truthy: '0', + noshow1: 0, + noshow2: '', + noshow3: null, + noshow4: undefined, + }, + }, + { 'class:list': ['test', 'set'] }, + { 'class:list': ['hello goodbye', { hello: true, world: true }, ['hello', 'friend']] }, + { 'class:list': ['foo', false && 'bar', true && 'baz'] }, + { 'class:list': [false && 'empty'] }, + ]); + const app = createTestApp([createPage(page, { route: '/test' })]); + const response = await app.render(new Request('http://example.com/test')); + const html = await response.text(); + const $ = cheerio.load(html); + + assert.equal($('[class="test control"]').length, 1); + assert.equal($('[class="test expression"]').length, 1); + assert.equal($('[class="test true"]').length, 1); + assert.equal($('[class="test truthy"]').length, 1); + assert.equal($('[class="test set"]').length, 1); + assert.equal($('[class="hello goodbye hello world hello friend"]').length, 1); + assert.equal($('[class="foo baz"]').length, 1); + assert.equal($('span:not([class])').length, 1); + + assert.equal($('[class="test control"]').text(), 'test control'); + assert.equal($('[class="test expression"]').text(), 'test expression'); + assert.equal($('[class="test true"]').text(), 'test true'); + assert.equal($('[class="test truthy"]').text(), 'test truthy'); + assert.equal($('[class="test set"]').text(), 'test set'); + assert.equal( + $('[class="hello goodbye hello world hello friend"]').text(), + 'hello goodbye hello world hello friend', + ); + assert.equal($('[class="foo baz"]').text(), 'foo baz'); + assert.equal($('span:not([class])').text(), ''); + }); +}); + +describe('style object forwarded to components', () => { + it('forwards style values through component props', async () => { + const page = createMultiChildPage(spreadPropsSpan, [ + { style: 'background-color:green' }, + { style: 'background-color:red' }, + { style: { backgroundColor: 'blue' } }, + { style: { backgroundImage: 'url("a")' } }, + ]); + const app = createTestApp([createPage(page, { route: '/test' })]); + const response = await app.render(new Request('http://example.com/test')); + const html = await response.text(); + const $ = cheerio.load(html); + + assert.equal($('[style="background-color:green"]').length, 1); + assert.equal($('[style="background-color:red"]').length, 1); + assert.equal($('[style="background-color:blue"]').length, 1); + assert.equal($(`[style='background-image:url("a")']`).length, 1); + }); +}); diff --git a/packages/astro/test/units/render/components.test.js b/packages/astro/test/units/render/components.test.js deleted file mode 100644 index 3d274702710a..000000000000 --- a/packages/astro/test/units/render/components.test.js +++ /dev/null @@ -1,201 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { createFixture, createRequestAndResponse, runInContainer } from '../test-utils.js'; - -describe('core/render components', () => { - it('should sanitize dynamic tags', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': ` - --- - const TagA = 'p style=color:red;' - const TagB = 'p>' - --- - - testing - - - - - - `, - }); - - await runInContainer( - { - inlineConfig: { - root: fixture.path, - logLevel: 'silent', - integrations: [], - }, - }, - async (container) => { - const { req, res, done, text } = createRequestAndResponse({ - method: 'GET', - url: '/', - }); - container.handle(req, res); - - await done; - const html = await text(); - const $ = cheerio.load(html); - const target = $('#target'); - - assert.ok(target); - assert.equal(target.attr('id'), 'target'); - assert.equal(typeof target.attr('style'), 'undefined'); - - assert.equal($('#pwnd').length, 0); - }, - ); - }); - - it('should merge `class` and `class:list`', async () => { - const fixture = await createFixture({ - '/src/pages/index.astro': ` - --- - import Class from '../components/Class.astro'; - import ClassList from '../components/ClassList.astro'; - import BothLiteral from '../components/BothLiteral.astro'; - import BothFlipped from '../components/BothFlipped.astro'; - import BothSpread from '../components/BothSpread.astro'; - --- - - - - - - `, - '/src/components/Class.astro': `
    `,
    -			'/src/components/ClassList.astro': `
    `,
    -			'/src/components/BothLiteral.astro': `
    `,
    -			'/src/components/BothFlipped.astro': `
    `,
    -			'/src/components/BothSpread.astro': `
    `,
    -		});
    -
    -		await runInContainer(
    -			{
    -				inlineConfig: {
    -					root: fixture.path,
    -					logLevel: 'silent',
    -					integrations: [],
    -				},
    -			},
    -			async (container) => {
    -				const { req, res, done, text } = createRequestAndResponse({
    -					method: 'GET',
    -					url: '/',
    -				});
    -				container.handle(req, res);
    -
    -				await done;
    -				const html = await text();
    -				const $ = cheerio.load(html);
    -
    -				const check = (name) => JSON.parse($(name).text() || '{}');
    -
    -				const Class = check('#class');
    -				const ClassList = check('#class-list');
    -				const BothLiteral = check('#both-literal');
    -				const BothFlipped = check('#both-flipped');
    -				const BothSpread = check('#both-spread');
    -
    -				assert.deepEqual(Class, { class: 'red blue' }, '#class');
    -				assert.deepEqual(ClassList, { class: 'red blue' }, '#class-list');
    -				assert.deepEqual(BothLiteral, { class: 'red blue' }, '#both-literal');
    -				assert.deepEqual(BothFlipped, { class: 'red blue' }, '#both-flipped');
    -				assert.deepEqual(BothSpread, { class: 'red blue' }, '#both-spread');
    -			},
    -		);
    -	});
    -
    -	it('should render component with `null` response', async () => {
    -		const fixture = await createFixture({
    -			'/src/pages/index.astro': `
    -				---
    -				import NullComponent from '../components/NullComponent.astro';
    -				---
    -				
    -			`,
    -			'/src/components/NullComponent.astro': `
    -				---
    -				return null;
    -				---
    -			`,
    -		});
    -
    -		await runInContainer(
    -			{
    -				inlineConfig: {
    -					root: fixture.path,
    -					logLevel: 'silent',
    -				},
    -			},
    -			async (container) => {
    -				const { req, res, done, text } = createRequestAndResponse({
    -					method: 'GET',
    -					url: '/',
    -				});
    -				container.handle(req, res);
    -
    -				await done;
    -				const html = await text();
    -				const $ = cheerio.load(html);
    -
    -				assert.equal($('body').text(), '');
    -				assert.equal(res.statusCode, 200);
    -			},
    -		);
    -	});
    -
    -	it('should render custom element attributes as strings instead of boolean attributes', async () => {
    -		const fixture = await createFixture({
    -			'/src/pages/index.astro': `
    -				---
    -				const selectedColor = "blue";
    -				const autoplay = 2000;
    -				---
    -				
    -					Custom Element Attributes Test
    -					
    -						
    -						
    -						Test with autoplay prop working
    -					
    -				
    -			`,
    -		});
    -
    -		await runInContainer(
    -			{
    -				inlineConfig: {
    -					root: fixture.path,
    -					logLevel: 'silent',
    -					integrations: [],
    -				},
    -			},
    -			async (container) => {
    -				const { req, res, done, text } = createRequestAndResponse({
    -					method: 'GET',
    -					url: '/',
    -				});
    -				container.handle(req, res);
    -
    -				await done;
    -				const html = await text();
    -
    -				// Extract test data - following same pattern as class merging test
    -				const hasSelectedBlue = html.includes('selected="blue"');
    -				const hasAutoplay2000 = html.includes('autoplay="2000"');
    -				const hasBooleanSelected = html.includes('');
    -				const hasBooleanAutoplay = html.includes('');
    -
    -				// Test custom elements render string attributes correctly
    -				assert.ok(hasSelectedBlue, 'selected="blue"');
    -				assert.ok(hasAutoplay2000, 'autoplay="2000"');
    -				assert.ok(!hasBooleanSelected, 'no boolean selected');
    -				assert.ok(!hasBooleanAutoplay, 'no boolean autoplay');
    -			},
    -		);
    -	});
    -});
    diff --git a/packages/astro/test/units/render/context-helpers.test.js b/packages/astro/test/units/render/context-helpers.test.js
    deleted file mode 100644
    index bfddaa8fcba0..000000000000
    --- a/packages/astro/test/units/render/context-helpers.test.js
    +++ /dev/null
    @@ -1,73 +0,0 @@
    -// @ts-check
    -import assert from 'node:assert/strict';
    -import { describe, it } from 'node:test';
    -import { createComponent, render } from '../../../dist/runtime/server/index.js';
    -import { createTestApp, createPage } from '../mocks.js';
    -
    -async function renderAndCapture(page, manifestOverrides = {}) {
    -	const app = createTestApp(
    -		[createPage(page, { route: '/test', prerender: false })],
    -		manifestOverrides,
    -	);
    -	const response = await app.render(new Request('http://example.com/test'));
    -	return response;
    -}
    -
    -describe('Astro.session getter', () => {
    -	it('returns undefined when no session driver is configured', async () => {
    -		let sessionValue = 'not-called';
    -		const page = createComponent((result, props, slots) => {
    -			const Astro = result.createAstro(props, slots);
    -			sessionValue = Astro.session;
    -			return render`

    done

    `; - }); - - await renderAndCapture(page); - - assert.equal(sessionValue, undefined); - }); -}); - -describe('Astro.csp getter', () => { - it('returns undefined when CSP is not configured in the manifest', async () => { - let cspValue = 'not-called'; - const page = createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - cspValue = Astro.csp; - return render`

    done

    `; - }); - - await renderAndCapture(page); - - assert.equal(cspValue, undefined); - }); - - it('returns an object with insert* methods when CSP is configured', async () => { - let cspValue; - const page = createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - cspValue = Astro.csp; - return render`

    done

    `; - }); - - await renderAndCapture(page, { - csp: { - algorithm: 'SHA-256', - cspDestination: 'header', - scriptHashes: [], - styleHashes: [], - scriptResources: [], - styleResources: [], - directives: [], - isStrictDynamic: false, - }, - }); - - assert.ok(cspValue !== undefined, 'expected csp object when CSP is configured'); - assert.equal(typeof cspValue.insertScriptHash, 'function'); - assert.equal(typeof cspValue.insertStyleHash, 'function'); - assert.equal(typeof cspValue.insertScriptResource, 'function'); - assert.equal(typeof cspValue.insertStyleResource, 'function'); - assert.equal(typeof cspValue.insertDirective, 'function'); - }); -}); diff --git a/packages/astro/test/units/render/context-helpers.test.ts b/packages/astro/test/units/render/context-helpers.test.ts new file mode 100644 index 000000000000..2dae5dc8291f --- /dev/null +++ b/packages/astro/test/units/render/context-helpers.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createComponent, render } from '../../../dist/runtime/server/index.js'; +import type { AstroComponentFactory } from '../../../dist/runtime/server/render/index.js'; +import { createTestApp, createPage } from '../mocks.ts'; + +async function renderAndCapture( + page: AstroComponentFactory, + manifestOverrides: Record = {}, +) { + const app = createTestApp( + [createPage(page, { route: '/test', prerender: false })], + manifestOverrides, + ); + const response = await app.render(new Request('http://example.com/test')); + return response; +} + +describe('Astro.session getter', () => { + it('returns undefined when no session driver is configured', async () => { + let sessionValue: unknown = 'not-called'; + const page = createComponent((result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + sessionValue = Astro.session; + return render`

    done

    `; + }); + + await renderAndCapture(page); + + assert.equal(sessionValue, undefined); + }); +}); + +describe('Astro.csp getter', () => { + it('returns undefined when CSP is not configured in the manifest', async () => { + let cspValue: unknown = 'not-called'; + const page = createComponent((result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + cspValue = Astro.csp; + return render`

    done

    `; + }); + + await renderAndCapture(page); + + assert.equal(cspValue, undefined); + }); + + it('returns an object with insert* methods when CSP is configured', async () => { + let cspValue: Record | undefined; + const page = createComponent((result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + cspValue = Astro.csp; + return render`

    done

    `; + }); + + await renderAndCapture(page, { + csp: { + algorithm: 'SHA-256', + cspDestination: 'header', + scriptHashes: [], + styleHashes: [], + scriptResources: [], + styleResources: [], + directives: [], + isStrictDynamic: false, + }, + }); + + assert.ok(cspValue !== undefined, 'expected csp object when CSP is configured'); + assert.equal(typeof cspValue.insertScriptHash, 'function'); + assert.equal(typeof cspValue.insertStyleHash, 'function'); + assert.equal(typeof cspValue.insertScriptResource, 'function'); + assert.equal(typeof cspValue.insertStyleResource, 'function'); + assert.equal(typeof cspValue.insertDirective, 'function'); + }); +}); diff --git a/packages/astro/test/units/render/escape.test.js b/packages/astro/test/units/render/escape.test.js deleted file mode 100644 index e38af171cc18..000000000000 --- a/packages/astro/test/units/render/escape.test.js +++ /dev/null @@ -1,136 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - escapeHTML, - isHTMLString, - markHTMLString, - unescapeHTML, -} from '../../../dist/runtime/server/escape.js'; - -describe('markHTMLString', () => { - it('wraps a string in an HTMLString instance', () => { - const result = markHTMLString('hello'); - assert.ok(isHTMLString(result), 'should be an HTMLString'); - assert.equal(String(result), 'hello'); - }); - - it('returns the value unchanged if already an HTMLString', () => { - const original = markHTMLString('hello'); - const again = markHTMLString(original); - assert.ok(isHTMLString(again)); - assert.equal(String(again), 'hello'); - }); -}); - -describe('isHTMLString', () => { - it('returns true for HTMLString instances', () => { - assert.equal(isHTMLString(markHTMLString('hello')), true); - }); - - it('returns false for plain strings', () => { - assert.equal(isHTMLString('hello'), false); - }); - - it('returns false for null and undefined', () => { - assert.equal(isHTMLString(null), false); - assert.equal(isHTMLString(undefined), false); - }); - - it('returns false for numbers and objects', () => { - assert.equal(isHTMLString(42), false); - assert.equal(isHTMLString({}), false); - }); -}); - -describe('escapeHTML', () => { - it('escapes < and > to < and >', () => { - const result = String(escapeHTML('')); - assert.equal(result, '<script>alert("xss")</script>'); - }); - - it('escapes & to &', () => { - assert.equal(String(escapeHTML('a & b')), 'a & b'); - }); - - it('escapes " to "', () => { - assert.equal(String(escapeHTML('"hello"')), '"hello"'); - }); - - it("escapes ' to '", () => { - assert.equal(String(escapeHTML("it's")), 'it's'); - }); - - it('escapes a plain string and returns a string', () => { - const result = escapeHTML('test'); - assert.equal(typeof result, 'string'); - assert.equal(result, '<b>test</b>'); - }); -}); - -describe('unescapeHTML', () => { - it('can take a string of HTML — Migrated from test/set-html.test.js', async () => { - const result = unescapeHTML('bold'); - assert.ok(isHTMLString(result), 'should return an HTMLString'); - assert.equal(String(result), 'bold'); - }); - - it('can take a Promise to a string of HTML — Migrated from test/set-html.test.js', async () => { - const result = await unescapeHTML(Promise.resolve('bold')); - assert.ok(isHTMLString(result)); - assert.ok(String(result).includes('bold')); - }); - - it('can take an Iterator — Migrated from test/set-html.test.js', async () => { - function* gen() { - yield '
  • 1
  • '; - yield '
  • 2
  • '; - } - const result = unescapeHTML(gen()); - const chunks = []; - for await (const chunk of result) { - chunks.push(String(chunk)); - } - assert.ok(chunks.join('').includes('
  • 1
  • ')); - assert.ok(chunks.join('').includes('
  • 2
  • ')); - }); - - it('can take an AsyncIterator', async () => { - async function* gen() { - yield '
  • a
  • '; - yield '
  • b
  • '; - } - const result = unescapeHTML(gen()); - const chunks = []; - for await (const chunk of result) { - chunks.push(String(chunk)); - } - assert.ok(chunks.join('').includes('
  • a
  • ')); - }); - - it('can take a Response', async () => { - const response = new Response('

    hello

    ', { headers: { 'content-type': 'text/html' } }); - const result = unescapeHTML(response); - const chunks = []; - const dec = new TextDecoder(); - for await (const chunk of result) { - chunks.push(chunk instanceof Uint8Array ? dec.decode(chunk) : String(chunk)); - } - assert.ok(chunks.join('').includes('

    hello

    ')); - }); - - it('can take a ReadableStream', async () => { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue('stream'); - controller.close(); - }, - }); - const result = unescapeHTML(stream); - const chunks = []; - for await (const chunk of result) { - chunks.push(String(chunk)); - } - assert.ok(chunks.join('').includes('stream')); - }); -}); diff --git a/packages/astro/test/units/render/escape.test.ts b/packages/astro/test/units/render/escape.test.ts new file mode 100644 index 000000000000..19e8402a01ac --- /dev/null +++ b/packages/astro/test/units/render/escape.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + escapeHTML, + isHTMLString, + markHTMLString, + unescapeHTML, +} from '../../../dist/runtime/server/escape.js'; + +describe('markHTMLString', () => { + it('wraps a string in an HTMLString instance', () => { + const result = markHTMLString('hello'); + assert.ok(isHTMLString(result), 'should be an HTMLString'); + assert.equal(String(result), 'hello'); + }); + + it('returns the value unchanged if already an HTMLString', () => { + const original = markHTMLString('hello'); + const again = markHTMLString(original); + assert.ok(isHTMLString(again)); + assert.equal(String(again), 'hello'); + }); +}); + +describe('isHTMLString', () => { + it('returns true for HTMLString instances', () => { + assert.equal(isHTMLString(markHTMLString('hello')), true); + }); + + it('returns false for plain strings', () => { + assert.equal(isHTMLString('hello'), false); + }); + + it('returns false for null and undefined', () => { + assert.equal(isHTMLString(null), false); + assert.equal(isHTMLString(undefined), false); + }); + + it('returns false for numbers and objects', () => { + assert.equal(isHTMLString(42), false); + assert.equal(isHTMLString({}), false); + }); +}); + +describe('escapeHTML', () => { + it('escapes < and > to < and >', () => { + const result = String(escapeHTML('')); + assert.equal(result, '<script>alert("xss")</script>'); + }); + + it('escapes & to &', () => { + assert.equal(String(escapeHTML('a & b')), 'a & b'); + }); + + it('escapes " to "', () => { + assert.equal(String(escapeHTML('"hello"')), '"hello"'); + }); + + it("escapes ' to '", () => { + assert.equal(String(escapeHTML("it's")), 'it's'); + }); + + it('escapes a plain string and returns a string', () => { + const result = escapeHTML('test'); + assert.equal(typeof result, 'string'); + assert.equal(result, '<b>test</b>'); + }); +}); + +describe('unescapeHTML', () => { + it('can take a string of HTML — Migrated from test/set-html.test.js', async () => { + const result = unescapeHTML('bold'); + assert.ok(isHTMLString(result), 'should return an HTMLString'); + assert.equal(String(result), 'bold'); + }); + + it('can take a Promise to a string of HTML — Migrated from test/set-html.test.js', async () => { + const result = await unescapeHTML(Promise.resolve('bold')); + assert.ok(isHTMLString(result)); + assert.ok(String(result).includes('bold')); + }); + + it('can take an Iterator — Migrated from test/set-html.test.js', async () => { + function* gen() { + yield '
  • 1
  • '; + yield '
  • 2
  • '; + } + const result = unescapeHTML(gen()) as AsyncIterable; + const chunks: string[] = []; + for await (const chunk of result) { + chunks.push(String(chunk)); + } + assert.ok(chunks.join('').includes('
  • 1
  • ')); + assert.ok(chunks.join('').includes('
  • 2
  • ')); + }); + + it('can take an AsyncIterator', async () => { + async function* gen() { + yield '
  • a
  • '; + yield '
  • b
  • '; + } + const result = unescapeHTML(gen()) as AsyncIterable; + const chunks: string[] = []; + for await (const chunk of result) { + chunks.push(String(chunk)); + } + assert.ok(chunks.join('').includes('
  • a
  • ')); + }); + + it('can take a Response', async () => { + const response = new Response('

    hello

    ', { headers: { 'content-type': 'text/html' } }); + const result = unescapeHTML(response) as AsyncIterable; + const chunks: string[] = []; + const dec = new TextDecoder(); + for await (const chunk of result) { + chunks.push(chunk instanceof Uint8Array ? dec.decode(chunk) : String(chunk)); + } + assert.ok(chunks.join('').includes('

    hello

    ')); + }); + + it('can take a ReadableStream', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue('stream'); + controller.close(); + }, + }); + const result = unescapeHTML(stream) as AsyncIterable; + const chunks: string[] = []; + for await (const chunk of result) { + chunks.push(String(chunk)); + } + assert.ok(chunks.join('').includes('stream')); + }); +}); diff --git a/packages/astro/test/units/render/head-injection-app.test.js b/packages/astro/test/units/render/head-injection-app.test.js deleted file mode 100644 index e718b57874f2..000000000000 --- a/packages/astro/test/units/render/head-injection-app.test.js +++ /dev/null @@ -1,174 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { RenderContext } from '../../../dist/core/render-context.js'; -import { - createComponent, - createHeadAndContent, - maybeRenderHead, - render, - renderComponent, - renderHead, - renderSlot, - renderSlotToString, - renderUniqueStylesheet, - unescapeHTML, -} from '../../../dist/runtime/server/index.js'; -import { createBasicPipeline } from '../test-utils.js'; - -const createAstroModule = (AstroComponent) => ({ default: AstroComponent }); - -describe('head injection app-level rendering', () => { - let pipeline; - - before(async () => { - pipeline = createBasicPipeline(); - pipeline.headElements = () => ({ - links: new Set(), - scripts: new Set(), - styles: new Set(), - }); - }); - - async function renderPage(Component) { - const request = new Request('http://example.com/'); - const routeData = { - type: 'page', - pathname: '/index', - component: 'src/pages/index.astro', - params: {}, - }; - const renderContext = await RenderContext.create({ pipeline, request, routeData }); - const response = await renderContext.render(createAstroModule(Component)); - return cheerio.load(await response.text()); - } - - it('injects propagated head from component created in page scope', async () => { - const Other = createComponent(() => render`
    Other
    `); - const HeadEntry = createComponent({ - factory(result, props, slots) { - const link = renderUniqueStylesheet(result, { - type: 'external', - src: '/some/fake/styles.css', - }); - return createHeadAndContent( - unescapeHTML(link), - render`${renderComponent(result, 'Other', Other, props, slots)}`, - ); - }, - propagation: 'self', - }); - - const Wrapper = createComponent( - (result) => - render`${renderHead(result)}${renderComponent(result, 'HeadEntry', HeadEntry, {}, {})}`, - ); - - const $ = await renderPage(Wrapper); - assert.equal($('head link[rel="stylesheet"][href="/some/fake/styles.css"]').length, 1); - assert.equal($('body link[rel="stylesheet"]').length, 0); - assert.equal($('#other').length, 1); - }); - - it('injects propagated head through nested layout components', async () => { - const Other = createComponent(() => render`
    Other
    `); - const HeadEntry = createComponent({ - factory(result, props, slots) { - const link = renderUniqueStylesheet(result, { - type: 'external', - src: '/some/fake/styles.css', - }); - return createHeadAndContent( - unescapeHTML(link), - render`${renderComponent(result, 'Other', Other, props, slots)}`, - ); - }, - propagation: 'self', - }); - - const Content = createComponent( - (result) => render`${renderComponent(result, 'HeadEntry', HeadEntry, {}, {})}`, - ); - Content.propagation = 'in-tree'; - const Inner = createComponent( - (result) => render`${renderComponent(result, 'Content', Content, {}, {})}`, - ); - Inner.propagation = 'in-tree'; - const Layout = createComponent({ - async factory(result, _props, slots) { - const slotted = await renderSlotToString(result, slots.default); - return render`Normal head stuff${renderHead(result)}${unescapeHTML(slotted)}`; - }, - }); - const Page = createComponent( - (result) => - render`${renderComponent(result, 'Layout', Layout, {}, { default: () => render`${renderComponent(result, 'Inner', Inner, {}, {})}` })}`, - ); - - const $ = await renderPage(Page); - assert.equal($('head link[rel="stylesheet"][href="/some/fake/styles.css"]').length, 1); - assert.equal($('body link[rel="stylesheet"]').length, 0); - assert.equal($('#other').length, 1); - }); - - it('supports slot rendering during head buffering without style bleed', async () => { - const SlottedContent = createComponent({ - factory(result) { - const link = renderUniqueStylesheet(result, { - type: 'external', - src: '/styles/from-slot.css', - }); - return createHeadAndContent(unescapeHTML(link), render`

    Paragraph.

    `); - }, - propagation: 'self', - }); - - const SlotRenderComponent = createComponent({ - async factory(result, _props, slots) { - const html = await renderSlotToString(result, slots.default); - const ownLink = renderUniqueStylesheet(result, { - type: 'external', - src: '/styles/slot-render.css', - }); - return createHeadAndContent( - ownLink, - render`
    ${unescapeHTML(html)}
    `, - ); - }, - propagation: 'self', - }); - - const Layout = createComponent( - (result, _props, slots) => - render`${maybeRenderHead(result)}${renderSlot(result, slots.default)}`, - ); - const Page = createComponent( - (result) => - render`${renderComponent( - result, - 'Layout', - Layout, - {}, - { - default: () => - render`${renderComponent( - result, - 'SlotRenderComponent', - SlotRenderComponent, - {}, - { - default: () => - render`${renderComponent(result, 'SlottedContent', SlottedContent, {}, {})}`, - }, - )}`, - }, - )}`, - ); - - const $ = await renderPage(Page); - assert.equal($('head link[href="/styles/slot-render.css"]').length, 1); - assert.equal($('head link[href="/styles/from-slot.css"]').length, 1); - assert.equal($('body link[rel="stylesheet"]').length, 0); - assert.equal($('p').text(), 'Paragraph.'); - }); -}); diff --git a/packages/astro/test/units/render/head-injection-app.test.ts b/packages/astro/test/units/render/head-injection-app.test.ts new file mode 100644 index 000000000000..daacf7972a4f --- /dev/null +++ b/packages/astro/test/units/render/head-injection-app.test.ts @@ -0,0 +1,184 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { RenderContext } from '../../../dist/core/render-context.js'; +import { + createComponent, + createHeadAndContent, + maybeRenderHead as _maybeRenderHead, + render, + renderComponent, + renderHead as _renderHead, + renderSlot, + renderSlotToString, + renderUniqueStylesheet, + unescapeHTML, +} from '../../../dist/runtime/server/index.js'; +import type { AstroComponentFactory } from '../../../dist/runtime/server/render/index.js'; +import type { Pipeline } from '../../../dist/core/render/index.js'; +import { createBasicPipeline } from '../test-utils.ts'; + +// The public types for renderHead/maybeRenderHead declare zero params, +// but the runtime implementation accepts a result argument. +const renderHead = _renderHead as (result: any) => any; +const maybeRenderHead = _maybeRenderHead as (result: any) => any; + +const createAstroModule = (AstroComponent: AstroComponentFactory) => ({ default: AstroComponent }); + +describe('head injection app-level rendering', () => { + let pipeline: Pipeline; + + before(async () => { + pipeline = createBasicPipeline(); + pipeline.headElements = () => ({ + links: new Set(), + scripts: new Set(), + styles: new Set(), + }); + }); + + async function renderPage(Component: AstroComponentFactory) { + const request = new Request('http://example.com/'); + const routeData = { + type: 'page', + pathname: '/index', + component: 'src/pages/index.astro', + params: {}, + }; + const renderContext = await RenderContext.create({ pipeline, request, routeData } as any); + const response = await renderContext.render(createAstroModule(Component)); + return cheerio.load(await response.text()); + } + + it('injects propagated head from component created in page scope', async () => { + const Other = createComponent(() => render`
    Other
    `); + const HeadEntry = createComponent({ + factory(result: any, props: any, slots: any) { + const link = renderUniqueStylesheet(result, { + type: 'external', + src: '/some/fake/styles.css', + }); + return createHeadAndContent( + unescapeHTML(link) as unknown as string, + render`${renderComponent(result, 'Other', Other, props, slots)}`, + ); + }, + propagation: 'self', + }); + + const Wrapper = createComponent( + (result: any) => + render`${renderHead(result)}${renderComponent(result, 'HeadEntry', HeadEntry, {}, {})}`, + ); + + const $ = await renderPage(Wrapper); + assert.equal($('head link[rel="stylesheet"][href="/some/fake/styles.css"]').length, 1); + assert.equal($('body link[rel="stylesheet"]').length, 0); + assert.equal($('#other').length, 1); + }); + + it('injects propagated head through nested layout components', async () => { + const Other = createComponent(() => render`
    Other
    `); + const HeadEntry = createComponent({ + factory(result: any, props: any, slots: any) { + const link = renderUniqueStylesheet(result, { + type: 'external', + src: '/some/fake/styles.css', + }); + return createHeadAndContent( + unescapeHTML(link) as unknown as string, + render`${renderComponent(result, 'Other', Other, props, slots)}`, + ); + }, + propagation: 'self', + }); + + const Content = createComponent( + (result: any) => render`${renderComponent(result, 'HeadEntry', HeadEntry, {}, {})}`, + ); + Content.propagation = 'in-tree'; + const Inner = createComponent( + (result: any) => render`${renderComponent(result, 'Content', Content, {}, {})}`, + ); + Inner.propagation = 'in-tree'; + const Layout = createComponent({ + async factory(result: any, _props: any, slots: any) { + const slotted = await renderSlotToString(result, slots.default); + return render`Normal head stuff${renderHead(result)}${unescapeHTML(slotted)}`; + }, + }); + const Page = createComponent( + (result: any) => + render`${renderComponent(result, 'Layout', Layout, {}, { default: () => render`${renderComponent(result, 'Inner', Inner, {}, {})}` })}`, + ); + + const $ = await renderPage(Page); + assert.equal($('head link[rel="stylesheet"][href="/some/fake/styles.css"]').length, 1); + assert.equal($('body link[rel="stylesheet"]').length, 0); + assert.equal($('#other').length, 1); + }); + + it('supports slot rendering during head buffering without style bleed', async () => { + const SlottedContent = createComponent({ + factory(result: any) { + const link = renderUniqueStylesheet(result, { + type: 'external', + src: '/styles/from-slot.css', + }); + return createHeadAndContent( + unescapeHTML(link) as unknown as string, + render`

    Paragraph.

    `, + ); + }, + propagation: 'self', + }); + + const SlotRenderComponent = createComponent({ + async factory(result: any, _props: any, slots: any) { + const html = await renderSlotToString(result, slots.default); + const ownLink = renderUniqueStylesheet(result, { + type: 'external', + src: '/styles/slot-render.css', + }); + return createHeadAndContent( + ownLink!, + render`
    ${unescapeHTML(html)}
    `, + ); + }, + propagation: 'self', + }); + + const Layout = createComponent( + (result: any, _props: any, slots: any) => + render`${maybeRenderHead(result)}${renderSlot(result, slots.default)}`, + ); + const Page = createComponent( + (result: any) => + render`${renderComponent( + result, + 'Layout', + Layout, + {}, + { + default: () => + render`${renderComponent( + result, + 'SlotRenderComponent', + SlotRenderComponent, + {}, + { + default: () => + render`${renderComponent(result, 'SlottedContent', SlottedContent, {}, {})}`, + }, + )}`, + }, + )}`, + ); + + const $ = await renderPage(Page); + assert.equal($('head link[href="/styles/slot-render.css"]').length, 1); + assert.equal($('head link[href="/styles/from-slot.css"]').length, 1); + assert.equal($('body link[rel="stylesheet"]').length, 0); + assert.equal($('p').text(), 'Paragraph.'); + }); +}); diff --git a/packages/astro/test/units/render/head-propagation/boundary.test.js b/packages/astro/test/units/render/head-propagation/boundary.test.ts similarity index 100% rename from packages/astro/test/units/render/head-propagation/boundary.test.js rename to packages/astro/test/units/render/head-propagation/boundary.test.ts diff --git a/packages/astro/test/units/render/head-propagation/buffer.test.js b/packages/astro/test/units/render/head-propagation/buffer.test.js deleted file mode 100644 index 4ae667f50ab4..000000000000 --- a/packages/astro/test/units/render/head-propagation/buffer.test.js +++ /dev/null @@ -1,90 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { collectPropagatedHeadParts } from '../../../../dist/core/head-propagation/buffer.js'; - -const headAndContentSym = Symbol.for('astro.headAndContent'); - -function createHeadAndContentLike(head) { - return { - [headAndContentSym]: true, - head, - }; -} - -function isHeadAndContent(value) { - return typeof value === 'object' && value !== null && headAndContentSym in value; -} - -function createResult() { - return {}; -} - -describe('head propagation buffer', () => { - it('returns empty head parts when no propagators exist', async () => { - const collected = await collectPropagatedHeadParts({ - propagators: new Set(), - result: createResult(), - isHeadAndContent, - }); - assert.deepEqual(collected, []); - }); - - it('collects non-empty head strings from propagators', async () => { - const propagators = new Set([ - { init: () => createHeadAndContentLike('') }, - { init: () => createHeadAndContentLike('') }, - ]); - - const collected = await collectPropagatedHeadParts({ - propagators, - result: createResult(), - isHeadAndContent, - }); - - assert.deepEqual(collected, [ - '', - '', - ]); - }); - - it('skips non-head-and-content values and empty heads', async () => { - const propagators = new Set([ - { init: () => 'value' }, - { init: () => createHeadAndContentLike('') }, - { init: () => createHeadAndContentLike('') }, - ]); - - const collected = await collectPropagatedHeadParts({ - propagators, - result: createResult(), - isHeadAndContent, - }); - - assert.deepEqual(collected, ['']); - }); - - it('processes propagators added while iterating', async () => { - const propagators = new Set(); - propagators.add({ - init() { - propagators.add({ - init() { - return createHeadAndContentLike(''); - }, - }); - return createHeadAndContentLike(''); - }, - }); - - const collected = await collectPropagatedHeadParts({ - propagators, - result: createResult(), - isHeadAndContent, - }); - - assert.deepEqual(collected, [ - '', - '', - ]); - }); -}); diff --git a/packages/astro/test/units/render/head-propagation/buffer.test.ts b/packages/astro/test/units/render/head-propagation/buffer.test.ts new file mode 100644 index 000000000000..6d1807b0ff0a --- /dev/null +++ b/packages/astro/test/units/render/head-propagation/buffer.test.ts @@ -0,0 +1,92 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { HeadPropagator } from '../../../../dist/core/head-propagation/buffer.js'; +import { collectPropagatedHeadParts } from '../../../../dist/core/head-propagation/buffer.js'; +import type { SSRResult } from '../../../../dist/types/public/internal.js'; + +const headAndContentSym = Symbol.for('astro.headAndContent'); + +function createHeadAndContentLike(head: string) { + return { + [headAndContentSym]: true, + head, + }; +} + +function isHeadAndContent(value: unknown): value is { head: string } { + return typeof value === 'object' && value !== null && headAndContentSym in value; +} + +function createResult(): SSRResult { + return {} as SSRResult; +} + +describe('head propagation buffer', () => { + it('returns empty head parts when no propagators exist', async () => { + const collected = await collectPropagatedHeadParts({ + propagators: new Set(), + result: createResult(), + isHeadAndContent, + }); + assert.deepEqual(collected, []); + }); + + it('collects non-empty head strings from propagators', async () => { + const propagators = new Set([ + { init: () => createHeadAndContentLike('') }, + { init: () => createHeadAndContentLike('') }, + ]); + + const collected = await collectPropagatedHeadParts({ + propagators, + result: createResult(), + isHeadAndContent, + }); + + assert.deepEqual(collected, [ + '', + '', + ]); + }); + + it('skips non-head-and-content values and empty heads', async () => { + const propagators = new Set([ + { init: () => 'value' }, + { init: () => createHeadAndContentLike('') }, + { init: () => createHeadAndContentLike('') }, + ]); + + const collected = await collectPropagatedHeadParts({ + propagators, + result: createResult(), + isHeadAndContent, + }); + + assert.deepEqual(collected, ['']); + }); + + it('processes propagators added while iterating', async () => { + const propagators = new Set(); + propagators.add({ + init() { + propagators.add({ + init() { + return createHeadAndContentLike(''); + }, + }); + return createHeadAndContentLike(''); + }, + }); + + const collected = await collectPropagatedHeadParts({ + propagators, + result: createResult(), + isHeadAndContent, + }); + + assert.deepEqual(collected, [ + '', + '', + ]); + }); +}); diff --git a/packages/astro/test/units/render/head-propagation/comment.test.js b/packages/astro/test/units/render/head-propagation/comment.test.ts similarity index 100% rename from packages/astro/test/units/render/head-propagation/comment.test.js rename to packages/astro/test/units/render/head-propagation/comment.test.ts diff --git a/packages/astro/test/units/render/head-propagation/graph.test.js b/packages/astro/test/units/render/head-propagation/graph.test.js deleted file mode 100644 index b5079aa6ac9c..000000000000 --- a/packages/astro/test/units/render/head-propagation/graph.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - buildImporterGraphFromModuleInfo, - computeInTreeAncestors, -} from '../../../../dist/core/head-propagation/graph.js'; - -describe('head propagation graph', () => { - it('computes in-tree ancestors for a linear chain', () => { - const importerGraph = new Map([ - ['leaf', new Set(['parent'])], - ['parent', new Set(['page'])], - ['page', new Set()], - ]); - const result = computeInTreeAncestors({ - seeds: ['leaf'], - importerGraph, - }); - assert.deepEqual(Array.from(result), ['leaf', 'parent', 'page']); - }); - - it('supports multiple seeds and cycles', () => { - const importerGraph = new Map([ - ['a', new Set(['b'])], - ['b', new Set(['a', 'page'])], - ['c', new Set(['page'])], - ['page', new Set()], - ]); - const result = computeInTreeAncestors({ - seeds: ['a', 'c'], - importerGraph, - }); - assert.equal(result.has('a'), true); - assert.equal(result.has('b'), true); - assert.equal(result.has('c'), true); - assert.equal(result.has('page'), true); - }); - - it('stops traversal at boundary predicate', () => { - const importerGraph = new Map([ - ['leaf', new Set(['boundary'])], - ['boundary', new Set(['page'])], - ['page', new Set()], - ]); - const result = computeInTreeAncestors({ - seeds: ['leaf'], - importerGraph, - stopAt: (id) => id === 'boundary', - }); - assert.deepEqual(Array.from(result), ['leaf']); - }); - - it('builds importer graph from module info provider', () => { - const provider = (id) => { - if (id === 'a') return { importers: ['page'], dynamicImporters: [] }; - if (id === 'b') return { importers: [], dynamicImporters: ['page'] }; - if (id === 'page') return { importers: [], dynamicImporters: [] }; - return null; - }; - - const graph = buildImporterGraphFromModuleInfo(['a', 'b', 'page'], provider); - assert.deepEqual(Array.from(graph.get('a') ?? []), ['page']); - assert.deepEqual(Array.from(graph.get('b') ?? []), ['page']); - }); -}); diff --git a/packages/astro/test/units/render/head-propagation/graph.test.ts b/packages/astro/test/units/render/head-propagation/graph.test.ts new file mode 100644 index 000000000000..e1d80b15bfa1 --- /dev/null +++ b/packages/astro/test/units/render/head-propagation/graph.test.ts @@ -0,0 +1,66 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { ImporterGraph } from '../../../../dist/core/head-propagation/graph.js'; +import { + buildImporterGraphFromModuleInfo, + computeInTreeAncestors, +} from '../../../../dist/core/head-propagation/graph.js'; + +describe('head propagation graph', () => { + it('computes in-tree ancestors for a linear chain', () => { + const importerGraph: ImporterGraph = new Map([ + ['leaf', new Set(['parent'])], + ['parent', new Set(['page'])], + ['page', new Set()], + ]); + const result = computeInTreeAncestors({ + seeds: ['leaf'], + importerGraph, + }); + assert.deepEqual(Array.from(result), ['leaf', 'parent', 'page']); + }); + + it('supports multiple seeds and cycles', () => { + const importerGraph: ImporterGraph = new Map([ + ['a', new Set(['b'])], + ['b', new Set(['a', 'page'])], + ['c', new Set(['page'])], + ['page', new Set()], + ]); + const result = computeInTreeAncestors({ + seeds: ['a', 'c'], + importerGraph, + }); + assert.equal(result.has('a'), true); + assert.equal(result.has('b'), true); + assert.equal(result.has('c'), true); + assert.equal(result.has('page'), true); + }); + + it('stops traversal at boundary predicate', () => { + const importerGraph: ImporterGraph = new Map([ + ['leaf', new Set(['boundary'])], + ['boundary', new Set(['page'])], + ['page', new Set()], + ]); + const result = computeInTreeAncestors({ + seeds: ['leaf'], + importerGraph, + stopAt: (id: string) => id === 'boundary', + }); + assert.deepEqual(Array.from(result), ['leaf']); + }); + + it('builds importer graph from module info provider', () => { + const provider = (id: string) => { + if (id === 'a') return { importers: ['page'], dynamicImporters: [] }; + if (id === 'b') return { importers: [], dynamicImporters: ['page'] }; + if (id === 'page') return { importers: [], dynamicImporters: [] }; + return null; + }; + + const graph = buildImporterGraphFromModuleInfo(['a', 'b', 'page'], provider); + assert.deepEqual(Array.from(graph.get('a') ?? []), ['page']); + assert.deepEqual(Array.from(graph.get('b') ?? []), ['page']); + }); +}); diff --git a/packages/astro/test/units/render/head-propagation/policy.test.js b/packages/astro/test/units/render/head-propagation/policy.test.ts similarity index 100% rename from packages/astro/test/units/render/head-propagation/policy.test.js rename to packages/astro/test/units/render/head-propagation/policy.test.ts diff --git a/packages/astro/test/units/render/head-propagation/resolver.test.js b/packages/astro/test/units/render/head-propagation/resolver.test.js deleted file mode 100644 index 947f4f85971f..000000000000 --- a/packages/astro/test/units/render/head-propagation/resolver.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - getPropagationHint, - isPropagatingHint, - resolvePropagationHint, -} from '../../../../dist/core/head-propagation/resolver.js'; - -describe('head propagation resolver', () => { - it('defaults to none', () => { - const hint = resolvePropagationHint({ - factoryHint: undefined, - moduleId: undefined, - metadataLookup: () => undefined, - }); - assert.equal(hint, 'none'); - }); - - it('prefers factory hint over metadata', () => { - const hint = resolvePropagationHint({ - factoryHint: 'self', - moduleId: '/src/Comp.astro', - metadataLookup: () => 'in-tree', - }); - assert.equal(hint, 'self'); - }); - - it('uses metadata fallback when factory hint is none', () => { - const hint = resolvePropagationHint({ - factoryHint: 'none', - moduleId: '/src/Comp.astro', - metadataLookup: () => 'in-tree', - }); - assert.equal(hint, 'in-tree'); - }); - - it('getPropagationHint reads from SSR result metadata', () => { - const result = { - componentMetadata: new Map([['/src/Comp.astro', { propagation: 'in-tree' }]]), - }; - const hint = getPropagationHint(result, { - moduleId: '/src/Comp.astro', - propagation: 'none', - }); - assert.equal(hint, 'in-tree'); - }); - - it('treats self and in-tree as propagating', () => { - assert.equal(isPropagatingHint('none'), false); - assert.equal(isPropagatingHint('self'), true); - assert.equal(isPropagatingHint('in-tree'), true); - }); -}); diff --git a/packages/astro/test/units/render/head-propagation/resolver.test.ts b/packages/astro/test/units/render/head-propagation/resolver.test.ts new file mode 100644 index 000000000000..84d62d737d40 --- /dev/null +++ b/packages/astro/test/units/render/head-propagation/resolver.test.ts @@ -0,0 +1,53 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + getPropagationHint, + isPropagatingHint, + resolvePropagationHint, +} from '../../../../dist/core/head-propagation/resolver.js'; + +describe('head propagation resolver', () => { + it('defaults to none', () => { + const hint = resolvePropagationHint({ + factoryHint: undefined, + moduleId: undefined, + metadataLookup: () => undefined, + }); + assert.equal(hint, 'none'); + }); + + it('prefers factory hint over metadata', () => { + const hint = resolvePropagationHint({ + factoryHint: 'self', + moduleId: '/src/Comp.astro', + metadataLookup: () => 'in-tree', + }); + assert.equal(hint, 'self'); + }); + + it('uses metadata fallback when factory hint is none', () => { + const hint = resolvePropagationHint({ + factoryHint: 'none', + moduleId: '/src/Comp.astro', + metadataLookup: () => 'in-tree', + }); + assert.equal(hint, 'in-tree'); + }); + + it('getPropagationHint reads from SSR result metadata', () => { + const result: any = { + componentMetadata: new Map([['/src/Comp.astro', { propagation: 'in-tree' }]]), + }; + const hint = getPropagationHint(result, { + moduleId: '/src/Comp.astro', + propagation: 'none', + }); + assert.equal(hint, 'in-tree'); + }); + + it('treats self and in-tree as propagating', () => { + assert.equal(isPropagatingHint('none'), false); + assert.equal(isPropagatingHint('self'), true); + assert.equal(isPropagatingHint('in-tree'), true); + }); +}); diff --git a/packages/astro/test/units/render/head-propagation/runtime-adapters.test.js b/packages/astro/test/units/render/head-propagation/runtime-adapters.test.js deleted file mode 100644 index f1fea6a5ad5f..000000000000 --- a/packages/astro/test/units/render/head-propagation/runtime-adapters.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { createAstroComponentInstance } from '../../../../dist/runtime/server/render/astro/instance.js'; -import { bufferHeadContent } from '../../../../dist/runtime/server/render/astro/render.js'; - -const headAndContentSym = Symbol.for('astro.headAndContent'); - -function createResult() { - return { - clientDirectives: new Map(), - componentMetadata: new Map(), - partial: false, - _metadata: { - hasRenderedHead: false, - headInTree: false, - propagators: new Set(), - extraHead: [], - }, - }; -} - -describe('head propagation runtime adapters', () => { - it('createAstroComponentInstance registers propagation via metadata fallback', () => { - const result = createResult(); - result.componentMetadata.set('/src/Comp.astro', { - propagation: 'in-tree', - containsHead: false, - }); - - createAstroComponentInstance( - result, - 'Comp', - Object.assign(() => null, { - moduleId: '/src/Comp.astro', - propagation: 'none', - }), - {}, - {}, - ); - - assert.equal(result._metadata.propagators.size, 1); - }); - - it('bufferHeadContent pushes propagated heads to extraHead', async () => { - const result = createResult(); - result._metadata.propagators.add({ - init() { - return { - [headAndContentSym]: true, - head: '', - }; - }, - }); - - await bufferHeadContent(result); - assert.deepEqual(result._metadata.extraHead, [ - '', - ]); - }); -}); diff --git a/packages/astro/test/units/render/head-propagation/runtime-adapters.test.ts b/packages/astro/test/units/render/head-propagation/runtime-adapters.test.ts new file mode 100644 index 000000000000..ecd03a07fd47 --- /dev/null +++ b/packages/astro/test/units/render/head-propagation/runtime-adapters.test.ts @@ -0,0 +1,61 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createAstroComponentInstance } from '../../../../dist/runtime/server/render/astro/instance.js'; +import { bufferHeadContent } from '../../../../dist/runtime/server/render/astro/render.js'; +import type { SSRResult } from '../../../../dist/types/public/internal.js'; + +const headAndContentSym = Symbol.for('astro.headAndContent'); + +function createResult() { + return { + clientDirectives: new Map(), + componentMetadata: new Map(), + partial: false, + _metadata: { + hasRenderedHead: false, + headInTree: false, + propagators: new Set(), + extraHead: [] as string[], + }, + }; +} + +describe('head propagation runtime adapters', () => { + it('createAstroComponentInstance registers propagation via metadata fallback', () => { + const result = createResult(); + result.componentMetadata.set('/src/Comp.astro', { + propagation: 'in-tree', + containsHead: false, + }); + + createAstroComponentInstance( + result as unknown as SSRResult, + 'Comp', + Object.assign((() => null) as () => null, { + moduleId: '/src/Comp.astro', + propagation: 'none' as const, + }) as unknown as Parameters[2], + {}, + {}, + ); + + assert.equal(result._metadata.propagators.size, 1); + }); + + it('bufferHeadContent pushes propagated heads to extraHead', async () => { + const result = createResult(); + result._metadata.propagators.add({ + init() { + return { + [headAndContentSym]: true, + head: '', + }; + }, + }); + + await bufferHeadContent(result as unknown as SSRResult); + assert.deepEqual(result._metadata.extraHead, [ + '', + ]); + }); +}); diff --git a/packages/astro/test/units/render/head-propagation/runtime.test.js b/packages/astro/test/units/render/head-propagation/runtime.test.js deleted file mode 100644 index 9a4f302fdd75..000000000000 --- a/packages/astro/test/units/render/head-propagation/runtime.test.js +++ /dev/null @@ -1,61 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - bufferPropagatedHead, - getInstructionRenderState, - registerIfPropagating, - shouldRenderInstruction, -} from '../../../../dist/runtime/server/render/head-propagation/runtime.js'; - -const headAndContentSym = Symbol.for('astro.headAndContent'); - -function createResult() { - return { - partial: false, - componentMetadata: new Map(), - _metadata: { - hasRenderedHead: false, - headInTree: false, - propagators: new Set(), - extraHead: [], - }, - }; -} - -describe('head propagation runtime facade', () => { - it('registers only propagating components', () => { - const result = createResult(); - registerIfPropagating(result, { propagation: 'none' }, { init: () => null }); - assert.equal(result._metadata.propagators.size, 0); - - registerIfPropagating(result, { propagation: 'self' }, { init: () => null }); - assert.equal(result._metadata.propagators.size, 1); - }); - - it('buffers propagated head output into extraHead', async () => { - const result = createResult(); - result._metadata.propagators.add({ - init() { - return { - [headAndContentSym]: true, - head: '', - }; - }, - }); - - await bufferPropagatedHead(result); - assert.deepEqual(result._metadata.extraHead, ['']); - }); - - it('exposes render state and evaluates instruction policy', () => { - const result = createResult(); - const state = getInstructionRenderState(result); - assert.deepEqual(state, { - hasRenderedHead: false, - headInTree: false, - partial: false, - }); - assert.equal(shouldRenderInstruction('head', state), true); - assert.equal(shouldRenderInstruction('maybe-head', state), true); - }); -}); diff --git a/packages/astro/test/units/render/head-propagation/runtime.test.ts b/packages/astro/test/units/render/head-propagation/runtime.test.ts new file mode 100644 index 000000000000..df9067f7b0e1 --- /dev/null +++ b/packages/astro/test/units/render/head-propagation/runtime.test.ts @@ -0,0 +1,70 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { SSRResult } from '../../../../dist/types/public/internal.js'; +import { + bufferPropagatedHead, + getInstructionRenderState, + registerIfPropagating, + shouldRenderInstruction, +} from '../../../../dist/runtime/server/render/head-propagation/runtime.js'; + +const headAndContentSym = Symbol.for('astro.headAndContent'); + +function createResult() { + return { + partial: false, + componentMetadata: new Map(), + _metadata: { + hasRenderedHead: false, + headInTree: false, + propagators: new Set(), + extraHead: [] as string[], + }, + }; +} + +describe('head propagation runtime facade', () => { + it('registers only propagating components', () => { + const result = createResult(); + registerIfPropagating( + result as unknown as SSRResult, + { propagation: 'none' } as Parameters[1], + { init: () => null }, + ); + assert.equal(result._metadata.propagators.size, 0); + + registerIfPropagating( + result as unknown as SSRResult, + { propagation: 'self' } as Parameters[1], + { init: () => null }, + ); + assert.equal(result._metadata.propagators.size, 1); + }); + + it('buffers propagated head output into extraHead', async () => { + const result = createResult(); + result._metadata.propagators.add({ + init() { + return { + [headAndContentSym]: true, + head: '', + }; + }, + }); + + await bufferPropagatedHead(result as unknown as SSRResult); + assert.deepEqual(result._metadata.extraHead, ['']); + }); + + it('exposes render state and evaluates instruction policy', () => { + const result = createResult(); + const state = getInstructionRenderState(result as unknown as SSRResult); + assert.deepEqual(state, { + hasRenderedHead: false, + headInTree: false, + partial: false, + }); + assert.equal(shouldRenderInstruction('head', state), true); + assert.equal(shouldRenderInstruction('maybe-head', state), true); + }); +}); diff --git a/packages/astro/test/units/render/head.test.js b/packages/astro/test/units/render/head.test.js deleted file mode 100644 index a289661f24cf..000000000000 --- a/packages/astro/test/units/render/head.test.js +++ /dev/null @@ -1,244 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { RenderContext } from '../../../dist/core/render-context.js'; -import { - createComponent, - Fragment, - maybeRenderHead, - render, - renderComponent, - renderHead, - renderSlot, -} from '../../../dist/runtime/server/index.js'; -import { createBasicPipeline } from '../test-utils.js'; - -const createAstroModule = (AstroComponent) => ({ default: AstroComponent }); - -describe('core/render', () => { - describe('Injected head contents', () => { - let pipeline; - before(async () => { - pipeline = createBasicPipeline(); - pipeline.headElements = () => ({ - links: new Set([ - { name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }, - ]), - scripts: new Set(), - styles: new Set(), - }); - }); - - it('Multi-level layouts and head injection, with explicit head', async () => { - const BaseLayout = createComponent((result, _props, slots) => { - return render` - - ${renderSlot(result, slots['head'])} - ${renderHead(result)} - - ${maybeRenderHead(result)} - - ${renderSlot(result, slots['default'])} - - `; - }); - - const PageLayout = createComponent((result, _props, slots) => { - return render`${renderComponent( - result, - 'Layout', - BaseLayout, - {}, - { - default: () => render` - ${maybeRenderHead(result)} -
    - ${renderSlot(result, slots['default'])} -
    - `, - head: () => render` - ${renderComponent( - result, - 'Fragment', - Fragment, - { slot: 'head' }, - { - default: () => render`${renderSlot(result, slots['head'])}`, - }, - )} - `, - }, - )} - `; - }); - - const Page = createComponent((result) => { - return render`${renderComponent( - result, - 'PageLayout', - PageLayout, - {}, - { - default: () => render`${maybeRenderHead(result)}
    hello world
    `, - head: () => render` - ${renderComponent( - result, - 'Fragment', - Fragment, - { slot: 'head' }, - { - default: () => render``, - }, - )} - `, - }, - )}`; - }); - - const PageModule = createAstroModule(Page); - const request = new Request('http://example.com/'); - const routeData = { - type: 'page', - pathname: '/index', - component: 'src/pages/index.astro', - params: {}, - }; - const renderContext = await RenderContext.create({ pipeline, request, routeData }); - const response = await renderContext.render(PageModule); - - const html = await response.text(); - const $ = cheerio.load(html); - - assert.equal($('head link').length, 1); - assert.equal($('body link').length, 0); - }); - - it('Multi-level layouts and head injection, without explicit head', async () => { - const BaseLayout = createComponent((result, _props, slots) => { - return render` - ${renderSlot(result, slots['head'])} - ${maybeRenderHead(result)} - - ${renderSlot(result, slots['default'])} - - `; - }); - - const PageLayout = createComponent((result, _props, slots) => { - return render`${renderComponent( - result, - 'Layout', - BaseLayout, - {}, - { - default: () => render` - ${maybeRenderHead(result)} -
    - ${renderSlot(result, slots['default'])} -
    - `, - head: () => render` - ${renderComponent( - result, - 'Fragment', - Fragment, - { slot: 'head' }, - { - default: () => render`${renderSlot(result, slots['head'])}`, - }, - )} - `, - }, - )} - `; - }); - - const Page = createComponent((result) => { - return render`${renderComponent( - result, - 'PageLayout', - PageLayout, - {}, - { - default: () => render`${maybeRenderHead(result)}
    hello world
    `, - head: () => render` - ${renderComponent( - result, - 'Fragment', - Fragment, - { slot: 'head' }, - { - default: () => render``, - }, - )} - `, - }, - )}`; - }); - - const PageModule = createAstroModule(Page); - const request = new Request('http://example.com/'); - const routeData = { - type: 'page', - pathname: '/index', - component: 'src/pages/index.astro', - params: {}, - }; - const renderContext = await RenderContext.create({ pipeline, request, routeData }); - const response = await renderContext.render(PageModule); - - const html = await response.text(); - const $ = cheerio.load(html); - - assert.equal($('head link').length, 1); - assert.equal($('body link').length, 0); - }); - - it('Multi-level layouts and head injection, without any content in layouts', async () => { - const BaseLayout = createComponent((result, _props, slots) => { - return render`${renderSlot(result, slots['default'])}`; - }); - - const PageLayout = createComponent((result, _props, slots) => { - return render`${renderComponent( - result, - 'Layout', - BaseLayout, - {}, - { - default: () => render`${renderSlot(result, slots['default'])} `, - }, - )} - `; - }); - - const Page = createComponent((result) => { - return render`${renderComponent( - result, - 'PageLayout', - PageLayout, - {}, - { - default: () => render`${maybeRenderHead(result)}
    hello world
    `, - }, - )}`; - }); - - const PageModule = createAstroModule(Page); - const request = new Request('http://example.com/'); - const routeData = { - type: 'page', - pathname: '/index', - component: 'src/pages/index.astro', - params: {}, - }; - const renderContext = await RenderContext.create({ pipeline, request, routeData }); - const response = await renderContext.render(PageModule); - - const html = await response.text(); - const $ = cheerio.load(html); - - assert.equal($('link').length, 1); - }); - }); -}); diff --git a/packages/astro/test/units/render/head.test.ts b/packages/astro/test/units/render/head.test.ts new file mode 100644 index 000000000000..87f935bec1ab --- /dev/null +++ b/packages/astro/test/units/render/head.test.ts @@ -0,0 +1,251 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { RenderContext } from '../../../dist/core/render-context.js'; +import { + createComponent, + Fragment, + maybeRenderHead as _maybeRenderHead, + render, + renderComponent, + renderHead as _renderHead, + renderSlot, +} from '../../../dist/runtime/server/index.js'; +import type { AstroComponentFactory } from '../../../dist/runtime/server/render/index.js'; +import type { Pipeline } from '../../../dist/core/render/index.js'; +import { createBasicPipeline } from '../test-utils.ts'; + +// The public types for renderHead/maybeRenderHead declare zero params, +// but the runtime implementation accepts a result argument. +const renderHead = _renderHead as (result: any) => any; +const maybeRenderHead = _maybeRenderHead as (result: any) => any; + +const createAstroModule = (AstroComponent: AstroComponentFactory) => ({ default: AstroComponent }); + +describe('core/render', () => { + describe('Injected head contents', () => { + let pipeline: Pipeline; + before(async () => { + pipeline = createBasicPipeline(); + pipeline.headElements = () => ({ + links: new Set([ + { name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }, + ]), + scripts: new Set(), + styles: new Set(), + }); + }); + + it('Multi-level layouts and head injection, with explicit head', async () => { + const BaseLayout = createComponent((result: any, _props: any, slots: any) => { + return render` + + ${renderSlot(result, slots['head'])} + ${renderHead(result)} + + ${maybeRenderHead(result)} + + ${renderSlot(result, slots['default'])} + + `; + }); + + const PageLayout = createComponent((result: any, _props: any, slots: any) => { + return render`${renderComponent( + result, + 'Layout', + BaseLayout, + {}, + { + default: () => render` + ${maybeRenderHead(result)} +
    + ${renderSlot(result, slots['default'])} +
    + `, + head: () => render` + ${renderComponent( + result, + 'Fragment', + Fragment, + { slot: 'head' }, + { + default: () => render`${renderSlot(result, slots['head'])}`, + }, + )} + `, + }, + )} + `; + }); + + const Page = createComponent((result: any) => { + return render`${renderComponent( + result, + 'PageLayout', + PageLayout, + {}, + { + default: () => render`${maybeRenderHead(result)}
    hello world
    `, + head: () => render` + ${renderComponent( + result, + 'Fragment', + Fragment, + { slot: 'head' }, + { + default: () => render``, + }, + )} + `, + }, + )}`; + }); + + const PageModule = createAstroModule(Page); + const request = new Request('http://example.com/'); + const routeData = { + type: 'page', + pathname: '/index', + component: 'src/pages/index.astro', + params: {}, + }; + const renderContext = await RenderContext.create({ pipeline, request, routeData } as any); + const response = await renderContext.render(PageModule); + + const html = await response.text(); + const $ = cheerio.load(html); + + assert.equal($('head link').length, 1); + assert.equal($('body link').length, 0); + }); + + it('Multi-level layouts and head injection, without explicit head', async () => { + const BaseLayout = createComponent((result: any, _props: any, slots: any) => { + return render` + ${renderSlot(result, slots['head'])} + ${maybeRenderHead(result)} + + ${renderSlot(result, slots['default'])} + + `; + }); + + const PageLayout = createComponent((result: any, _props: any, slots: any) => { + return render`${renderComponent( + result, + 'Layout', + BaseLayout, + {}, + { + default: () => render` + ${maybeRenderHead(result)} +
    + ${renderSlot(result, slots['default'])} +
    + `, + head: () => render` + ${renderComponent( + result, + 'Fragment', + Fragment, + { slot: 'head' }, + { + default: () => render`${renderSlot(result, slots['head'])}`, + }, + )} + `, + }, + )} + `; + }); + + const Page = createComponent((result: any) => { + return render`${renderComponent( + result, + 'PageLayout', + PageLayout, + {}, + { + default: () => render`${maybeRenderHead(result)}
    hello world
    `, + head: () => render` + ${renderComponent( + result, + 'Fragment', + Fragment, + { slot: 'head' }, + { + default: () => render``, + }, + )} + `, + }, + )}`; + }); + + const PageModule = createAstroModule(Page); + const request = new Request('http://example.com/'); + const routeData = { + type: 'page', + pathname: '/index', + component: 'src/pages/index.astro', + params: {}, + }; + const renderContext = await RenderContext.create({ pipeline, request, routeData } as any); + const response = await renderContext.render(PageModule); + + const html = await response.text(); + const $ = cheerio.load(html); + + assert.equal($('head link').length, 1); + assert.equal($('body link').length, 0); + }); + + it('Multi-level layouts and head injection, without any content in layouts', async () => { + const BaseLayout = createComponent((result: any, _props: any, slots: any) => { + return render`${renderSlot(result, slots['default'])}`; + }); + + const PageLayout = createComponent((result: any, _props: any, slots: any) => { + return render`${renderComponent( + result, + 'Layout', + BaseLayout, + {}, + { + default: () => render`${renderSlot(result, slots['default'])} `, + }, + )} + `; + }); + + const Page = createComponent((result: any) => { + return render`${renderComponent( + result, + 'PageLayout', + PageLayout, + {}, + { + default: () => render`${maybeRenderHead(result)}
    hello world
    `, + }, + )}`; + }); + + const PageModule = createAstroModule(Page); + const request = new Request('http://example.com/'); + const routeData = { + type: 'page', + pathname: '/index', + component: 'src/pages/index.astro', + params: {}, + }; + const renderContext = await RenderContext.create({ pipeline, request, routeData } as any); + const response = await renderContext.render(PageModule); + + const html = await response.text(); + const $ = cheerio.load(html); + + assert.equal($('link').length, 1); + }); + }); +}); diff --git a/packages/astro/test/units/render/html-primitives.test.js b/packages/astro/test/units/render/html-primitives.test.js deleted file mode 100644 index 2de5f10cdf4d..000000000000 --- a/packages/astro/test/units/render/html-primitives.test.js +++ /dev/null @@ -1,371 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { - addAttribute, - defineScriptVars, - formatList, - internalSpreadAttributes, - renderElement, - toAttributeString, - toStyleString, -} from '../../../dist/runtime/server/render/util.js'; -import { - createComponent, - render as renderTemplate, - renderComponent, - renderSlot, - unescapeHTML, -} from '../../../dist/runtime/server/index.js'; -import { createTestApp, createPage } from '../mocks.js'; - -describe('toAttributeString', () => { - it('escapes & to &', () => { - assert.equal(toAttributeString('a&b'), 'a&b'); - }); - - it('escapes " to "', () => { - assert.equal(toAttributeString('say "hello"'), 'say "hello"'); - }); - - it('escapes both & and " in the same string', () => { - assert.equal(toAttributeString('"a&b"'), '"a&b"'); - }); - - it('passes through normal strings unchanged', () => { - assert.equal(toAttributeString('hello world'), 'hello world'); - }); - - it('does not escape when shouldEscape is false', () => { - assert.equal(toAttributeString('a&b', false), 'a&b'); - }); - - it('coerces non-string values to string', () => { - assert.equal(toAttributeString(42), '42'); - assert.equal(toAttributeString(true), 'true'); - }); -}); - -describe('toStyleString', () => { - it('converts camelCase properties to kebab-case', () => { - assert.equal(toStyleString({ backgroundColor: 'red' }), 'background-color:red'); - }); - - it('leaves lowercase properties unchanged', () => { - assert.equal(toStyleString({ color: 'blue' }), 'color:blue'); - }); - - it('leaves CSS custom properties (--) unchanged', () => { - assert.equal(toStyleString({ '--my-var': '10px' }), '--my-var:10px'); - }); - - it('joins multiple properties with semicolons', () => { - assert.equal(toStyleString({ color: 'red', fontSize: '16px' }), 'color:red;font-size:16px'); - }); - - it('includes numeric values', () => { - assert.equal(toStyleString({ opacity: 0 }), 'opacity:0'); - assert.equal(toStyleString({ zIndex: 99 }), 'z-index:99'); - }); - - it('filters out empty string values', () => { - assert.equal(toStyleString({ color: '' }), ''); - }); - - it('filters out null and undefined values', () => { - assert.equal(toStyleString({ color: null, background: undefined }), ''); - }); - - it('returns empty string for empty object', () => { - assert.equal(toStyleString({}), ''); - }); -}); - -describe('defineScriptVars', () => { - it('generates const declaration for a string value', () => { - const result = String(defineScriptVars({ name: 'Astro' })); - assert.ok(result.includes('const name = "Astro";')); - }); - - it('generates const declaration for a number value', () => { - const result = String(defineScriptVars({ count: 42 })); - assert.ok(result.includes('const count = 42;')); - }); - - it('generates const declaration for an object value', () => { - const result = String(defineScriptVars({ config: { debug: true } })); - assert.ok(result.includes('const config = {"debug":true};')); - }); - - it('generates multiple const declarations', () => { - const result = String(defineScriptVars({ a: 1, b: 2 })); - assert.ok(result.includes('const a = 1;')); - assert.ok(result.includes('const b = 2;')); - }); - - it('sanitizes to prevent XSS injection', () => { - const result = String(defineScriptVars({ evil: '' })); - assert.ok(!result.includes(''), 'should not contain literal '); - assert.ok(result.includes('\\x3C/script>'), 'should escape the closing tag'); - }); - - it('converts keys with spaces to valid JS identifiers', () => { - const result = String(defineScriptVars({ 'my key': 'value' })); - assert.ok(result.includes('const myKey = "value";')); - }); -}); - -describe('formatList', () => { - it('returns the single item for a one-element array', () => { - assert.equal(formatList(['only']), 'only'); - }); - - it('joins two items with "or"', () => { - assert.equal(formatList(['a', 'b']), 'a or b'); - }); - - it('joins three items with comma and "or"', () => { - assert.equal(formatList(['a', 'b', 'c']), 'a, b or c'); - }); - - it('joins four or more items correctly', () => { - assert.equal(formatList(['a', 'b', 'c', 'd']), 'a, b, c or d'); - }); -}); - -describe('renderElement', () => { - it('renders a void element without a closing tag', () => { - const result = renderElement('br', { props: {}, children: '' }); - assert.equal(result, '
    '); - }); - - it('renders a void element with attributes', () => { - const result = renderElement('img', { props: { src: '/hero.png', alt: 'Hero' }, children: '' }); - assert.equal(result, 'Hero'); - }); - - it('renders a non-void element with children', () => { - const result = renderElement('div', { props: {}, children: 'Hello' }); - assert.equal(result, '
    Hello
    '); - }); - - it('renders a non-void element with attributes and children', () => { - const result = renderElement('span', { props: { class: 'bold' }, children: 'text' }); - assert.equal(result, 'text'); - }); - - it('renders a script element with children', () => { - const result = renderElement('script', { props: {}, children: 'console.log(1)' }); - assert.equal(result, ''); - }); - - it('does not render lang, data-astro-id props', () => { - const result = renderElement('style', { - props: { lang: 'scss', 'data-astro-id': 'abc' }, - children: 'body{}', - }); - assert.ok(!result.includes('lang=')); - assert.ok(!result.includes('data-astro-id=')); - }); - - it('injects defineVars into script children', () => { - const result = renderElement('script', { - props: { 'define:vars': { count: 5 } }, - children: 'console.log(count)', - }); - assert.ok(result.includes('const count = 5;')); - assert.ok(result.includes('console.log(count)')); - }); -}); - -// --------------------------------------------------------------------------- -// Migrated from: test/astro-basic.test.js -// These tests verify the primitives work correctly when invoked through -// createComponent/createTestApp — no Vite build needed. -// --------------------------------------------------------------------------- - -describe('Correctly serializes boolean attributes (#astro-basic)', async () => { - // h1 data-something and h2 not-data-ok are both empty-string boolean-ish attrs - it('renders empty-value attribute for data-* attr with no value', () => { - assert.equal(String(addAttribute('', 'data-something')), ' data-something'); - }); - - it('renders empty-value attribute for arbitrary attr with no value', () => { - assert.equal(String(addAttribute('', 'not-data-ok')), ' not-data-ok'); - }); -}); - -describe('Allows spread attributes (#521)', async () => { - const spread = { a: 0, b: 1, c: 2 }; - - it('spreads attributes correctly regardless of position', async () => { - const spreadPage = createComponent( - () => renderTemplate` - -
    - `, - ); - - const app = createTestApp([createPage(spreadPage, { route: '/spread' })]); - const response = await app.render(new Request('http://example.com/spread')); - const $ = cheerio.load(await response.text()); - - assert.equal($('#spread-leading').attr('a'), '0'); - assert.equal($('#spread-leading').attr('b'), '1'); - assert.equal($('#spread-leading').attr('c'), '2'); - assert.equal($('#spread-trailing').attr('a'), '0'); - assert.equal($('#spread-trailing').attr('b'), '1'); - assert.equal($('#spread-trailing').attr('c'), '2'); - }); -}); - -describe('Supports void elements whose name is a string (#2062)', async () => { - // Mirrors Input.astro: a component that picks between input/select/textarea - // based on the `type` prop, demonstrating that void element detection works - // when the tag name is a runtime string value, not a literal. - const Input = createComponent((result, props, slots) => { - const Astro = result.createAstro(props, slots); - const { type: initialType, ...rest } = Astro.props; - const isSelect = /^select$/i.test(initialType); - const isTextarea = /^textarea$/i.test(initialType); - const Control = isSelect ? 'select' : isTextarea ? 'textarea' : 'input'; - if (Control === 'input' && initialType) rest.type = initialType; - const hasSlot = 'default' in Astro.slots; - // unescapeHTML prevents the string from being HTML-escaped when interpolated - return renderTemplate`${unescapeHTML( - renderElement(Control, { - props: rest, - children: hasSlot ? String(renderSlot(result, Astro.slots.default)) : '', - }), - )}`; - }); - - const inputPage = createComponent( - (result) => renderTemplate` - ${renderComponent(result, 'Input', Input, {})} - ${renderComponent(result, 'Input', Input, { type: 'password' })} - ${renderComponent(result, 'Input', Input, { type: 'text' })} - `, - ); - - it('renders void/non-void elements correctly based on runtime tag name', async () => { - const app = createTestApp([createPage(inputPage, { route: '/input' })]); - const response = await app.render(new Request('http://example.com/input')); - const $ = cheerio.load(await response.text()); - - // - assert.equal($('body > :nth-child(1)').prop('outerHTML'), ''); - // - assert.equal($('body > :nth-child(2)').prop('outerHTML'), ''); - // - assert.equal($('body > :nth-child(3)').prop('outerHTML'), ''); - }); -}); - -// --------------------------------------------------------------------------- -// Migrated from: test/astro-basic.test.js 'Astro basic build' -// --------------------------------------------------------------------------- - -describe('Can load page', async () => { - it('renders h1 content', async () => { - const page = createComponent( - () => renderTemplate`

    Hello world!

    `, - ); - const app = createTestApp([createPage(page, { route: '/' })]); - const response = await app.render(new Request('http://example.com/')); - const $ = cheerio.load(await response.text()); - assert.equal($('h1').text(), 'Hello world!'); - }); -}); - -describe('Selector with an empty body', async () => { - it('renders element with empty CSS class body', async () => { - const page = createComponent( - () => renderTemplate`
    `, - ); - const app = createTestApp([createPage(page, { route: '/empty-class' })]); - const response = await app.render(new Request('http://example.com/empty-class')); - const $ = cheerio.load(await response.text()); - assert.equal($('.author').length, 1); - }); -}); - -describe('Allows forward-slashes in mustache tags (#407)', async () => { - it('renders hrefs with forward slashes from template expressions', async () => { - const slugs = ['one', 'two', 'three']; - const page = createComponent( - () => - renderTemplate`${slugs.map((slug) => renderTemplate`${slug}`)}`, - ); - const app = createTestApp([createPage(page, { route: '/forward-slash' })]); - const response = await app.render(new Request('http://example.com/forward-slash')); - const $ = cheerio.load(await response.text()); - assert.equal($('a[href="/post/one"]').length, 1); - assert.equal($('a[href="/post/two"]').length, 1); - assert.equal($('a[href="/post/three"]').length, 1); - }); -}); - -describe('Allows using the Fragment element', async () => { - it('renders Fragment children without a wrapper element', async () => { - // Fragment in Astro is transparent — its children render directly into the parent. - // Simulate by inlining the children without a wrapper component. - const page = createComponent( - () => - renderTemplate`
      ${renderTemplate`
    • One
    • `}
    `, - ); - const app = createTestApp([createPage(page, { route: '/fragment' })]); - const response = await app.render(new Request('http://example.com/fragment')); - const $ = cheerio.load(await response.text()); - assert.equal($('#one').length, 1); - }); -}); - -describe('renders the components top-down', async () => { - it('renders sibling components in document order', async () => { - // Mirrors order.astro + OrderA/B/Last.astro using globalThis to track render order - globalThis.__ASTRO_TEST_ORDER__ = []; - - const OrderA = createComponent((result, _p, slots) => { - globalThis.__ASTRO_TEST_ORDER__.push('A'); - return renderTemplate`

    A

    ${renderSlot(result, slots.default)}`; - }); - const OrderB = createComponent((result, _p, slots) => { - globalThis.__ASTRO_TEST_ORDER__.push('B'); - return renderTemplate`

    B

    ${renderSlot(result, slots.default)}`; - }); - const OrderLast = createComponent( - () => - renderTemplate`

    Rendered order: ${() => (globalThis.__ASTRO_TEST_ORDER__ ?? []).join(', ')}

    `, - ); - - const page = createComponent( - (result) => - renderTemplate`${renderComponent( - result, - 'OrderA', - OrderA, - {}, - { - default: (result2) => - renderTemplate`${renderComponent( - result2, - 'OrderB', - OrderB, - {}, - { - default: (result3) => - renderTemplate`${renderComponent(result3, 'OrderLast', OrderLast, {})}`, - }, - )}`, - }, - )}`, - ); - - const app = createTestApp([createPage(page, { route: '/order' })]); - const response = await app.render(new Request('http://example.com/order')); - const $ = cheerio.load(await response.text()); - assert.equal($('#rendered-order').text(), 'Rendered order: A, B'); - }); -}); diff --git a/packages/astro/test/units/render/html-primitives.test.ts b/packages/astro/test/units/render/html-primitives.test.ts new file mode 100644 index 000000000000..cea841009124 --- /dev/null +++ b/packages/astro/test/units/render/html-primitives.test.ts @@ -0,0 +1,448 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { + addAttribute, + defineScriptVars, + formatList, + internalSpreadAttributes, + renderElement, + toAttributeString, + toStyleString, +} from '../../../dist/runtime/server/render/util.js'; +import { + createComponent, + Fragment, + render as renderTemplate, + renderComponent, + renderSlot, + unescapeHTML, +} from '../../../dist/runtime/server/index.js'; +import { createTestApp, createPage } from '../mocks.ts'; + +describe('toAttributeString', () => { + it('escapes & to &', () => { + assert.equal(toAttributeString('a&b'), 'a&b'); + }); + + it('escapes " to "', () => { + assert.equal(toAttributeString('say "hello"'), 'say "hello"'); + }); + + it('escapes both & and " in the same string', () => { + assert.equal(toAttributeString('"a&b"'), '"a&b"'); + }); + + it('passes through normal strings unchanged', () => { + assert.equal(toAttributeString('hello world'), 'hello world'); + }); + + it('does not escape when shouldEscape is false', () => { + assert.equal(toAttributeString('a&b', false), 'a&b'); + }); + + it('coerces non-string values to string', () => { + assert.equal(toAttributeString(42), '42'); + assert.equal(toAttributeString(true), 'true'); + }); +}); + +describe('toStyleString', () => { + it('converts camelCase properties to kebab-case', () => { + assert.equal(toStyleString({ backgroundColor: 'red' }), 'background-color:red'); + }); + + it('leaves lowercase properties unchanged', () => { + assert.equal(toStyleString({ color: 'blue' }), 'color:blue'); + }); + + it('leaves CSS custom properties (--) unchanged', () => { + assert.equal(toStyleString({ '--my-var': '10px' }), '--my-var:10px'); + }); + + it('joins multiple properties with semicolons', () => { + assert.equal(toStyleString({ color: 'red', fontSize: '16px' }), 'color:red;font-size:16px'); + }); + + it('includes numeric values', () => { + assert.equal(toStyleString({ opacity: 0 }), 'opacity:0'); + assert.equal(toStyleString({ zIndex: 99 }), 'z-index:99'); + }); + + it('filters out empty string values', () => { + assert.equal(toStyleString({ color: '' }), ''); + }); + + it('filters out null and undefined values', () => { + assert.equal(toStyleString({ color: null, background: undefined }), ''); + }); + + it('returns empty string for empty object', () => { + assert.equal(toStyleString({}), ''); + }); +}); + +describe('defineScriptVars', () => { + it('generates const declaration for a string value', () => { + const result = String(defineScriptVars({ name: 'Astro' })); + assert.ok(result.includes('const name = "Astro";')); + }); + + it('generates const declaration for a number value', () => { + const result = String(defineScriptVars({ count: 42 })); + assert.ok(result.includes('const count = 42;')); + }); + + it('generates const declaration for an object value', () => { + const result = String(defineScriptVars({ config: { debug: true } })); + assert.ok(result.includes('const config = {"debug":true};')); + }); + + it('generates multiple const declarations', () => { + const result = String(defineScriptVars({ a: 1, b: 2 })); + assert.ok(result.includes('const a = 1;')); + assert.ok(result.includes('const b = 2;')); + }); + + it('sanitizes to prevent XSS injection', () => { + const result = String(defineScriptVars({ evil: '' })); + assert.ok(!result.includes(''), 'should not contain literal '); + assert.ok(result.includes('\\u003c/script>'), 'should escape the closing tag'); + }); + + it('sanitizes case-insensitive variants', () => { + for (const tag of ['', '', '']) { + const result = String(defineScriptVars({ evil: tag })); + assert.ok(!result.includes(tag), `should not contain literal ${tag}`); + } + }); + + it('sanitizes with trailing whitespace before >', () => { + for (const tag of ['', '', '']) { + const result = String(defineScriptVars({ evil: tag })); + assert.ok(!result.includes(tag), `should not contain literal ${JSON.stringify(tag)}`); + } + }); + + it('sanitizes self-closing ', () => { + const result = String(defineScriptVars({ evil: '' })); + assert.ok(!result.includes(''), 'should not contain literal '); + }); + + it('handles undefined values without throwing', () => { + const result = String(defineScriptVars({ undef: undefined })); + assert.ok(result.includes('const undef = undefined;')); + }); + + it('converts keys with spaces to valid JS identifiers', () => { + const result = String(defineScriptVars({ 'my key': 'value' })); + assert.ok(result.includes('const myKey = "value";')); + }); +}); + +describe('formatList', () => { + it('returns the single item for a one-element array', () => { + assert.equal(formatList(['only']), 'only'); + }); + + it('joins two items with "or"', () => { + assert.equal(formatList(['a', 'b']), 'a or b'); + }); + + it('joins three items with comma and "or"', () => { + assert.equal(formatList(['a', 'b', 'c']), 'a, b or c'); + }); + + it('joins four or more items correctly', () => { + assert.equal(formatList(['a', 'b', 'c', 'd']), 'a, b, c or d'); + }); +}); + +describe('renderElement', () => { + it('renders a void element without a closing tag', () => { + const result = renderElement('br', { props: {}, children: '' }); + assert.equal(result, '
    '); + }); + + it('renders a void element with attributes', () => { + const result = renderElement('img', { props: { src: '/hero.png', alt: 'Hero' }, children: '' }); + assert.equal(result, 'Hero'); + }); + + it('renders a non-void element with children', () => { + const result = renderElement('div', { props: {}, children: 'Hello' }); + assert.equal(result, '
    Hello
    '); + }); + + it('renders a non-void element with attributes and children', () => { + const result = renderElement('span', { props: { class: 'bold' }, children: 'text' }); + assert.equal(result, 'text'); + }); + + it('renders a script element with children', () => { + const result = renderElement('script', { props: {}, children: 'console.log(1)' }); + assert.equal(result, ''); + }); + + it('does not render lang, data-astro-id props', () => { + const result = renderElement('style', { + props: { lang: 'scss', 'data-astro-id': 'abc' }, + children: 'body{}', + }); + assert.ok(!result.includes('lang=')); + assert.ok(!result.includes('data-astro-id=')); + }); + + it('injects defineVars into script children', () => { + const result = renderElement('script', { + props: { 'define:vars': { count: 5 } }, + children: 'console.log(count)', + }); + assert.ok(result.includes('const count = 5;')); + assert.ok(result.includes('console.log(count)')); + }); +}); + +// --------------------------------------------------------------------------- +// Migrated from: test/astro-basic.test.js +// These tests verify the primitives work correctly when invoked through +// createComponent/createTestApp — no Vite build needed. +// --------------------------------------------------------------------------- + +describe('Correctly serializes boolean attributes (#astro-basic)', async () => { + // h1 data-something and h2 not-data-ok are both empty-string boolean-ish attrs + it('renders empty-value attribute for data-* attr with no value', () => { + assert.equal(String(addAttribute('', 'data-something')), ' data-something'); + }); + + it('renders empty-value attribute for arbitrary attr with no value', () => { + assert.equal(String(addAttribute('', 'not-data-ok')), ' not-data-ok'); + }); +}); + +describe('Allows spread attributes (#521)', async () => { + const spread = { a: 0, b: 1, c: 2 }; + + it('spreads attributes correctly regardless of position', async () => { + const spreadPage = createComponent( + () => renderTemplate` + +
    + `, + ); + + const app = createTestApp([createPage(spreadPage, { route: '/spread' })]); + const response = await app.render(new Request('http://example.com/spread')); + const $ = cheerio.load(await response.text()); + + assert.equal($('#spread-leading').attr('a'), '0'); + assert.equal($('#spread-leading').attr('b'), '1'); + assert.equal($('#spread-leading').attr('c'), '2'); + assert.equal($('#spread-trailing').attr('a'), '0'); + assert.equal($('#spread-trailing').attr('b'), '1'); + assert.equal($('#spread-trailing').attr('c'), '2'); + }); +}); + +describe('Supports void elements whose name is a string (#2062)', async () => { + // Mirrors Input.astro: a component that picks between input/select/textarea + // based on the `type` prop, demonstrating that void element detection works + // when the tag name is a runtime string value, not a literal. + const Input = createComponent((result: any, props: any, slots: any) => { + const Astro = result.createAstro(props, slots); + const { type: initialType, ...rest } = Astro.props; + const isSelect = /^select$/i.test(initialType); + const isTextarea = /^textarea$/i.test(initialType); + const Control = isSelect ? 'select' : isTextarea ? 'textarea' : 'input'; + if (Control === 'input' && initialType) rest.type = initialType; + const hasSlot = 'default' in Astro.slots; + // unescapeHTML prevents the string from being HTML-escaped when interpolated + return renderTemplate`${unescapeHTML( + renderElement(Control, { + props: rest, + children: hasSlot ? String(renderSlot(result, Astro.slots.default)) : '', + }), + )}`; + }); + + const inputPage = createComponent( + (result: any) => renderTemplate` + ${renderComponent(result, 'Input', Input, {})} + ${renderComponent(result, 'Input', Input, { type: 'password' })} + ${renderComponent(result, 'Input', Input, { type: 'text' })} + `, + ); + + it('renders void/non-void elements correctly based on runtime tag name', async () => { + const app = createTestApp([createPage(inputPage, { route: '/input' })]); + const response = await app.render(new Request('http://example.com/input')); + const $ = cheerio.load(await response.text()); + + // + assert.equal($('body > :nth-child(1)').prop('outerHTML'), ''); + // + assert.equal($('body > :nth-child(2)').prop('outerHTML'), ''); + // + assert.equal($('body > :nth-child(3)').prop('outerHTML'), ''); + }); +}); + +// --------------------------------------------------------------------------- +// Migrated from: test/astro-basic.test.js 'Astro basic build' +// --------------------------------------------------------------------------- + +describe('Can load page', async () => { + it('renders h1 content', async () => { + const page = createComponent( + () => renderTemplate`

    Hello world!

    `, + ); + const app = createTestApp([createPage(page, { route: '/' })]); + const response = await app.render(new Request('http://example.com/')); + const $ = cheerio.load(await response.text()); + assert.equal($('h1').text(), 'Hello world!'); + }); +}); + +describe('Selector with an empty body', async () => { + it('renders element with empty CSS class body', async () => { + const page = createComponent( + () => renderTemplate`
    `, + ); + const app = createTestApp([createPage(page, { route: '/empty-class' })]); + const response = await app.render(new Request('http://example.com/empty-class')); + const $ = cheerio.load(await response.text()); + assert.equal($('.author').length, 1); + }); +}); + +describe('Allows forward-slashes in mustache tags (#407)', async () => { + it('renders hrefs with forward slashes from template expressions', async () => { + const slugs = ['one', 'two', 'three']; + const page = createComponent( + () => + renderTemplate`${slugs.map((slug) => renderTemplate`${slug}`)}`, + ); + const app = createTestApp([createPage(page, { route: '/forward-slash' })]); + const response = await app.render(new Request('http://example.com/forward-slash')); + const $ = cheerio.load(await response.text()); + assert.equal($('a[href="/post/one"]').length, 1); + assert.equal($('a[href="/post/two"]').length, 1); + assert.equal($('a[href="/post/three"]').length, 1); + }); +}); + +describe('Allows using the Fragment element', async () => { + it('renders Fragment children without a wrapper element', async () => { + // Fragment in Astro is transparent — its children render directly into the parent. + // Simulate by inlining the children without a wrapper component. + const page = createComponent( + () => + renderTemplate`
      ${renderTemplate`
    • One
    • `}
    `, + ); + const app = createTestApp([createPage(page, { route: '/fragment' })]); + const response = await app.render(new Request('http://example.com/fragment')); + const $ = cheerio.load(await response.text()); + assert.equal($('#one').length, 1); + }); + + it('streams sync siblings before async children resolve (issue #13283)', async () => { + // A deferred promise simulates a slow async child inside the Fragment. + let resolveAsync: () => void; + const asyncChild = new Promise((resolve) => { + resolveAsync = resolve; + }); + + const DEFAULT_RESULT = { clientDirectives: new Map() }; + + // Build a Fragment whose default slot contains a sync

    followed by an async

    . + const renderInstance = renderComponent( + DEFAULT_RESULT as any, + 'Fragment', + Fragment, + {}, + { + default: (_result: any) => + renderTemplate`

    sync

    ${asyncChild.then( + () => renderTemplate`

    async

    `, + )}`, + }, + ); + + // Collect chunks as they are written so we can inspect ordering. + const chunks: string[] = []; + const destination = { + write(chunk: unknown) { + chunks.push(String(chunk)); + }, + }; + + // Start rendering — do NOT await yet so we can inspect mid-flight state. + const instance = await Promise.resolve(renderInstance); + const renderPromise = (instance as any).render(destination); + + // Yield to the microtask queue so the sync portion can flush. + await Promise.resolve(); + + // The sync

    must have been written before the async promise resolved. + const syncFlushed = chunks.join('').includes('sync'); + assert.ok(syncFlushed, 'sync sibling should stream before async child resolves'); + + // Now resolve the async child and finish rendering. + resolveAsync!(); + await renderPromise; + + const html = chunks.join(''); + assert.ok(html.includes('sync'), 'sync content present in final output'); + assert.ok(html.includes('async'), 'async content present in final output'); + // Sync must appear before async in the output. + assert.ok(html.indexOf('sync') < html.indexOf('async'), 'sync appears before async in output'); + }); +}); + +describe('renders the components top-down', async () => { + it('renders sibling components in document order', async () => { + // Mirrors order.astro + OrderA/B/Last.astro using globalThis to track render order + (globalThis as any).__ASTRO_TEST_ORDER__ = []; + + const OrderA = createComponent((result: any, _p: any, slots: any) => { + (globalThis as any).__ASTRO_TEST_ORDER__.push('A'); + return renderTemplate`

    A

    ${renderSlot(result, slots.default)}`; + }); + const OrderB = createComponent((result: any, _p: any, slots: any) => { + (globalThis as any).__ASTRO_TEST_ORDER__.push('B'); + return renderTemplate`

    B

    ${renderSlot(result, slots.default)}`; + }); + const OrderLast = createComponent( + () => + renderTemplate`

    Rendered order: ${() => ((globalThis as any).__ASTRO_TEST_ORDER__ ?? []).join(', ')}

    `, + ); + + const page = createComponent( + (result: any) => + renderTemplate`${renderComponent( + result, + 'OrderA', + OrderA, + {}, + { + default: (result2: any) => + renderTemplate`${renderComponent( + result2, + 'OrderB', + OrderB, + {}, + { + default: (result3: any) => + renderTemplate`${renderComponent(result3, 'OrderLast', OrderLast, {})}`, + }, + )}`, + }, + )}`, + ); + + const app = createTestApp([createPage(page, { route: '/order' })]); + const response = await app.render(new Request('http://example.com/order')); + const $ = cheerio.load(await response.text()); + assert.equal($('#rendered-order').text(), 'Rendered order: A, B'); + }); +}); diff --git a/packages/astro/test/units/render/hydration.test.js b/packages/astro/test/units/render/hydration.test.js deleted file mode 100644 index 5b11e90a9564..000000000000 --- a/packages/astro/test/units/render/hydration.test.js +++ /dev/null @@ -1,148 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { extractDirectives } from '../../../dist/runtime/server/hydration.js'; - -// Minimal clientDirectives map matching what Astro registers by default -const clientDirectives = new Map([ - ['load', ''], - ['idle', ''], - ['visible', ''], - ['media', ''], - ['only', ''], -]); - -describe('extractDirectives', () => { - it('returns empty hydration and all props when no client: directives present', () => { - const result = extractDirectives({ title: 'Hello', count: 42 }, clientDirectives); - assert.equal(result.hydration, null); - assert.deepEqual(result.props, { title: 'Hello', count: 42 }); - assert.equal(result.isPage, false); - }); - - it('extracts client:load directive', () => { - const result = extractDirectives( - { - 'client:load': '', - 'client:component-path': '/src/Button.jsx', - 'client:component-export': 'default', - title: 'Hello', - }, - clientDirectives, - ); - assert.ok(result.hydration !== null); - assert.equal(result.hydration.directive, 'load'); - assert.equal(result.hydration.value, ''); - assert.equal(result.hydration.componentUrl, '/src/Button.jsx'); - assert.equal(result.hydration.componentExport.value, 'default'); - assert.deepEqual(result.props, { title: 'Hello' }); - }); - - it('extracts client:idle directive', () => { - const result = extractDirectives( - { - 'client:idle': '', - 'client:component-path': '/src/Widget.jsx', - 'client:component-export': 'Widget', - }, - clientDirectives, - ); - assert.equal(result.hydration?.directive, 'idle'); - }); - - it('extracts client:visible directive', () => { - const result = extractDirectives( - { - 'client:visible': { rootMargin: '200px' }, - 'client:component-path': '/src/Component.jsx', - 'client:component-export': 'default', - }, - clientDirectives, - ); - assert.equal(result.hydration?.directive, 'visible'); - assert.deepEqual(result.hydration?.value, { rootMargin: '200px' }); - }); - - it('extracts client:media directive with query value', () => { - const result = extractDirectives( - { - 'client:media': '(max-width: 768px)', - 'client:component-path': '/src/Nav.jsx', - 'client:component-export': 'default', - }, - clientDirectives, - ); - assert.equal(result.hydration?.directive, 'media'); - assert.equal(result.hydration?.value, '(max-width: 768px)'); - }); - - it('extracts client:only directive and sets componentUrl', () => { - const result = extractDirectives( - { - 'client:only': 'react', - 'client:component-path': '/src/ReactComp.jsx', - 'client:component-export': 'default', - }, - clientDirectives, - ); - assert.equal(result.hydration?.directive, 'only'); - assert.equal(result.hydration?.value, 'react'); - }); - - it('separates client: props from regular props', () => { - const result = extractDirectives( - { - 'client:load': '', - 'client:component-path': '/src/Btn.jsx', - 'client:component-export': 'default', - class: 'btn', - disabled: true, - }, - clientDirectives, - ); - assert.deepEqual(result.props, { class: 'btn', disabled: true }); - assert.ok(!('client:load' in result.props)); - }); - - it('sets isPage = true when server:root is present', () => { - const result = extractDirectives({ 'server:root': true }, clientDirectives); - assert.equal(result.isPage, true); - }); - - it('handles data-astro-transition-scope: included in props but excluded from propsWithoutTransitionAttributes', () => { - const result = extractDirectives( - { 'data-astro-transition-scope': 'astro-abc-1', title: 'Hello' }, - clientDirectives, - ); - assert.ok('data-astro-transition-scope' in result.props); - assert.ok(!('data-astro-transition-scope' in result.propsWithoutTransitionAttributes)); - assert.ok('title' in result.propsWithoutTransitionAttributes); - }); - - it('handles data-astro-transition-persist: excluded from propsWithoutTransitionAttributes', () => { - const result = extractDirectives( - { 'data-astro-transition-persist': 'hero', class: 'img' }, - clientDirectives, - ); - assert.ok('data-astro-transition-persist' in result.props); - assert.ok(!('data-astro-transition-persist' in result.propsWithoutTransitionAttributes)); - }); - - it('copies symbol props to both props and propsWithoutTransitionAttributes', () => { - const sym = Symbol('test'); - const result = extractDirectives({ [sym]: 'symbol-value' }, clientDirectives); - assert.equal(result.props[sym], 'symbol-value'); - assert.equal(result.propsWithoutTransitionAttributes[sym], 'symbol-value'); - }); - - it('throws for an invalid hydration directive', () => { - assert.throws( - () => extractDirectives({ 'client:unknown': '' }, clientDirectives), - (err) => { - assert.ok(err.message.includes('invalid hydration directive')); - assert.ok(err.message.includes('client:unknown')); - return true; - }, - ); - }); -}); diff --git a/packages/astro/test/units/render/hydration.test.ts b/packages/astro/test/units/render/hydration.test.ts new file mode 100644 index 000000000000..afc22e94978d --- /dev/null +++ b/packages/astro/test/units/render/hydration.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { extractDirectives } from '../../../dist/runtime/server/hydration.js'; + +// Minimal clientDirectives map matching what Astro registers by default +const clientDirectives = new Map([ + ['load', ''], + ['idle', ''], + ['visible', ''], + ['media', ''], + ['only', ''], +]); + +describe('extractDirectives', () => { + it('returns empty hydration and all props when no client: directives present', () => { + const result = extractDirectives({ title: 'Hello', count: 42 }, clientDirectives); + assert.equal(result.hydration, null); + assert.deepEqual(result.props, { title: 'Hello', count: 42 }); + assert.equal(result.isPage, false); + }); + + it('extracts client:load directive', () => { + const result = extractDirectives( + { + 'client:load': '', + 'client:component-path': '/src/Button.jsx', + 'client:component-export': 'default', + title: 'Hello', + }, + clientDirectives, + ); + assert.ok(result.hydration !== null); + assert.equal(result.hydration.directive, 'load'); + assert.equal(result.hydration.value, ''); + assert.equal(result.hydration.componentUrl, '/src/Button.jsx'); + assert.equal(result.hydration.componentExport.value, 'default'); + assert.deepEqual(result.props, { title: 'Hello' }); + }); + + it('extracts client:idle directive', () => { + const result = extractDirectives( + { + 'client:idle': '', + 'client:component-path': '/src/Widget.jsx', + 'client:component-export': 'Widget', + }, + clientDirectives, + ); + assert.equal(result.hydration?.directive, 'idle'); + }); + + it('extracts client:visible directive', () => { + const result = extractDirectives( + { + 'client:visible': { rootMargin: '200px' }, + 'client:component-path': '/src/Component.jsx', + 'client:component-export': 'default', + }, + clientDirectives, + ); + assert.equal(result.hydration?.directive, 'visible'); + assert.deepEqual(result.hydration?.value, { rootMargin: '200px' }); + }); + + it('extracts client:media directive with query value', () => { + const result = extractDirectives( + { + 'client:media': '(max-width: 768px)', + 'client:component-path': '/src/Nav.jsx', + 'client:component-export': 'default', + }, + clientDirectives, + ); + assert.equal(result.hydration?.directive, 'media'); + assert.equal(result.hydration?.value, '(max-width: 768px)'); + }); + + it('extracts client:only directive and sets componentUrl', () => { + const result = extractDirectives( + { + 'client:only': 'react', + 'client:component-path': '/src/ReactComp.jsx', + 'client:component-export': 'default', + }, + clientDirectives, + ); + assert.equal(result.hydration?.directive, 'only'); + assert.equal(result.hydration?.value, 'react'); + }); + + it('separates client: props from regular props', () => { + const result = extractDirectives( + { + 'client:load': '', + 'client:component-path': '/src/Btn.jsx', + 'client:component-export': 'default', + class: 'btn', + disabled: true, + }, + clientDirectives, + ); + assert.deepEqual(result.props, { class: 'btn', disabled: true }); + assert.ok(!('client:load' in result.props)); + }); + + it('sets isPage = true when server:root is present', () => { + const result = extractDirectives({ 'server:root': true }, clientDirectives); + assert.equal(result.isPage, true); + }); + + it('handles data-astro-transition-scope: included in props but excluded from propsWithoutTransitionAttributes', () => { + const result = extractDirectives( + { 'data-astro-transition-scope': 'astro-abc-1', title: 'Hello' }, + clientDirectives, + ); + assert.ok('data-astro-transition-scope' in result.props); + assert.ok(!('data-astro-transition-scope' in result.propsWithoutTransitionAttributes)); + assert.ok('title' in result.propsWithoutTransitionAttributes); + }); + + it('handles data-astro-transition-persist: excluded from propsWithoutTransitionAttributes', () => { + const result = extractDirectives( + { 'data-astro-transition-persist': 'hero', class: 'img' }, + clientDirectives, + ); + assert.ok('data-astro-transition-persist' in result.props); + assert.ok(!('data-astro-transition-persist' in result.propsWithoutTransitionAttributes)); + }); + + it('copies symbol props to both props and propsWithoutTransitionAttributes', () => { + const sym = Symbol('test'); + const result = extractDirectives({ [sym]: 'symbol-value' }, clientDirectives); + assert.equal(result.props[sym], 'symbol-value'); + assert.equal(result.propsWithoutTransitionAttributes[sym], 'symbol-value'); + }); + + it('throws for an invalid hydration directive', () => { + assert.throws( + () => extractDirectives({ 'client:unknown': '' }, clientDirectives), + (err: unknown) => { + assert.ok((err as Error).message.includes('invalid hydration directive')); + assert.ok((err as Error).message.includes('client:unknown')); + return true; + }, + ); + }); +}); diff --git a/packages/astro/test/units/render/paginate.test.js b/packages/astro/test/units/render/paginate.test.js deleted file mode 100644 index ff74c301bfac..000000000000 --- a/packages/astro/test/units/render/paginate.test.js +++ /dev/null @@ -1,185 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { generatePaginateFunction } from '../../../dist/core/render/paginate.js'; -import { createRouteData } from '../mocks.js'; - -const items = Array.from({ length: 25 }, (_, i) => `item-${i + 1}`); - -describe('Pagination — optional root page (spread route)', () => { - const route = createRouteData({ - route: '/posts/optional-root-page/[...page]', - params: ['...page'], - segments: [ - [{ content: 'posts', dynamic: false, spread: false }], - [{ content: 'optional-root-page', dynamic: false, spread: false }], - [{ content: '...page', dynamic: true, spread: true }], - ], - }); - const paginate = generatePaginateFunction(route, '/blog', 'ignore'); - const pages = paginate(items, { pageSize: 10 }); - - it('generates 3 pages for 25 items with pageSize 10', () => { - assert.equal(pages.length, 3); - }); - - it('page 1 has page param undefined (no number in URL)', () => { - assert.equal(pages[0].params.page, undefined); - }); - - it('page 2 has page param "2"', () => { - assert.equal(pages[1].params.page, '2'); - }); - - it('page 3 has page param "3"', () => { - assert.equal(pages[2].params.page, '3'); - }); - - it('generates correct output paths', () => { - // page 1 has no number → /blog/posts/optional-root-page/ - assert.ok(pages[0].params.page === undefined); - // page 2 → /posts/optional-root-page/2 - assert.ok(pages[1].props.page.url.current.includes('optional-root-page/2')); - // page 3 → /posts/optional-root-page/3 - assert.ok(pages[2].props.page.url.current.includes('optional-root-page/3')); - }); -}); - -describe('Pagination — named root page (non-spread route)', () => { - // Non-spread route => page 1 has page param "1" (always included) - const route = createRouteData({ - route: '/posts/named-root-page/[page]', - params: ['page'], - segments: [ - [{ content: 'posts', dynamic: false, spread: false }], - [{ content: 'named-root-page', dynamic: false, spread: false }], - [{ content: 'page', dynamic: true, spread: false }], - ], - }); - const paginate = generatePaginateFunction(route, '/blog', 'ignore'); - const pages = paginate(items, { pageSize: 10 }); - - it('generates 3 pages for 25 items with pageSize 10', () => { - assert.equal(pages.length, 3); - }); - - it('page 1 has page param "1" (number always included)', () => { - assert.equal(pages[0].params.page, '1'); - }); - - it('page 2 has page param "2"', () => { - assert.equal(pages[1].params.page, '2'); - }); - - it('generates correct output paths including /1/', () => { - assert.ok(pages[0].props.page.url.current.includes('named-root-page/1')); - assert.ok(pages[1].props.page.url.current.includes('named-root-page/2')); - assert.ok(pages[2].props.page.url.current.includes('named-root-page/3')); - }); -}); - -describe('Pagination — multiple params (color + page)', () => { - // Each color has its own set of pages; base='/blog', trailingSlash='never' - const route = createRouteData({ - route: '/posts/[color]/[page]', - params: ['color', 'page'], - segments: [ - [{ content: 'posts', dynamic: false, spread: false }], - [{ content: 'color', dynamic: true, spread: false }], - [{ content: 'page', dynamic: true, spread: false }], - ], - }); - const paginate = generatePaginateFunction(route, '/blog', 'never'); - - const redItems = ['r1', 'r2', 'r3']; - const blueItems = Array.from({ length: 15 }, (_, i) => `b${i + 1}`); - - const redPages = paginate(redItems, { pageSize: 10, params: { color: 'red' } }); - const bluePages = paginate(blueItems, { pageSize: 10, params: { color: 'blue' } }); - - it('red has 1 page (3 items, pageSize 10)', () => { - assert.equal(redPages.length, 1); - }); - - it('blue has 2 pages (15 items, pageSize 10)', () => { - assert.equal(bluePages.length, 2); - }); - - it('red page 1: no prev, no next', () => { - const { url } = redPages[0].props.page; - assert.equal(url.prev, undefined); - assert.equal(url.next, undefined); - }); - - it('blue page 1: no prev, next points to page 2', () => { - const { url } = bluePages[0].props.page; - assert.equal(url.prev, undefined); - assert.ok( - url.next?.includes('/blog/posts/blue/2'), - `expected /blog/posts/blue/2, got ${url.next}`, - ); - }); - - it('blue page 2: prev points to page 1, no next', () => { - const { url } = bluePages[1].props.page; - assert.ok( - url.prev?.includes('/blog/posts/blue/1'), - `expected /blog/posts/blue/1, got ${url.prev}`, - ); - assert.equal(url.next, undefined); - }); - - it('page data contains correct currentPage, data slice, and filter param', () => { - assert.equal(redPages[0].props.page.currentPage, 1); - assert.equal(redPages[0].params.color, 'red'); - assert.equal(bluePages[1].props.page.currentPage, 2); - assert.equal(bluePages[1].params.color, 'blue'); - }); -}); - -describe('Pagination — root spread, correct prev URL — Migrated from astro-pagination-root-spread.test.js', () => { - // 4 items, pageSize 1 → 4 pages; root spread means page 1 has no number in URL. - const route = createRouteData({ - route: '/[...page]', - params: ['...page'], - segments: [[{ content: '...page', dynamic: true, spread: true }]], - }); - const paginate = generatePaginateFunction(route, '/blog', 'ignore'); - const astronauts = [ - { astronaut: 'Neil Armstrong' }, - { astronaut: 'Buzz Aldrin' }, - { astronaut: 'Sally Ride' }, - { astronaut: 'John Glenn' }, - ]; - const pages = paginate(astronauts, { pageSize: 1 }); - - it('generates 4 pages', () => { - assert.equal(pages.length, 4); - }); - - it('page 1 (root): no prev', () => { - assert.equal(pages[0].props.page.url.prev, undefined); - }); - - it('page 2: prev is /blog/', () => { - // page 1 has no number in URL → its url.current is /blog/ - assert.ok( - pages[1].props.page.url.prev?.endsWith('/blog/') || pages[1].props.page.url.prev === '/blog/', - `got ${pages[1].props.page.url.prev}`, - ); - }); - - it('page 3: prev is /blog/2', () => { - assert.ok( - pages[2].props.page.url.prev?.includes('/blog/2'), - `got ${pages[2].props.page.url.prev}`, - ); - }); - - it('page 4: prev is /blog/3', () => { - assert.ok( - pages[3].props.page.url.prev?.includes('/blog/3'), - `got ${pages[3].props.page.url.prev}`, - ); - }); -}); diff --git a/packages/astro/test/units/render/paginate.test.ts b/packages/astro/test/units/render/paginate.test.ts new file mode 100644 index 000000000000..d8b73eb49d1e --- /dev/null +++ b/packages/astro/test/units/render/paginate.test.ts @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { generatePaginateFunction } from '../../../dist/core/render/paginate.js'; +import { createRouteData } from '../mocks.ts'; + +const items = Array.from({ length: 25 }, (_, i) => `item-${i + 1}`); + +describe('Pagination — optional root page (spread route)', () => { + const route = createRouteData({ + route: '/posts/optional-root-page/[...page]', + segments: [ + [{ content: 'posts', dynamic: false, spread: false }], + [{ content: 'optional-root-page', dynamic: false, spread: false }], + [{ content: '...page', dynamic: true, spread: true }], + ], + }); + const paginate = generatePaginateFunction(route, '/blog', 'ignore'); + const pages = paginate(items, { pageSize: 10 }); + + it('generates 3 pages for 25 items with pageSize 10', () => { + assert.equal(pages.length, 3); + }); + + it('page 1 has page param undefined (no number in URL)', () => { + assert.equal(pages[0].params.page, undefined); + }); + + it('page 2 has page param "2"', () => { + assert.equal(pages[1].params.page, '2'); + }); + + it('page 3 has page param "3"', () => { + assert.equal(pages[2].params.page, '3'); + }); + + it('generates correct output paths', () => { + // page 1 has no number → /blog/posts/optional-root-page/ + assert.ok(pages[0].params.page === undefined); + // page 2 → /posts/optional-root-page/2 + assert.ok(pages[1].props.page.url.current.includes('optional-root-page/2')); + // page 3 → /posts/optional-root-page/3 + assert.ok(pages[2].props.page.url.current.includes('optional-root-page/3')); + }); +}); + +describe('Pagination — named root page (non-spread route)', () => { + // Non-spread route => page 1 has page param "1" (always included) + const route = createRouteData({ + route: '/posts/named-root-page/[page]', + segments: [ + [{ content: 'posts', dynamic: false, spread: false }], + [{ content: 'named-root-page', dynamic: false, spread: false }], + [{ content: 'page', dynamic: true, spread: false }], + ], + }); + const paginate = generatePaginateFunction(route, '/blog', 'ignore'); + const pages = paginate(items, { pageSize: 10 }); + + it('generates 3 pages for 25 items with pageSize 10', () => { + assert.equal(pages.length, 3); + }); + + it('page 1 has page param "1" (number always included)', () => { + assert.equal(pages[0].params.page, '1'); + }); + + it('page 2 has page param "2"', () => { + assert.equal(pages[1].params.page, '2'); + }); + + it('generates correct output paths including /1/', () => { + assert.ok(pages[0].props.page.url.current.includes('named-root-page/1')); + assert.ok(pages[1].props.page.url.current.includes('named-root-page/2')); + assert.ok(pages[2].props.page.url.current.includes('named-root-page/3')); + }); +}); + +describe('Pagination — multiple params (color + page)', () => { + // Each color has its own set of pages; base='/blog', trailingSlash='never' + const route = createRouteData({ + route: '/posts/[color]/[page]', + segments: [ + [{ content: 'posts', dynamic: false, spread: false }], + [{ content: 'color', dynamic: true, spread: false }], + [{ content: 'page', dynamic: true, spread: false }], + ], + }); + const paginate = generatePaginateFunction(route, '/blog', 'never'); + + const redItems = ['r1', 'r2', 'r3']; + const blueItems = Array.from({ length: 15 }, (_, i) => `b${i + 1}`); + + const redPages = paginate(redItems, { pageSize: 10, params: { color: 'red' } }); + const bluePages = paginate(blueItems, { pageSize: 10, params: { color: 'blue' } }); + + it('red has 1 page (3 items, pageSize 10)', () => { + assert.equal(redPages.length, 1); + }); + + it('blue has 2 pages (15 items, pageSize 10)', () => { + assert.equal(bluePages.length, 2); + }); + + it('red page 1: no prev, no next', () => { + const { url } = redPages[0].props.page; + assert.equal(url.prev, undefined); + assert.equal(url.next, undefined); + }); + + it('blue page 1: no prev, next points to page 2', () => { + const { url } = bluePages[0].props.page; + assert.equal(url.prev, undefined); + assert.ok( + url.next?.includes('/blog/posts/blue/2'), + `expected /blog/posts/blue/2, got ${url.next}`, + ); + }); + + it('blue page 2: prev points to page 1, no next', () => { + const { url } = bluePages[1].props.page; + assert.ok( + url.prev?.includes('/blog/posts/blue/1'), + `expected /blog/posts/blue/1, got ${url.prev}`, + ); + assert.equal(url.next, undefined); + }); + + it('page data contains correct currentPage, data slice, and filter param', () => { + assert.equal(redPages[0].props.page.currentPage, 1); + assert.equal(redPages[0].params.color, 'red'); + assert.equal(bluePages[1].props.page.currentPage, 2); + assert.equal(bluePages[1].params.color, 'blue'); + }); +}); + +describe('Pagination — root spread, correct prev URL — Migrated from astro-pagination-root-spread.test.js', () => { + // 4 items, pageSize 1 → 4 pages; root spread means page 1 has no number in URL. + const route = createRouteData({ + route: '/[...page]', + segments: [[{ content: '...page', dynamic: true, spread: true }]], + }); + const paginate = generatePaginateFunction(route, '/blog', 'ignore'); + const astronauts = [ + { astronaut: 'Neil Armstrong' }, + { astronaut: 'Buzz Aldrin' }, + { astronaut: 'Sally Ride' }, + { astronaut: 'John Glenn' }, + ]; + const pages = paginate(astronauts, { pageSize: 1 }); + + it('generates 4 pages', () => { + assert.equal(pages.length, 4); + }); + + it('page 1 (root): no prev', () => { + assert.equal(pages[0].props.page.url.prev, undefined); + }); + + it('page 2: prev is /blog/', () => { + // page 1 has no number in URL → its url.current is /blog/ + assert.ok( + pages[1].props.page.url.prev?.endsWith('/blog/') || pages[1].props.page.url.prev === '/blog/', + `got ${pages[1].props.page.url.prev}`, + ); + }); + + it('page 3: prev is /blog/2', () => { + assert.ok( + pages[2].props.page.url.prev?.includes('/blog/2'), + `got ${pages[2].props.page.url.prev}`, + ); + }); + + it('page 4: prev is /blog/3', () => { + assert.ok( + pages[3].props.page.url.prev?.includes('/blog/3'), + `got ${pages[3].props.page.url.prev}`, + ); + }); +}); diff --git a/packages/astro/test/units/render/queue-batching.test.js b/packages/astro/test/units/render/queue-batching.test.js deleted file mode 100644 index db56cf242198..000000000000 --- a/packages/astro/test/units/render/queue-batching.test.js +++ /dev/null @@ -1,169 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { buildRenderQueue } from '../../../dist/runtime/server/render/queue/builder.js'; -import { renderQueue } from '../../../dist/runtime/server/render/queue/renderer.js'; -import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; -import { markHTMLString } from '../../../dist/runtime/server/index.js'; - -// Mock SSRResult for testing -function createMockResult() { - return { - _metadata: { - hasHydrationScript: false, - hasRenderedHead: false, - hasDirectives: new Set(), - headInTree: false, - extraHead: [], - propagators: new Set(), - }, - styles: new Set(), - scripts: new Set(), - links: new Set(), - }; -} - -// Create a NodePool for testing -function createMockPool() { - return new NodePool(1000); -} - -describe('Queue batching optimization', () => { - it('should batch consecutive text nodes', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const items = ['Hello', ' ', 'world', '!']; - - const queue = await buildRenderQueue(items, result, pool); - - // All text nodes should be in the queue - assert.equal(queue.nodes.length, 4); - assert.equal( - queue.nodes.every((n) => n.type === 'text'), - true, - ); - - // When rendered, they should be batched into one write - let writeCount = 0; - let output = ''; - const destination = { - write(chunk) { - writeCount++; - output += String(chunk); - }, - }; - - await renderQueue(queue, destination); - - assert.equal(output, 'Hello world!'); - assert.equal(writeCount, 1); // All 4 nodes batched into 1 write! - }); - - it('should batch consecutive html-string nodes', async () => { - const result = createMockResult(); - const pool = createMockPool(); - - const items = [markHTMLString('
    '), markHTMLString('content'), markHTMLString('
    ')]; - - const queue = await buildRenderQueue(items, result, pool); - - let writeCount = 0; - let output = ''; - const destination = { - write(chunk) { - writeCount++; - output += String(chunk); - }, - }; - - await renderQueue(queue, destination); - - // Should batch into single write - assert.equal(writeCount, 1, 'Should batch consecutive html-string nodes'); - assert.equal(output, '
    content
    '); - }); - - it('should NOT batch across component boundaries', async () => { - const result = createMockResult(); - const pool = createMockPool(); - - // Create a simple component - const componentInstance = { - render(dest) { - dest.write('

    Component

    '); - }, - }; - - const items = ['before', componentInstance, 'after']; - - const queue = await buildRenderQueue(items, result, pool); - - let writeCount = 0; - let output = ''; - const destination = { - write(chunk) { - writeCount++; - output += String(chunk); - }, - }; - - await renderQueue(queue, destination); - - // Should have 3 writes: batched 'before', component output, batched 'after' - assert.equal(writeCount, 3, 'Should NOT batch across component boundaries'); - assert.equal(output, 'before

    Component

    after'); - }); - - it('should demonstrate performance improvement with large arrays', async () => { - const result = createMockResult(); - const pool = createMockPool(); - - // Create a large array of text items (simulating a list) - const items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`); - - const queue = await buildRenderQueue(items, result, pool); - - assert.equal(queue.nodes.length, 1000); - - let writeCount = 0; - const destination = { - write() { - writeCount++; - }, - }; - - await renderQueue(queue, destination); - - // With batching: 1 write (all text nodes batched together) - // Without batching: 1000 writes (one per node) - assert.equal(writeCount, 1, 'Should batch 1000 text nodes into 1 write (99.9% reduction!)'); - }); - - it('should batch mixed text and html-string nodes', async () => { - const result = createMockResult(); - const pool = createMockPool(); - - const items = [ - 'Text 1', - markHTMLString('Bold'), - 'Text 2', - markHTMLString('Italic'), - ]; - - const queue = await buildRenderQueue(items, result, pool); - - let writeCount = 0; - let output = ''; - const destination = { - write(chunk) { - writeCount++; - output += String(chunk); - }, - }; - - await renderQueue(queue, destination); - - // All should be batched since they're all batchable types - assert.equal(writeCount, 1); - assert.equal(output, 'Text 1BoldText 2Italic'); - }); -}); diff --git a/packages/astro/test/units/render/queue-batching.test.ts b/packages/astro/test/units/render/queue-batching.test.ts new file mode 100644 index 000000000000..8f50afa445f2 --- /dev/null +++ b/packages/astro/test/units/render/queue-batching.test.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { buildRenderQueue } from '../../../dist/runtime/server/render/queue/builder.js'; +import { renderQueue } from '../../../dist/runtime/server/render/queue/renderer.js'; +import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; +import { markHTMLString } from '../../../dist/runtime/server/index.js'; +import type { RenderDestination } from '../../../dist/runtime/server/render/common.js'; + +// Mock SSRResult for testing +function createMockResult() { + return { + _metadata: { + hasHydrationScript: false, + hasRenderedHead: false, + hasDirectives: new Set(), + headInTree: false, + extraHead: [] as string[], + propagators: new Set(), + }, + styles: new Set(), + scripts: new Set(), + links: new Set(), + }; +} + +// Create a NodePool for testing +function createMockPool(): NodePool { + return new NodePool(1000); +} + +describe('Queue batching optimization', () => { + it('should batch consecutive text nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + const items = ['Hello', ' ', 'world', '!']; + + const queue = await buildRenderQueue(items, result as any, pool); + + // All text nodes should be in the queue + assert.equal(queue.nodes.length, 4); + assert.equal( + queue.nodes.every((n) => n.type === 'text'), + true, + ); + + // When rendered, they should be batched into one write + let writeCount = 0; + let output = ''; + const destination: RenderDestination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + assert.equal(output, 'Hello world!'); + assert.equal(writeCount, 1); // All 4 nodes batched into 1 write! + }); + + it('should batch consecutive html-string nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + const items = [markHTMLString('
    '), markHTMLString('content'), markHTMLString('
    ')]; + + const queue = await buildRenderQueue(items, result as any, pool); + + let writeCount = 0; + let output = ''; + const destination: RenderDestination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + // Should batch into single write + assert.equal(writeCount, 1, 'Should batch consecutive html-string nodes'); + assert.equal(output, '
    content
    '); + }); + + it('should NOT batch across component boundaries', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + // Create a simple component + const componentInstance = { + render(dest: RenderDestination) { + dest.write('

    Component

    '); + }, + }; + + const items = ['before', componentInstance, 'after']; + + const queue = await buildRenderQueue(items, result as any, pool); + + let writeCount = 0; + let output = ''; + const destination: RenderDestination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + // Should have 3 writes: batched 'before', component output, batched 'after' + assert.equal(writeCount, 3, 'Should NOT batch across component boundaries'); + assert.equal(output, 'before

    Component

    after'); + }); + + it('should demonstrate performance improvement with large arrays', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + // Create a large array of text items (simulating a list) + const items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`); + + const queue = await buildRenderQueue(items, result as any, pool); + + assert.equal(queue.nodes.length, 1000); + + let writeCount = 0; + const destination: RenderDestination = { + write() { + writeCount++; + }, + }; + + await renderQueue(queue, destination); + + // With batching: 1 write (all text nodes batched together) + // Without batching: 1000 writes (one per node) + assert.equal(writeCount, 1, 'Should batch 1000 text nodes into 1 write (99.9% reduction!)'); + }); + + it('should batch mixed text and html-string nodes', async () => { + const result = createMockResult(); + const pool = createMockPool(); + + const items = [ + 'Text 1', + markHTMLString('Bold'), + 'Text 2', + markHTMLString('Italic'), + ]; + + const queue = await buildRenderQueue(items, result as any, pool); + + let writeCount = 0; + let output = ''; + const destination: RenderDestination = { + write(chunk) { + writeCount++; + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + + // All should be batched since they're all batchable types + assert.equal(writeCount, 1); + assert.equal(output, 'Text 1BoldText 2Italic'); + }); +}); diff --git a/packages/astro/test/units/render/queue-pool.test.js b/packages/astro/test/units/render/queue-pool.test.js deleted file mode 100644 index a0b9a6aed168..000000000000 --- a/packages/astro/test/units/render/queue-pool.test.js +++ /dev/null @@ -1,275 +0,0 @@ -import { describe, it } from 'node:test'; -import { strictEqual, notStrictEqual } from 'node:assert'; -import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; - -describe('NodePool', () => { - it('should acquire a new node when pool is empty', () => { - const pool = new NodePool(); - const node = pool.acquire('text'); - - strictEqual(node.type, 'text'); - strictEqual(node.content, ''); // Default value for new TextNode - }); - - it('should reuse released nodes of the same type', () => { - const pool = new NodePool(); - - // Acquire and set up a text node - const node1 = pool.acquire('text'); - node1.content = 'Hello'; - - // Release it back to the pool - pool.release(node1); - strictEqual(pool.size(), 1); - - // Acquire another text node - should reuse the same object - const node2 = pool.acquire('text'); - strictEqual(node2.type, 'text'); - strictEqual(node2.content, ''); // Content was reset on release - strictEqual(node1, node2); // Same object reference (actual reuse) - - // Pool size should decrease (node was consumed from the text sub-pool) - strictEqual(pool.size(), 0); - }); - - it('should not reuse released nodes across different types', () => { - const pool = new NodePool(); - - // Acquire and release a text node - const node1 = pool.acquire('text'); - node1.content = 'Hello'; - pool.release(node1); - strictEqual(pool.size(), 1); - - // Acquire an html-string node - should NOT reuse the text node - const node2 = pool.acquire('html-string'); - strictEqual(node2.type, 'html-string'); - strictEqual(node2.html, ''); - - // Text node still in the text sub-pool (html-string pool was empty) - strictEqual(pool.size(), 1); - }); - - it('should respect maxSize limit', () => { - const pool = new NodePool(2); // Max size of 2 - - const node1 = pool.acquire('text'); - const node2 = pool.acquire('text'); - const node3 = pool.acquire('text'); - - pool.release(node1); - pool.release(node2); - pool.release(node3); // Should be discarded - - strictEqual(pool.size(), 2); // Only 2 nodes retained - }); - - it('should clear the pool', () => { - const pool = new NodePool(); - - // Acquire nodes first, then release them all at once - const node1 = pool.acquire('text'); - const node2 = pool.acquire('text'); - const node3 = pool.acquire('text'); - - pool.release(node1); - pool.release(node2); - pool.release(node3); - - strictEqual(pool.size(), 3); - - pool.clear(); - strictEqual(pool.size(), 0); - }); - - it('should release all nodes in an array', () => { - const pool = new NodePool(); - - const nodes = [pool.acquire('text'), pool.acquire('html-string'), pool.acquire('component')]; - - pool.releaseAll(nodes); - strictEqual(pool.size(), 3); - }); - - it('should properly create nodes with correct discriminated union types', () => { - const pool = new NodePool(); - - // Acquire different node types - const textNode = pool.acquire('text'); - const htmlNode = pool.acquire('html-string'); - const componentNode = pool.acquire('component'); - const instructionNode = pool.acquire('instruction'); - - // Each node should have only its relevant fields (discriminated union) - strictEqual(textNode.type, 'text'); - strictEqual(textNode.content, ''); - - strictEqual(htmlNode.type, 'html-string'); - strictEqual(htmlNode.html, ''); - - strictEqual(componentNode.type, 'component'); - strictEqual(componentNode.instance, undefined); - - strictEqual(instructionNode.type, 'instruction'); - strictEqual(instructionNode.instruction, undefined); - }); - - it('should handle multiple acquire/release cycles', () => { - const pool = new NodePool(10); - - // First cycle - acquire and release text nodes - const batch1 = []; - for (let i = 0; i < 5; i++) { - batch1.push(pool.acquire('text')); - } - pool.releaseAll(batch1); - strictEqual(pool.size(), 5); - - // Second cycle - reuse from the same type (text) sub-pool - const batch2 = []; - for (let i = 0; i < 3; i++) { - batch2.push(pool.acquire('text')); - } - strictEqual(pool.size(), 2); // 5 - 3 = 2 remaining in text pool - - pool.releaseAll(batch2); - strictEqual(pool.size(), 5); // 2 + 3 = 5 - }); - - it('should work correctly with default maxSize', () => { - const pool = new NodePool(); // Default maxSize = 1000 - - // Create and release many nodes - const nodes = []; - for (let i = 0; i < 100; i++) { - nodes.push(pool.acquire('text')); - } - - pool.releaseAll(nodes); - strictEqual(pool.size(), 100); - - // All should be reusable - for (let i = 0; i < 100; i++) { - pool.acquire('text'); - } - strictEqual(pool.size(), 0); // All reused - }); - - it('should return the same object reference when reusing pooled nodes', () => { - const pool = new NodePool(); - - // Test all four node types for identity reuse - const types = ['text', 'html-string', 'component', 'instruction']; - - for (const type of types) { - const original = pool.acquire(type); - pool.release(original); - const reused = pool.acquire(type); - strictEqual(original, reused, `${type} node should be same object after reuse`); - } - }); - - it('should clear references on component and instruction nodes when released', () => { - const pool = new NodePool(); - - // Component node - instance should be cleared - const compNode = pool.acquire('component'); - compNode.instance = { render: () => {} }; // Simulate a component instance - pool.release(compNode); - - const reusedComp = pool.acquire('component'); - strictEqual(reusedComp, compNode); // Same object - strictEqual(reusedComp.instance, undefined); // Instance cleared on release - - // Instruction node - instruction should be cleared - const instrNode = pool.acquire('instruction'); - instrNode.instruction = { type: 'head' }; // Simulate an instruction - pool.release(instrNode); - - const reusedInstr = pool.acquire('instruction'); - strictEqual(reusedInstr, instrNode); // Same object - strictEqual(reusedInstr.instruction, undefined); // Instruction cleared on release - }); - - it('should track pool size across mixed types correctly', () => { - const pool = new NodePool(10); - - // Release nodes of different types - const text1 = pool.acquire('text'); - const text2 = pool.acquire('text'); - const html1 = pool.acquire('html-string'); - const comp1 = pool.acquire('component'); - const instr1 = pool.acquire('instruction'); - - pool.releaseAll([text1, text2, html1, comp1, instr1]); - strictEqual(pool.size(), 5); // Total across all sub-pools - - // Acquire from specific types - only those sub-pools decrease - pool.acquire('text'); - strictEqual(pool.size(), 4); - - pool.acquire('text'); - strictEqual(pool.size(), 3); - - // Text sub-pool is now empty; acquiring another text creates new (no change to pool size) - const newText = pool.acquire('text'); - strictEqual(pool.size(), 3); // Still 3 (html, component, instruction remain) - notStrictEqual(newText, text1); // Not reused - new object - notStrictEqual(newText, text2); // Not reused - new object - }); - - it('should apply shared maxSize cap across all sub-pools', () => { - const pool = new NodePool(3); // Max 3 total across all types - - const text1 = pool.acquire('text'); - const html1 = pool.acquire('html-string'); - const comp1 = pool.acquire('component'); - const instr1 = pool.acquire('instruction'); - - pool.release(text1); // 1/3 - pool.release(html1); // 2/3 - pool.release(comp1); // 3/3 - pool.release(instr1); // Exceeds cap - dropped - - strictEqual(pool.size(), 3); - - // The instruction node was dropped, so acquiring instruction creates new - const newInstr = pool.acquire('instruction'); - notStrictEqual(newInstr, instr1); - }); - - it('should set content on reused nodes via acquire', () => { - const pool = new NodePool(); - - // Release a text node - const node = pool.acquire('text'); - node.content = 'old content'; - pool.release(node); - - // Acquire with content parameter - content should be set on the reused node - const reused = pool.acquire('text', 'new content'); - strictEqual(reused, node); // Same object - strictEqual(reused.content, 'new content'); - }); - - it('should clear all sub-pools on clear()', () => { - const pool = new NodePool(); - - // Release one of each type - const nodes = [ - pool.acquire('text'), - pool.acquire('html-string'), - pool.acquire('component'), - pool.acquire('instruction'), - ]; - pool.releaseAll(nodes); - strictEqual(pool.size(), 4); - - pool.clear(); - strictEqual(pool.size(), 0); - - // Acquiring after clear should create new objects (not reuse) - const newText = pool.acquire('text'); - notStrictEqual(newText, nodes[0]); - }); -}); diff --git a/packages/astro/test/units/render/queue-pool.test.ts b/packages/astro/test/units/render/queue-pool.test.ts new file mode 100644 index 000000000000..8308c4d7fe8d --- /dev/null +++ b/packages/astro/test/units/render/queue-pool.test.ts @@ -0,0 +1,281 @@ +import { describe, it } from 'node:test'; +import { strictEqual, notStrictEqual } from 'node:assert'; +import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; +import type { + TextNode, + HtmlStringNode, + ComponentNode, + InstructionNode, +} from '../../../dist/runtime/server/render/queue/types.js'; + +describe('NodePool', () => { + it('should acquire a new node when pool is empty', () => { + const pool = new NodePool(); + const node = pool.acquire('text'); + + strictEqual(node.type, 'text'); + strictEqual(node.content, ''); // Default value for new TextNode + }); + + it('should reuse released nodes of the same type', () => { + const pool = new NodePool(); + + // Acquire and set up a text node + const node1 = pool.acquire('text') as TextNode; + node1.content = 'Hello'; + + // Release it back to the pool + pool.release(node1); + strictEqual(pool.size(), 1); + + // Acquire another text node - should reuse the same object + const node2 = pool.acquire('text'); + strictEqual(node2.type, 'text'); + strictEqual(node2.content, ''); // Content was reset on release + strictEqual(node1, node2); // Same object reference (actual reuse) + + // Pool size should decrease (node was consumed from the text sub-pool) + strictEqual(pool.size(), 0); + }); + + it('should not reuse released nodes across different types', () => { + const pool = new NodePool(); + + // Acquire and release a text node + const node1 = pool.acquire('text') as TextNode; + node1.content = 'Hello'; + pool.release(node1); + strictEqual(pool.size(), 1); + + // Acquire an html-string node - should NOT reuse the text node + const node2 = pool.acquire('html-string') as HtmlStringNode; + strictEqual(node2.type, 'html-string'); + strictEqual(node2.html, ''); + + // Text node still in the text sub-pool (html-string pool was empty) + strictEqual(pool.size(), 1); + }); + + it('should respect maxSize limit', () => { + const pool = new NodePool(2); // Max size of 2 + + const node1 = pool.acquire('text'); + const node2 = pool.acquire('text'); + const node3 = pool.acquire('text'); + + pool.release(node1); + pool.release(node2); + pool.release(node3); // Should be discarded + + strictEqual(pool.size(), 2); // Only 2 nodes retained + }); + + it('should clear the pool', () => { + const pool = new NodePool(); + + // Acquire nodes first, then release them all at once + const node1 = pool.acquire('text'); + const node2 = pool.acquire('text'); + const node3 = pool.acquire('text'); + + pool.release(node1); + pool.release(node2); + pool.release(node3); + + strictEqual(pool.size(), 3); + + pool.clear(); + strictEqual(pool.size(), 0); + }); + + it('should release all nodes in an array', () => { + const pool = new NodePool(); + + const nodes = [pool.acquire('text'), pool.acquire('html-string'), pool.acquire('component')]; + + pool.releaseAll(nodes); + strictEqual(pool.size(), 3); + }); + + it('should properly create nodes with correct discriminated union types', () => { + const pool = new NodePool(); + + // Acquire different node types + const textNode = pool.acquire('text'); + const htmlNode = pool.acquire('html-string'); + const componentNode = pool.acquire('component'); + const instructionNode = pool.acquire('instruction'); + + // Each node should have only its relevant fields (discriminated union) + strictEqual(textNode.type, 'text'); + strictEqual(textNode.content, ''); + + strictEqual(htmlNode.type, 'html-string'); + strictEqual(htmlNode.html, ''); + + strictEqual(componentNode.type, 'component'); + strictEqual(componentNode.instance, undefined); + + strictEqual(instructionNode.type, 'instruction'); + strictEqual(instructionNode.instruction, undefined); + }); + + it('should handle multiple acquire/release cycles', () => { + const pool = new NodePool(10); + + // First cycle - acquire and release text nodes + const batch1 = []; + for (let i = 0; i < 5; i++) { + batch1.push(pool.acquire('text')); + } + pool.releaseAll(batch1); + strictEqual(pool.size(), 5); + + // Second cycle - reuse from the same type (text) sub-pool + const batch2 = []; + for (let i = 0; i < 3; i++) { + batch2.push(pool.acquire('text')); + } + strictEqual(pool.size(), 2); // 5 - 3 = 2 remaining in text pool + + pool.releaseAll(batch2); + strictEqual(pool.size(), 5); // 2 + 3 = 5 + }); + + it('should work correctly with default maxSize', () => { + const pool = new NodePool(); // Default maxSize = 1000 + + // Create and release many nodes + const nodes = []; + for (let i = 0; i < 100; i++) { + nodes.push(pool.acquire('text')); + } + + pool.releaseAll(nodes); + strictEqual(pool.size(), 100); + + // All should be reusable + for (let i = 0; i < 100; i++) { + pool.acquire('text'); + } + strictEqual(pool.size(), 0); // All reused + }); + + it('should return the same object reference when reusing pooled nodes', () => { + const pool = new NodePool(); + + // Test all four node types for identity reuse + const types = ['text', 'html-string', 'component', 'instruction'] as const; + + for (const type of types) { + const original = pool.acquire(type); + pool.release(original); + const reused = pool.acquire(type); + strictEqual(original, reused, `${type} node should be same object after reuse`); + } + }); + + it('should clear references on component and instruction nodes when released', () => { + const pool = new NodePool(); + + // Component node - instance should be cleared + const compNode = pool.acquire('component') as ComponentNode; + compNode.instance = { render: () => {} } as any; // Simulate a component instance + pool.release(compNode); + + const reusedComp = pool.acquire('component') as ComponentNode; + strictEqual(reusedComp, compNode); // Same object + strictEqual(reusedComp.instance, undefined); // Instance cleared on release + + // Instruction node - instruction should be cleared + const instrNode = pool.acquire('instruction') as InstructionNode; + instrNode.instruction = { type: 'head' } as any; // Simulate an instruction + pool.release(instrNode); + + const reusedInstr = pool.acquire('instruction') as InstructionNode; + strictEqual(reusedInstr, instrNode); // Same object + strictEqual(reusedInstr.instruction, undefined); // Instruction cleared on release + }); + + it('should track pool size across mixed types correctly', () => { + const pool = new NodePool(10); + + // Release nodes of different types + const text1 = pool.acquire('text'); + const text2 = pool.acquire('text'); + const html1 = pool.acquire('html-string'); + const comp1 = pool.acquire('component'); + const instr1 = pool.acquire('instruction'); + + pool.releaseAll([text1, text2, html1, comp1, instr1]); + strictEqual(pool.size(), 5); // Total across all sub-pools + + // Acquire from specific types - only those sub-pools decrease + pool.acquire('text'); + strictEqual(pool.size(), 4); + + pool.acquire('text'); + strictEqual(pool.size(), 3); + + // Text sub-pool is now empty; acquiring another text creates new (no change to pool size) + const newText = pool.acquire('text'); + strictEqual(pool.size(), 3); // Still 3 (html, component, instruction remain) + notStrictEqual(newText, text1); // Not reused - new object + notStrictEqual(newText, text2); // Not reused - new object + }); + + it('should apply shared maxSize cap across all sub-pools', () => { + const pool = new NodePool(3); // Max 3 total across all types + + const text1 = pool.acquire('text'); + const html1 = pool.acquire('html-string'); + const comp1 = pool.acquire('component'); + const instr1 = pool.acquire('instruction'); + + pool.release(text1); // 1/3 + pool.release(html1); // 2/3 + pool.release(comp1); // 3/3 + pool.release(instr1); // Exceeds cap - dropped + + strictEqual(pool.size(), 3); + + // The instruction node was dropped, so acquiring instruction creates new + const newInstr = pool.acquire('instruction'); + notStrictEqual(newInstr, instr1); + }); + + it('should set content on reused nodes via acquire', () => { + const pool = new NodePool(); + + // Release a text node + const node = pool.acquire('text') as TextNode; + node.content = 'old content'; + pool.release(node); + + // Acquire with content parameter - content should be set on the reused node + const reused = pool.acquire('text', 'new content') as TextNode; + strictEqual(reused, node); // Same object + strictEqual(reused.content, 'new content'); + }); + + it('should clear all sub-pools on clear()', () => { + const pool = new NodePool(); + + // Release one of each type + const nodes = [ + pool.acquire('text'), + pool.acquire('html-string'), + pool.acquire('component'), + pool.acquire('instruction'), + ]; + pool.releaseAll(nodes); + strictEqual(pool.size(), 4); + + pool.clear(); + strictEqual(pool.size(), 0); + + // Acquiring after clear should create new objects (not reuse) + const newText = pool.acquire('text'); + notStrictEqual(newText, nodes[0]); + }); +}); diff --git a/packages/astro/test/units/render/queue-rendering.test.js b/packages/astro/test/units/render/queue-rendering.test.js deleted file mode 100644 index 724812396a7f..000000000000 --- a/packages/astro/test/units/render/queue-rendering.test.js +++ /dev/null @@ -1,345 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { buildRenderQueue } from '../../../dist/runtime/server/render/queue/builder.js'; -import { renderQueue } from '../../../dist/runtime/server/render/queue/renderer.js'; -import { NodePool } from '../../../dist/runtime/server/render/queue/pool.js'; -import { renderPage } from '../../../dist/runtime/server/render/page.js'; - -/** - * Tests for the queue-based rendering engine - * These are unit tests for the core queue building and rendering logic - */ -describe('Queue-based rendering engine', () => { - // Create a minimal SSRResult mock for testing - function createMockResult() { - return { - _metadata: { - hasHydrationScript: false, - rendererSpecificHydrationScripts: new Set(), - hasRenderedHead: false, - renderedScripts: new Set(), - hasDirectives: new Set(), - hasRenderedServerIslandRuntime: false, - headInTree: false, - extraHead: [], - extraStyleHashes: [], - extraScriptHashes: [], - propagators: new Set(), - }, - styles: new Set(), - scripts: new Set(), - links: new Set(), - componentMetadata: new Map(), - cancelled: false, - compressHTML: false, - }; - } - - // Create a NodePool for testing - function createMockPool() { - return new NodePool(1000); - } - - describe('buildRenderQueue()', () => { - it('should handle simple text nodes', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue('Hello, World!', result, pool); - - assert.ok(queue.nodes.length > 0); - assert.equal(queue.nodes[0].type, 'text'); - assert.equal(queue.nodes[0].content, 'Hello, World!'); - }); - - it('should handle numbers', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue(42, result, pool); - - assert.ok(queue.nodes.length > 0); - assert.equal(queue.nodes[0].type, 'text'); - assert.equal(queue.nodes[0].content, '42'); - }); - - it('should handle booleans', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue(true, result, pool); - - assert.ok(queue.nodes.length > 0); - assert.equal(queue.nodes[0].type, 'text'); - assert.equal(queue.nodes[0].content, 'true'); - }); - - it('should handle arrays', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue(['Hello', ' ', 'World'], result, pool); - - assert.equal(queue.nodes.length, 3); - assert.equal(queue.nodes[0].content, 'Hello'); - assert.equal(queue.nodes[1].content, ' '); - assert.equal(queue.nodes[2].content, 'World'); - }); - - it('should handle null and undefined (skip them)', async () => { - const result = createMockResult(); - const nullQueue = await buildRenderQueue(null, result); - const undefinedQueue = await buildRenderQueue(undefined, result); - - assert.equal(nullQueue.nodes.length, 0); - assert.equal(undefinedQueue.nodes.length, 0); - }); - - it('should skip false but render 0', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const falseQueue = await buildRenderQueue(false, result, pool); - const zeroQueue = await buildRenderQueue(0, result, pool); - - assert.equal(falseQueue.nodes.length, 0); - assert.equal(zeroQueue.nodes.length, 1); - assert.equal(zeroQueue.nodes[0].content, '0'); - }); - - it('should handle promises', async () => { - const result = createMockResult(); - const promise = Promise.resolve('Resolved value'); - const pool = createMockPool(); - const queue = await buildRenderQueue(promise, result, pool); - - assert.equal(queue.nodes.length, 1); - assert.equal(queue.nodes[0].content, 'Resolved value'); - }); - - it('should handle nested arrays', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue([['Nested', ' '], 'Array'], result, pool); - - assert.equal(queue.nodes.length, 3); - assert.equal(queue.nodes[0].content, 'Nested'); - assert.equal(queue.nodes[1].content, ' '); - assert.equal(queue.nodes[2].content, 'Array'); - }); - - it('should handle async iterables', async () => { - const result = createMockResult(); - - async function* asyncGen() { - yield 'First'; - yield 'Second'; - yield 'Third'; - } - - const pool = createMockPool(); - const queue = await buildRenderQueue(asyncGen(), result, pool); - - assert.equal(queue.nodes.length, 3); - assert.equal(queue.nodes[0].content, 'First'); - assert.equal(queue.nodes[1].content, 'Second'); - assert.equal(queue.nodes[2].content, 'Third'); - }); - - it('should track parent relationships', async () => { - const result = createMockResult(); - const nestedArray = [['child1', 'child2'], 'sibling']; - const pool = createMockPool(); - const queue = await buildRenderQueue(nestedArray, result, pool); - - // Verify correct node structure - assert.equal(queue.nodes.length, 3); - assert.equal(queue.nodes[0].content, 'child1'); - assert.equal(queue.nodes[1].content, 'child2'); - assert.equal(queue.nodes[2].content, 'sibling'); - }); - - it('should maintain correct rendering order', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue(['A', 'B', 'C'], result, pool); - - assert.equal(queue.nodes[0].content, 'A'); - assert.equal(queue.nodes[1].content, 'B'); - assert.equal(queue.nodes[2].content, 'C'); - }); - - it('should handle sync iterables (Set)', async () => { - const result = createMockResult(); - const set = new Set(['One', 'Two', 'Three']); - const pool = createMockPool(); - const queue = await buildRenderQueue(set, result, pool); - - assert.equal(queue.nodes.length, 3); - // Set iteration order is insertion order - const contents = queue.nodes.map((n) => n.content); - assert.ok(contents.includes('One')); - assert.ok(contents.includes('Two')); - assert.ok(contents.includes('Three')); - }); - }); - - describe('renderQueue()', () => { - it('should render simple text to string', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue('Test content', result, pool); - - let output = ''; - const destination = { - write(chunk) { - output += String(chunk); - }, - }; - - await renderQueue(queue, destination); - assert.ok(output.includes('Test content')); - }); - - it('should render array to concatenated string', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue(['Hello', ' ', 'World'], result, pool); - - let output = ''; - const destination = { - write(chunk) { - output += String(chunk); - }, - }; - - await renderQueue(queue, destination); - assert.equal(output, 'Hello World'); - }); - - it('should escape HTML in text nodes', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue('', result, pool); - - let output = ''; - const destination = { - write(chunk) { - output += String(chunk); - }, - }; - - await renderQueue(queue, destination); - assert.ok(!output.includes('\n'; - }; - htmlPageFactory['astro:html'] = true; - htmlPageFactory.moduleId = 'src/pages/admin/index.html'; - - const result = createMockResultWithQueue(); - - const response = await renderPage(result, htmlPageFactory, {}, null, false); - const html = await response.text(); - - // The raw '), - `Expected unescaped '; - }; - // No astro:html flag set — this is the default for non-.html components - regularFactory.moduleId = 'src/pages/regular.astro'; - - const result = createMockResultWithQueue(); - - const response = await renderPage(result, regularFactory, {}, null, false); - const html = await response.text(); - - assert.ok(!html.includes('', result as any, pool); + + let output = ''; + const destination: RenderDestination = { + write(chunk) { + output += String(chunk); + }, + }; + + await renderQueue(queue, destination); + assert.ok(!output.includes('\n'; + }; + (htmlPageFactory as any)['astro:html'] = true; + (htmlPageFactory as any).moduleId = 'src/pages/admin/index.html'; + + const result = createMockResultWithQueue(); + + const response = await renderPage(result as any, htmlPageFactory as any, {}, null, false); + const html = await response.text(); + + // The raw '), + `Expected unescaped '; + }; + // No astro:html flag set — this is the default for non-.html components + (regularFactory as any).moduleId = 'src/pages/regular.astro'; + + const result = createMockResultWithQueue(); + + const response = await renderPage(result as any, regularFactory as any, {}, null, false); + const html = await response.text(); + + assert.ok(!html.includes(''), 'should include closing tag'); - assert.ok( - output.includes('replaceServerIsland'), - 'should include the replaceServerIsland function', - ); - }); - - it('escapes sequences inside the runtime script', () => { - const output = renderServerIslandRuntime(); - // The content between the script tags must not contain an unescaped /, '').replace(/<\/script>$/, ''); - assert.ok( - !inner.includes(' { - // #region getIslandContent() - describe('getIslandContent()', () => { - it('omits props from the URL when no user props are provided', async () => { - const result = await createStubResult(); - const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); - const content = await component.getIslandContent(); - // Matches `p=` with nothing after it before the next param or quote, - // e.g. `?e=...&p=&s=` (GET) or `encryptedProps: ''` (POST). - // This confirms the encrypted props value is empty when no user props are passed. - const emptyPropsPattern = /[?&]p=(?:&|')/; - assert.ok(emptyPropsPattern.test(content), `expected empty p= in URL, got: ${content}`); - }); - - it('includes encrypted props in the URL when user props are provided', async () => { - const result = await createStubResult(); - const props = islandProps({ message: 'hello' }); - const component = new ServerIslandComponent(result, props, {}, 'Island'); - const content = await component.getIslandContent(); - // p= should be non-empty when props are present - assert.ok(!content.includes("'p', ''"), 'p= should not be empty when props are present'); - }); - - it('produces different ciphertexts on each call (IV randomness)', async () => { - const result = await createStubResult(); - const propsA = islandProps({ value: 'test' }); - const propsB = islandProps({ value: 'test' }); - const compA = new ServerIslandComponent(result, propsA, {}, 'Island'); - const compB = new ServerIslandComponent(result, propsB, {}, 'Island'); - const [contentA, contentB] = await Promise.all([ - compA.getIslandContent(), - compB.getIslandContent(), - ]); - // Two separate instances with identical props should produce different encrypted values - assert.notEqual(contentA, contentB, 'encrypted values should differ due to unique IVs'); - }); - - it('uses a GET request for small payloads (under 2048 chars)', async () => { - const result = await createStubResult(); - const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); - const content = await component.getIslandContent(); - // A small payload should use a plain fetch() GET call (no method: 'POST') - assert.ok(!content.includes("method: 'POST'"), 'small payloads should use GET, not POST'); - assert.ok(content.includes("fetch('"), 'should use fetch()'); - }); - - it('uses a POST request for large payloads (over 2048 chars)', async () => { - const result = await createStubResult(); - // Create a large prop value to push past the 2048 character URL limit - const largeValue = 'x'.repeat(2048); - const props = islandProps({ data: largeValue }); - const component = new ServerIslandComponent(result, props, {}, 'Island'); - const content = await component.getIslandContent(); - assert.ok(content.includes("method: 'POST'"), 'large payloads should fall back to POST'); - }); - - it('builds the island URL from base + componentId', async () => { - const result = await createStubResult({ base: '/app' }); - const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); - const content = await component.getIslandContent(); - assert.ok( - content.includes('/app/_server-islands/Island'), - `island URL should include base + componentId, got: ${content}`, - ); - }); - - it('appends a trailing slash when trailingSlash is "always"', async () => { - const result = await createStubResult({ trailingSlash: 'always' }); - const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); - const content = await component.getIslandContent(); - assert.ok( - content.includes('/_server-islands/Island/'), - `should append trailing slash, got: ${content}`, - ); - }); - - it('does not append a trailing slash when trailingSlash is "never"', async () => { - const result = await createStubResult({ trailingSlash: 'never' }); - const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); - const content = await component.getIslandContent(); - assert.ok( - !content.includes('/_server-islands/Island/'), - `should NOT append trailing slash, got: ${content}`, - ); - }); - - it('the encrypted componentExport round-trips correctly', async () => { - const key = await createKey(); - const result = await createStubResult({ key: Promise.resolve(key) }); - const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); - await component.getIslandContent(); - // Extract the encrypted export value embedded in the generated script - // The GET path embeds it in the URL; POST path embeds it in the data object. - // We verify the field is encrypted (not plaintext "default") in both cases. - const content = await component.getIslandContent(); - assert.ok( - !content.includes('"default"') && !content.includes("'default'"), - 'componentExport should be encrypted, not plaintext "default"', - ); - }); - - it('removes internal server: props before encrypting user props', async () => { - const key = await createKey(); - const result = await createStubResult({ key: Promise.resolve(key) }); - const props = islandProps({ userProp: 'visible' }); - const component = new ServerIslandComponent(result, props, {}, 'Island'); - await component.getIslandContent(); - // After getIslandContent(), the internal props should have been removed from this.props - for (const internalKey of [ - 'server:component-path', - 'server:component-export', - 'server:component-directive', - 'server:defer', - ]) { - assert.ok( - !(internalKey in component.props), - `internal prop ${internalKey} should be removed`, - ); - } - // The user prop should remain - assert.ok('userProp' in component.props, 'user prop should still be present'); - }); - - it('throws when the component path is not in serverIslandNameMap', async () => { - const result = await createStubResult(); - const props = islandProps({ 'server:component-path': 'src/components/Unknown.astro' }); - const component = new ServerIslandComponent(result, props, {}, 'Unknown'); - await assert.rejects( - () => component.getIslandContent(), - /Could not find server component name/, - ); - }); - }); - // #endregion - - // #region render() - describe('render()', () => { - it('emits the server-island-start HTML comment marker', async () => { - const result = await createStubResult(); - const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); - const dest = createDestination(); - await component.render(dest.destination); - const out = dest.output(); - assert.ok( - out.includes('server-island-start'), - `should emit server-island-start marker, got: ${out}`, - ); - }); - - it('emits a '), 'should include closing tag'); + assert.ok( + output.includes('replaceServerIsland'), + 'should include the replaceServerIsland function', + ); + }); + + it('escapes sequences inside the runtime script', () => { + const output = renderServerIslandRuntime(); + // The content between the script tags must not contain an unescaped /, '').replace(/<\/script>$/, ''); + assert.ok( + !inner.includes(' { + // #region getIslandContent() + describe('getIslandContent()', () => { + it('omits props from the URL when no user props are provided', async () => { + const result = await createStubResult(); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + // Matches `p=` with nothing after it before the next param or quote, + // e.g. `?e=...&p=&s=` (GET) or `encryptedProps: ''` (POST). + // This confirms the encrypted props value is empty when no user props are passed. + const emptyPropsPattern = /[?&]p=(?:&|')/; + assert.ok(emptyPropsPattern.test(content), `expected empty p= in URL, got: ${content}`); + }); + + it('includes encrypted props in the URL when user props are provided', async () => { + const result = await createStubResult(); + const props = islandProps({ message: 'hello' }); + const component = new ServerIslandComponent(result, props, {}, 'Island'); + const content = await component.getIslandContent(); + // p= should be non-empty when props are present + assert.ok(!content.includes("'p', ''"), 'p= should not be empty when props are present'); + }); + + it('produces different ciphertexts on each call (IV randomness)', async () => { + const result = await createStubResult(); + const propsA = islandProps({ value: 'test' }); + const propsB = islandProps({ value: 'test' }); + const compA = new ServerIslandComponent(result, propsA, {}, 'Island'); + const compB = new ServerIslandComponent(result, propsB, {}, 'Island'); + const [contentA, contentB] = await Promise.all([ + compA.getIslandContent(), + compB.getIslandContent(), + ]); + // Two separate instances with identical props should produce different encrypted values + assert.notEqual(contentA, contentB, 'encrypted values should differ due to unique IVs'); + }); + + it('uses a GET request for small payloads (under 2048 chars)', async () => { + const result = await createStubResult(); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + // A small payload should use a plain fetch() GET call (no method: 'POST') + assert.ok(!content.includes("method: 'POST'"), 'small payloads should use GET, not POST'); + assert.ok(content.includes("fetch('"), 'should use fetch()'); + }); + + it('uses a POST request for large payloads (over 2048 chars)', async () => { + const result = await createStubResult(); + // Create a large prop value to push past the 2048 character URL limit + const largeValue = 'x'.repeat(2048); + const props = islandProps({ data: largeValue }); + const component = new ServerIslandComponent(result, props, {}, 'Island'); + const content = await component.getIslandContent(); + assert.ok(content.includes("method: 'POST'"), 'large payloads should fall back to POST'); + }); + + it('builds the island URL from base + componentId', async () => { + const result = await createStubResult({ base: '/app' }); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + assert.ok( + content.includes('/app/_server-islands/Island'), + `island URL should include base + componentId, got: ${content}`, + ); + }); + + it('appends a trailing slash when trailingSlash is "always"', async () => { + const result = await createStubResult({ trailingSlash: 'always' }); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + assert.ok( + content.includes('/_server-islands/Island/'), + `should append trailing slash, got: ${content}`, + ); + }); + + it('does not append a trailing slash when trailingSlash is "never"', async () => { + const result = await createStubResult({ trailingSlash: 'never' }); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const content = await component.getIslandContent(); + assert.ok( + !content.includes('/_server-islands/Island/'), + `should NOT append trailing slash, got: ${content}`, + ); + }); + + it('the encrypted componentExport round-trips correctly', async () => { + const key = await createKey(); + const result = await createStubResult({ key: Promise.resolve(key) }); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + await component.getIslandContent(); + // Extract the encrypted export value embedded in the generated script + // The GET path embeds it in the URL; POST path embeds it in the data object. + // We verify the field is encrypted (not plaintext "default") in both cases. + const content = await component.getIslandContent(); + assert.ok( + !content.includes('"default"') && !content.includes("'default'"), + 'componentExport should be encrypted, not plaintext "default"', + ); + }); + + it('removes internal server: props before encrypting user props', async () => { + const key = await createKey(); + const result = await createStubResult({ key: Promise.resolve(key) }); + const props = islandProps({ userProp: 'visible' }); + const component = new ServerIslandComponent(result, props, {}, 'Island'); + await component.getIslandContent(); + // After getIslandContent(), the internal props should have been removed from this.props + for (const internalKey of [ + 'server:component-path', + 'server:component-export', + 'server:component-directive', + 'server:defer', + ]) { + assert.ok( + !(internalKey in component.props), + `internal prop ${internalKey} should be removed`, + ); + } + // The user prop should remain + assert.ok('userProp' in component.props, 'user prop should still be present'); + }); + + it('throws when the component path is not in serverIslandNameMap', async () => { + const result = await createStubResult(); + const props = islandProps({ 'server:component-path': 'src/components/Unknown.astro' }); + const component = new ServerIslandComponent(result, props, {}, 'Unknown'); + await assert.rejects( + () => component.getIslandContent(), + /Could not find server component name/, + ); + }); + }); + // #endregion + + // #region render() + describe('render()', () => { + it('emits the server-island-start HTML comment marker', async () => { + const result = await createStubResult(); + const component = new ServerIslandComponent(result, islandProps(), {}, 'Island'); + const dest = createDestination(); + await component.render(dest.destination); + const out = dest.output(); + assert.ok( + out.includes('server-island-start'), + `should emit server-island-start marker, got: ${out}`, + ); + }); + + it('emits a '; - const expected = ''; - const result = await testEscapeTransform(input, expected); - assert.equal(result, expected); - }); - - it.skip('handles multiple escapes in single element', async () => { - // Skipping: There's a bug in replaceAttribute with multiple attributes - const input = '
    ${text}
    '; - const expected = '
    \\${text}
    '; - const result = await testEscapeTransform(input); - assert.equal(result, expected); - }); - - it('preserves content without template literal characters', async () => { - const input = '
    Hello world!
    '; - const result = await testEscapeTransform(input, input); - assert.equal(result, input); - }); - - it('handles empty attributes correctly', async () => { - const input = '
    '; - const expected = '
    '; - const result = await testEscapeTransform(input, expected); - assert.equal(result, expected); - }); -}); diff --git a/packages/astro/test/units/vite-plugin-html/escape.test.ts b/packages/astro/test/units/vite-plugin-html/escape.test.ts new file mode 100644 index 000000000000..123e8a5774d3 --- /dev/null +++ b/packages/astro/test/units/vite-plugin-html/escape.test.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import MagicString from 'magic-string'; +import { rehype } from 'rehype'; +import { + escapeTemplateLiteralCharacters, + needsEscape, +} from '../../../dist/vite-plugin-html/transform/utils.js'; +import rehypeEscape from '../../../dist/vite-plugin-html/transform/escape.js'; + +describe('vite-plugin-html: escape utilities', () => { + describe('needsEscape', () => { + it('returns true for strings with template literal characters', () => { + assert.equal(needsEscape('${foo}'), true); + assert.equal(needsEscape('`hello`'), true); + assert.equal(needsEscape('\\'), true); + assert.equal(needsEscape('test${var}'), true); + assert.equal(needsEscape('\\${escaped}'), true); + }); + + it('returns false for strings without template literal characters', () => { + assert.equal(needsEscape('hello world'), false); + assert.equal(needsEscape(''), false); + assert.equal(needsEscape('normal text'), false); + assert.equal(needsEscape(123), false); + assert.equal(needsEscape(null), false); + assert.equal(needsEscape(undefined), false); + }); + }); + + describe('escapeTemplateLiteralCharacters', () => { + it('escapes dollar brace expressions', () => { + assert.equal(escapeTemplateLiteralCharacters('${foo}'), '\\${foo}'); + assert.equal(escapeTemplateLiteralCharacters('hello ${world}'), 'hello \\${world}'); + assert.equal(escapeTemplateLiteralCharacters('${a} ${b}'), '\\${a} \\${b}'); + }); + + it('escapes backticks', () => { + assert.equal(escapeTemplateLiteralCharacters('`hello`'), '\\`hello\\`'); + assert.equal(escapeTemplateLiteralCharacters('test `code` here'), 'test \\`code\\` here'); + }); + + it('escapes backslashes', () => { + assert.equal(escapeTemplateLiteralCharacters('\\'), '\\\\'); + assert.equal(escapeTemplateLiteralCharacters('path\\to\\file'), 'path\\\\to\\\\file'); + }); + + it('handles complex escape sequences from html-escape-complex fixture', () => { + // Test cases from the html-escape-complex fixture + assert.equal(escapeTemplateLiteralCharacters('\\'), '\\\\'); + assert.equal(escapeTemplateLiteralCharacters('\\\\'), '\\\\\\\\'); + assert.equal(escapeTemplateLiteralCharacters('\\\\\\'), '\\\\\\\\\\\\'); + assert.equal(escapeTemplateLiteralCharacters('\\\\\\\\'), '\\\\\\\\\\\\\\\\'); + assert.equal(escapeTemplateLiteralCharacters('\\\\\\\\\\'), '\\\\\\\\\\\\\\\\\\\\'); + assert.equal(escapeTemplateLiteralCharacters('\\\\\\\\\\\\'), '\\\\\\\\\\\\\\\\\\\\\\\\'); + }); + + it('handles already escaped sequences correctly', () => { + assert.equal(escapeTemplateLiteralCharacters('\\${foo}'), '\\\\\\${foo}'); + assert.equal(escapeTemplateLiteralCharacters('\\`'), '\\\\\\`'); + }); + + it('preserves non-escape characters', () => { + assert.equal(escapeTemplateLiteralCharacters('hello world'), 'hello world'); + assert.equal(escapeTemplateLiteralCharacters(''), ''); + assert.equal(escapeTemplateLiteralCharacters('normal-text_123'), 'normal-text_123'); + }); + + it('handles mixed content', () => { + assert.equal( + escapeTemplateLiteralCharacters('console.log(`hello ${"world"}!`)'), + 'console.log(\\`hello \\${"world"}!\\`)', + ); + }); + }); +}); + +describe('vite-plugin-html: escape transformer', () => { + async function testEscapeTransform(html: string) { + const s = new MagicString(html); + const processor = rehype().data('settings', { fragment: true }).use(rehypeEscape, { s }); + + await processor.process(html); + return s.toString(); + } + + it('escapes text content', async () => { + const result = await testEscapeTransform('
    ${foo}
    '); + assert.equal(result, '
    \\${foo}
    '); + }); + + it('escapes comment content', async () => { + const result = await testEscapeTransform(''); + // Comments are parsed as text nodes by rehype, so only the content is returned + assert.equal(result, ' \\${comment} '); + }); + + it('escapes attribute names with template literal characters', async () => { + const result = await testEscapeTransform(''); + assert.equal(result, ''); + }); + + it('escapes attribute values with template literal characters', async () => { + const result = await testEscapeTransform( + '', + ); + assert.equal(result, ''); + }); + + it('escapes camelCase attributes', async () => { + // Note: The escape transformer converts camelCase to kebab-case but doesn't escape properly + const result = await testEscapeTransform('
    '); + // Current behavior: camelCase is preserved but value is not escaped + assert.equal(result, '
    '); + }); + + it('escapes complex nested structures', async () => { + const input = ''; + const expected = ''; + const result = await testEscapeTransform(input); + assert.equal(result, expected); + }); + + it.skip('handles multiple escapes in single element', async () => { + // Skipping: There's a bug in replaceAttribute with multiple attributes + const input = '
    ${text}
    '; + const expected = '
    \\${text}
    '; + const result = await testEscapeTransform(input); + assert.equal(result, expected); + }); + + it('preserves content without template literal characters', async () => { + const input = '
    Hello world!
    '; + const result = await testEscapeTransform(input); + assert.equal(result, input); + }); + + it('handles empty attributes correctly', async () => { + const input = '
    '; + const expected = '
    '; + const result = await testEscapeTransform(input); + assert.equal(result, expected); + }); +}); diff --git a/packages/astro/test/units/vite-plugin-html/slots.test.js b/packages/astro/test/units/vite-plugin-html/slots.test.js deleted file mode 100644 index 0b6694992ae6..000000000000 --- a/packages/astro/test/units/vite-plugin-html/slots.test.js +++ /dev/null @@ -1,136 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import MagicString from 'magic-string'; -import { rehype } from 'rehype'; -import { VFile } from 'vfile'; -import rehypeSlots, { SLOT_PREFIX } from '../../../dist/vite-plugin-html/transform/slots.js'; - -describe('vite-plugin-html: slot transformer', () => { - async function testSlotTransform(html) { - const s = new MagicString(html); - const processor = rehype().data('settings', { fragment: true }).use(rehypeSlots, { s }); - - const vfile = new VFile({ value: html, path: 'test.html' }); - await processor.process(vfile); - return s.toString(); - } - - it('transforms default slots', async () => { - const result = await testSlotTransform('Default content'); - assert.equal(result, `\${${SLOT_PREFIX}["default"] ?? \`Default content\`}`); - }); - - it('transforms named slots', async () => { - const result = await testSlotTransform('Header content'); - assert.equal(result, `\${${SLOT_PREFIX}["header"] ?? \`Header content\`}`); - }); - - it('transforms multiple named slots', async () => { - const html = `
    -
    -
    `; - - const result = await testSlotTransform(html); - // Slots extract their inner HTML including the slot tag itself when empty - assert.match( - result, - new RegExp( - `
    \\$\\{${SLOT_PREFIX}\\["a"\\] \\?\\? \`\`\\}
    `, - ), - ); - assert.match( - result, - new RegExp( - `
    \\$\\{${SLOT_PREFIX}\\["b"\\] \\?\\? \`\`\\}
    `, - ), - ); - assert.match( - result, - new RegExp( - `
    \\$\\{${SLOT_PREFIX}\\["c"\\] \\?\\? \`\`\\}
    `, - ), - ); - }); - - it('preserves inline slots', async () => { - const result = await testSlotTransform(''); - assert.equal(result, ''); - }); - - it('preserves inline slots with name', async () => { - const result = await testSlotTransform('Content'); - assert.equal(result, 'Content'); - }); - - it('escapes template literal characters in slot content', async () => { - const result = await testSlotTransform('Content with ${variable} and `backticks`'); - assert.equal( - result, - `\${${SLOT_PREFIX}["default"] ?? \`Content with \\$\{variable\} and \\\`backticks\\\`\`}`, - ); - }); - - it('handles slots with multiple children', async () => { - const html = 'Child 1Child 2'; - const result = await testSlotTransform(html); - assert.equal( - result, - `\${${SLOT_PREFIX}["default"] ?? \`Child 1Child 2\`}`, - ); - }); - - it('trims slot content', async () => { - const html = '\n Content with whitespace \n'; - const result = await testSlotTransform(html); - assert.equal(result, `\${${SLOT_PREFIX}["default"] ?? \`Content with whitespace\`}`); - }); - - it('handles empty slots', async () => { - const result = await testSlotTransform(''); - // Empty slots still include the slot tag itself in the fallback - assert.equal(result, `\${${SLOT_PREFIX}["default"] ?? \`\`}`); - }); - - it('handles empty named slots', async () => { - const result = await testSlotTransform(''); - // Empty slots include the slot tag itself - assert.equal(result, `\${${SLOT_PREFIX}["empty"] ?? \`\`}`); - }); - - it('preserves non-slot elements', async () => { - const html = '
    Not a slot
    '; - const result = await testSlotTransform(html); - assert.equal(result, html); - }); - - it('transforms mixed content correctly', async () => { - const html = `
    - Default Header -

    Static content

    - Default body -
    `; - - const result = await testSlotTransform(html); - assert.match(result, /
    \s*\n/); - assert.match( - result, - new RegExp(`\\$\\{${SLOT_PREFIX}\\["header"\\] \\?\\? \`Default Header\`\\}`), - ); - assert.match(result, /

    Static content<\/p>/); - assert.match( - result, - new RegExp(`\\$\\{${SLOT_PREFIX}\\["default"\\] \\?\\? \`Default body\`\\}`), - ); - assert.match(result, /\n<\/div>$/); - }); - - it('handles complex slot content with special characters', async () => { - const html = 'Complex: ${foo} \\ `bar` \\${baz}'; - const result = await testSlotTransform(html); - // The content should be escaped when transformed - assert.equal( - result, - `\${${SLOT_PREFIX}["default"] ?? \`Complex: \\$\{foo\} \\\\ \\\`bar\\\` \\\\\\$\{baz\}\`}`, - ); - }); -}); diff --git a/packages/astro/test/units/vite-plugin-html/slots.test.ts b/packages/astro/test/units/vite-plugin-html/slots.test.ts new file mode 100644 index 000000000000..829fd5511b7a --- /dev/null +++ b/packages/astro/test/units/vite-plugin-html/slots.test.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import MagicString from 'magic-string'; +import { rehype } from 'rehype'; +import { VFile } from 'vfile'; +import rehypeSlots, { SLOT_PREFIX } from '../../../dist/vite-plugin-html/transform/slots.js'; + +describe('vite-plugin-html: slot transformer', () => { + async function testSlotTransform(html: string) { + const s = new MagicString(html); + const processor = rehype().data('settings', { fragment: true }).use(rehypeSlots, { s }); + + const vfile = new VFile({ value: html, path: 'test.html' }); + await processor.process(vfile); + return s.toString(); + } + + it('transforms default slots', async () => { + const result = await testSlotTransform('Default content'); + assert.equal(result, `\${${SLOT_PREFIX}["default"] ?? \`Default content\`}`); + }); + + it('transforms named slots', async () => { + const result = await testSlotTransform('Header content'); + assert.equal(result, `\${${SLOT_PREFIX}["header"] ?? \`Header content\`}`); + }); + + it('transforms multiple named slots', async () => { + const html = `

    +
    +
    `; + + const result = await testSlotTransform(html); + // Slots extract their inner HTML including the slot tag itself when empty + assert.match( + result, + new RegExp( + `
    \\$\\{${SLOT_PREFIX}\\["a"\\] \\?\\? \`\`\\}
    `, + ), + ); + assert.match( + result, + new RegExp( + `
    \\$\\{${SLOT_PREFIX}\\["b"\\] \\?\\? \`\`\\}
    `, + ), + ); + assert.match( + result, + new RegExp( + `
    \\$\\{${SLOT_PREFIX}\\["c"\\] \\?\\? \`\`\\}
    `, + ), + ); + }); + + it('preserves inline slots', async () => { + const result = await testSlotTransform(''); + assert.equal(result, ''); + }); + + it('preserves inline slots with name', async () => { + const result = await testSlotTransform('Content'); + assert.equal(result, 'Content'); + }); + + it('escapes template literal characters in slot content', async () => { + const result = await testSlotTransform('Content with ${variable} and `backticks`'); + assert.equal( + result, + `\${${SLOT_PREFIX}["default"] ?? \`Content with \\$\{variable\} and \\\`backticks\\\`\`}`, + ); + }); + + it('handles slots with multiple children', async () => { + const html = 'Child 1Child 2'; + const result = await testSlotTransform(html); + assert.equal( + result, + `\${${SLOT_PREFIX}["default"] ?? \`Child 1Child 2\`}`, + ); + }); + + it('trims slot content', async () => { + const html = '\n Content with whitespace \n'; + const result = await testSlotTransform(html); + assert.equal(result, `\${${SLOT_PREFIX}["default"] ?? \`Content with whitespace\`}`); + }); + + it('handles empty slots', async () => { + const result = await testSlotTransform(''); + // Empty slots still include the slot tag itself in the fallback + assert.equal(result, `\${${SLOT_PREFIX}["default"] ?? \`\`}`); + }); + + it('handles empty named slots', async () => { + const result = await testSlotTransform(''); + // Empty slots include the slot tag itself + assert.equal(result, `\${${SLOT_PREFIX}["empty"] ?? \`\`}`); + }); + + it('preserves non-slot elements', async () => { + const html = '
    Not a slot
    '; + const result = await testSlotTransform(html); + assert.equal(result, html); + }); + + it('transforms mixed content correctly', async () => { + const html = `
    + Default Header +

    Static content

    + Default body +
    `; + + const result = await testSlotTransform(html); + assert.match(result, /
    \s*\n/); + assert.match( + result, + new RegExp(`\\$\\{${SLOT_PREFIX}\\["header"\\] \\?\\? \`Default Header\`\\}`), + ); + assert.match(result, /

    Static content<\/p>/); + assert.match( + result, + new RegExp(`\\$\\{${SLOT_PREFIX}\\["default"\\] \\?\\? \`Default body\`\\}`), + ); + assert.match(result, /\n<\/div>$/); + }); + + it('handles complex slot content with special characters', async () => { + const html = 'Complex: ${foo} \\ `bar` \\${baz}'; + const result = await testSlotTransform(html); + // The content should be escaped when transformed + assert.equal( + result, + `\${${SLOT_PREFIX}["default"] ?? \`Complex: \\$\{foo\} \\\\ \\\`bar\\\` \\\\\\$\{baz\}\`}`, + ); + }); +}); diff --git a/packages/astro/test/units/vite-plugin-html/transform.test.js b/packages/astro/test/units/vite-plugin-html/transform.test.js deleted file mode 100644 index e6cbf6be88c4..000000000000 --- a/packages/astro/test/units/vite-plugin-html/transform.test.js +++ /dev/null @@ -1,116 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { transform } from '../../../dist/vite-plugin-html/transform/index.js'; - -describe('vite-plugin-html: transform integration', () => { - it('transforms basic HTML file', async () => { - const code = '

    Hello World
    '; - const result = await transform(code, 'test.html'); - - assert.match(result.code, /function render\(\{ slots: ___SLOTS___ \}\)/); - assert.match(result.code, /return `/); - assert.match(result.code, /`/); - assert.match(result.code, /render\["astro:html"\] = true;/); - assert.match(result.code, /export default render;/); - assert.ok(result.map); - }); - - it('transforms HTML with slots', async () => { - const code = '
    Default content
    '; - const result = await transform(code, 'test.html'); - - assert.match(result.code, /\$\{___SLOTS___\["default"\] \?\? `Default content`\}/); - }); - - it('transforms HTML with named slots', async () => { - const code = '
    HeaderFooter
    '; - const result = await transform(code, 'test.html'); - - assert.match(result.code, /\$\{___SLOTS___\["header"\] \?\? `Header`\}/); - assert.match(result.code, /\$\{___SLOTS___\["footer"\] \?\? `Footer`\}/); - }); - - it('escapes template literal characters', async () => { - const code = '
    ${variable}
    '; - const result = await transform(code, 'test.html'); - - assert.match(result.code, /\\\$\{variable\}/); - }); - - it('escapes backticks in content', async () => { - const code = '
    `backticks`
    '; - const result = await transform(code, 'test.html'); - - assert.match(result.code, /\\`backticks\\`/); - }); - - it('escapes backslashes', async () => { - const code = '
    \\backslash
    '; - const result = await transform(code, 'test.html'); - - assert.match(result.code, /\\\\backslash/); - }); - - it('preserves inline slots', async () => { - const code = '
    Inline content
    '; - const result = await transform(code, 'test.html'); - - assert.match(result.code, /Inline content<\/slot>/); - assert.doesNotMatch(result.code, /\$\{___SLOTS___\["default"\]/); - }); - - it( - 'handles complex escaping in attributes', - { skip: 'There is a bug in replaceAttribute with multiple attributes' }, - async () => { - const code = '
    '; - const result = await transform(code, 'test.html'); - - assert.match(result.code, /data-value="\\\$\{foo\}"/); - assert.match(result.code, /data-template="\\`\\\$\{bar\}\\`"/); - }, - ); - - it('transforms empty HTML', async () => { - const code = ''; - const result = await transform(code, 'test.html'); - - assert.match(result.code, /function render/); - assert.match(result.code, /return ``/); - }); - - it('handles multiple elements at root level', async () => { - const code = '

    Title

    \n

    Paragraph

    '; - const result = await transform(code, 'test.html'); - - assert.match(result.code, /

    Title<\/h1>/); - assert.match(result.code, /

    Paragraph<\/p>/); - }); - - it('escapes content in script tags', async () => { - const code = ''; - const result = await transform(code, 'test.html'); - - assert.match(result.code, /console\.log\(\\`\\\$\{variable\}\\`\)/); - }); - - it('handles comments with template literals', async () => { - const code = ''; - const result = await transform(code, 'test.html'); - - // Comments are parsed as text nodes, so only content is preserved - assert.match(result.code, / Comment with \\\$\{variable\} /); - }); - - it('produces valid source maps', async () => { - const code = '

    Hello
    '; - const result = await transform(code, 'test.html'); - - assert.ok(result.map); - assert.equal(result.map.version, 3); - // MagicString doesn't set file property by default - assert.ok(result.map.mappings); - // Sources array is populated by MagicString based on options - assert.ok(result.map.sources === undefined || Array.isArray(result.map.sources)); - }); -}); diff --git a/packages/astro/test/units/vite-plugin-html/transform.test.ts b/packages/astro/test/units/vite-plugin-html/transform.test.ts new file mode 100644 index 000000000000..df5cc9078e3e --- /dev/null +++ b/packages/astro/test/units/vite-plugin-html/transform.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { transform } from '../../../dist/vite-plugin-html/transform/index.js'; + +describe('vite-plugin-html: transform integration', () => { + it('transforms basic HTML file', async () => { + const code = '
    Hello World
    '; + const result = await transform(code, 'test.html'); + + assert.match(result.code, /function render\(\{ slots: ___SLOTS___ \}\)/); + assert.match(result.code, /return `/); + assert.match(result.code, /`/); + assert.match(result.code, /render\["astro:html"\] = true;/); + assert.match(result.code, /export default render;/); + assert.ok(result.map); + }); + + it('transforms HTML with slots', async () => { + const code = '
    Default content
    '; + const result = await transform(code, 'test.html'); + + assert.match(result.code, /\$\{___SLOTS___\["default"\] \?\? `Default content`\}/); + }); + + it('transforms HTML with named slots', async () => { + const code = '
    HeaderFooter
    '; + const result = await transform(code, 'test.html'); + + assert.match(result.code, /\$\{___SLOTS___\["header"\] \?\? `Header`\}/); + assert.match(result.code, /\$\{___SLOTS___\["footer"\] \?\? `Footer`\}/); + }); + + it('escapes template literal characters', async () => { + const code = '
    ${variable}
    '; + const result = await transform(code, 'test.html'); + + assert.match(result.code, /\\\$\{variable\}/); + }); + + it('escapes backticks in content', async () => { + const code = '
    `backticks`
    '; + const result = await transform(code, 'test.html'); + + assert.match(result.code, /\\`backticks\\`/); + }); + + it('escapes backslashes', async () => { + const code = '
    \\backslash
    '; + const result = await transform(code, 'test.html'); + + assert.match(result.code, /\\\\backslash/); + }); + + it('preserves inline slots', async () => { + const code = '
    Inline content
    '; + const result = await transform(code, 'test.html'); + + assert.match(result.code, /Inline content<\/slot>/); + assert.doesNotMatch(result.code, /\$\{___SLOTS___\["default"\]/); + }); + + it('handles complex escaping in attributes', { + skip: 'There is a bug in replaceAttribute with multiple attributes', + }, async () => { + const code = '
    '; + const result = await transform(code, 'test.html'); + + assert.match(result.code, /data-value="\\\$\{foo\}"/); + assert.match(result.code, /data-template="\\`\\\$\{bar\}\\`"/); + }); + + it('transforms empty HTML', async () => { + const code = ''; + const result = await transform(code, 'test.html'); + + assert.match(result.code, /function render/); + assert.match(result.code, /return ``/); + }); + + it('handles multiple elements at root level', async () => { + const code = '

    Title

    \n

    Paragraph

    '; + const result = await transform(code, 'test.html'); + + assert.match(result.code, /

    Title<\/h1>/); + assert.match(result.code, /

    Paragraph<\/p>/); + }); + + it('escapes content in script tags', async () => { + const code = ''; + const result = await transform(code, 'test.html'); + + assert.match(result.code, /console\.log\(\\`\\\$\{variable\}\\`\)/); + }); + + it('handles comments with template literals', async () => { + const code = ''; + const result = await transform(code, 'test.html'); + + // Comments are parsed as text nodes, so only content is preserved + assert.match(result.code, / Comment with \\\$\{variable\} /); + }); + + it('produces valid source maps', async () => { + const code = '

    Hello
    '; + const result = await transform(code, 'test.html'); + + assert.ok(result.map); + assert.equal(result.map.version, 3); + // MagicString doesn't set file property by default + assert.ok(result.map.mappings); + // Sources array is populated by MagicString based on options + assert.ok(result.map.sources === undefined || Array.isArray(result.map.sources)); + }); +}); diff --git a/packages/astro/test/unused-slot.test.js b/packages/astro/test/unused-slot.test.js deleted file mode 100644 index 38d4b860ba06..000000000000 --- a/packages/astro/test/unused-slot.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './test-utils.js'; - -describe('Unused slot', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ root: './fixtures/unused-slot/' }); - await fixture.build(); - }); - - it('is able to build with the slot missing', async () => { - let html = await fixture.readFile('/index.html'); - let $ = cheerio.load(html); - // No children, slot rendered as empty - assert.equal($('body p').children().length, 0); - }); -}); diff --git a/packages/astro/tsconfig.json b/packages/astro/tsconfig.json index b12a70b882d0..473fa36b5f04 100644 --- a/packages/astro/tsconfig.json +++ b/packages/astro/tsconfig.json @@ -3,10 +3,13 @@ "include": ["src", "dev-only.d.ts"], "exclude": ["dist"], "compilerOptions": { + "composite": true, "rootDir": "./src", "allowJs": true, "declarationDir": "./dist", "outDir": "./dist", - "jsx": "preserve" + "jsx": "preserve", + "tsBuildInfoFile": "${configDir}/dist/._cache/tsconfig/tsbuildinfo.json", + "erasableSyntaxOnly": true } } diff --git a/packages/astro/tsconfig.test.json b/packages/astro/tsconfig.test.json index 65a0ab0b0c44..9750a44694a4 100644 --- a/packages/astro/tsconfig.test.json +++ b/packages/astro/tsconfig.test.json @@ -1,12 +1,32 @@ { "extends": "../../tsconfig.base.json", - "include": ["test/units/**/*.ts"], - "exclude": ["test/units/_temp-fixtures/**", "test/fixtures/**"], + "include": [ + "test/units/**/*.ts", + "test/*.ts", + "e2e/*.ts", + "test/test-adapter.js", + "test/test-image-service.js", + "test/test-plugins.js", + "test/test-prerenderer.js", + "test/test-remote-image-service.js", + "test/test-utils.js", + "package.json" + ], + "exclude": ["test/units/_temp-fixtures/**", "test/fixtures/**", "e2e/fixtures/**"], "compilerOptions": { - "noEmit": true, + "types": ["vite/client", "node"], + "composite": true, "allowJs": true, "noUnusedLocals": false, "noUnusedParameters": false, "rewriteRelativeImportExtensions": true - } + }, + "references": [ + { + "path": "./tsconfig.json" + }, + { + "path": "../../scripts/tsconfig.json" + } + ] } diff --git a/packages/create-astro/package.json b/packages/create-astro/package.json index 1f43630eaf1e..596eeed7417b 100644 --- a/packages/create-astro/package.json +++ b/packages/create-astro/package.json @@ -22,7 +22,8 @@ "build": "astro-scripts build \"src/index.ts\" --bundle && tsc", "build:ci": "astro-scripts build \"src/index.ts\" --bundle", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "files": [ "dist", diff --git a/packages/create-astro/test/context.test.js b/packages/create-astro/test/context.test.js deleted file mode 100644 index 7836d4410914..000000000000 --- a/packages/create-astro/test/context.test.js +++ /dev/null @@ -1,86 +0,0 @@ -import assert from 'node:assert/strict'; -import os from 'node:os'; -import { describe, it } from 'node:test'; -import { getContext } from '../dist/index.js'; - -describe('context', () => { - it('no arguments', async () => { - const ctx = await getContext([]); - assert.ok(!ctx.projectName); - assert.ok(!ctx.template); - assert.deepEqual(ctx.skipHouston, os.platform() === 'win32'); - assert.ok(!ctx.dryRun); - }); - - it('project name', async () => { - const ctx = await getContext(['foobar']); - assert.deepEqual(ctx.projectName, 'foobar'); - }); - - it('template', async () => { - const ctx = await getContext(['--template', 'minimal']); - assert.deepEqual(ctx.template, 'minimal'); - }); - - it('skip houston (explicit)', async () => { - const ctx = await getContext(['--skip-houston']); - assert.deepEqual(ctx.skipHouston, true); - }); - - it('skip houston (yes)', async () => { - const ctx = await getContext(['-y']); - assert.deepEqual(ctx.skipHouston, true); - }); - - it('skip houston (no)', async () => { - const ctx = await getContext(['-n']); - assert.deepEqual(ctx.skipHouston, true); - }); - - it('skip houston (install)', async () => { - const ctx = await getContext(['--install']); - assert.deepEqual(ctx.skipHouston, true); - }); - - it('dry run', async () => { - const ctx = await getContext(['--dry-run']); - assert.deepEqual(ctx.dryRun, true); - }); - - it('install', async () => { - const ctx = await getContext(['--install']); - assert.deepEqual(ctx.install, true); - }); - - it('add', async () => { - const ctx = await getContext(['--add', 'node']); - assert.deepEqual(ctx.add, ['node']); - }); - - it('no install', async () => { - const ctx = await getContext(['--no-install']); - assert.deepEqual(ctx.install, false); - }); - - it('git', async () => { - const ctx = await getContext(['--git']); - assert.deepEqual(ctx.git, true); - }); - - it('no git', async () => { - const ctx = await getContext(['--no-git']); - assert.deepEqual(ctx.git, false); - }); - - it('--add with --no-install conflicts', async () => { - const exitCode = await new Promise((resolve) => { - const originalExit = process.exit; - process.exit = (code) => { - process.exit = originalExit; - resolve(code); - }; - getContext(['--add', 'cloudflare', '--no-install']); - }); - assert.equal(exitCode, 1); - }); -}); diff --git a/packages/create-astro/test/context.test.ts b/packages/create-astro/test/context.test.ts new file mode 100644 index 000000000000..fb689a2f47b5 --- /dev/null +++ b/packages/create-astro/test/context.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import os from 'node:os'; +import { describe, it } from 'node:test'; +import { getContext } from '../dist/index.js'; + +describe('context', () => { + it('no arguments', async () => { + const ctx = await getContext([]); + assert.ok(!ctx.projectName); + assert.ok(!ctx.template); + assert.deepEqual(ctx.skipHouston, os.platform() === 'win32'); + assert.ok(!ctx.dryRun); + }); + + it('project name', async () => { + const ctx = await getContext(['foobar']); + assert.deepEqual(ctx.projectName, 'foobar'); + }); + + it('template', async () => { + const ctx = await getContext(['--template', 'minimal']); + assert.deepEqual(ctx.template, 'minimal'); + }); + + it('skip houston (explicit)', async () => { + const ctx = await getContext(['--skip-houston']); + assert.deepEqual(ctx.skipHouston, true); + }); + + it('skip houston (yes)', async () => { + const ctx = await getContext(['-y']); + assert.deepEqual(ctx.skipHouston, true); + }); + + it('skip houston (no)', async () => { + const ctx = await getContext(['-n']); + assert.deepEqual(ctx.skipHouston, true); + }); + + it('skip houston (install)', async () => { + const ctx = await getContext(['--install']); + assert.deepEqual(ctx.skipHouston, true); + }); + + it('dry run', async () => { + const ctx = await getContext(['--dry-run']); + assert.deepEqual(ctx.dryRun, true); + }); + + it('install', async () => { + const ctx = await getContext(['--install']); + assert.deepEqual(ctx.install, true); + }); + + it('add', async () => { + const ctx = await getContext(['--add', 'node']); + assert.deepEqual(ctx.add, ['node']); + }); + + it('no install', async () => { + const ctx = await getContext(['--no-install']); + assert.deepEqual(ctx.install, false); + }); + + it('git', async () => { + const ctx = await getContext(['--git']); + assert.deepEqual(ctx.git, true); + }); + + it('no git', async () => { + const ctx = await getContext(['--no-git']); + assert.deepEqual(ctx.git, false); + }); + + it('--add with --no-install conflicts', async () => { + const originalExit = process.exit; + let exitCode: number | string | null | undefined; + const patchedExit: typeof originalExit = (code) => { + exitCode = code; + throw code; + }; + process.exit = patchedExit; + try { + await getContext(['--add', 'cloudflare', '--no-install']); + } catch { + // expected: patchedExit throws to unwind getContext + } finally { + process.exit = originalExit; + } + assert.equal(exitCode, 1); + }); +}); diff --git a/packages/create-astro/test/dependencies.test.js b/packages/create-astro/test/dependencies.test.js deleted file mode 100644 index bde585130b37..000000000000 --- a/packages/create-astro/test/dependencies.test.js +++ /dev/null @@ -1,177 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { dependencies } from '../dist/index.js'; -import { setup } from './utils.js'; - -describe('dependencies', () => { - const fixture = setup(); - - it('--yes', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - prompt: () => ({ deps: true }), - }; - - await dependencies(context); - - assert.ok(fixture.hasMessage('Skipping dependency installation')); - }); - - it('--yes with third-party template warns', async () => { - const context = { - cwd: '', - yes: true, - template: 'github:someone/starter', - packageManager: 'npm', - dryRun: true, - prompt: () => ({ deps: true }), - }; - - await dependencies(context); - - assert.ok(fixture.hasMessage('Third-party template detected')); - }); - - it('starlight templates do not warn', async () => { - const context = { - cwd: '', - yes: true, - template: 'starlight/tailwind', - packageManager: 'npm', - dryRun: true, - prompt: () => ({ deps: true }), - }; - - await dependencies(context); - - assert.equal(fixture.hasMessage('Third-party template detected'), false); - }); - - it('starlight-prefixed third-party templates warn', async () => { - const context = { - cwd: '', - yes: true, - template: 'starlightevil/foo', - packageManager: 'npm', - dryRun: true, - prompt: () => ({ deps: true }), - }; - - await dependencies(context); - - assert.ok(fixture.hasMessage('Third-party template detected')); - }); - - it('warns without --yes when install is enabled', async () => { - const context = { - cwd: '', - install: true, - template: 'github:someone/starter', - packageManager: 'npm', - dryRun: true, - prompt: () => ({ deps: true }), - }; - - await dependencies(context); - - assert.ok(fixture.hasMessage('Third-party template detected')); - }); - - it('prompt yes', async () => { - const context = { - cwd: '', - packageManager: 'npm', - dryRun: true, - prompt: () => ({ deps: true }), - install: undefined, - }; - - await dependencies(context); - - assert.ok(fixture.hasMessage('Skipping dependency installation')); - assert.equal(context.install, true); - }); - - it('prompt no', async () => { - const context = { - cwd: '', - packageManager: 'npm', - dryRun: true, - prompt: () => ({ deps: false }), - install: undefined, - }; - - await dependencies(context); - - assert.ok(fixture.hasMessage('Skipping dependency installation')); - assert.equal(context.install, false); - }); - - it('--install', async () => { - const context = { - cwd: '', - install: true, - packageManager: 'npm', - dryRun: true, - prompt: () => ({ deps: false }), - }; - await dependencies(context); - assert.ok(fixture.hasMessage('Skipping dependency installation')); - assert.equal(context.install, true); - }); - - it('--no-install ', async () => { - const context = { - cwd: '', - install: false, - packageManager: 'npm', - dryRun: true, - prompt: () => ({ deps: false }), - }; - - await dependencies(context); - - assert.ok(fixture.hasMessage('Skipping dependency installation')); - assert.equal(context.install, false); - }); - - describe('--add', async () => { - it('fails for non-supported integration', async () => { - let context = { - cwd: '', - add: ['foo '], - dryRun: true, - prompt: () => ({ deps: false }), - }; - - try { - await dependencies(context); - assert.fail('The function should throw an error'); - } catch (error) { - assert.ok( - error.message.includes('Invalid package name "foo "'), - `Expected error about invalid package name, got: ${error.message}`, - ); - } - context = { - cwd: '', - add: ['react', 'bar lorem'], - dryRun: true, - prompt: () => ({ deps: false }), - }; - - try { - await dependencies(context); - assert.fail('The function should throw an error'); - } catch (error) { - assert.ok( - error.message.includes('Invalid package name "bar lorem"'), - `Expected error about invalid package name, got: ${error.message}`, - ); - } - }); - }); -}); diff --git a/packages/create-astro/test/dependencies.test.ts b/packages/create-astro/test/dependencies.test.ts new file mode 100644 index 000000000000..5285abba6bcd --- /dev/null +++ b/packages/create-astro/test/dependencies.test.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { dependencies } from '../dist/index.js'; +import { type DependenciesContext, mockPrompt, setup } from './utils.ts'; + +describe('dependencies', () => { + const fixture = setup(); + + it('--yes', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + prompt: mockPrompt({ deps: true }), + tasks: [], + }; + + await dependencies(context); + + assert.ok(fixture.hasMessage('Skipping dependency installation')); + }); + + it('--yes with third-party template warns', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + template: 'github:someone/starter', + packageManager: 'npm', + dryRun: true, + prompt: mockPrompt({ deps: true }), + tasks: [], + }; + + await dependencies(context); + + assert.ok(fixture.hasMessage('Third-party template detected')); + }); + + it('starlight templates do not warn', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + template: 'starlight/tailwind', + packageManager: 'npm', + dryRun: true, + prompt: mockPrompt({ deps: true }), + tasks: [], + }; + + await dependencies(context); + + assert.equal(fixture.hasMessage('Third-party template detected'), false); + }); + + it('starlight-prefixed third-party templates warn', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + template: 'starlightevil/foo', + packageManager: 'npm', + dryRun: true, + prompt: mockPrompt({ deps: true }), + tasks: [], + }; + + await dependencies(context); + + assert.ok(fixture.hasMessage('Third-party template detected')); + }); + + it('warns without --yes when install is enabled', async () => { + const context: DependenciesContext = { + cwd: '', + install: true, + template: 'github:someone/starter', + packageManager: 'npm', + dryRun: true, + prompt: mockPrompt({ deps: true }), + tasks: [], + }; + + await dependencies(context); + + assert.ok(fixture.hasMessage('Third-party template detected')); + }); + + it('prompt yes', async () => { + const context: DependenciesContext = { + cwd: '', + packageManager: 'npm', + dryRun: true, + prompt: mockPrompt({ deps: true }), + install: undefined, + tasks: [], + }; + + await dependencies(context); + + assert.ok(fixture.hasMessage('Skipping dependency installation')); + assert.equal(context.install, true); + }); + + it('prompt no', async () => { + const context: DependenciesContext = { + cwd: '', + packageManager: 'npm', + dryRun: true, + prompt: mockPrompt({ deps: false }), + install: undefined, + tasks: [], + }; + + await dependencies(context); + + assert.ok(fixture.hasMessage('Skipping dependency installation')); + assert.equal(context.install, false); + }); + + it('--install', async () => { + const context: DependenciesContext = { + cwd: '', + install: true, + packageManager: 'npm', + dryRun: true, + prompt: mockPrompt({ deps: false }), + tasks: [], + }; + await dependencies(context); + assert.ok(fixture.hasMessage('Skipping dependency installation')); + assert.equal(context.install, true); + }); + + it('--no-install ', async () => { + const context: DependenciesContext = { + cwd: '', + install: false, + packageManager: 'npm', + dryRun: true, + prompt: mockPrompt({ deps: false }), + tasks: [], + }; + + await dependencies(context); + + assert.ok(fixture.hasMessage('Skipping dependency installation')); + assert.equal(context.install, false); + }); + + describe('--add', async () => { + it('fails for non-supported integration', async () => { + let context: DependenciesContext = { + cwd: '', + add: ['foo '], + dryRun: true, + prompt: mockPrompt({ deps: false }), + packageManager: 'npm', + tasks: [], + }; + + try { + await dependencies(context); + assert.fail('The function should throw an error'); + } catch (error) { + assert.ok(error instanceof Error); + assert.ok( + error.message.includes('Invalid package name "foo "'), + `Expected error about invalid package name, got: ${error.message}`, + ); + } + context = { + cwd: '', + add: ['react', 'bar lorem'], + dryRun: true, + prompt: mockPrompt({ deps: false }), + packageManager: 'npm', + tasks: [], + }; + + try { + await dependencies(context); + assert.fail('The function should throw an error'); + } catch (error) { + assert.ok(error instanceof Error); + assert.ok( + error.message.includes('Invalid package name "bar lorem"'), + `Expected error about invalid package name, got: ${error.message}`, + ); + } + }); + }); +}); diff --git a/packages/create-astro/test/git.test.js b/packages/create-astro/test/git.test.js deleted file mode 100644 index 85854f0d59b4..000000000000 --- a/packages/create-astro/test/git.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import assert from 'node:assert/strict'; -import { rmSync } from 'node:fs'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { after, before, describe, it } from 'node:test'; - -import { git } from '../dist/index.js'; -import { setup } from './utils.js'; - -describe('git', () => { - const fixture = setup(); - - it('none', async () => { - const context = { cwd: '', dryRun: true, prompt: () => ({ git: false }) }; - await git(context); - - assert.ok(fixture.hasMessage('Skipping Git initialization')); - }); - - it('yes (--dry-run)', async () => { - const context = { cwd: '', dryRun: true, prompt: () => ({ git: true }) }; - await git(context); - assert.ok(fixture.hasMessage('Skipping Git initialization')); - }); - - it('no (--dry-run)', async () => { - const context = { cwd: '', dryRun: true, prompt: () => ({ git: false }) }; - await git(context); - - assert.ok(fixture.hasMessage('Skipping Git initialization')); - }); -}); - -describe('git initialized', () => { - const fixture = setup(); - const dir = new URL(new URL('./fixtures/not-empty/.git', import.meta.url)); - - before(async () => { - await mkdir(dir, { recursive: true }); - await writeFile(new URL('./git.json', dir), '{}', { encoding: 'utf8' }); - }); - - it('already initialized', async () => { - const context = { - git: true, - cwd: './test/fixtures/not-empty', - dryRun: false, - prompt: () => ({ git: false }), - }; - await git(context); - - assert.ok(fixture.hasMessage('Git has already been initialized')); - }); - - after(() => { - rmSync(dir, { recursive: true, force: true }); - }); -}); diff --git a/packages/create-astro/test/git.test.ts b/packages/create-astro/test/git.test.ts new file mode 100644 index 000000000000..f2ceb6dfb727 --- /dev/null +++ b/packages/create-astro/test/git.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { rmSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { after, before, describe, it } from 'node:test'; + +import { git } from '../dist/index.js'; +import { type GitContext, mockPrompt, setup } from './utils.ts'; + +describe('git', () => { + const fixture = setup(); + + it('none', async () => { + const context: GitContext = { + cwd: '', + dryRun: true, + prompt: mockPrompt({ git: false }), + tasks: [], + }; + await git(context); + + assert.ok(fixture.hasMessage('Skipping Git initialization')); + }); + + it('yes (--dry-run)', async () => { + const context: GitContext = { + cwd: '', + dryRun: true, + prompt: mockPrompt({ git: true }), + tasks: [], + }; + await git(context); + assert.ok(fixture.hasMessage('Skipping Git initialization')); + }); + + it('no (--dry-run)', async () => { + const context: GitContext = { + cwd: '', + dryRun: true, + prompt: mockPrompt({ git: false }), + tasks: [], + }; + await git(context); + + assert.ok(fixture.hasMessage('Skipping Git initialization')); + }); +}); + +describe('git initialized', () => { + const fixture = setup(); + const dir = new URL(new URL('./fixtures/not-empty/.git', import.meta.url)); + + before(async () => { + await mkdir(dir, { recursive: true }); + await writeFile(new URL('./git.json', dir), '{}', { encoding: 'utf8' }); + }); + + it('already initialized', async () => { + const context: GitContext = { + git: true, + cwd: './test/fixtures/not-empty', + dryRun: false, + prompt: mockPrompt({ git: false }), + tasks: [], + }; + await git(context); + + assert.ok(fixture.hasMessage('Git has already been initialized')); + }); + + after(() => { + rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/packages/create-astro/test/integrations.test.js b/packages/create-astro/test/integrations.test.js deleted file mode 100644 index 4db46965ea65..000000000000 --- a/packages/create-astro/test/integrations.test.js +++ /dev/null @@ -1,197 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { dependencies } from '../dist/index.js'; -import { setup } from './utils.js'; - -describe('integrations', () => { - const fixture = setup(); - - it('--add node', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - add: ['node'], - }; - - await dependencies(context); - - assert.ok(fixture.hasMessage('--dry-run Skipping dependency installation and adding node')); - }); - - it('--add node --add react', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - add: ['node', 'react'], - }; - - await dependencies(context); - - assert.ok( - fixture.hasMessage('--dry-run Skipping dependency installation and adding node, react'), - ); - }); - - it('--add node,react', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - add: ['node,react'], - }; - - await dependencies(context); - - assert.ok( - fixture.hasMessage('--dry-run Skipping dependency installation and adding node, react'), - ); - }); - - it('-y', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - }; - await dependencies(context); - assert.ok(fixture.hasMessage('--dry-run Skipping dependency installation')); - }); - - describe('Security: Command injection protection', () => { - it('blocks semicolon command injection', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - add: ['react;whoami'], - }; - - await assert.rejects( - () => dependencies(context), - /Invalid package name/, - 'Should reject command injection with semicolon', - ); - }); - - it('blocks command substitution with $()', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - add: ['react$(whoami)'], - }; - - await assert.rejects( - () => dependencies(context), - /Invalid package name/, - 'Should reject command substitution with $()', - ); - }); - - it('blocks command substitution with backticks', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - add: ['react`whoami`'], - }; - - await assert.rejects( - () => dependencies(context), - /Invalid package name/, - 'Should reject command substitution with backticks', - ); - }); - - it('blocks pipe operators', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - add: ['react|whoami'], - }; - - await assert.rejects( - () => dependencies(context), - /Invalid package name/, - 'Should reject pipe operator injection', - ); - }); - - it('blocks ampersand operators', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - add: ['react&&whoami'], - }; - - await assert.rejects( - () => dependencies(context), - /Invalid package name/, - 'Should reject ampersand operator injection', - ); - }); - - it('blocks redirect operators', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - add: ['react>file'], - }; - - await assert.rejects( - () => dependencies(context), - /Invalid package name/, - 'Should reject redirect operator injection', - ); - }); - - it('allows scoped packages', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - add: ['@astrojs/tailwind'], - }; - - await dependencies(context); - assert.ok( - fixture.hasMessage( - '--dry-run Skipping dependency installation and adding @astrojs/tailwind', - ), - ); - }); - - it('allows valid package names', async () => { - const context = { - cwd: '', - yes: true, - packageManager: 'npm', - dryRun: true, - add: ['my-package', 'package_2.0'], - }; - - await dependencies(context); - assert.ok( - fixture.hasMessage( - '--dry-run Skipping dependency installation and adding my-package, package_2.0', - ), - ); - }); - }); -}); diff --git a/packages/create-astro/test/integrations.test.ts b/packages/create-astro/test/integrations.test.ts new file mode 100644 index 000000000000..a1ccf434dfa8 --- /dev/null +++ b/packages/create-astro/test/integrations.test.ts @@ -0,0 +1,221 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { dependencies } from '../dist/index.js'; +import { type DependenciesContext, mockPrompt, setup } from './utils.ts'; + +describe('integrations', () => { + const fixture = setup(); + + it('--add node', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + add: ['node'], + prompt: mockPrompt({}), + tasks: [], + }; + + await dependencies(context); + + assert.ok(fixture.hasMessage('--dry-run Skipping dependency installation and adding node')); + }); + + it('--add node --add react', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + add: ['node', 'react'], + prompt: mockPrompt({}), + tasks: [], + }; + + await dependencies(context); + + assert.ok( + fixture.hasMessage('--dry-run Skipping dependency installation and adding node, react'), + ); + }); + + it('--add node,react', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + add: ['node,react'], + prompt: mockPrompt({}), + tasks: [], + }; + + await dependencies(context); + + assert.ok( + fixture.hasMessage('--dry-run Skipping dependency installation and adding node, react'), + ); + }); + + it('-y', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + prompt: mockPrompt({}), + tasks: [], + }; + await dependencies(context); + assert.ok(fixture.hasMessage('--dry-run Skipping dependency installation')); + }); + + describe('Security: Command injection protection', () => { + it('blocks semicolon command injection', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + add: ['react;whoami'], + prompt: mockPrompt({}), + tasks: [], + }; + + await assert.rejects( + () => dependencies(context), + /Invalid package name/, + 'Should reject command injection with semicolon', + ); + }); + + it('blocks command substitution with $()', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + add: ['react$(whoami)'], + prompt: mockPrompt({}), + tasks: [], + }; + + await assert.rejects( + () => dependencies(context), + /Invalid package name/, + 'Should reject command substitution with $()', + ); + }); + + it('blocks command substitution with backticks', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + add: ['react`whoami`'], + prompt: mockPrompt({}), + tasks: [], + }; + + await assert.rejects( + () => dependencies(context), + /Invalid package name/, + 'Should reject command substitution with backticks', + ); + }); + + it('blocks pipe operators', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + add: ['react|whoami'], + prompt: mockPrompt({}), + tasks: [], + }; + + await assert.rejects( + () => dependencies(context), + /Invalid package name/, + 'Should reject pipe operator injection', + ); + }); + + it('blocks ampersand operators', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + add: ['react&&whoami'], + prompt: mockPrompt({}), + tasks: [], + }; + + await assert.rejects( + () => dependencies(context), + /Invalid package name/, + 'Should reject ampersand operator injection', + ); + }); + + it('blocks redirect operators', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + add: ['react>file'], + prompt: mockPrompt({}), + tasks: [], + }; + + await assert.rejects( + () => dependencies(context), + /Invalid package name/, + 'Should reject redirect operator injection', + ); + }); + + it('allows scoped packages', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + add: ['@astrojs/tailwind'], + prompt: mockPrompt({}), + tasks: [], + }; + + await dependencies(context); + assert.ok( + fixture.hasMessage( + '--dry-run Skipping dependency installation and adding @astrojs/tailwind', + ), + ); + }); + + it('allows valid package names', async () => { + const context: DependenciesContext = { + cwd: '', + yes: true, + packageManager: 'npm', + dryRun: true, + add: ['my-package', 'package_2.0'], + prompt: mockPrompt({}), + tasks: [], + }; + + await dependencies(context); + assert.ok( + fixture.hasMessage( + '--dry-run Skipping dependency installation and adding my-package, package_2.0', + ), + ); + }); + }); +}); diff --git a/packages/create-astro/test/intro.test.js b/packages/create-astro/test/intro.test.js deleted file mode 100644 index d042dad7fc6b..000000000000 --- a/packages/create-astro/test/intro.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { intro } from '../dist/index.js'; -import { setup } from './utils.js'; - -describe('intro', () => { - const fixture = setup(); - - it('no arguments', async () => { - await intro({ skipHouston: false, version: '0.0.0', username: 'user' }); - assert.ok(fixture.hasMessage('Houston:')); - assert.ok(fixture.hasMessage('Welcome to astro v0.0.0')); - }); - it('--skip-houston', async () => { - await intro({ skipHouston: true, version: '0.0.0', username: 'user' }); - assert.equal(fixture.length(), 1); - assert.ok(!fixture.hasMessage('Houston:')); - assert.ok(fixture.hasMessage('Launch sequence initiated')); - }); -}); diff --git a/packages/create-astro/test/intro.test.ts b/packages/create-astro/test/intro.test.ts new file mode 100644 index 000000000000..8e0da3755927 --- /dev/null +++ b/packages/create-astro/test/intro.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { intro } from '../dist/index.js'; +import { type IntroContext, setup } from './utils.ts'; + +describe('intro', () => { + const fixture = setup(); + + it('no arguments', async () => { + const context: IntroContext = { + skipHouston: false, + version: Promise.resolve('0.0.0'), + username: Promise.resolve('user'), + }; + await intro(context); + assert.ok(fixture.hasMessage('Houston:')); + assert.ok(fixture.hasMessage('Welcome to astro v0.0.0')); + }); + it('--skip-houston', async () => { + const context: IntroContext = { + skipHouston: true, + version: Promise.resolve('0.0.0'), + username: Promise.resolve('user'), + }; + await intro(context); + assert.equal(fixture.length(), 1); + assert.ok(!fixture.hasMessage('Houston:')); + assert.ok(fixture.hasMessage('Launch sequence initiated')); + }); +}); diff --git a/packages/create-astro/test/next.test.js b/packages/create-astro/test/next.test.js deleted file mode 100644 index 5b9b22b30632..000000000000 --- a/packages/create-astro/test/next.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { next } from '../dist/index.js'; -import { setup } from './utils.js'; - -describe('next steps', () => { - const fixture = setup(); - - it('no arguments', async () => { - await next({ skipHouston: false, cwd: './it/fixtures/not-empty', packageManager: 'npm' }); - assert.ok(fixture.hasMessage('Liftoff confirmed.')); - assert.ok(fixture.hasMessage('npm run dev')); - assert.ok(fixture.hasMessage('Good luck out there, astronaut!')); - }); - - it('--skip-houston', async () => { - await next({ skipHouston: true, cwd: './it/fixtures/not-empty', packageManager: 'npm' }); - assert.ok(!fixture.hasMessage('Good luck out there, astronaut!')); - }); -}); diff --git a/packages/create-astro/test/next.test.ts b/packages/create-astro/test/next.test.ts new file mode 100644 index 000000000000..1f948b78e214 --- /dev/null +++ b/packages/create-astro/test/next.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { next } from '../dist/index.js'; +import { type NextContext, setup } from './utils.ts'; + +describe('next steps', () => { + const fixture = setup(); + + it('no arguments', async () => { + const context: NextContext = { + skipHouston: false, + cwd: './it/fixtures/not-empty', + packageManager: 'npm', + }; + await next(context); + assert.ok(fixture.hasMessage('Liftoff confirmed.')); + assert.ok(fixture.hasMessage('npm run dev')); + assert.ok(fixture.hasMessage('Good luck out there, astronaut!')); + }); + + it('--skip-houston', async () => { + const context: NextContext = { + skipHouston: true, + cwd: './it/fixtures/not-empty', + packageManager: 'npm', + }; + await next(context); + assert.ok(!fixture.hasMessage('Good luck out there, astronaut!')); + }); +}); diff --git a/packages/create-astro/test/package-name-validation.test.js b/packages/create-astro/test/package-name-validation.test.ts similarity index 100% rename from packages/create-astro/test/package-name-validation.test.js rename to packages/create-astro/test/package-name-validation.test.ts diff --git a/packages/create-astro/test/project-name.test.js b/packages/create-astro/test/project-name.test.js deleted file mode 100644 index 0aebd1c79268..000000000000 --- a/packages/create-astro/test/project-name.test.js +++ /dev/null @@ -1,124 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { projectName } from '../dist/index.js'; -import { setup } from './utils.js'; - -describe('project name', async () => { - const fixture = setup(); - - it('pass in name', async () => { - const context = { projectName: '', cwd: './foo/bar/baz', prompt: () => {} }; - await projectName(context); - assert.equal(context.cwd, './foo/bar/baz'); - assert.equal(context.projectName, 'baz'); - }); - - it('dot', async () => { - const context = { projectName: '', cwd: '.', prompt: () => ({ name: 'foobar' }) }; - await projectName(context); - assert.ok(fixture.hasMessage('"." is not empty!')); - assert.equal(context.projectName, 'foobar'); - }); - - it('dot slash', async () => { - const context = { projectName: '', cwd: './', prompt: () => ({ name: 'foobar' }) }; - await projectName(context); - assert.ok(fixture.hasMessage('"./" is not empty!')); - assert.equal(context.projectName, 'foobar'); - }); - - it('empty', async () => { - const context = { - projectName: '', - cwd: './test/fixtures/empty', - prompt: () => ({ name: 'foobar' }), - }; - await projectName(context); - assert.ok(!fixture.hasMessage('"./test/fixtures/empty" is not empty!')); - assert.equal(context.projectName, 'empty'); - }); - - it('not empty', async () => { - const context = { - projectName: '', - cwd: './test/fixtures/not-empty', - prompt: () => ({ name: 'foobar' }), - }; - await projectName(context); - assert.ok(fixture.hasMessage('"./test/fixtures/not-empty" is not empty!')); - assert.equal(context.projectName, 'foobar'); - }); - - it('basic', async () => { - const context = { projectName: '', cwd: '', prompt: () => ({ name: 'foobar' }) }; - await projectName(context); - assert.equal(context.cwd, 'foobar'); - assert.equal(context.projectName, 'foobar'); - }); - - it('head and tail blank spaces should be trimmed', async () => { - const context = { projectName: '', cwd: '', prompt: () => ({ name: ' foobar ' }) }; - await projectName(context); - assert.equal(context.cwd, 'foobar'); - assert.equal(context.projectName, 'foobar'); - }); - - it('normalize', async () => { - const context = { projectName: '', cwd: '', prompt: () => ({ name: 'Invalid Name' }) }; - await projectName(context); - assert.equal(context.cwd, 'Invalid Name'); - assert.equal(context.projectName, 'invalid-name'); - }); - - it('remove leading/trailing dashes', async () => { - const context = { projectName: '', cwd: '', prompt: () => ({ name: '(invalid)' }) }; - await projectName(context); - assert.equal(context.projectName, 'invalid'); - }); - - it('handles scoped packages', async () => { - const context = { projectName: '', cwd: '', prompt: () => ({ name: '@astro/site' }) }; - await projectName(context); - assert.equal(context.cwd, '@astro/site'); - assert.equal(context.projectName, '@astro/site'); - }); - - it('--yes', async () => { - const context = { projectName: '', cwd: './foo/bar/baz', yes: true, prompt: () => {} }; - await projectName(context); - assert.equal(context.projectName, 'baz'); - }); - - it('dry run with name', async () => { - const context = { - projectName: '', - cwd: './foo/bar/baz', - dryRun: true, - prompt: () => {}, - }; - await projectName(context); - assert.equal(context.projectName, 'baz'); - }); - - it('dry run with dot', async () => { - const context = { - projectName: '', - cwd: '.', - dryRun: true, - prompt: () => ({ name: 'foobar' }), - }; - await projectName(context); - assert.equal(context.projectName, 'foobar'); - }); - - it('dry run with empty', async () => { - const context = { - projectName: '', - cwd: './test/fixtures/empty', - dryRun: true, - prompt: () => ({ name: 'foobar' }), - }; - await projectName(context); - assert.equal(context.projectName, 'empty'); - }); -}); diff --git a/packages/create-astro/test/project-name.test.ts b/packages/create-astro/test/project-name.test.ts new file mode 100644 index 000000000000..f4de3aef34bd --- /dev/null +++ b/packages/create-astro/test/project-name.test.ts @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { projectName } from '../dist/index.js'; +import { mockExit, mockPrompt, type ProjectNameContext, setup } from './utils.ts'; + +describe('project name', async () => { + const fixture = setup(); + + it('pass in name', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: './foo/bar/baz', + prompt: mockPrompt({}), + exit: mockExit, + }; + await projectName(context); + assert.equal(context.cwd, './foo/bar/baz'); + assert.equal(context.projectName, 'baz'); + }); + + it('dot', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: '.', + prompt: mockPrompt({ name: 'foobar' }), + exit: mockExit, + }; + await projectName(context); + assert.ok(fixture.hasMessage('"." is not empty!')); + assert.equal(context.projectName, 'foobar'); + }); + + it('dot slash', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: './', + prompt: mockPrompt({ name: 'foobar' }), + exit: mockExit, + }; + await projectName(context); + assert.ok(fixture.hasMessage('"./" is not empty!')); + assert.equal(context.projectName, 'foobar'); + }); + + it('empty', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: './test/fixtures/empty', + prompt: mockPrompt({ name: 'foobar' }), + exit: mockExit, + }; + await projectName(context); + assert.ok(!fixture.hasMessage('"./test/fixtures/empty" is not empty!')); + assert.equal(context.projectName, 'empty'); + }); + + it('not empty', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: './test/fixtures/not-empty', + prompt: mockPrompt({ name: 'foobar' }), + exit: mockExit, + }; + await projectName(context); + assert.ok(fixture.hasMessage('"./test/fixtures/not-empty" is not empty!')); + assert.equal(context.projectName, 'foobar'); + }); + + it('basic', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: '', + prompt: mockPrompt({ name: 'foobar' }), + exit: mockExit, + }; + await projectName(context); + assert.equal(context.cwd, 'foobar'); + assert.equal(context.projectName, 'foobar'); + }); + + it('head and tail blank spaces should be trimmed', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: '', + prompt: mockPrompt({ name: ' foobar ' }), + exit: mockExit, + }; + await projectName(context); + assert.equal(context.cwd, 'foobar'); + assert.equal(context.projectName, 'foobar'); + }); + + it('normalize', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: '', + prompt: mockPrompt({ name: 'Invalid Name' }), + exit: mockExit, + }; + await projectName(context); + assert.equal(context.cwd, 'Invalid Name'); + assert.equal(context.projectName, 'invalid-name'); + }); + + it('remove leading/trailing dashes', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: '', + prompt: mockPrompt({ name: '(invalid)' }), + exit: mockExit, + }; + await projectName(context); + assert.equal(context.projectName, 'invalid'); + }); + + it('handles scoped packages', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: '', + prompt: mockPrompt({ name: '@astro/site' }), + exit: mockExit, + }; + await projectName(context); + assert.equal(context.cwd, '@astro/site'); + assert.equal(context.projectName, '@astro/site'); + }); + + it('--yes', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: './foo/bar/baz', + yes: true, + prompt: mockPrompt({}), + exit: mockExit, + }; + await projectName(context); + assert.equal(context.projectName, 'baz'); + }); + + it('dry run with name', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: './foo/bar/baz', + dryRun: true, + prompt: mockPrompt({}), + exit: mockExit, + }; + await projectName(context); + assert.equal(context.projectName, 'baz'); + }); + + it('dry run with dot', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: '.', + dryRun: true, + prompt: mockPrompt({ name: 'foobar' }), + exit: mockExit, + }; + await projectName(context); + assert.equal(context.projectName, 'foobar'); + }); + + it('dry run with empty', async () => { + const context: ProjectNameContext = { + projectName: '', + cwd: './test/fixtures/empty', + dryRun: true, + prompt: mockPrompt({ name: 'foobar' }), + exit: mockExit, + }; + await projectName(context); + assert.equal(context.projectName, 'empty'); + }); +}); diff --git a/packages/create-astro/test/template-processing.test.js b/packages/create-astro/test/template-processing.test.ts similarity index 100% rename from packages/create-astro/test/template-processing.test.js rename to packages/create-astro/test/template-processing.test.ts diff --git a/packages/create-astro/test/template.test.js b/packages/create-astro/test/template.test.js deleted file mode 100644 index 821ac9c2e9ac..000000000000 --- a/packages/create-astro/test/template.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { template } from '../dist/index.js'; -import { setup } from './utils.js'; - -describe('template', async () => { - const fixture = setup(); - - it('none', async () => { - const context = { template: '', cwd: '', dryRun: true, prompt: () => ({ template: 'blog' }) }; - await template(context); - assert.ok(fixture.hasMessage('Skipping template copying')); - assert.equal(context.template, 'blog'); - }); - - it('minimal (--dry-run)', async () => { - const context = { template: 'minimal', cwd: '', dryRun: true, prompt: () => {} }; - await template(context); - assert.ok(fixture.hasMessage('Using minimal as project template')); - }); - - it('basics (--dry-run)', async () => { - const context = { template: 'basics', cwd: '', dryRun: true, prompt: () => {} }; - await template(context); - assert.ok(fixture.hasMessage('Using basics as project template')); - }); - - it('blog (--dry-run)', async () => { - const context = { template: 'blog', cwd: '', dryRun: true, prompt: () => {} }; - await template(context); - assert.ok(fixture.hasMessage('Using blog as project template')); - }); - - it('minimal (--yes)', async () => { - const context = { template: 'minimal', cwd: '', dryRun: true, yes: true, prompt: () => {} }; - await template(context); - assert.ok(fixture.hasMessage('Using minimal as project template')); - }); -}); diff --git a/packages/create-astro/test/template.test.ts b/packages/create-astro/test/template.test.ts new file mode 100644 index 000000000000..5f934d5bec6b --- /dev/null +++ b/packages/create-astro/test/template.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { template } from '../dist/index.js'; +import { mockExit, mockPrompt, setup, type TemplateContext } from './utils.ts'; + +describe('template', async () => { + const fixture = setup(); + + it('none', async () => { + const context: TemplateContext = { + template: '', + dryRun: true, + prompt: mockPrompt({ template: 'blog' }), + exit: mockExit, + tasks: [], + }; + await template(context); + assert.ok(fixture.hasMessage('Skipping template copying')); + assert.equal(context.template, 'blog'); + }); + + it('minimal (--dry-run)', async () => { + const context: TemplateContext = { + template: 'minimal', + dryRun: true, + prompt: mockPrompt({}), + exit: mockExit, + tasks: [], + }; + await template(context); + assert.ok(fixture.hasMessage('Using minimal as project template')); + }); + + it('basics (--dry-run)', async () => { + const context: TemplateContext = { + template: 'basics', + dryRun: true, + prompt: mockPrompt({}), + exit: mockExit, + tasks: [], + }; + await template(context); + assert.ok(fixture.hasMessage('Using basics as project template')); + }); + + it('blog (--dry-run)', async () => { + const context: TemplateContext = { + template: 'blog', + dryRun: true, + prompt: mockPrompt({}), + exit: mockExit, + tasks: [], + }; + await template(context); + assert.ok(fixture.hasMessage('Using blog as project template')); + }); + + it('minimal (--yes)', async () => { + const context: TemplateContext = { + template: 'minimal', + dryRun: true, + yes: true, + prompt: mockPrompt({}), + exit: mockExit, + tasks: [], + }; + await template(context); + assert.ok(fixture.hasMessage('Using minimal as project template')); + }); +}); diff --git a/packages/create-astro/test/utils.js b/packages/create-astro/test/utils.js deleted file mode 100644 index 20063ec53255..000000000000 --- a/packages/create-astro/test/utils.js +++ /dev/null @@ -1,32 +0,0 @@ -import { before, beforeEach } from 'node:test'; -import { stripVTControlCharacters } from 'node:util'; -import { setStdout } from '../dist/index.js'; - -export function setup() { - const ctx = { messages: [] }; - before(() => { - setStdout( - Object.assign({}, process.stdout, { - write(buf) { - ctx.messages.push(stripVTControlCharacters(String(buf)).trim()); - return true; - }, - }), - ); - }); - beforeEach(() => { - ctx.messages = []; - }); - - return { - messages() { - return ctx.messages; - }, - length() { - return ctx.messages.length; - }, - hasMessage(content) { - return !!ctx.messages.find((msg) => msg.includes(content)); - }, - }; -} diff --git a/packages/create-astro/test/utils.ts b/packages/create-astro/test/utils.ts new file mode 100644 index 000000000000..8946819a00ef --- /dev/null +++ b/packages/create-astro/test/utils.ts @@ -0,0 +1,62 @@ +import { before, beforeEach } from 'node:test'; +import { stripVTControlCharacters } from 'node:util'; +import { setStdout } from '../dist/index.js'; +import type { Context } from '../src/actions/context.ts'; +import type { + dependencies, + git, + intro, + next, + projectName, + template, + verify, +} from '../dist/index.js'; + +export type { Context }; +export type DependenciesContext = Parameters[0]; +export type GitContext = Parameters[0]; +export type IntroContext = Parameters[0]; +export type NextContext = Parameters[0]; +export type ProjectNameContext = Parameters[0]; +export type TemplateContext = Parameters[0]; +export type VerifyContext = Parameters[0]; + +export function mockPrompt(answers: Record): Context['prompt'] { + const fn = async (q: { name: string }) => { + return { [q.name]: answers[q.name] }; + }; + return fn as unknown as Context['prompt']; +} + +export const mockExit: Context['exit'] = (code) => { + throw code; +}; + +export function setup() { + const ctx: { messages: string[] } = { messages: [] }; + before(() => { + setStdout( + Object.assign({}, process.stdout, { + write(buf: Uint8Array | string) { + ctx.messages.push(stripVTControlCharacters(String(buf)).trim()); + return true; + }, + }), + ); + }); + beforeEach(() => { + ctx.messages = []; + }); + + return { + messages() { + return ctx.messages; + }, + length() { + return ctx.messages.length; + }, + hasMessage(content: string) { + return !!ctx.messages.find((msg) => msg.includes(content)); + }, + }; +} diff --git a/packages/create-astro/test/verify.test.js b/packages/create-astro/test/verify.test.js deleted file mode 100644 index ff335014517c..000000000000 --- a/packages/create-astro/test/verify.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { verify } from '../dist/index.js'; -import { setup } from './utils.js'; - -describe('verify', async () => { - const fixture = setup(); - const exit = (code) => { - throw code; - }; - - it('basics', async () => { - const context = { template: 'basics', exit }; - await verify(context); - assert.equal(fixture.messages().length, 0, 'Did not expect `verify` to log any messages'); - }); - - it('missing', async () => { - const context = { template: 'missing', exit }; - let err = null; - try { - await verify(context); - } catch (e) { - err = e; - } - assert.equal(err, 1); - assert.ok(!fixture.hasMessage('Template missing does not exist!')); - }); - - it('starlight', async () => { - const context = { template: 'starlight', exit }; - await verify(context); - assert.equal(fixture.messages().length, 0, 'Did not expect `verify` to log any messages'); - }); - - it('starlight/tailwind', async () => { - const context = { template: 'starlight/tailwind', exit }; - await verify(context); - assert.equal(fixture.messages().length, 0, 'Did not expect `verify` to log any messages'); - }); -}); diff --git a/packages/create-astro/test/verify.test.ts b/packages/create-astro/test/verify.test.ts new file mode 100644 index 000000000000..a1447e2ab41e --- /dev/null +++ b/packages/create-astro/test/verify.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { verify } from '../dist/index.js'; +import { mockExit, setup, type VerifyContext } from './utils.ts'; + +describe('verify', async () => { + const fixture = setup(); + const baseContext = { + version: Promise.resolve('0.0.0'), + ref: 'latest', + exit: mockExit, + } satisfies Partial; + + it('basics', async () => { + const context: VerifyContext = { ...baseContext, template: 'basics' }; + await verify(context); + assert.equal(fixture.messages().length, 0, 'Did not expect `verify` to log any messages'); + }); + + it('missing', async () => { + const context: VerifyContext = { ...baseContext, template: 'missing' }; + let err = null; + try { + await verify(context); + } catch (e) { + err = e; + } + assert.equal(err, 1); + assert.ok(!fixture.hasMessage('Template missing does not exist!')); + }); + + it('starlight', async () => { + const context: VerifyContext = { ...baseContext, template: 'starlight' }; + await verify(context); + assert.equal(fixture.messages().length, 0, 'Did not expect `verify` to log any messages'); + }); + + it('starlight/tailwind', async () => { + const context: VerifyContext = { ...baseContext, template: 'starlight/tailwind' }; + await verify(context); + assert.equal(fixture.messages().length, 0, 'Did not expect `verify` to log any messages'); + }); +}); diff --git a/packages/create-astro/tsconfig.test.json b/packages/create-astro/tsconfig.test.json new file mode 100644 index 000000000000..cff674d824b9 --- /dev/null +++ b/packages/create-astro/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../astro/tsconfig.test.json" + } + ] +} diff --git a/packages/db/package.json b/packages/db/package.json index 5def3f051e9c..71509decb282 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -68,8 +68,9 @@ "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", "test": "pnpm run test:integration && pnpm run test:types", - "test:integration": "astro-scripts test \"test/**/*.test.js\"", - "test:types": "tsc --project test/types/tsconfig.json" + "test:integration": "astro-scripts test \"test/**/*.test.ts\"", + "test:types": "tsc --project test/types/tsconfig.json", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "dependencies": { "@clack/prompts": "^1.0.1", diff --git a/packages/db/test/basics.test.js b/packages/db/test/basics.test.js deleted file mode 100644 index 6186af174703..000000000000 --- a/packages/db/test/basics.test.js +++ /dev/null @@ -1,231 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import testAdapter from '../../astro/test/test-adapter.js'; -import { loadFixture } from '../../astro/test/test-utils.js'; -import { resolveDbAppToken } from '../dist/core/utils.js'; -import { clearEnvironment, setupRemoteDb } from './test-utils.js'; - -describe('astro:db', () => { - let fixture; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/basics/', import.meta.url), - output: 'server', - adapter: testAdapter(), - }); - }); - - describe('development', () => { - let devServer; - - before(async () => { - clearEnvironment(); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('Prints the list of authors', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const ul = $('.authors-list'); - assert.equal(ul.children().length, 5); - assert.match(ul.children().eq(0).text(), /Ben/); - }); - - it('Allows expression defaults for date columns', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const themeAdded = $($('.themes-list .theme-added')[0]).text(); - assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); - }); - - it('Defaults can be overridden for dates', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const themeAdded = $($('.themes-list .theme-added')[1]).text(); - assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); - }); - - it('Allows expression defaults for text columns', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const themeOwner = $($('.themes-list .theme-owner')[0]).text(); - assert.equal(themeOwner, ''); - }); - - it('Allows expression defaults for boolean columns', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const themeDark = $($('.themes-list .theme-dark')[0]).text(); - assert.match(themeDark, /dark mode/); - }); - - it('text fields can be used as references', async () => { - const html = await fixture.fetch('/login').then((res) => res.text()); - const $ = cheerioLoad(html); - - assert.match($('.session-id').text(), /12345/); - assert.match($('.username').text(), /Mario/); - }); - - it('Prints authors from raw sql call', async () => { - const json = await fixture.fetch('run.json').then((res) => res.json()); - assert.deepEqual(json, { - columns: ['_id', 'name', 'age2'], - columnTypes: ['INTEGER', 'TEXT', 'INTEGER'], - rows: [ - [1, 'Ben', null], - [2, 'Nate', null], - [3, 'Erika', null], - [4, 'Bjorn', null], - [5, 'Sarah', null], - ], - rowsAffected: 0, - lastInsertRowid: null, - }); - }); - }); - - describe('development --remote', () => { - let devServer; - let remoteDbServer; - - before(async () => { - clearEnvironment(); - remoteDbServer = await setupRemoteDb(fixture.config); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer?.stop(); - await remoteDbServer?.stop(); - }); - - it('Prints the list of authors', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const ul = $('.authors-list'); - assert.equal(ul.children().length, 5); - assert.match(ul.children().eq(0).text(), /Ben/); - }); - - it('Allows expression defaults for date columns', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const themeAdded = $($('.themes-list .theme-added')[0]).text(); - assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); - }); - - it('Defaults can be overridden for dates', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const themeAdded = $($('.themes-list .theme-added')[1]).text(); - assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); - }); - - it('Allows expression defaults for text columns', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const themeOwner = $($('.themes-list .theme-owner')[0]).text(); - assert.equal(themeOwner, ''); - }); - - it('Allows expression defaults for boolean columns', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const themeDark = $($('.themes-list .theme-dark')[0]).text(); - assert.match(themeDark, /dark mode/); - }); - - it('text fields can be used as references', async () => { - const html = await fixture.fetch('/login').then((res) => res.text()); - const $ = cheerioLoad(html); - - assert.match($('.session-id').text(), /12345/); - assert.match($('.username').text(), /Mario/); - }); - - it('Prints authors from raw sql call', async () => { - const json = await fixture.fetch('run.json').then((res) => res.json()); - assert.deepEqual(json, { - columns: ['_id', 'name', 'age2'], - columnTypes: ['INTEGER', 'TEXT', 'INTEGER'], - rows: [ - [1, 'Ben', null], - [2, 'Nate', null], - [3, 'Erika', null], - [4, 'Bjorn', null], - [5, 'Sarah', null], - ], - rowsAffected: 0, - lastInsertRowid: null, - }); - }); - }); - - describe('build --remote', () => { - let remoteDbServer; - - before(async () => { - clearEnvironment(); - remoteDbServer = await setupRemoteDb(fixture.config); - await fixture.build(); - }); - - after(async () => { - await remoteDbServer?.stop(); - }); - - it('Can render page', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/'); - const response = await app.render(request); - const html = await response.text(); - const $ = cheerioLoad(html); - - const ul = $('.authors-list'); - assert.equal(ul.children().length, 5); - }); - }); - - describe('cli --db-app-token', () => { - it('Seeds remote database with --db-app-token flag set and without ASTRO_DB_APP_TOKEN env being set', async () => { - clearEnvironment(); - assert.equal(process.env.ASTRO_DB_APP_TOKEN, undefined); - - const remoteDbServer = await setupRemoteDb(fixture.config, { useDbAppTokenFlag: true }); - try { - assert.equal(process.env.ASTRO_DB_APP_TOKEN, undefined); - } finally { - await remoteDbServer.stop(); - } - assert.equal(process.env.ASTRO_DB_APP_TOKEN, undefined); - }); - }); - - describe('Precedence for --db-app-token and ASTRO_DB_APP_TOKEN handled correctly', () => { - it('prefers --db-app-token over `ASTRO_DB_APP_TOKEN`', () => { - const flags = /** @type {any} */ ({ _: [], dbAppToken: 'from-flag' }); - assert.equal(resolveDbAppToken(flags, 'from-env'), 'from-flag'); - }); - - it('falls back to ASTRO_DB_APP_TOKEN if no flags set', () => { - const flags = /** @type {any} */ ({ _: [] }); - assert.equal(resolveDbAppToken(flags, 'from-env'), 'from-env'); - }); - }); -}); diff --git a/packages/db/test/basics.test.ts b/packages/db/test/basics.test.ts new file mode 100644 index 000000000000..e0d9f945c091 --- /dev/null +++ b/packages/db/test/basics.test.ts @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import testAdapter from '../../astro/test/test-adapter.js'; +import { type DevServer, type Fixture, loadFixture } from '../../astro/test/test-utils.js'; +import { resolveDbAppToken } from '../dist/core/utils.js'; +import { clearEnvironment, type RemoteDbServer, setupRemoteDb } from './test-utils.ts'; + +describe('astro:db', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/basics/', import.meta.url), + output: 'server', + adapter: testAdapter(), + }); + }); + + describe('development', () => { + let devServer: DevServer; + + before(async () => { + clearEnvironment(); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Prints the list of authors', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + + it('Allows expression defaults for date columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeAdded = $($('.themes-list .theme-added')[0]).text(); + assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); + }); + + it('Defaults can be overridden for dates', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeAdded = $($('.themes-list .theme-added')[1]).text(); + assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); + }); + + it('Allows expression defaults for text columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeOwner = $($('.themes-list .theme-owner')[0]).text(); + assert.equal(themeOwner, ''); + }); + + it('Allows expression defaults for boolean columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeDark = $($('.themes-list .theme-dark')[0]).text(); + assert.match(themeDark, /dark mode/); + }); + + it('text fields can be used as references', async () => { + const html = await fixture.fetch('/login').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.match($('.session-id').text(), /12345/); + assert.match($('.username').text(), /Mario/); + }); + + it('Prints authors from raw sql call', async () => { + const json = await fixture.fetch('run.json').then((res) => res.json()); + assert.deepEqual(json, { + columns: ['_id', 'name', 'age2'], + columnTypes: ['INTEGER', 'TEXT', 'INTEGER'], + rows: [ + [1, 'Ben', null], + [2, 'Nate', null], + [3, 'Erika', null], + [4, 'Bjorn', null], + [5, 'Sarah', null], + ], + rowsAffected: 0, + lastInsertRowid: null, + }); + }); + }); + + describe('development --remote', () => { + let devServer: DevServer; + let remoteDbServer: RemoteDbServer; + + before(async () => { + clearEnvironment(); + remoteDbServer = await setupRemoteDb(fixture.config); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer?.stop(); + await remoteDbServer?.stop(); + }); + + it('Prints the list of authors', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + + it('Allows expression defaults for date columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeAdded = $($('.themes-list .theme-added')[0]).text(); + assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); + }); + + it('Defaults can be overridden for dates', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeAdded = $($('.themes-list .theme-added')[1]).text(); + assert.equal(Number.isNaN(new Date(themeAdded).getTime()), false); + }); + + it('Allows expression defaults for text columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeOwner = $($('.themes-list .theme-owner')[0]).text(); + assert.equal(themeOwner, ''); + }); + + it('Allows expression defaults for boolean columns', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const themeDark = $($('.themes-list .theme-dark')[0]).text(); + assert.match(themeDark, /dark mode/); + }); + + it('text fields can be used as references', async () => { + const html = await fixture.fetch('/login').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.match($('.session-id').text(), /12345/); + assert.match($('.username').text(), /Mario/); + }); + + it('Prints authors from raw sql call', async () => { + const json = await fixture.fetch('run.json').then((res) => res.json()); + assert.deepEqual(json, { + columns: ['_id', 'name', 'age2'], + columnTypes: ['INTEGER', 'TEXT', 'INTEGER'], + rows: [ + [1, 'Ben', null], + [2, 'Nate', null], + [3, 'Erika', null], + [4, 'Bjorn', null], + [5, 'Sarah', null], + ], + rowsAffected: 0, + lastInsertRowid: null, + }); + }); + }); + + describe('build --remote', () => { + let remoteDbServer: RemoteDbServer; + + before(async () => { + clearEnvironment(); + remoteDbServer = await setupRemoteDb(fixture.config); + await fixture.build(); + }); + + after(async () => { + await remoteDbServer?.stop(); + }); + + it('Can render page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + }); + }); + + describe('cli --db-app-token', () => { + it('Seeds remote database with --db-app-token flag set and without ASTRO_DB_APP_TOKEN env being set', async () => { + clearEnvironment(); + assert.equal(process.env.ASTRO_DB_APP_TOKEN, undefined); + + const remoteDbServer = await setupRemoteDb(fixture.config, { useDbAppTokenFlag: true }); + try { + assert.equal(process.env.ASTRO_DB_APP_TOKEN, undefined); + } finally { + await remoteDbServer.stop(); + } + assert.equal(process.env.ASTRO_DB_APP_TOKEN, undefined); + }); + }); + + describe('Precedence for --db-app-token and ASTRO_DB_APP_TOKEN handled correctly', () => { + it('prefers --db-app-token over `ASTRO_DB_APP_TOKEN`', () => { + assert.equal(resolveDbAppToken({ _: [], dbAppToken: 'from-flag' }, 'from-env'), 'from-flag'); + }); + + it('falls back to ASTRO_DB_APP_TOKEN if no flags set', () => { + assert.equal(resolveDbAppToken({ _: [] }, 'from-env'), 'from-env'); + }); + }); +}); diff --git a/packages/db/test/db-in-src.test.js b/packages/db/test/db-in-src.test.js deleted file mode 100644 index 5e29b73724b3..000000000000 --- a/packages/db/test/db-in-src.test.js +++ /dev/null @@ -1,38 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import testAdapter from '../../astro/test/test-adapter.js'; -import { loadFixture } from '../../astro/test/test-utils.js'; - -describe('astro:db', () => { - let fixture; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/db-in-src/', import.meta.url), - output: 'server', - srcDir: '.', - adapter: testAdapter(), - }); - }); - - describe('development: db/ folder inside srcDir', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('Prints the list of authors', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const ul = $('.users-list'); - assert.equal(ul.children().length, 1); - assert.match($('.users-list li').text(), /Mario/); - }); - }); -}); diff --git a/packages/db/test/db-in-src.test.ts b/packages/db/test/db-in-src.test.ts new file mode 100644 index 000000000000..f224b0fca936 --- /dev/null +++ b/packages/db/test/db-in-src.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import testAdapter from '../../astro/test/test-adapter.js'; +import { type DevServer, type Fixture, loadFixture } from '../../astro/test/test-utils.js'; + +describe('astro:db', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/db-in-src/', import.meta.url), + output: 'server', + srcDir: '.', + adapter: testAdapter(), + }); + }); + + describe('development: db/ folder inside srcDir', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Prints the list of authors', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('.users-list'); + assert.equal(ul.children().length, 1); + assert.match($('.users-list li').text(), /Mario/); + }); + }); +}); diff --git a/packages/db/test/error-handling.test.js b/packages/db/test/error-handling.test.js deleted file mode 100644 index 8dc6e1e89b13..000000000000 --- a/packages/db/test/error-handling.test.js +++ /dev/null @@ -1,90 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { loadFixture } from '../../astro/test/test-utils.js'; -import { cli } from '../dist/core/cli/index.js'; -import { setupRemoteDb } from './test-utils.js'; - -const foreignKeyConstraintError = 'LibsqlError: SQLITE_CONSTRAINT: FOREIGN KEY constraint failed'; - -describe('astro:db - error handling', () => { - let fixture; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/error-handling/', import.meta.url), - }); - }); - - it('Errors on invalid --db-app-token input', async () => { - const originalExit = process.exit; - const originalError = console.error; - /** @type {string[]} */ - const errorMessages = []; - console.error = (...args) => { - errorMessages.push(args.map(String).join(' ')); - }; - process.exit = (code) => { - throw new Error(`EXIT_${code}`); - }; - - try { - await cli({ - config: fixture.config, - flags: { - _: [undefined, 'astro', 'db', 'verify'], - dbAppToken: true, - }, - }); - assert.fail('Expected command to exit'); - } catch (err) { - assert.match(String(err), /EXIT_1/); - assert.ok( - errorMessages.some((m) => m.includes('Invalid value for --db-app-token')), - `Expected error output to mention invalid --db-app-token, got: ${errorMessages.join('\n')}`, - ); - } finally { - process.exit = originalExit; - console.error = originalError; - } - }); - - describe('development', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('Raises foreign key constraint LibsqlError', async () => { - const json = await fixture.fetch('/foreign-key-constraint.json').then((res) => res.json()); - assert.deepEqual(json, { - message: foreignKeyConstraintError, - code: 'SQLITE_CONSTRAINT', - }); - }); - }); - - describe('build --remote', () => { - let remoteDbServer; - - before(async () => { - remoteDbServer = await setupRemoteDb(fixture.config); - await fixture.build(); - }); - - after(async () => { - await remoteDbServer?.stop(); - }); - - it('Raises foreign key constraint LibsqlError', async () => { - const json = await fixture.readFile('/foreign-key-constraint.json'); - assert.deepEqual(JSON.parse(json), { - message: foreignKeyConstraintError, - code: 'SQLITE_CONSTRAINT', - }); - }); - }); -}); diff --git a/packages/db/test/error-handling.test.ts b/packages/db/test/error-handling.test.ts new file mode 100644 index 000000000000..07c634a9562a --- /dev/null +++ b/packages/db/test/error-handling.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { type DevServer, type Fixture, loadFixture } from '../../astro/test/test-utils.js'; +import { cli } from '../dist/core/cli/index.js'; +import { type RemoteDbServer, setupRemoteDb } from './test-utils.ts'; + +const foreignKeyConstraintError = 'LibsqlError: SQLITE_CONSTRAINT: FOREIGN KEY constraint failed'; + +describe('astro:db - error handling', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/error-handling/', import.meta.url), + }); + }); + + it('Errors on invalid --db-app-token input', async () => { + const originalExit = process.exit; + const originalError = console.error; + const errorMessages: string[] = []; + console.error = (...args: unknown[]) => { + errorMessages.push(args.map(String).join(' ')); + }; + process.exit = ((code?: number) => { + throw new Error(`EXIT_${code}`); + }) as typeof process.exit; + + try { + await cli({ + config: fixture.config, + flags: { + _: ['', 'astro', 'db', 'verify'], + dbAppToken: true, + }, + }); + assert.fail('Expected command to exit'); + } catch (err) { + assert.match(String(err), /EXIT_1/); + assert.ok( + errorMessages.some((m) => m.includes('Invalid value for --db-app-token')), + `Expected error output to mention invalid --db-app-token, got: ${errorMessages.join('\n')}`, + ); + } finally { + process.exit = originalExit; + console.error = originalError; + } + }); + + describe('development', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Raises foreign key constraint LibsqlError', async () => { + const json = await fixture.fetch('/foreign-key-constraint.json').then((res) => res.json()); + assert.deepEqual(json, { + message: foreignKeyConstraintError, + code: 'SQLITE_CONSTRAINT', + }); + }); + }); + + describe('build --remote', () => { + let remoteDbServer: RemoteDbServer; + + before(async () => { + remoteDbServer = await setupRemoteDb(fixture.config); + await fixture.build(); + }); + + after(async () => { + await remoteDbServer?.stop(); + }); + + it('Raises foreign key constraint LibsqlError', async () => { + const json = await fixture.readFile('/foreign-key-constraint.json'); + assert.deepEqual(JSON.parse(json), { + message: foreignKeyConstraintError, + code: 'SQLITE_CONSTRAINT', + }); + }); + }); +}); diff --git a/packages/db/test/integration-only.test.js b/packages/db/test/integration-only.test.js deleted file mode 100644 index b95d7d141a68..000000000000 --- a/packages/db/test/integration-only.test.js +++ /dev/null @@ -1,48 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from '../../astro/test/test-utils.js'; - -describe('astro:db with only integrations, no user db config', () => { - let fixture; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/integration-only/', import.meta.url), - }); - }); - - describe('development', () => { - let devServer; - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('Prints the list of menu items from integration-defined table', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const ul = $('ul.menu'); - assert.equal(ul.children().length, 4); - assert.match(ul.children().eq(0).text(), /Pancakes/); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('Prints the list of menu items from integration-defined table', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - const ul = $('ul.menu'); - assert.equal(ul.children().length, 4); - assert.match(ul.children().eq(0).text(), /Pancakes/); - }); - }); -}); diff --git a/packages/db/test/integration-only.test.ts b/packages/db/test/integration-only.test.ts new file mode 100644 index 000000000000..009b6e48fc7b --- /dev/null +++ b/packages/db/test/integration-only.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { type DevServer, type Fixture, loadFixture } from '../../astro/test/test-utils.js'; + +describe('astro:db with only integrations, no user db config', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/integration-only/', import.meta.url), + }); + }); + + describe('development', () => { + let devServer: DevServer; + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Prints the list of menu items from integration-defined table', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('ul.menu'); + assert.equal(ul.children().length, 4); + assert.match(ul.children().eq(0).text(), /Pancakes/); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('Prints the list of menu items from integration-defined table', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + const ul = $('ul.menu'); + assert.equal(ul.children().length, 4); + assert.match(ul.children().eq(0).text(), /Pancakes/); + }); + }); +}); diff --git a/packages/db/test/integrations.test.js b/packages/db/test/integrations.test.js deleted file mode 100644 index b05b28d6a670..000000000000 --- a/packages/db/test/integrations.test.js +++ /dev/null @@ -1,67 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from '../../astro/test/test-utils.js'; - -describe('astro:db with integrations', () => { - let fixture; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/integrations/', import.meta.url), - }); - }); - - describe('development', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('Prints the list of authors from user-defined table', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const ul = $('.authors-list'); - assert.equal(ul.children().length, 5); - assert.match(ul.children().eq(0).text(), /Ben/); - }); - - it('Prints the list of menu items from integration-defined table', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const ul = $('ul.menu'); - assert.equal(ul.children().length, 4); - assert.match(ul.children().eq(0).text(), /Pancakes/); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('Prints the list of authors from user-defined table', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - const ul = $('.authors-list'); - assert.equal(ul.children().length, 5); - assert.match(ul.children().eq(0).text(), /Ben/); - }); - - it('Prints the list of menu items from integration-defined table', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - const ul = $('ul.menu'); - assert.equal(ul.children().length, 4); - assert.match(ul.children().eq(0).text(), /Pancakes/); - }); - }); -}); diff --git a/packages/db/test/integrations.test.ts b/packages/db/test/integrations.test.ts new file mode 100644 index 000000000000..fcb9b1120000 --- /dev/null +++ b/packages/db/test/integrations.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { type DevServer, type Fixture, loadFixture } from '../../astro/test/test-utils.js'; + +describe('astro:db with integrations', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/integrations/', import.meta.url), + }); + }); + + describe('development', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Prints the list of authors from user-defined table', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + + it('Prints the list of menu items from integration-defined table', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('ul.menu'); + assert.equal(ul.children().length, 4); + assert.match(ul.children().eq(0).text(), /Pancakes/); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('Prints the list of authors from user-defined table', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + + it('Prints the list of menu items from integration-defined table', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + const ul = $('ul.menu'); + assert.equal(ul.children().length, 4); + assert.match(ul.children().eq(0).text(), /Pancakes/); + }); + }); +}); diff --git a/packages/db/test/libsql-remote.test.js b/packages/db/test/libsql-remote.test.js deleted file mode 100644 index d009cba1db84..000000000000 --- a/packages/db/test/libsql-remote.test.js +++ /dev/null @@ -1,86 +0,0 @@ -import assert from 'node:assert/strict'; -import { rm } from 'node:fs/promises'; -import { relative } from 'node:path'; -import { after, before, describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import testAdapter from '../../astro/test/test-adapter.js'; -import { loadFixture } from '../../astro/test/test-utils.js'; -import { clearEnvironment, initializeRemoteDb } from './test-utils.js'; - -describe('astro:db local database', () => { - describe('build --remote with local libSQL file (absolute path)', () => { - let fixture; - before(async () => { - clearEnvironment(); - - const absoluteFileUrl = new URL('./fixtures/libsql-remote/temp/absolute.db', import.meta.url); - // Remove the file if it exists to avoid conflict between test runs - await rm(absoluteFileUrl, { force: true }); - - process.env.ASTRO_INTERNAL_TEST_REMOTE = true; - process.env.ASTRO_DB_REMOTE_URL = absoluteFileUrl.toString(); - - const root = new URL('./fixtures/libsql-remote/', import.meta.url); - fixture = await loadFixture({ - root, - outDir: fileURLToPath(new URL('./dist/absolute/', root)), - output: 'server', - adapter: testAdapter(), - }); - - await fixture.build(); - await initializeRemoteDb(fixture.config); - }); - - after(async () => { - delete process.env.ASTRO_INTERNAL_TEST_REMOTE; - delete process.env.ASTRO_DB_REMOTE_URL; - }); - - it('Can render page', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/'); - const response = await app.render(request); - assert.equal(response.status, 200); - }); - }); - - describe('build --remote with local libSQL file (relative path)', () => { - let fixture; - before(async () => { - clearEnvironment(); - - const absoluteFileUrl = new URL('./fixtures/libsql-remote/temp/relative.db', import.meta.url); - const prodDbPath = relative(process.cwd(), fileURLToPath(absoluteFileUrl)); - - // Remove the file if it exists to avoid conflict between test runs - await rm(prodDbPath, { force: true }); - - process.env.ASTRO_INTERNAL_TEST_REMOTE = true; - process.env.ASTRO_DB_REMOTE_URL = `file:${prodDbPath}`; - - const root = new URL('./fixtures/libsql-remote/', import.meta.url); - fixture = await loadFixture({ - root, - outDir: fileURLToPath(new URL('./dist/relative/', root)), - output: 'server', - adapter: testAdapter(), - }); - - await fixture.build(); - await initializeRemoteDb(fixture.config); - }); - - after(async () => { - delete process.env.ASTRO_INTERNAL_TEST_REMOTE; - delete process.env.ASTRO_DB_REMOTE_URL; - }); - - it('Can render page', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/'); - const response = await app.render(request); - assert.equal(response.status, 200); - }); - }); -}); diff --git a/packages/db/test/libsql-remote.test.ts b/packages/db/test/libsql-remote.test.ts new file mode 100644 index 000000000000..7cf42b856a1c --- /dev/null +++ b/packages/db/test/libsql-remote.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { rm } from 'node:fs/promises'; +import { relative } from 'node:path'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import testAdapter from '../../astro/test/test-adapter.js'; +import { type Fixture, loadFixture } from '../../astro/test/test-utils.js'; +import { clearEnvironment, initializeRemoteDb } from './test-utils.ts'; + +describe('astro:db local database', () => { + describe('build --remote with local libSQL file (absolute path)', () => { + let fixture: Fixture; + before(async () => { + clearEnvironment(); + + const absoluteFileUrl = new URL('./fixtures/libsql-remote/temp/absolute.db', import.meta.url); + // Remove the file if it exists to avoid conflict between test runs + await rm(absoluteFileUrl, { force: true }); + + process.env.ASTRO_INTERNAL_TEST_REMOTE = 'true'; + process.env.ASTRO_DB_REMOTE_URL = absoluteFileUrl.toString(); + + const root = new URL('./fixtures/libsql-remote/', import.meta.url); + fixture = await loadFixture({ + root, + outDir: fileURLToPath(new URL('./dist/absolute/', root)), + output: 'server', + adapter: testAdapter(), + }); + + await fixture.build(); + await initializeRemoteDb(fixture.config); + }); + + after(async () => { + delete process.env.ASTRO_INTERNAL_TEST_REMOTE; + delete process.env.ASTRO_DB_REMOTE_URL; + }); + + it('Can render page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + assert.equal(response.status, 200); + }); + }); + + describe('build --remote with local libSQL file (relative path)', () => { + let fixture: Fixture; + before(async () => { + clearEnvironment(); + + const absoluteFileUrl = new URL('./fixtures/libsql-remote/temp/relative.db', import.meta.url); + const prodDbPath = relative(process.cwd(), fileURLToPath(absoluteFileUrl)); + + // Remove the file if it exists to avoid conflict between test runs + await rm(prodDbPath, { force: true }); + + process.env.ASTRO_INTERNAL_TEST_REMOTE = 'true'; + process.env.ASTRO_DB_REMOTE_URL = `file:${prodDbPath}`; + + const root = new URL('./fixtures/libsql-remote/', import.meta.url); + fixture = await loadFixture({ + root, + outDir: fileURLToPath(new URL('./dist/relative/', root)), + output: 'server', + adapter: testAdapter(), + }); + + await fixture.build(); + await initializeRemoteDb(fixture.config); + }); + + after(async () => { + delete process.env.ASTRO_INTERNAL_TEST_REMOTE; + delete process.env.ASTRO_DB_REMOTE_URL; + }); + + it('Can render page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + assert.equal(response.status, 200); + }); + }); +}); diff --git a/packages/db/test/local-prod.test.js b/packages/db/test/local-prod.test.js deleted file mode 100644 index 9b84f09a2420..000000000000 --- a/packages/db/test/local-prod.test.js +++ /dev/null @@ -1,105 +0,0 @@ -import assert from 'node:assert/strict'; -import { relative } from 'node:path'; -import { after, before, describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import testAdapter from '../../astro/test/test-adapter.js'; -import { loadFixture } from '../../astro/test/test-utils.js'; - -describe('astro:db local database', () => { - describe('build (not remote) with DATABASE_FILE env (file URL)', () => { - let fixture; - const prodDbPath = new URL('./fixtures/basics/dist/astro.db', import.meta.url).toString(); - before(async () => { - process.env.ASTRO_DATABASE_FILE = prodDbPath; - const root = new URL('./fixtures/local-prod/', import.meta.url); - fixture = await loadFixture({ - root, - outDir: fileURLToPath(new URL('./dist/file-url/', root)), - output: 'server', - adapter: testAdapter(), - }); - await fixture.build(); - }); - - after(async () => { - delete process.env.ASTRO_DATABASE_FILE; - }); - - it('Can render page', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/'); - const response = await app.render(request); - assert.equal(response.status, 200); - }); - }); - - describe('build (not remote) with DATABASE_FILE env (relative file path)', () => { - let fixture; - const absoluteFileUrl = new URL('./fixtures/basics/dist/astro.db', import.meta.url); - const prodDbPath = relative(process.cwd(), fileURLToPath(absoluteFileUrl)); - - before(async () => { - process.env.ASTRO_DATABASE_FILE = prodDbPath; - const root = new URL('./fixtures/local-prod/', import.meta.url); - fixture = await loadFixture({ - root, - outDir: fileURLToPath(new URL('./dist/relative/', root)), - output: 'server', - adapter: testAdapter(), - }); - await fixture.build(); - }); - - after(async () => { - delete process.env.ASTRO_DATABASE_FILE; - }); - - it('Can render page', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/'); - const response = await app.render(request); - assert.equal(response.status, 200); - }); - }); - - describe('build (not remote)', () => { - it('should throw during the build for server output', async () => { - delete process.env.ASTRO_DATABASE_FILE; - const root = new URL('./fixtures/local-prod/', import.meta.url); - const fixture = await loadFixture({ - root, - outDir: fileURLToPath(new URL('./dist/not-remote/', root)), - output: 'server', - adapter: testAdapter(), - }); - let buildError = null; - try { - await fixture.build(); - } catch (err) { - buildError = err; - } - - assert.equal(buildError instanceof Error, true); - }); - - it('should throw during the build for hybrid output', async () => { - let root = new URL('./fixtures/local-prod/', import.meta.url); - const fixture2 = await loadFixture({ - root, - outDir: fileURLToPath(new URL('./dist/hybrid-output/', root)), - output: 'static', - adapter: testAdapter(), - }); - - delete process.env.ASTRO_DATABASE_FILE; - let buildError = null; - try { - await fixture2.build(); - } catch (err) { - buildError = err; - } - - assert.equal(buildError instanceof Error, true); - }); - }); -}); diff --git a/packages/db/test/local-prod.test.ts b/packages/db/test/local-prod.test.ts new file mode 100644 index 000000000000..aa0d1c6d3f9e --- /dev/null +++ b/packages/db/test/local-prod.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { relative } from 'node:path'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import testAdapter from '../../astro/test/test-adapter.js'; +import { type Fixture, loadFixture } from '../../astro/test/test-utils.js'; + +describe('astro:db local database', () => { + describe('build (not remote) with DATABASE_FILE env (file URL)', () => { + let fixture: Fixture; + const prodDbPath = new URL('./fixtures/basics/dist/astro.db', import.meta.url).toString(); + before(async () => { + process.env.ASTRO_DATABASE_FILE = prodDbPath; + const root = new URL('./fixtures/local-prod/', import.meta.url); + fixture = await loadFixture({ + root, + outDir: fileURLToPath(new URL('./dist/file-url/', root)), + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + }); + + after(async () => { + delete process.env.ASTRO_DATABASE_FILE; + }); + + it('Can render page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + assert.equal(response.status, 200); + }); + }); + + describe('build (not remote) with DATABASE_FILE env (relative file path)', () => { + let fixture: Fixture; + const absoluteFileUrl = new URL('./fixtures/basics/dist/astro.db', import.meta.url); + const prodDbPath = relative(process.cwd(), fileURLToPath(absoluteFileUrl)); + + before(async () => { + process.env.ASTRO_DATABASE_FILE = prodDbPath; + const root = new URL('./fixtures/local-prod/', import.meta.url); + fixture = await loadFixture({ + root, + outDir: fileURLToPath(new URL('./dist/relative/', root)), + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + }); + + after(async () => { + delete process.env.ASTRO_DATABASE_FILE; + }); + + it('Can render page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + assert.equal(response.status, 200); + }); + }); + + describe('build (not remote)', () => { + it('should throw during the build for server output', async () => { + delete process.env.ASTRO_DATABASE_FILE; + const root = new URL('./fixtures/local-prod/', import.meta.url); + const fixture = await loadFixture({ + root, + outDir: fileURLToPath(new URL('./dist/not-remote/', root)), + output: 'server', + adapter: testAdapter(), + }); + let buildError: unknown = null; + try { + await fixture.build(); + } catch (err) { + buildError = err; + } + + assert.equal(buildError instanceof Error, true); + }); + + it('should throw during the build for hybrid output', async () => { + let root = new URL('./fixtures/local-prod/', import.meta.url); + const fixture2 = await loadFixture({ + root, + outDir: fileURLToPath(new URL('./dist/hybrid-output/', root)), + output: 'static', + adapter: testAdapter(), + }); + + delete process.env.ASTRO_DATABASE_FILE; + let buildError: unknown = null; + try { + await fixture2.build(); + } catch (err) { + buildError = err; + } + + assert.equal(buildError instanceof Error, true); + }); + }); +}); diff --git a/packages/db/test/no-seed.test.js b/packages/db/test/no-seed.test.js deleted file mode 100644 index 0583521760d4..000000000000 --- a/packages/db/test/no-seed.test.js +++ /dev/null @@ -1,48 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from '../../astro/test/test-utils.js'; - -describe('astro:db with no seed file', () => { - let fixture; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/no-seed/', import.meta.url), - }); - }); - - describe('development', () => { - let devServer; - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('Prints the list of authors', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const ul = $('.authors-list'); - assert.equal(ul.children().length, 5); - assert.match(ul.children().eq(0).text(), /Ben/); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('Prints the list of authors', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - const ul = $('.authors-list'); - assert.equal(ul.children().length, 5); - assert.match(ul.children().eq(0).text(), /Ben/); - }); - }); -}); diff --git a/packages/db/test/no-seed.test.ts b/packages/db/test/no-seed.test.ts new file mode 100644 index 000000000000..983b7ddcca86 --- /dev/null +++ b/packages/db/test/no-seed.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { type DevServer, type Fixture, loadFixture } from '../../astro/test/test-utils.js'; + +describe('astro:db with no seed file', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/no-seed/', import.meta.url), + }); + }); + + describe('development', () => { + let devServer: DevServer; + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Prints the list of authors', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('Prints the list of authors', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + const ul = $('.authors-list'); + assert.equal(ul.children().length, 5); + assert.match(ul.children().eq(0).text(), /Ben/); + }); + }); +}); diff --git a/packages/db/test/ssr-no-apptoken.test.js b/packages/db/test/ssr-no-apptoken.test.js deleted file mode 100644 index 87817d3eca95..000000000000 --- a/packages/db/test/ssr-no-apptoken.test.js +++ /dev/null @@ -1,37 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import testAdapter from '../../astro/test/test-adapter.js'; -import { loadFixture } from '../../astro/test/test-utils.js'; -import { setupRemoteDb } from './test-utils.js'; - -describe('missing app token', () => { - let fixture; - let remoteDbServer; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/no-apptoken/', import.meta.url), - output: 'server', - adapter: testAdapter(), - }); - - remoteDbServer = await setupRemoteDb(fixture.config); - await fixture.build(); - // Ensure there's no token at runtime - delete process.env.ASTRO_DB_APP_TOKEN; - }); - - after(async () => { - await remoteDbServer?.stop(); - }); - - it('Errors as runtime', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/'); - const response = await app.render(request); - try { - await response.text(); - } catch { - assert.equal(response.status, 501); - } - }); -}); diff --git a/packages/db/test/ssr-no-apptoken.test.ts b/packages/db/test/ssr-no-apptoken.test.ts new file mode 100644 index 000000000000..76594bc06484 --- /dev/null +++ b/packages/db/test/ssr-no-apptoken.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import testAdapter from '../../astro/test/test-adapter.js'; +import { type Fixture, loadFixture } from '../../astro/test/test-utils.js'; +import { type RemoteDbServer, setupRemoteDb } from './test-utils.ts'; + +describe('missing app token', () => { + let fixture: Fixture; + let remoteDbServer: RemoteDbServer; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/no-apptoken/', import.meta.url), + output: 'server', + adapter: testAdapter(), + }); + + remoteDbServer = await setupRemoteDb(fixture.config); + await fixture.build(); + // Ensure there's no token at runtime + delete process.env.ASTRO_DB_APP_TOKEN; + }); + + after(async () => { + await remoteDbServer?.stop(); + }); + + it('Errors as runtime', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + try { + await response.text(); + } catch { + assert.equal(response.status, 501); + } + }); +}); diff --git a/packages/db/test/static-remote.test.js b/packages/db/test/static-remote.test.js deleted file mode 100644 index 34d662688229..000000000000 --- a/packages/db/test/static-remote.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from '../../astro/test/test-utils.js'; -import { clearEnvironment, setupRemoteDb } from './test-utils.js'; - -describe('astro:db', () => { - let fixture; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/static-remote/', import.meta.url), - output: 'static', - }); - }); - - describe('static build --remote', () => { - let remoteDbServer; - - before(async () => { - remoteDbServer = await setupRemoteDb(fixture.config); - await fixture.build(); - }); - - after(async () => { - await remoteDbServer?.stop(); - }); - - it('Can render page', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - assert.equal($('li').length, 1); - }); - - it('Returns correct shape from db.run()', async () => { - const html = await fixture.readFile('/run/index.html'); - const $ = cheerioLoad(html); - - assert.match($('#row').text(), /1/); - }); - }); - - describe('static build --remote with custom LibSQL', () => { - let remoteDbServer; - - before(async () => { - clearEnvironment(); - process.env.ASTRO_DB_REMOTE_URL = `memory:`; - await fixture.build(); - }); - - after(async () => { - await remoteDbServer?.stop(); - }); - - it('Can render page', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - assert.equal($('li').length, 1); - }); - - it('Returns correct shape from db.run()', async () => { - const html = await fixture.readFile('/run/index.html'); - const $ = cheerioLoad(html); - - assert.match($('#row').text(), /1/); - }); - }); -}); diff --git a/packages/db/test/static-remote.test.ts b/packages/db/test/static-remote.test.ts new file mode 100644 index 000000000000..929efba9a9a2 --- /dev/null +++ b/packages/db/test/static-remote.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { type Fixture, loadFixture } from '../../astro/test/test-utils.js'; +import { clearEnvironment, type RemoteDbServer, setupRemoteDb } from './test-utils.ts'; + +describe('astro:db', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/static-remote/', import.meta.url), + output: 'static', + }); + }); + + describe('static build --remote', () => { + let remoteDbServer: RemoteDbServer; + + before(async () => { + remoteDbServer = await setupRemoteDb(fixture.config); + await fixture.build(); + }); + + after(async () => { + await remoteDbServer?.stop(); + }); + + it('Can render page', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('li').length, 1); + }); + + it('Returns correct shape from db.run()', async () => { + const html = await fixture.readFile('/run/index.html'); + const $ = cheerioLoad(html); + + assert.match($('#row').text(), /1/); + }); + }); + + describe('static build --remote with custom LibSQL', () => { + let remoteDbServer: RemoteDbServer | undefined; + + before(async () => { + clearEnvironment(); + process.env.ASTRO_DB_REMOTE_URL = `memory:`; + await fixture.build(); + }); + + after(async () => { + await remoteDbServer?.stop(); + }); + + it('Can render page', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('li').length, 1); + }); + + it('Returns correct shape from db.run()', async () => { + const html = await fixture.readFile('/run/index.html'); + const $ = cheerioLoad(html); + + assert.match($('#row').text(), /1/); + }); + }); +}); diff --git a/packages/db/test/test-utils.js b/packages/db/test/test-utils.js deleted file mode 100644 index 7e1a6623debd..000000000000 --- a/packages/db/test/test-utils.js +++ /dev/null @@ -1,96 +0,0 @@ -import { mkdir, unlink } from 'node:fs/promises'; -import { createClient } from '@libsql/client'; -import { cli } from '../dist/core/cli/index.js'; -import { resolveDbConfig } from '../dist/core/load-file.js'; -import { getCreateIndexQueries, getCreateTableQuery } from '../dist/core/queries.js'; - -const isWindows = process.platform === 'win32'; - -/** - * @param {import('astro').AstroConfig} astroConfig - */ -export async function setupRemoteDb(astroConfig, options = {}) { - const url = isWindows - ? new URL(`./.astro/${Date.now()}.db`, astroConfig.root) - : new URL(`./${Date.now()}.db`, astroConfig.root); - const token = 'foo'; - process.env.ASTRO_DB_REMOTE_URL = url.toString(); - if (!options.useDbAppTokenFlag) { - process.env.ASTRO_DB_APP_TOKEN = token; - } - process.env.ASTRO_INTERNAL_TEST_REMOTE = true; - - if (isWindows) { - await mkdir(new URL('.', url), { recursive: true }); - } - - const dbClient = createClient({ - url, - authToken: token, - }); - - const { dbConfig } = await resolveDbConfig(astroConfig); - const setupQueries = []; - for (const [name, table] of Object.entries(dbConfig?.tables ?? {})) { - const createQuery = getCreateTableQuery(name, table); - const indexQueries = getCreateIndexQueries(name, table); - setupQueries.push(createQuery, ...indexQueries); - } - - for (const sql of setupQueries) { - await dbClient.execute({ - sql, - args: [], - }); - } - - await cli({ - config: astroConfig, - flags: { - _: [undefined, 'astro', 'db', 'execute', 'db/seed.ts'], - remote: true, - ...(options.useDbAppTokenFlag ? { dbAppToken: token } : {}), - }, - }); - - return { - async stop() { - delete process.env.ASTRO_DB_REMOTE_URL; - delete process.env.ASTRO_DB_APP_TOKEN; - delete process.env.ASTRO_INTERNAL_TEST_REMOTE; - dbClient.close(); - if (!isWindows) { - await unlink(url); - } - }, - }; -} - -export async function initializeRemoteDb(astroConfig) { - await cli({ - config: astroConfig, - flags: { - _: [undefined, 'astro', 'db', 'push'], - remote: true, - }, - }); - await cli({ - config: astroConfig, - flags: { - _: [undefined, 'astro', 'db', 'execute', 'db/seed.ts'], - remote: true, - }, - }); -} - -/** - * Clears the environment variables related to Astro DB. - */ -export function clearEnvironment() { - const keys = Array.from(Object.keys(process.env)); - for (const key of keys) { - if (key.startsWith('ASTRO_DB_')) { - delete process.env[key]; - } - } -} diff --git a/packages/db/test/test-utils.ts b/packages/db/test/test-utils.ts new file mode 100644 index 000000000000..ef880a745858 --- /dev/null +++ b/packages/db/test/test-utils.ts @@ -0,0 +1,104 @@ +import { mkdir, unlink } from 'node:fs/promises'; +import type { AstroConfig } from 'astro'; +import { createClient } from '@libsql/client'; +import { cli } from '../dist/core/cli/index.js'; +import { resolveDbConfig } from '../dist/core/load-file.js'; +import { getCreateIndexQueries, getCreateTableQuery } from '../dist/core/queries.js'; +import type { DBTable, ResolvedDBTable } from '../dist/core/types.js'; + +const isWindows = process.platform === 'win32'; + +export type RemoteDbServer = { stop: () => Promise }; + +export async function setupRemoteDb( + astroConfig: AstroConfig, + options: { useDbAppTokenFlag?: boolean } = {}, +): Promise { + const url = isWindows + ? new URL(`./.astro/${Date.now()}.db`, astroConfig.root) + : new URL(`./${Date.now()}.db`, astroConfig.root); + const token = 'foo'; + process.env.ASTRO_DB_REMOTE_URL = url.toString(); + if (!options.useDbAppTokenFlag) { + process.env.ASTRO_DB_APP_TOKEN = token; + } + process.env.ASTRO_INTERNAL_TEST_REMOTE = 'true'; + + if (isWindows) { + await mkdir(new URL('.', url), { recursive: true }); + } + + const dbClient = createClient({ + url: url.toString(), + authToken: token, + }); + + const { dbConfig } = await resolveDbConfig(astroConfig); + const setupQueries: string[] = []; + for (const [name, table] of Object.entries(dbConfig?.tables ?? {})) { + const createQuery = getCreateTableQuery(name, table); + const indexQueries = getCreateIndexQueries(name, table); + setupQueries.push(createQuery, ...indexQueries); + } + + for (const sql of setupQueries) { + await dbClient.execute({ + sql, + args: [], + }); + } + + await cli({ + config: astroConfig, + flags: { + _: ['', 'astro', 'db', 'execute', 'db/seed.ts'], + remote: true, + ...(options.useDbAppTokenFlag ? { dbAppToken: token } : {}), + }, + }); + + return { + async stop() { + delete process.env.ASTRO_DB_REMOTE_URL; + delete process.env.ASTRO_DB_APP_TOKEN; + delete process.env.ASTRO_INTERNAL_TEST_REMOTE; + dbClient.close(); + if (!isWindows) { + await unlink(url); + } + }, + }; +} + +export async function initializeRemoteDb(astroConfig: AstroConfig) { + await cli({ + config: astroConfig, + flags: { + _: ['', 'astro', 'db', 'push'], + remote: true, + }, + }); + await cli({ + config: astroConfig, + flags: { + _: ['', 'astro', 'db', 'execute', 'db/seed.ts'], + remote: true, + }, + }); +} + +/** + * Clears the environment variables related to Astro DB. + */ +export function clearEnvironment() { + const keys = Array.from(Object.keys(process.env)); + for (const key of keys) { + if (key.startsWith('ASTRO_DB_')) { + delete process.env[key]; + } + } +} + +export function asResolved(table: DBTable): ResolvedDBTable { + return table as ResolvedDBTable; +} diff --git a/packages/db/test/unit/column-queries.test.js b/packages/db/test/unit/column-queries.test.js deleted file mode 100644 index 8f1518ca37eb..000000000000 --- a/packages/db/test/unit/column-queries.test.js +++ /dev/null @@ -1,496 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - getMigrationQueries, - getTableChangeQueries, -} from '../../dist/core/cli/migration-queries.js'; -import { MIGRATION_VERSION } from '../../dist/core/consts.js'; -import { tableSchema } from '../../dist/core/schemas.js'; -import { column, defineTable, NOW } from '../../dist/runtime/virtual.js'; - -const TABLE_NAME = 'Users'; - -// `parse` to resolve schema transformations -// ex. convert column.date() to ISO strings -const userInitial = tableSchema.parse( - defineTable({ - columns: { - name: column.text(), - age: column.number(), - email: column.text({ unique: true }), - mi: column.text({ optional: true }), - }, - }), -); - -function userChangeQueries(oldTable, newTable) { - return getTableChangeQueries({ - tableName: TABLE_NAME, - oldTable, - newTable, - }); -} - -function configChangeQueries(oldTables, newTables) { - return getMigrationQueries({ - oldSnapshot: { schema: oldTables, version: MIGRATION_VERSION }, - newSnapshot: { schema: newTables, version: MIGRATION_VERSION }, - }); -} - -describe('column queries', () => { - describe('getMigrationQueries', () => { - it('should be empty when tables are the same', async () => { - const oldTables = { [TABLE_NAME]: userInitial }; - const newTables = { [TABLE_NAME]: userInitial }; - const { queries } = await configChangeQueries(oldTables, newTables); - assert.deepEqual(queries, []); - }); - - it('should create table for new tables', async () => { - const oldTables = {}; - const newTables = { [TABLE_NAME]: userInitial }; - const { queries } = await configChangeQueries(oldTables, newTables); - assert.deepEqual(queries, [ - `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, - ]); - }); - - it('should drop table for removed tables', async () => { - const oldTables = { [TABLE_NAME]: userInitial }; - const newTables = {}; - const { queries } = await configChangeQueries(oldTables, newTables); - assert.deepEqual(queries, [`DROP TABLE "${TABLE_NAME}"`]); - }); - - it('should error if possible table rename is detected', async () => { - const rename = 'Peeps'; - const oldTables = { [TABLE_NAME]: userInitial }; - const newTables = { [rename]: userInitial }; - let error = null; - try { - await configChangeQueries(oldTables, newTables); - } catch (e) { - error = e.message; - } - assert.match(error, /Potential table rename detected/); - }); - - it('should error if possible column rename is detected', async () => { - const blogInitial = tableSchema.parse({ - columns: { - title: column.text(), - }, - }); - const blogFinal = tableSchema.parse({ - columns: { - title2: column.text(), - }, - }); - let error = null; - try { - await configChangeQueries({ [TABLE_NAME]: blogInitial }, { [TABLE_NAME]: blogFinal }); - } catch (e) { - error = e.message; - } - assert.match(error, /Potential column rename detected/); - }); - }); - - describe('getTableChangeQueries', () => { - it('should be empty when tables are the same', async () => { - const { queries } = await userChangeQueries(userInitial, userInitial); - assert.deepEqual(queries, []); - }); - - it('should return warning if column type change introduces data loss', async () => { - const blogInitial = tableSchema.parse({ - ...userInitial, - columns: { - date: column.text(), - }, - }); - const blogFinal = tableSchema.parse({ - ...userInitial, - columns: { - date: column.date(), - }, - }); - const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal); - assert.deepEqual(queries, [ - 'DROP TABLE "Users"', - 'CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "date" text NOT NULL)', - ]); - assert.equal(confirmations.length, 1); - }); - - it('should return warning if new required column added', async () => { - const blogInitial = tableSchema.parse({ - ...userInitial, - columns: {}, - }); - const blogFinal = tableSchema.parse({ - ...userInitial, - columns: { - date: column.date({ optional: false }), - }, - }); - const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal); - assert.deepEqual(queries, [ - 'DROP TABLE "Users"', - 'CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "date" text NOT NULL)', - ]); - assert.equal(confirmations.length, 1); - }); - - it('should return warning if non-number primary key with no default added', async () => { - const blogInitial = tableSchema.parse({ - ...userInitial, - columns: {}, - }); - const blogFinal = tableSchema.parse({ - ...userInitial, - columns: { - id: column.text({ primaryKey: true }), - }, - }); - const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal); - assert.deepEqual(queries, [ - 'DROP TABLE "Users"', - 'CREATE TABLE "Users" ("id" text PRIMARY KEY)', - ]); - assert.equal(confirmations.length, 1); - }); - - it('should be empty when type updated to same underlying SQL type', async () => { - const blogInitial = tableSchema.parse({ - ...userInitial, - columns: { - title: column.text(), - draft: column.boolean(), - }, - }); - const blogFinal = tableSchema.parse({ - ...userInitial, - columns: { - ...blogInitial.columns, - draft: column.number(), - }, - }); - const { queries } = await userChangeQueries(blogInitial, blogFinal); - assert.deepEqual(queries, []); - }); - - it('should respect user primary key without adding a hidden id', async () => { - const user = tableSchema.parse({ - ...userInitial, - columns: { - ...userInitial.columns, - id: column.number({ primaryKey: true }), - }, - }); - - const userFinal = tableSchema.parse({ - ...user, - columns: { - ...user.columns, - name: column.text({ unique: true, optional: true }), - }, - }); - - const { queries } = await userChangeQueries(user, userFinal); - assert.equal(queries[0] !== undefined, true); - const tempTableName = getTempTableName(queries[0]); - - assert.deepEqual(queries, [ - `CREATE TABLE \"${tempTableName}\" (\"name\" text UNIQUE, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text, \"id\" integer PRIMARY KEY)`, - `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\", \"id\") SELECT \"name\", \"age\", \"email\", \"mi\", \"id\" FROM \"Users\"`, - 'DROP TABLE "Users"', - `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, - ]); - }); - - describe('Lossy table recreate', () => { - it('when changing a column type', async () => { - const userFinal = { - ...userInitial, - columns: { - ...userInitial.columns, - age: column.text(), - }, - }; - - const { queries } = await userChangeQueries(userInitial, userFinal); - - assert.deepEqual(queries, [ - 'DROP TABLE "Users"', - `CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, - ]); - }); - - it('when adding a required column without a default', async () => { - const userFinal = { - ...userInitial, - columns: { - ...userInitial.columns, - phoneNumber: column.text(), - }, - }; - - const { queries } = await userChangeQueries(userInitial, userFinal); - - assert.deepEqual(queries, [ - 'DROP TABLE "Users"', - `CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text NOT NULL)`, - ]); - }); - }); - - describe('Lossless table recreate', () => { - it('when adding a primary key', async () => { - const userFinal = { - ...userInitial, - columns: { - ...userInitial.columns, - id: column.number({ primaryKey: true }), - }, - }; - - const { queries } = await userChangeQueries(userInitial, userFinal); - assert.equal(queries[0] !== undefined, true); - - const tempTableName = getTempTableName(queries[0]); - assert.deepEqual(queries, [ - `CREATE TABLE \"${tempTableName}\" (\"name\" text NOT NULL, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text, \"id\" integer PRIMARY KEY)`, - `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\") SELECT \"name\", \"age\", \"email\", \"mi\" FROM \"Users\"`, - 'DROP TABLE "Users"', - `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, - ]); - }); - - it('when dropping a primary key', async () => { - const user = { - ...userInitial, - columns: { - ...userInitial.columns, - id: column.number({ primaryKey: true }), - }, - }; - - const { queries } = await userChangeQueries(user, userInitial); - assert.equal(queries[0] !== undefined, true); - - const tempTableName = getTempTableName(queries[0]); - assert.deepEqual(queries, [ - `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"name\" text NOT NULL, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text)`, - `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\") SELECT \"name\", \"age\", \"email\", \"mi\" FROM \"Users\"`, - 'DROP TABLE "Users"', - `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, - ]); - }); - - it('when adding an optional unique column', async () => { - const userFinal = { - ...userInitial, - columns: { - ...userInitial.columns, - phoneNumber: column.text({ unique: true, optional: true }), - }, - }; - - const { queries } = await userChangeQueries(userInitial, userFinal); - assert.equal(queries.length, 4); - - const tempTableName = getTempTableName(queries[0]); - assert.equal(typeof tempTableName, 'string'); - assert.deepEqual(queries, [ - `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text UNIQUE)`, - `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, - 'DROP TABLE "Users"', - `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, - ]); - }); - - it('when dropping unique column', async () => { - const userFinal = { - ...userInitial, - columns: { - ...userInitial.columns, - }, - }; - delete userFinal.columns.email; - - const { queries } = await userChangeQueries(userInitial, userFinal); - assert.equal(queries.length, 4); - assert.equal(queries.length, 4); - - const tempTableName = getTempTableName(queries[0]); - assert.equal(typeof tempTableName, 'string'); - assert.deepEqual(queries, [ - `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "mi" text)`, - `INSERT INTO "${tempTableName}" ("_id", "name", "age", "mi") SELECT "_id", "name", "age", "mi" FROM "Users"`, - 'DROP TABLE "Users"', - `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, - ]); - }); - - it('when updating to a runtime default', async () => { - const initial = tableSchema.parse({ - ...userInitial, - columns: { - ...userInitial.columns, - age: column.date(), - }, - }); - - const userFinal = tableSchema.parse({ - ...initial, - columns: { - ...initial.columns, - age: column.date({ default: NOW }), - }, - }); - - const { queries } = await userChangeQueries(initial, userFinal); - assert.equal(queries.length, 4); - - const tempTableName = getTempTableName(queries[0]); - assert.equal(typeof tempTableName, 'string'); - assert.deepEqual(queries, [ - `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL DEFAULT CURRENT_TIMESTAMP, "email" text NOT NULL UNIQUE, "mi" text)`, - `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, - 'DROP TABLE "Users"', - `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, - ]); - }); - - it('when adding a column with a runtime default', async () => { - const userFinal = tableSchema.parse({ - ...userInitial, - columns: { - ...userInitial.columns, - birthday: column.date({ default: NOW }), - }, - }); - - const { queries } = await userChangeQueries(userInitial, userFinal); - assert.equal(queries.length, 4); - - const tempTableName = getTempTableName(queries[0]); - assert.equal(typeof tempTableName, 'string'); - assert.deepEqual(queries, [ - `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "birthday" text NOT NULL DEFAULT CURRENT_TIMESTAMP)`, - `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, - 'DROP TABLE "Users"', - `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, - ]); - }); - - /** - * REASON: to follow the "expand" and "contract" migration model, - * you'll need to update the schema from NOT NULL to NULL. - * It's up to the user to ensure all data follows the new schema! - * - * @see https://planetscale.com/blog/safely-making-database-schema-changes#backwards-compatible-changes - */ - it('when changing a column to required', async () => { - const userFinal = { - ...userInitial, - columns: { - ...userInitial.columns, - mi: column.text(), - }, - }; - - const { queries } = await userChangeQueries(userInitial, userFinal); - - assert.equal(queries.length, 4); - - const tempTableName = getTempTableName(queries[0]); - assert.equal(typeof tempTableName, 'string'); - assert.deepEqual(queries, [ - `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text NOT NULL)`, - `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, - 'DROP TABLE "Users"', - `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, - ]); - }); - - it('when changing a column to unique', async () => { - const userFinal = { - ...userInitial, - columns: { - ...userInitial.columns, - age: column.number({ unique: true }), - }, - }; - - const { queries } = await userChangeQueries(userInitial, userFinal); - assert.equal(queries.length, 4); - - const tempTableName = getTempTableName(queries[0]); - assert.equal(typeof tempTableName, 'string'); - assert.deepEqual(queries, [ - `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL UNIQUE, "email" text NOT NULL UNIQUE, "mi" text)`, - `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, - 'DROP TABLE "Users"', - `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, - ]); - }); - }); - - describe('ALTER ADD COLUMN', () => { - it('when adding an optional column', async () => { - const userFinal = { - ...userInitial, - columns: { - ...userInitial.columns, - birthday: column.date({ optional: true }), - }, - }; - - const { queries } = await userChangeQueries(userInitial, userFinal); - assert.deepEqual(queries, ['ALTER TABLE "Users" ADD COLUMN "birthday" text']); - }); - - it('when adding a required column with default', async () => { - const defaultDate = new Date('2023-01-01'); - const userFinal = tableSchema.parse({ - ...userInitial, - columns: { - ...userInitial.columns, - birthday: column.date({ default: new Date('2023-01-01') }), - }, - }); - - const { queries } = await userChangeQueries(userInitial, userFinal); - assert.deepEqual(queries, [ - `ALTER TABLE "Users" ADD COLUMN "birthday" text NOT NULL DEFAULT '${defaultDate.toISOString()}'`, - ]); - }); - }); - - describe('ALTER DROP COLUMN', () => { - it('when removing optional or required columns', async () => { - const userFinal = { - ...userInitial, - columns: { - name: userInitial.columns.name, - email: userInitial.columns.email, - }, - }; - - const { queries } = await userChangeQueries(userInitial, userFinal); - assert.deepEqual(queries, [ - 'ALTER TABLE "Users" DROP COLUMN "age"', - 'ALTER TABLE "Users" DROP COLUMN "mi"', - ]); - }); - }); - }); -}); - -/** @param {string} query */ -function getTempTableName(query) { - return /Users_[a-z\d]+/.exec(query)?.[0]; -} diff --git a/packages/db/test/unit/column-queries.test.ts b/packages/db/test/unit/column-queries.test.ts new file mode 100644 index 000000000000..14e6d9177b3d --- /dev/null +++ b/packages/db/test/unit/column-queries.test.ts @@ -0,0 +1,509 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + getMigrationQueries, + getTableChangeQueries, +} from '../../dist/core/cli/migration-queries.js'; +import { MIGRATION_VERSION } from '../../dist/core/consts.js'; +import { tableSchema } from '../../dist/core/schemas.js'; +import type { DBTable, ResolvedDBTable } from '../../dist/core/types.js'; +import { column, defineTable, NOW } from '../../dist/runtime/virtual.js'; +import { asResolved } from '../test-utils.ts'; + +const TABLE_NAME = 'Users'; + +// `parse` to resolve schema transformations +// ex. convert column.date() to ISO strings +const userInitial = tableSchema.parse( + defineTable({ + columns: { + name: column.text(), + age: column.number(), + email: column.text({ unique: true }), + mi: column.text({ optional: true }), + }, + }), +); + +function userChangeQueries(oldTable: DBTable, newTable: DBTable) { + return getTableChangeQueries({ + tableName: TABLE_NAME, + oldTable: asResolved(oldTable), + newTable: asResolved(newTable), + }); +} + +function configChangeQueries( + oldTables: Record, + newTables: Record, +) { + return getMigrationQueries({ + oldSnapshot: { + schema: oldTables, + version: MIGRATION_VERSION, + }, + newSnapshot: { + schema: newTables, + version: MIGRATION_VERSION, + }, + }); +} + +describe('column queries', () => { + describe('getMigrationQueries', () => { + it('should be empty when tables are the same', async () => { + const oldTables = { [TABLE_NAME]: asResolved(userInitial) }; + const newTables = { [TABLE_NAME]: asResolved(userInitial) }; + const { queries } = await configChangeQueries(oldTables, newTables); + assert.deepEqual(queries, []); + }); + + it('should create table for new tables', async () => { + const oldTables = {}; + const newTables = { [TABLE_NAME]: asResolved(userInitial) }; + const { queries } = await configChangeQueries(oldTables, newTables); + assert.deepEqual(queries, [ + `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, + ]); + }); + + it('should drop table for removed tables', async () => { + const oldTables = { [TABLE_NAME]: asResolved(userInitial) }; + const newTables = {}; + const { queries } = await configChangeQueries(oldTables, newTables); + assert.deepEqual(queries, [`DROP TABLE "${TABLE_NAME}"`]); + }); + + it('should error if possible table rename is detected', async () => { + const rename = 'Peeps'; + const oldTables = { [TABLE_NAME]: asResolved(userInitial) }; + const newTables = { [rename]: asResolved(userInitial) }; + let error: string | null = null; + try { + await configChangeQueries(oldTables, newTables); + } catch (e) { + error = (e as Error).message; + } + assert.match(error!, /Potential table rename detected/); + }); + + it('should error if possible column rename is detected', async () => { + const blogInitial = tableSchema.parse({ + columns: { + title: column.text(), + }, + }); + const blogFinal = tableSchema.parse({ + columns: { + title2: column.text(), + }, + }); + let error: string | null = null; + try { + await configChangeQueries( + { [TABLE_NAME]: asResolved(blogInitial) }, + { [TABLE_NAME]: asResolved(blogFinal) }, + ); + } catch (e) { + error = (e as Error).message; + } + assert.match(error!, /Potential column rename detected/); + }); + }); + + describe('getTableChangeQueries', () => { + it('should be empty when tables are the same', async () => { + const { queries } = await userChangeQueries(userInitial, userInitial); + assert.deepEqual(queries, []); + }); + + it('should return warning if column type change introduces data loss', async () => { + const blogInitial = tableSchema.parse({ + ...userInitial, + columns: { + date: column.text(), + }, + }); + const blogFinal = tableSchema.parse({ + ...userInitial, + columns: { + date: column.date(), + }, + }); + const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal); + assert.deepEqual(queries, [ + 'DROP TABLE "Users"', + 'CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "date" text NOT NULL)', + ]); + assert.equal(confirmations.length, 1); + }); + + it('should return warning if new required column added', async () => { + const blogInitial = tableSchema.parse({ + ...userInitial, + columns: {}, + }); + const blogFinal = tableSchema.parse({ + ...userInitial, + columns: { + date: column.date({ optional: false }), + }, + }); + const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal); + assert.deepEqual(queries, [ + 'DROP TABLE "Users"', + 'CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "date" text NOT NULL)', + ]); + assert.equal(confirmations.length, 1); + }); + + it('should return warning if non-number primary key with no default added', async () => { + const blogInitial = tableSchema.parse({ + ...userInitial, + columns: {}, + }); + const blogFinal = tableSchema.parse({ + ...userInitial, + columns: { + id: column.text({ primaryKey: true }), + }, + }); + const { queries, confirmations } = await userChangeQueries(blogInitial, blogFinal); + assert.deepEqual(queries, [ + 'DROP TABLE "Users"', + 'CREATE TABLE "Users" ("id" text PRIMARY KEY)', + ]); + assert.equal(confirmations.length, 1); + }); + + it('should be empty when type updated to same underlying SQL type', async () => { + const blogInitial = tableSchema.parse({ + ...userInitial, + columns: { + title: column.text(), + draft: column.boolean(), + }, + }); + const blogFinal = tableSchema.parse({ + ...userInitial, + columns: { + ...blogInitial.columns, + draft: column.number(), + }, + }); + const { queries } = await userChangeQueries(blogInitial, blogFinal); + assert.deepEqual(queries, []); + }); + + it('should respect user primary key without adding a hidden id', async () => { + const user = tableSchema.parse({ + ...userInitial, + columns: { + ...userInitial.columns, + id: column.number({ primaryKey: true }), + }, + }); + + const userFinal = tableSchema.parse({ + ...user, + columns: { + ...user.columns, + name: column.text({ unique: true, optional: true }), + }, + }); + + const { queries } = await userChangeQueries(user, userFinal); + assert.equal(queries[0] !== undefined, true); + const tempTableName = getTempTableName(queries[0]); + + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (\"name\" text UNIQUE, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text, \"id\" integer PRIMARY KEY)`, + `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\", \"id\") SELECT \"name\", \"age\", \"email\", \"mi\", \"id\" FROM \"Users\"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + describe('Lossy table recreate', () => { + it('when changing a column type', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + age: column.text(), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + + assert.deepEqual(queries, [ + 'DROP TABLE "Users"', + `CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, + ]); + }); + + it('when adding a required column without a default', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + phoneNumber: column.text(), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + + assert.deepEqual(queries, [ + 'DROP TABLE "Users"', + `CREATE TABLE "Users" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text NOT NULL)`, + ]); + }); + }); + + describe('Lossless table recreate', () => { + it('when adding a primary key', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + id: column.number({ primaryKey: true }), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.equal(queries[0] !== undefined, true); + + const tempTableName = getTempTableName(queries[0]); + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (\"name\" text NOT NULL, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text, \"id\" integer PRIMARY KEY)`, + `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\") SELECT \"name\", \"age\", \"email\", \"mi\" FROM \"Users\"`, + 'DROP TABLE "Users"', + `ALTER TABLE \"${tempTableName}\" RENAME TO \"Users\"`, + ]); + }); + + it('when dropping a primary key', async () => { + const user = { + ...userInitial, + columns: { + ...userInitial.columns, + id: column.number({ primaryKey: true }), + }, + }; + + const { queries } = await userChangeQueries(user, userInitial); + assert.equal(queries[0] !== undefined, true); + + const tempTableName = getTempTableName(queries[0]); + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"name\" text NOT NULL, \"age\" integer NOT NULL, \"email\" text NOT NULL UNIQUE, \"mi\" text)`, + `INSERT INTO \"${tempTableName}\" (\"name\", \"age\", \"email\", \"mi\") SELECT \"name\", \"age\", \"email\", \"mi\" FROM \"Users\"`, + 'DROP TABLE "Users"', + `ALTER TABLE \"${tempTableName}\" RENAME TO \"Users\"`, + ]); + }); + + it('when adding an optional unique column', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + phoneNumber: column.text({ unique: true, optional: true }), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text UNIQUE)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when dropping unique column', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + }, + }; + delete userFinal.columns.email; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.equal(queries.length, 4); + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "mi" text)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "mi") SELECT "_id", "name", "age", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when updating to a runtime default', async () => { + const initial = tableSchema.parse({ + ...userInitial, + columns: { + ...userInitial.columns, + age: column.date(), + }, + }); + + const userFinal = tableSchema.parse({ + ...initial, + columns: { + ...initial.columns, + age: column.date({ default: NOW }), + }, + }); + + const { queries } = await userChangeQueries(initial, userFinal); + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL DEFAULT CURRENT_TIMESTAMP, "email" text NOT NULL UNIQUE, "mi" text)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when adding a column with a runtime default', async () => { + const userFinal = tableSchema.parse({ + ...userInitial, + columns: { + ...userInitial.columns, + birthday: column.date({ default: NOW }), + }, + }); + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "birthday" text NOT NULL DEFAULT CURRENT_TIMESTAMP)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + /** + * REASON: to follow the "expand" and "contract" migration model, + * you'll need to update the schema from NOT NULL to NULL. + * It's up to the user to ensure all data follows the new schema! + * + * @see https://planetscale.com/blog/safely-making-database-schema-changes#backwards-compatible-changes + */ + it('when changing a column to required', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + mi: column.text(), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text NOT NULL)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + + it('when changing a column to unique', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + age: column.number({ unique: true }), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.equal(queries.length, 4); + + const tempTableName = getTempTableName(queries[0]); + assert.equal(typeof tempTableName, 'string'); + assert.deepEqual(queries, [ + `CREATE TABLE "${tempTableName}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL UNIQUE, "email" text NOT NULL UNIQUE, "mi" text)`, + `INSERT INTO "${tempTableName}" ("_id", "name", "age", "email", "mi") SELECT "_id", "name", "age", "email", "mi" FROM "Users"`, + 'DROP TABLE "Users"', + `ALTER TABLE "${tempTableName}" RENAME TO "Users"`, + ]); + }); + }); + + describe('ALTER ADD COLUMN', () => { + it('when adding an optional column', async () => { + const userFinal = { + ...userInitial, + columns: { + ...userInitial.columns, + birthday: column.date({ optional: true }), + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.deepEqual(queries, ['ALTER TABLE "Users" ADD COLUMN "birthday" text']); + }); + + it('when adding a required column with default', async () => { + const defaultDate = new Date('2023-01-01'); + const userFinal = tableSchema.parse({ + ...userInitial, + columns: { + ...userInitial.columns, + birthday: column.date({ default: new Date('2023-01-01') }), + }, + }); + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.deepEqual(queries, [ + `ALTER TABLE "Users" ADD COLUMN "birthday" text NOT NULL DEFAULT '${defaultDate.toISOString()}'`, + ]); + }); + }); + + describe('ALTER DROP COLUMN', () => { + it('when removing optional or required columns', async () => { + const userFinal = { + ...userInitial, + columns: { + name: userInitial.columns.name, + email: userInitial.columns.email, + }, + }; + + const { queries } = await userChangeQueries(userInitial, userFinal); + assert.deepEqual(queries, [ + 'ALTER TABLE "Users" DROP COLUMN "age"', + 'ALTER TABLE "Users" DROP COLUMN "mi"', + ]); + }); + }); + }); +}); + +function getTempTableName(query: string) { + return /Users_[a-z\d]+/.exec(query)?.[0]; +} diff --git a/packages/db/test/unit/db-client.test.js b/packages/db/test/unit/db-client.test.ts similarity index 100% rename from packages/db/test/unit/db-client.test.js rename to packages/db/test/unit/db-client.test.ts diff --git a/packages/db/test/unit/index-queries.test.js b/packages/db/test/unit/index-queries.test.js deleted file mode 100644 index 4b4722baa7e9..000000000000 --- a/packages/db/test/unit/index-queries.test.js +++ /dev/null @@ -1,283 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { getTableChangeQueries } from '../../dist/core/cli/migration-queries.js'; -import { dbConfigSchema, tableSchema } from '../../dist/core/schemas.js'; -import { column } from '../../dist/runtime/virtual.js'; - -const userInitial = tableSchema.parse({ - columns: { - name: column.text(), - age: column.number(), - email: column.text({ unique: true }), - mi: column.text({ optional: true }), - }, - indexes: {}, - writable: false, -}); - -describe('index queries', () => { - it('generates index names by table and combined column names', async () => { - // Use dbConfigSchema.parse to resolve generated idx names - const dbConfig = dbConfigSchema.parse({ - tables: { - oldTable: userInitial, - newTable: { - ...userInitial, - indexes: [ - { on: ['name', 'age'], unique: false }, - { on: ['email'], unique: true }, - ], - }, - }, - }); - - const { queries } = await getTableChangeQueries({ - tableName: 'user', - oldTable: dbConfig.tables.oldTable, - newTable: dbConfig.tables.newTable, - }); - - assert.deepEqual(queries, [ - 'CREATE INDEX "newTable_age_name_idx" ON "user" ("age", "name")', - 'CREATE UNIQUE INDEX "newTable_email_idx" ON "user" ("email")', - ]); - }); - - it('generates index names with consistent column ordering', async () => { - const initial = dbConfigSchema.parse({ - tables: { - user: { - ...userInitial, - indexes: [ - { on: ['email'], unique: true }, - { on: ['name', 'age'], unique: false }, - ], - }, - }, - }); - - const final = dbConfigSchema.parse({ - tables: { - user: { - ...userInitial, - indexes: [ - // flip columns - { on: ['age', 'name'], unique: false }, - // flip index order - { on: ['email'], unique: true }, - ], - }, - }, - }); - - const { queries } = await getTableChangeQueries({ - tableName: 'user', - oldTable: initial.tables.user, - newTable: final.tables.user, - }); - - assert.equal(queries.length, 0); - }); - - it('does not trigger queries when changing from legacy to new format', async () => { - const initial = dbConfigSchema.parse({ - tables: { - user: { - ...userInitial, - indexes: { - emailIdx: { on: ['email'], unique: true }, - nameAgeIdx: { on: ['name', 'age'], unique: false }, - }, - }, - }, - }); - - const final = dbConfigSchema.parse({ - tables: { - user: { - ...userInitial, - indexes: [ - { on: ['email'], unique: true, name: 'emailIdx' }, - { on: ['name', 'age'], unique: false, name: 'nameAgeIdx' }, - ], - }, - }, - }); - - const { queries } = await getTableChangeQueries({ - tableName: 'user', - oldTable: initial.tables.user, - newTable: final.tables.user, - }); - - assert.equal(queries.length, 0); - }); - - it('adds indexes', async () => { - const dbConfig = dbConfigSchema.parse({ - tables: { - oldTable: userInitial, - newTable: { - ...userInitial, - indexes: [ - { on: ['name'], unique: false, name: 'nameIdx' }, - { on: ['email'], unique: true, name: 'emailIdx' }, - ], - }, - }, - }); - - const { queries } = await getTableChangeQueries({ - tableName: 'user', - oldTable: dbConfig.tables.oldTable, - newTable: dbConfig.tables.newTable, - }); - - assert.deepEqual(queries, [ - 'CREATE INDEX "nameIdx" ON "user" ("name")', - 'CREATE UNIQUE INDEX "emailIdx" ON "user" ("email")', - ]); - }); - - it('drops indexes', async () => { - const dbConfig = dbConfigSchema.parse({ - tables: { - oldTable: { - ...userInitial, - indexes: [ - { on: ['name'], unique: false, name: 'nameIdx' }, - { on: ['email'], unique: true, name: 'emailIdx' }, - ], - }, - newTable: { - ...userInitial, - indexes: {}, - }, - }, - }); - - const { queries } = await getTableChangeQueries({ - tableName: 'user', - oldTable: dbConfig.tables.oldTable, - newTable: dbConfig.tables.newTable, - }); - - assert.deepEqual(queries, ['DROP INDEX "nameIdx"', 'DROP INDEX "emailIdx"']); - }); - - it('drops and recreates modified indexes', async () => { - const dbConfig = dbConfigSchema.parse({ - tables: { - oldTable: { - ...userInitial, - indexes: [ - { unique: false, on: ['name'], name: 'nameIdx' }, - { unique: true, on: ['email'], name: 'emailIdx' }, - ], - }, - newTable: { - ...userInitial, - indexes: [ - { unique: true, on: ['name'], name: 'nameIdx' }, - { on: ['email'], name: 'emailIdx' }, - ], - }, - }, - }); - - const { queries } = await getTableChangeQueries({ - tableName: 'user', - oldTable: dbConfig.tables.oldTable, - newTable: dbConfig.tables.newTable, - }); - - assert.deepEqual(queries, [ - 'DROP INDEX "nameIdx"', - 'DROP INDEX "emailIdx"', - 'CREATE UNIQUE INDEX "nameIdx" ON "user" ("name")', - 'CREATE INDEX "emailIdx" ON "user" ("email")', - ]); - }); - - describe('legacy object config', () => { - it('adds indexes', async () => { - /** @type {import('../../dist/core/types.js').DBTable} */ - const userFinal = { - ...userInitial, - indexes: { - nameIdx: { on: ['name'], unique: false }, - emailIdx: { on: ['email'], unique: true }, - }, - }; - - const { queries } = await getTableChangeQueries({ - tableName: 'user', - oldTable: userInitial, - newTable: userFinal, - }); - - assert.deepEqual(queries, [ - 'CREATE INDEX "nameIdx" ON "user" ("name")', - 'CREATE UNIQUE INDEX "emailIdx" ON "user" ("email")', - ]); - }); - - it('drops indexes', async () => { - /** @type {import('../../dist/core/types.js').DBTable} */ - const initial = { - ...userInitial, - indexes: { - nameIdx: { on: ['name'], unique: false }, - emailIdx: { on: ['email'], unique: true }, - }, - }; - - /** @type {import('../../dist/core/types.js').DBTable} */ - const final = { - ...userInitial, - indexes: {}, - }; - - const { queries } = await getTableChangeQueries({ - tableName: 'user', - oldTable: initial, - newTable: final, - }); - - assert.deepEqual(queries, ['DROP INDEX "nameIdx"', 'DROP INDEX "emailIdx"']); - }); - - it('drops and recreates modified indexes', async () => { - /** @type {import('../../dist/core/types.js').DBTable} */ - const initial = { - ...userInitial, - indexes: { - nameIdx: { on: ['name'], unique: false }, - emailIdx: { on: ['email'], unique: true }, - }, - }; - - /** @type {import('../../dist/core/types.js').DBTable} */ - const final = { - ...userInitial, - indexes: { - nameIdx: { on: ['name'], unique: true }, - emailIdx: { on: ['email'] }, - }, - }; - - const { queries } = await getTableChangeQueries({ - tableName: 'user', - oldTable: initial, - newTable: final, - }); - - assert.deepEqual(queries, [ - 'DROP INDEX "nameIdx"', - 'DROP INDEX "emailIdx"', - 'CREATE UNIQUE INDEX "nameIdx" ON "user" ("name")', - 'CREATE INDEX "emailIdx" ON "user" ("email")', - ]); - }); - }); -}); diff --git a/packages/db/test/unit/index-queries.test.ts b/packages/db/test/unit/index-queries.test.ts new file mode 100644 index 000000000000..f9179f09e6ab --- /dev/null +++ b/packages/db/test/unit/index-queries.test.ts @@ -0,0 +1,280 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getTableChangeQueries } from '../../dist/core/cli/migration-queries.js'; +import { dbConfigSchema, tableSchema } from '../../dist/core/schemas.js'; +import type { DBTable } from '../../dist/core/types.js'; +import { column } from '../../dist/runtime/virtual.js'; +import { asResolved } from '../test-utils.ts'; + +const userInitial = tableSchema.parse({ + columns: { + name: column.text(), + age: column.number(), + email: column.text({ unique: true }), + mi: column.text({ optional: true }), + }, + indexes: {}, + writable: false, +}); + +describe('index queries', () => { + it('generates index names by table and combined column names', async () => { + // Use dbConfigSchema.parse to resolve generated idx names + const dbConfig = dbConfigSchema.parse({ + tables: { + oldTable: userInitial, + newTable: { + ...userInitial, + indexes: [ + { on: ['name', 'age'], unique: false }, + { on: ['email'], unique: true }, + ], + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: dbConfig.tables.oldTable, + newTable: dbConfig.tables.newTable, + }); + + assert.deepEqual(queries, [ + 'CREATE INDEX "newTable_age_name_idx" ON "user" ("age", "name")', + 'CREATE UNIQUE INDEX "newTable_email_idx" ON "user" ("email")', + ]); + }); + + it('generates index names with consistent column ordering', async () => { + const initial = dbConfigSchema.parse({ + tables: { + user: { + ...userInitial, + indexes: [ + { on: ['email'], unique: true }, + { on: ['name', 'age'], unique: false }, + ], + }, + }, + }); + + const final = dbConfigSchema.parse({ + tables: { + user: { + ...userInitial, + indexes: [ + // flip columns + { on: ['age', 'name'], unique: false }, + // flip index order + { on: ['email'], unique: true }, + ], + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: initial.tables.user, + newTable: final.tables.user, + }); + + assert.equal(queries.length, 0); + }); + + it('does not trigger queries when changing from legacy to new format', async () => { + const initial = dbConfigSchema.parse({ + tables: { + user: { + ...userInitial, + indexes: { + emailIdx: { on: ['email'], unique: true }, + nameAgeIdx: { on: ['name', 'age'], unique: false }, + }, + }, + }, + }); + + const final = dbConfigSchema.parse({ + tables: { + user: { + ...userInitial, + indexes: [ + { on: ['email'], unique: true, name: 'emailIdx' }, + { on: ['name', 'age'], unique: false, name: 'nameAgeIdx' }, + ], + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: initial.tables.user, + newTable: final.tables.user, + }); + + assert.equal(queries.length, 0); + }); + + it('adds indexes', async () => { + const dbConfig = dbConfigSchema.parse({ + tables: { + oldTable: userInitial, + newTable: { + ...userInitial, + indexes: [ + { on: ['name'], unique: false, name: 'nameIdx' }, + { on: ['email'], unique: true, name: 'emailIdx' }, + ], + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: dbConfig.tables.oldTable, + newTable: dbConfig.tables.newTable, + }); + + assert.deepEqual(queries, [ + 'CREATE INDEX "nameIdx" ON "user" ("name")', + 'CREATE UNIQUE INDEX "emailIdx" ON "user" ("email")', + ]); + }); + + it('drops indexes', async () => { + const dbConfig = dbConfigSchema.parse({ + tables: { + oldTable: { + ...userInitial, + indexes: [ + { on: ['name'], unique: false, name: 'nameIdx' }, + { on: ['email'], unique: true, name: 'emailIdx' }, + ], + }, + newTable: { + ...userInitial, + indexes: {}, + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: dbConfig.tables.oldTable, + newTable: dbConfig.tables.newTable, + }); + + assert.deepEqual(queries, ['DROP INDEX "nameIdx"', 'DROP INDEX "emailIdx"']); + }); + + it('drops and recreates modified indexes', async () => { + const dbConfig = dbConfigSchema.parse({ + tables: { + oldTable: { + ...userInitial, + indexes: [ + { unique: false, on: ['name'], name: 'nameIdx' }, + { unique: true, on: ['email'], name: 'emailIdx' }, + ], + }, + newTable: { + ...userInitial, + indexes: [ + { unique: true, on: ['name'], name: 'nameIdx' }, + { on: ['email'], name: 'emailIdx' }, + ], + }, + }, + }); + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: dbConfig.tables.oldTable, + newTable: dbConfig.tables.newTable, + }); + + assert.deepEqual(queries, [ + 'DROP INDEX "nameIdx"', + 'DROP INDEX "emailIdx"', + 'CREATE UNIQUE INDEX "nameIdx" ON "user" ("name")', + 'CREATE INDEX "emailIdx" ON "user" ("email")', + ]); + }); + + describe('legacy object config', () => { + it('adds indexes', async () => { + const userFinal: DBTable = { + ...userInitial, + indexes: { + nameIdx: { on: ['name'], unique: false }, + emailIdx: { on: ['email'], unique: true }, + }, + }; + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: asResolved(userInitial), + newTable: asResolved(userFinal), + }); + + assert.deepEqual(queries, [ + 'CREATE INDEX "nameIdx" ON "user" ("name")', + 'CREATE UNIQUE INDEX "emailIdx" ON "user" ("email")', + ]); + }); + + it('drops indexes', async () => { + const initial: DBTable = { + ...userInitial, + indexes: { + nameIdx: { on: ['name'], unique: false }, + emailIdx: { on: ['email'], unique: true }, + }, + }; + + const final: DBTable = { + ...userInitial, + indexes: {}, + }; + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: asResolved(initial), + newTable: asResolved(final), + }); + + assert.deepEqual(queries, ['DROP INDEX "nameIdx"', 'DROP INDEX "emailIdx"']); + }); + + it('drops and recreates modified indexes', async () => { + const initial: DBTable = { + ...userInitial, + indexes: { + nameIdx: { on: ['name'], unique: false }, + emailIdx: { on: ['email'], unique: true }, + }, + }; + + const final: DBTable = { + ...userInitial, + indexes: { + nameIdx: { on: ['name'], unique: true }, + emailIdx: { on: ['email'] }, + }, + }; + + const { queries } = await getTableChangeQueries({ + tableName: 'user', + oldTable: asResolved(initial), + newTable: asResolved(final), + }); + + assert.deepEqual(queries, [ + 'DROP INDEX "nameIdx"', + 'DROP INDEX "emailIdx"', + 'CREATE UNIQUE INDEX "nameIdx" ON "user" ("name")', + 'CREATE INDEX "emailIdx" ON "user" ("email")', + ]); + }); + }); +}); diff --git a/packages/db/test/unit/reference-queries.test.js b/packages/db/test/unit/reference-queries.test.js deleted file mode 100644 index 04f5f84aaba1..000000000000 --- a/packages/db/test/unit/reference-queries.test.js +++ /dev/null @@ -1,169 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { getTableChangeQueries } from '../../dist/core/cli/migration-queries.js'; -import { tablesSchema } from '../../dist/core/schemas.js'; -import { column, defineTable } from '../../dist/runtime/virtual.js'; - -const BaseUser = defineTable({ - columns: { - id: column.number({ primaryKey: true }), - name: column.text(), - age: column.number(), - email: column.text({ unique: true }), - mi: column.text({ optional: true }), - }, -}); - -const BaseSentBox = defineTable({ - columns: { - to: column.number(), - toName: column.text(), - subject: column.text(), - body: column.text(), - }, -}); - -/** - * @typedef {import('../../dist/core/types.js').DBTable} DBTable - * @param {{ User: DBTable, SentBox: DBTable }} params - * @returns - */ -function resolveReferences( - { User = BaseUser, SentBox = BaseSentBox } = { - User: BaseUser, - SentBox: BaseSentBox, - }, -) { - return tablesSchema.parse({ User, SentBox }); -} - -function userChangeQueries(oldTable, newTable) { - return getTableChangeQueries({ - tableName: 'User', - oldTable, - newTable, - }); -} - -describe('reference queries', () => { - it('adds references with lossless table recreate', async () => { - const { SentBox: Initial } = resolveReferences(); - const { SentBox: Final } = resolveReferences({ - SentBox: defineTable({ - columns: { - ...BaseSentBox.columns, - to: column.number({ references: () => BaseUser.columns.id }), - }, - }), - }); - - const { queries } = await userChangeQueries(Initial, Final); - - assert.equal(queries[0] !== undefined, true); - const tempTableName = getTempTableName(queries[0]); - assert.notEqual(typeof tempTableName, 'undefined'); - - assert.deepEqual(queries, [ - `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL REFERENCES \"User\" (\"id\"), \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL)`, - `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, - 'DROP TABLE "User"', - `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, - ]); - }); - - it('removes references with lossless table recreate', async () => { - const { SentBox: Initial } = resolveReferences({ - SentBox: defineTable({ - columns: { - ...BaseSentBox.columns, - to: column.number({ references: () => BaseUser.columns.id }), - }, - }), - }); - const { SentBox: Final } = resolveReferences(); - - const { queries } = await userChangeQueries(Initial, Final); - - assert.equal(queries[0] !== undefined, true); - const tempTableName = getTempTableName(queries[0]); - assert.notEqual(typeof tempTableName, 'undefined'); - - assert.deepEqual(queries, [ - `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL)`, - `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, - 'DROP TABLE "User"', - `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, - ]); - }); - - it('does not use ADD COLUMN when adding optional column with reference', async () => { - const { SentBox: Initial } = resolveReferences(); - const { SentBox: Final } = resolveReferences({ - SentBox: defineTable({ - columns: { - ...BaseSentBox.columns, - from: column.number({ references: () => BaseUser.columns.id, optional: true }), - }, - }), - }); - - const { queries } = await userChangeQueries(Initial, Final); - assert.equal(queries[0] !== undefined, true); - const tempTableName = getTempTableName(queries[0]); - - assert.deepEqual(queries, [ - `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL, \"from\" integer REFERENCES \"User\" (\"id\"))`, - `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, - 'DROP TABLE "User"', - `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, - ]); - }); - - it('adds and updates foreign key with lossless table recreate', async () => { - const { SentBox: InitialWithoutFK } = resolveReferences(); - const { SentBox: InitialWithDifferentFK } = resolveReferences({ - SentBox: defineTable({ - ...BaseSentBox, - foreignKeys: [{ columns: ['to'], references: () => [BaseUser.columns.id] }], - }), - }); - const { SentBox: Final } = resolveReferences({ - SentBox: defineTable({ - ...BaseSentBox, - foreignKeys: [ - { - columns: ['to', 'toName'], - references: () => [BaseUser.columns.id, BaseUser.columns.name], - }, - ], - }), - }); - - const expected = (tempTableName) => [ - `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL, FOREIGN KEY (\"to\", \"toName\") REFERENCES \"User\"(\"id\", \"name\"))`, - `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, - 'DROP TABLE "User"', - `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, - ]; - - const addedForeignKey = await userChangeQueries(InitialWithoutFK, Final); - const updatedForeignKey = await userChangeQueries(InitialWithDifferentFK, Final); - - assert.notEqual(typeof addedForeignKey.queries[0], 'undefined'); - assert.notEqual(typeof updatedForeignKey.queries[0], 'undefined'); - assert.deepEqual( - addedForeignKey.queries, - expected(getTempTableName(addedForeignKey.queries[0])), - ); - - assert.deepEqual( - updatedForeignKey.queries, - expected(getTempTableName(updatedForeignKey.queries[0])), - ); - }); -}); - -/** @param {string} query */ -function getTempTableName(query) { - return /User_[a-z\d]+/.exec(query)?.[0]; -} diff --git a/packages/db/test/unit/reference-queries.test.ts b/packages/db/test/unit/reference-queries.test.ts new file mode 100644 index 000000000000..11b10bfe1010 --- /dev/null +++ b/packages/db/test/unit/reference-queries.test.ts @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getTableChangeQueries } from '../../dist/core/cli/migration-queries.js'; +import { tablesSchema } from '../../dist/core/schemas.js'; +import type { DBTable } from '../../dist/core/types.js'; +import { column, defineTable } from '../../dist/runtime/virtual.js'; +import { asResolved } from '../test-utils.ts'; + +const BaseUser = defineTable({ + columns: { + id: column.number({ primaryKey: true }), + name: column.text(), + age: column.number(), + email: column.text({ unique: true }), + mi: column.text({ optional: true }), + }, +}); + +const BaseSentBox = defineTable({ + columns: { + to: column.number(), + toName: column.text(), + subject: column.text(), + body: column.text(), + }, +}); + +function resolveReferences( + { User = BaseUser, SentBox = BaseSentBox }: { User?: DBTable; SentBox?: DBTable } = { + User: BaseUser, + SentBox: BaseSentBox, + }, +) { + return tablesSchema.parse({ User, SentBox }); +} + +function userChangeQueries(oldTable: DBTable, newTable: DBTable) { + return getTableChangeQueries({ + tableName: 'User', + oldTable: asResolved(oldTable), + newTable: asResolved(newTable), + }); +} + +describe('reference queries', () => { + it('adds references with lossless table recreate', async () => { + const { SentBox: Initial } = resolveReferences(); + const { SentBox: Final } = resolveReferences({ + SentBox: defineTable({ + columns: { + ...BaseSentBox.columns, + to: column.number({ references: () => BaseUser.columns.id }), + }, + }), + }); + + const { queries } = await userChangeQueries(Initial, Final); + + assert.equal(queries[0] !== undefined, true); + const tempTableName = getTempTableName(queries[0]); + assert.notEqual(typeof tempTableName, 'undefined'); + + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL REFERENCES \"User\" (\"id\"), \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL)`, + `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, + 'DROP TABLE "User"', + `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, + ]); + }); + + it('removes references with lossless table recreate', async () => { + const { SentBox: Initial } = resolveReferences({ + SentBox: defineTable({ + columns: { + ...BaseSentBox.columns, + to: column.number({ references: () => BaseUser.columns.id }), + }, + }), + }); + const { SentBox: Final } = resolveReferences(); + + const { queries } = await userChangeQueries(Initial, Final); + + assert.equal(queries[0] !== undefined, true); + const tempTableName = getTempTableName(queries[0]); + assert.notEqual(typeof tempTableName, 'undefined'); + + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL)`, + `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, + 'DROP TABLE "User"', + `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, + ]); + }); + + it('does not use ADD COLUMN when adding optional column with reference', async () => { + const { SentBox: Initial } = resolveReferences(); + const { SentBox: Final } = resolveReferences({ + SentBox: defineTable({ + columns: { + ...BaseSentBox.columns, + from: column.number({ references: () => BaseUser.columns.id, optional: true }), + }, + }), + }); + + const { queries } = await userChangeQueries(Initial, Final); + assert.equal(queries[0] !== undefined, true); + const tempTableName = getTempTableName(queries[0]); + + assert.deepEqual(queries, [ + `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL, \"from\" integer REFERENCES \"User\" (\"id\"))`, + `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, + 'DROP TABLE "User"', + `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, + ]); + }); + + it('adds and updates foreign key with lossless table recreate', async () => { + const { SentBox: InitialWithoutFK } = resolveReferences(); + const { SentBox: InitialWithDifferentFK } = resolveReferences({ + SentBox: defineTable({ + ...BaseSentBox, + foreignKeys: [{ columns: ['to'], references: () => [BaseUser.columns.id] }], + }), + }); + const { SentBox: Final } = resolveReferences({ + SentBox: defineTable({ + ...BaseSentBox, + foreignKeys: [ + { + columns: ['to', 'toName'], + references: () => [BaseUser.columns.id, BaseUser.columns.name], + }, + ], + }), + }); + + const expected = (tempTableName: string | undefined) => [ + `CREATE TABLE \"${tempTableName}\" (_id INTEGER PRIMARY KEY, \"to\" integer NOT NULL, \"toName\" text NOT NULL, \"subject\" text NOT NULL, \"body\" text NOT NULL, FOREIGN KEY (\"to\", \"toName\") REFERENCES \"User\"(\"id\", \"name\"))`, + `INSERT INTO \"${tempTableName}\" (\"_id\", \"to\", \"toName\", \"subject\", \"body\") SELECT \"_id\", \"to\", \"toName\", \"subject\", \"body\" FROM \"User\"`, + 'DROP TABLE "User"', + `ALTER TABLE \"${tempTableName}\" RENAME TO \"User\"`, + ]; + + const addedForeignKey = await userChangeQueries(InitialWithoutFK, Final); + const updatedForeignKey = await userChangeQueries(InitialWithDifferentFK, Final); + + assert.notEqual(typeof addedForeignKey.queries[0], 'undefined'); + assert.notEqual(typeof updatedForeignKey.queries[0], 'undefined'); + assert.deepEqual( + addedForeignKey.queries, + expected(getTempTableName(addedForeignKey.queries[0])), + ); + + assert.deepEqual( + updatedForeignKey.queries, + expected(getTempTableName(updatedForeignKey.queries[0])), + ); + }); +}); + +function getTempTableName(query: string) { + return /User_[a-z\d]+/.exec(query)?.[0]; +} diff --git a/packages/db/test/unit/remote-info.test.js b/packages/db/test/unit/remote-info.test.js deleted file mode 100644 index 4940f2e4a283..000000000000 --- a/packages/db/test/unit/remote-info.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import assert from 'node:assert'; -import test, { beforeEach, describe } from 'node:test'; -import { getRemoteDatabaseInfo } from '../../dist/core/utils.js'; -import { clearEnvironment } from '../test-utils.js'; - -describe('RemoteDatabaseInfo', () => { - beforeEach(() => { - clearEnvironment(); - }); - - test('default remote info', () => { - const dbInfo = getRemoteDatabaseInfo(); - - assert.deepEqual(dbInfo, { - url: undefined, - token: undefined, - }); - }); - - test('configured libSQL remote', () => { - process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted'; - process.env.ASTRO_DB_APP_TOKEN = 'foo'; - const dbInfo = getRemoteDatabaseInfo(); - - assert.deepEqual(dbInfo, { - url: 'libsql://libsql.self.hosted', - token: 'foo', - }); - }); -}); diff --git a/packages/db/test/unit/remote-info.test.ts b/packages/db/test/unit/remote-info.test.ts new file mode 100644 index 000000000000..55b5babd3a7e --- /dev/null +++ b/packages/db/test/unit/remote-info.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert'; +import test, { beforeEach, describe } from 'node:test'; +import { getRemoteDatabaseInfo } from '../../dist/core/utils.js'; +import { clearEnvironment } from '../test-utils.ts'; + +describe('RemoteDatabaseInfo', () => { + beforeEach(() => { + clearEnvironment(); + }); + + test('default remote info', () => { + const dbInfo = getRemoteDatabaseInfo(); + + assert.deepEqual(dbInfo, { + url: undefined, + token: undefined, + }); + }); + + test('configured libSQL remote', () => { + process.env.ASTRO_DB_REMOTE_URL = 'libsql://libsql.self.hosted'; + process.env.ASTRO_DB_APP_TOKEN = 'foo'; + const dbInfo = getRemoteDatabaseInfo(); + + assert.deepEqual(dbInfo, { + url: 'libsql://libsql.self.hosted', + token: 'foo', + }); + }); +}); diff --git a/packages/db/test/unit/reset-queries.test.js b/packages/db/test/unit/reset-queries.test.js deleted file mode 100644 index 9fb99f91e53f..000000000000 --- a/packages/db/test/unit/reset-queries.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { getMigrationQueries } from '../../dist/core/cli/migration-queries.js'; -import { MIGRATION_VERSION } from '../../dist/core/consts.js'; -import { tableSchema } from '../../dist/core/schemas.js'; -import { column, defineTable } from '../../dist/runtime/virtual.js'; - -const TABLE_NAME = 'Users'; - -// `parse` to resolve schema transformations -// ex. convert column.date() to ISO strings -const userInitial = tableSchema.parse( - defineTable({ - columns: { - name: column.text(), - age: column.number(), - email: column.text({ unique: true }), - mi: column.text({ optional: true }), - }, - }), -); - -describe('force reset', () => { - describe('getMigrationQueries', () => { - it('should drop table and create new version', async () => { - const oldTables = { [TABLE_NAME]: userInitial }; - const newTables = { [TABLE_NAME]: userInitial }; - const { queries } = await getMigrationQueries({ - oldSnapshot: { schema: oldTables, version: MIGRATION_VERSION }, - newSnapshot: { schema: newTables, version: MIGRATION_VERSION }, - reset: true, - }); - - assert.deepEqual(queries, [ - `DROP TABLE IF EXISTS "${TABLE_NAME}"`, - `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, - ]); - }); - - it('should not drop table when previous snapshot did not have it', async () => { - const oldTables = {}; - const newTables = { [TABLE_NAME]: userInitial }; - const { queries } = await getMigrationQueries({ - oldSnapshot: { schema: oldTables, version: MIGRATION_VERSION }, - newSnapshot: { schema: newTables, version: MIGRATION_VERSION }, - reset: true, - }); - - assert.deepEqual(queries, [ - `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, - ]); - }); - }); -}); diff --git a/packages/db/test/unit/reset-queries.test.ts b/packages/db/test/unit/reset-queries.test.ts new file mode 100644 index 000000000000..2235f5672e62 --- /dev/null +++ b/packages/db/test/unit/reset-queries.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getMigrationQueries } from '../../dist/core/cli/migration-queries.js'; +import { MIGRATION_VERSION } from '../../dist/core/consts.js'; +import { tableSchema } from '../../dist/core/schemas.js'; +import type { ResolvedDBTable } from '../../dist/core/types.js'; +import { column, defineTable } from '../../dist/runtime/virtual.js'; +import { asResolved } from '../test-utils.ts'; + +const TABLE_NAME = 'Users'; + +// `parse` to resolve schema transformations +// ex. convert column.date() to ISO strings +const userInitial = tableSchema.parse( + defineTable({ + columns: { + name: column.text(), + age: column.number(), + email: column.text({ unique: true }), + mi: column.text({ optional: true }), + }, + }), +); + +describe('force reset', () => { + describe('getMigrationQueries', () => { + it('should drop table and create new version', async () => { + const oldTables = { [TABLE_NAME]: asResolved(userInitial) }; + const newTables = { [TABLE_NAME]: asResolved(userInitial) }; + const { queries } = await getMigrationQueries({ + oldSnapshot: { schema: oldTables, version: MIGRATION_VERSION }, + newSnapshot: { schema: newTables, version: MIGRATION_VERSION }, + reset: true, + }); + + assert.deepEqual(queries, [ + `DROP TABLE IF EXISTS "${TABLE_NAME}"`, + `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, + ]); + }); + + it('should not drop table when previous snapshot did not have it', async () => { + const oldTables: Record = {}; + const newTables = { [TABLE_NAME]: asResolved(userInitial) }; + const { queries } = await getMigrationQueries({ + oldSnapshot: { schema: oldTables, version: MIGRATION_VERSION }, + newSnapshot: { schema: newTables, version: MIGRATION_VERSION }, + reset: true, + }); + + assert.deepEqual(queries, [ + `CREATE TABLE "${TABLE_NAME}" (_id INTEGER PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`, + ]); + }); + }); +}); diff --git a/packages/db/tsconfig.test.json b/packages/db/tsconfig.test.json new file mode 100644 index 000000000000..7d6bc4428b35 --- /dev/null +++ b/packages/db/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [{ "path": "../astro/tsconfig.test.json" }] +} diff --git a/packages/integrations/alpinejs/package.json b/packages/integrations/alpinejs/package.json index 4e0f4843709d..55f505440206 100644 --- a/packages/integrations/alpinejs/package.json +++ b/packages/integrations/alpinejs/package.json @@ -31,7 +31,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "peerDependencies": { "@types/alpinejs": "^3.0.0", diff --git a/packages/integrations/alpinejs/test/basics.test.js b/packages/integrations/alpinejs/test/basics.test.ts similarity index 100% rename from packages/integrations/alpinejs/test/basics.test.js rename to packages/integrations/alpinejs/test/basics.test.ts diff --git a/packages/integrations/alpinejs/test/directive.test.js b/packages/integrations/alpinejs/test/directive.test.ts similarity index 100% rename from packages/integrations/alpinejs/test/directive.test.js rename to packages/integrations/alpinejs/test/directive.test.ts diff --git a/packages/integrations/alpinejs/test/plugin-script-import.test.js b/packages/integrations/alpinejs/test/plugin-script-import.test.ts similarity index 100% rename from packages/integrations/alpinejs/test/plugin-script-import.test.js rename to packages/integrations/alpinejs/test/plugin-script-import.test.ts diff --git a/packages/integrations/alpinejs/test/test-utils.js b/packages/integrations/alpinejs/test/test-utils.js deleted file mode 100644 index f18bf6add250..000000000000 --- a/packages/integrations/alpinejs/test/test-utils.js +++ /dev/null @@ -1,65 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { test as testBase } from '@playwright/test'; -import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; - -// Get all test files in directory, assign unique port for each of them so they don't conflict -const testFiles = await fs.readdir(new URL('.', import.meta.url)); -const testFileToPort = new Map(); -for (let i = 0; i < testFiles.length; i++) { - const file = testFiles[i]; - if (file.endsWith('.test.js')) { - testFileToPort.set(file.slice(0, -8), 4000 + i); - } -} - -function loadFixture(inlineConfig) { - if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }"); - - // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath - // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test` - return baseLoadFixture({ - ...inlineConfig, - root: fileURLToPath(new URL(inlineConfig.root, import.meta.url)), - server: { - port: testFileToPort.get(path.basename(inlineConfig.root)), - }, - }); -} - -function testFactory(inlineConfig) { - let fixture; - - const test = testBase.extend({ - // biome-ignore lint/correctness/noEmptyPattern: playwright needs this - astro: async ({}, use) => { - fixture = fixture || (await loadFixture(inlineConfig)); - await use(fixture); - }, - }); - - test.afterEach(() => { - fixture.resetAllFiles(); - }); - - return test; -} - -export function prepareTestFactory(opts) { - const test = testFactory(opts); - - let devServer; - - test.beforeAll(async ({ astro }) => { - devServer = await astro.startDevServer(); - }); - - test.afterAll(async () => { - await devServer.stop(); - }); - - return { - test, - }; -} diff --git a/packages/integrations/alpinejs/test/test-utils.ts b/packages/integrations/alpinejs/test/test-utils.ts new file mode 100644 index 000000000000..16c57ff68553 --- /dev/null +++ b/packages/integrations/alpinejs/test/test-utils.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test as testBase } from '@playwright/test'; +import { + loadFixture as baseLoadFixture, + type Fixture, + type AstroInlineConfig, + type DevServer, +} from '../../../astro/test/test-utils.js'; + +// Get all test files in directory, assign unique port for each of them so they don't conflict +const testFiles = await fs.readdir(new URL('.', import.meta.url)); +const testFileToPort = new Map(); +for (let i = 0; i < testFiles.length; i++) { + const file = testFiles[i]; + if (file.endsWith('.test.js')) { + testFileToPort.set(file.slice(0, -8), 4000 + i); + } +} + +function loadFixture(inlineConfig: AstroInlineConfig) { + if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }"); + + // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath + // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test` + return baseLoadFixture({ + ...inlineConfig, + root: fileURLToPath(new URL(inlineConfig.root, import.meta.url)), + server: { + port: testFileToPort.get(path.basename(String(inlineConfig.root))), + }, + }); +} + +function testFactory(inlineConfig: AstroInlineConfig) { + let fixture: Fixture; + + const test = testBase.extend<{ astro: Fixture }>({ + // biome-ignore lint/correctness/noEmptyPattern: playwright needs this + astro: async ({}, use) => { + fixture = fixture || (await loadFixture(inlineConfig)); + await use(fixture); + }, + }); + + test.afterEach(() => { + fixture.resetAllFiles(); + }); + + return test; +} + +export function prepareTestFactory(opts: AstroInlineConfig) { + const test = testFactory(opts); + + let devServer: DevServer; + + test.beforeAll(async ({ astro }) => { + devServer = await astro.startDevServer(); + }); + + test.afterAll(async () => { + await devServer.stop(); + }); + + return { + test, + }; +} diff --git a/packages/integrations/alpinejs/tsconfig.test.json b/packages/integrations/alpinejs/tsconfig.test.json new file mode 100644 index 000000000000..27c89c5fe7a7 --- /dev/null +++ b/packages/integrations/alpinejs/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../../astro/tsconfig.test.json" + } + ] +} diff --git a/packages/integrations/cloudflare/CHANGELOG.md b/packages/integrations/cloudflare/CHANGELOG.md index f30009913c86..4ffa6c503f3d 100644 --- a/packages/integrations/cloudflare/CHANGELOG.md +++ b/packages/integrations/cloudflare/CHANGELOG.md @@ -1,5 +1,49 @@ # @astrojs/cloudflare +## 13.1.10 + +### Patch Changes + +- [#16320](https://github.com/withastro/astro/pull/16320) [`a43eb4b`](https://github.com/withastro/astro/commit/a43eb4b40b4f81530e3c9b5e2959495900320433) Thanks [@matthewp](https://github.com/matthewp)! - Uses `redirect: 'manual'` for remote image fetches in the Cloudflare binding image transform, consistent with all other image fetch paths + +- [#16307](https://github.com/withastro/astro/pull/16307) [`a81dd3e`](https://github.com/withastro/astro/commit/a81dd3e7932f18b4c10c04378416324f0fea00f2) Thanks [@matthewp](https://github.com/matthewp)! - Surfaces `console.log` and `console.warn` output from workerd during prerendering + +- Updated dependencies []: + - @astrojs/underscore-redirects@1.0.3 + +## 13.1.9 + +### Patch Changes + +- [#16210](https://github.com/withastro/astro/pull/16210) [`e030bd0`](https://github.com/withastro/astro/commit/e030bd058457505b605ef573cfc71239baa963f0) Thanks [@matthewp](https://github.com/matthewp)! - Fixes `.svelte` files in `node_modules` failing with `Unknown file extension ".svelte"` when using the Cloudflare adapter with `prerenderEnvironment: 'node'` + +- Updated dependencies []: + - @astrojs/underscore-redirects@1.0.3 + +## 13.1.8 + +### Patch Changes + +- [#16225](https://github.com/withastro/astro/pull/16225) [`756e7be`](https://github.com/withastro/astro/commit/756e7be510a315516f6aa1647c93d11e8b43f5a9) Thanks [@travisbreaks](https://github.com/travisbreaks)! - Fixes `ERR_MULTIPLE_CONSUMERS` error when using Cloudflare Queues with prerendered pages. The prerender worker config callback now excludes `queues.consumers` from the entry worker config, since the prerender worker only renders static HTML and should not register as a queue consumer. Queue producers (bindings) are preserved. + +- [#16192](https://github.com/withastro/astro/pull/16192) [`79d86b8`](https://github.com/withastro/astro/commit/79d86b88ef199d6a2195584ec53b225c6a9df5f9) Thanks [@alexanderniebuhr](https://github.com/alexanderniebuhr)! - Removes an unused function re-export from the `/info` package path + +- Updated dependencies []: + - @astrojs/underscore-redirects@1.0.3 + +## 13.1.7 + +### Patch Changes + +- Updated dependencies [[`814406d`](https://github.com/withastro/astro/commit/814406de7dc3ea014b47d2d886d55c45e4e1c034)]: + - @astrojs/underscore-redirects@1.0.3 + +## 13.1.6 + +### Patch Changes + +- [#16151](https://github.com/withastro/astro/pull/16151) [`4978165`](https://github.com/withastro/astro/commit/4978165af4ca4c672edad904d7b6c85fc3647dd9) Thanks [@matthewp](https://github.com/matthewp)! - Fixes a dev-mode crash loop in the Cloudflare adapter when using Starlight by excluding `@astrojs/starlight` from SSR dependency optimization + ## 13.1.5 ### Patch Changes diff --git a/packages/integrations/cloudflare/package.json b/packages/integrations/cloudflare/package.json index 688cff786a63..8225537f50c5 100644 --- a/packages/integrations/cloudflare/package.json +++ b/packages/integrations/cloudflare/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/cloudflare", "description": "Deploy your site to Cloudflare Workers", - "version": "13.1.5", + "version": "13.1.10", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", @@ -19,7 +19,6 @@ "homepage": "https://docs.astro.build/en/guides/integrations-guide/cloudflare/", "exports": { ".": "./dist/index.js", - "./info": "./dist/info.js", "./entrypoints/server": "./dist/entrypoints/server.js", "./entrypoints/preview": "./dist/entrypoints/preview.js", "./entrypoints/server.js": "./dist/entrypoints/server.js", @@ -39,7 +38,8 @@ "dev": "astro-scripts dev \"src/**/*.ts\"", "build": "astro-scripts build \"src/**/*.ts\" --clean-dts && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", - "test": "astro-scripts test --force-exit \"test/**/*.test.js\"" + "test": "astro-scripts test --force-exit \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "dependencies": { "@astrojs/internal-helpers": "workspace:*", diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index efb3619e59cc..1e7de2a2cfcc 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -181,11 +181,15 @@ export default function createIntegration({ experimental: { prerenderWorker: { config(_, { entryWorkerConfig }) { + const { queues, ...restWorkerConfig } = entryWorkerConfig; return { - ...entryWorkerConfig, + ...restWorkerConfig, name: 'prerender', + ...(queues?.producers?.length && { + queues: { producers: queues.producers }, + }), ...(needsImagesBinding && - !entryWorkerConfig.images && { + !restWorkerConfig.images && { images: { binding: imagesBindingName }, }), }; @@ -271,6 +275,7 @@ export default function createIntegration({ 'virtual:astro:*', 'virtual:astro-cloudflare:*', 'virtual:@astrojs/*', + '@astrojs/starlight', ], esbuildOptions: { // Suppress Vite's `createRequire(import.meta.url)` banner to work around @@ -305,7 +310,6 @@ export default function createIntegration({ if (conf.ssr) { // Cloudflare does not support externalizing modules in server environments conf.ssr.external = undefined; - conf.ssr.noExternal = true; } }, }, diff --git a/packages/integrations/cloudflare/src/info.ts b/packages/integrations/cloudflare/src/info.ts deleted file mode 100644 index 26b1a053ee7b..000000000000 --- a/packages/integrations/cloudflare/src/info.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Re-exports utilities for use by astro add CLI. - * This provides a resolvable path from the user's project. - */ -export { getLocalWorkerdCompatibilityDate } from '@cloudflare/vite-plugin'; diff --git a/packages/integrations/cloudflare/src/prerenderer.ts b/packages/integrations/cloudflare/src/prerenderer.ts index f55a8a9a036e..f95c38eb4b2d 100644 --- a/packages/integrations/cloudflare/src/prerenderer.ts +++ b/packages/integrations/cloudflare/src/prerenderer.ts @@ -4,7 +4,7 @@ import type { AssetsGlobalStaticImagesList, PathWithRoute, } from 'astro'; -import { preview, type PreviewServer as VitePreviewServer } from 'vite'; +import { preview, createLogger, type PreviewServer as VitePreviewServer } from 'vite'; import { fileURLToPath } from 'node:url'; import { mkdir } from 'node:fs/promises'; import { cloudflare as cfVitePlugin, type PluginConfig } from '@cloudflare/vite-plugin'; @@ -53,6 +53,21 @@ export function createCloudflarePrerenderer({ // Ensure client dir exists (CF plugin expects it for assets) await mkdir(clientDir, { recursive: true }); + // Create a custom logger that filters out internal HTTP request logs (e.g. "POST /__astro_prerender 200 OK") + // from the Cloudflare vite plugin while still allowing user console.log output to pass through. + // We strip ANSI codes before testing because the Cloudflare vite plugin wraps messages in color codes. + const defaultLogger = createLogger('info'); + // eslint-disable-next-line no-control-regex + const ansiRe = /\x1b\[[0-9;]*m/g; + const astroRequestLogRe = /^(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+\/__astro_/; + const customLogger: ReturnType = { + ...defaultLogger, + info(msg, opts) { + if (astroRequestLogRe.test(msg.replace(ansiRe, ''))) return; + defaultLogger.info(msg, opts); + }, + }; + previewServer = await preview({ configFile: false, base, @@ -61,7 +76,7 @@ export function createCloudflarePrerenderer({ outDir: fileURLToPath(serverDir), }, root: fileURLToPath(root), - logLevel: 'error', + customLogger, preview: { host: 'localhost', port: 0, // Let the OS pick a free port diff --git a/packages/integrations/cloudflare/src/utils/image-binding-transform.ts b/packages/integrations/cloudflare/src/utils/image-binding-transform.ts index d1d0c2db555e..d4f3311afbea 100644 --- a/packages/integrations/cloudflare/src/utils/image-binding-transform.ts +++ b/packages/integrations/cloudflare/src/utils/image-binding-transform.ts @@ -25,7 +25,14 @@ export async function transform( } const imageSrc = new URL(href, url.origin); - const content = await (isRemotePath(href) ? fetch(imageSrc) : assets.fetch(imageSrc)); + const content = await (isRemotePath(href) + ? fetch(imageSrc, { redirect: 'manual' }) + : assets.fetch(imageSrc)); + + if (content.status >= 300 && content.status < 400) { + return new Response('Not Found', { status: 404 }); + } + if (!content.body) { return new Response(null, { status: 404 }); } diff --git a/packages/integrations/cloudflare/src/vite-plugin-dev-server-prerender-middleware.ts b/packages/integrations/cloudflare/src/vite-plugin-dev-server-prerender-middleware.ts index 25fc90ba6d13..5bcd6651e998 100644 --- a/packages/integrations/cloudflare/src/vite-plugin-dev-server-prerender-middleware.ts +++ b/packages/integrations/cloudflare/src/vite-plugin-dev-server-prerender-middleware.ts @@ -18,14 +18,6 @@ export function createNodePrerenderPlugin(): vite.Plugin { }; }, - // Disable dep optimization for the `prerender` environment so dependencies - // are loaded via native import() with correct import.meta.url semantics. - configEnvironment(environmentName) { - if (environmentName === 'prerender') { - return { optimizeDeps: { noDiscovery: true, include: [] } }; - } - }, - configureServer(server) { (server as any)[devPrerenderMiddlewareSymbol] = true; }, diff --git a/packages/integrations/cloudflare/src/wrangler.ts b/packages/integrations/cloudflare/src/wrangler.ts index 96b1d6803fbc..2c9f115cfea3 100644 --- a/packages/integrations/cloudflare/src/wrangler.ts +++ b/packages/integrations/cloudflare/src/wrangler.ts @@ -1,13 +1,13 @@ -import type { PluginConfig } from '@cloudflare/vite-plugin'; +import type { PluginConfig, WorkerConfig } from '@cloudflare/vite-plugin'; export const DEFAULT_SESSION_KV_BINDING_NAME = 'SESSION'; export const DEFAULT_IMAGES_BINDING_NAME = 'IMAGES'; export const DEFAULT_ASSETS_BINDING_NAME = 'ASSETS'; interface CloudflareConfigOptions { - sessionKVBindingName: string | undefined; + sessionKVBindingName?: string | undefined; needsSessionKVBinding?: boolean; - imagesBindingName: string | false | undefined; + imagesBindingName?: string | false | undefined; } /** @@ -15,8 +15,8 @@ interface CloudflareConfigOptions { * Sets the main entrypoint and adds bindings for auto-provisioning. */ export function cloudflareConfigCustomizer( - options: CloudflareConfigOptions, -): PluginConfig['config'] { + options?: CloudflareConfigOptions, +): (config: Partial) => Partial { const sessionKVBindingName = options?.sessionKVBindingName ?? DEFAULT_SESSION_KV_BINDING_NAME; const needsSessionKVBinding = options?.needsSessionKVBinding ?? true; const imagesBindingName = @@ -24,7 +24,7 @@ export function cloudflareConfigCustomizer( ? undefined : (options?.imagesBindingName ?? DEFAULT_IMAGES_BINDING_NAME); - return (config) => { + const customizer = (config: Partial): Partial => { const hasSessionBinding = config.kv_namespaces?.some( (kv) => kv.binding === sessionKVBindingName, ); @@ -54,4 +54,6 @@ export function cloudflareConfigCustomizer( }, }; }; + + return customizer satisfies PluginConfig['config']; } diff --git a/packages/integrations/cloudflare/test/_test-utils.js b/packages/integrations/cloudflare/test/_test-utils.js deleted file mode 100644 index 2859a680c2d1..000000000000 --- a/packages/integrations/cloudflare/test/_test-utils.js +++ /dev/null @@ -1,38 +0,0 @@ -import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; - -/** - * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture - */ -export async function loadFixture(inlineConfig) { - if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }"); - - // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath - // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test` - const fixture = await baseLoadFixture({ - ...inlineConfig, - root: new URL(inlineConfig.root, import.meta.url).toString(), - }); - - // For unknown reasons, the error below could raise during testing. We add a retry mechanism to handle it. - // Some further investigation is needed to understand the root cause. - // - // Unable to build fixture for the attempt 1: Error: There is a new version of the pre-bundle for "/astro/packages/integrations/cloudflare/test/fixtures/with-svelte/node_modules/.vite/deps_ssr/svelte_server.js?v=9924cddf", a page reload is going to ask for it. - const buildWithRetry = async function (...args) { - let err; - for (let attempt = 1; attempt <= 3; attempt++) { - try { - const result = await fixture.build(...args); - return result; - } catch (error) { - console.error(`Unable to build fixture for the attempt ${attempt}:`, error); - err = error; - } - } - - if (err) { - throw err; - } - }; - - return { ...fixture, build: buildWithRetry }; -} diff --git a/packages/integrations/cloudflare/test/astro-dev-platform.test.js b/packages/integrations/cloudflare/test/astro-dev-platform.test.js deleted file mode 100644 index 779d2a1b2a25..000000000000 --- a/packages/integrations/cloudflare/test/astro-dev-platform.test.js +++ /dev/null @@ -1,73 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './_test-utils.js'; - -describe('AstroDevPlatform', () => { - let fixture; - let devServer; - before(async () => { - fixture = await loadFixture({ - root: './fixtures/astro-dev-platform/', - }); - devServer = await fixture.startDevServer(); - // Do an initial request to prime preloading - await fixture.fetch('/'); - }); - - after(async () => { - await devServer.stop(); - }); - - it('adds cf object', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#hasCF').text(), 'true'); - }); - - it('adds cache mocking', async () => { - const res = await fixture.fetch('/caches'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#hasCACHE').text(), 'true'); - }); - - it('adds D1 mocking', async () => { - const res = await fixture.fetch('/d1'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#hasDB').text(), 'true'); - assert.equal($('#hasPRODDB').text(), 'true'); - assert.equal($('#hasACCESS').text(), 'true'); - }); - - it('adds R2 mocking', async () => { - const res = await fixture.fetch('/r2'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#hasBUCKET').text(), 'true'); - assert.equal($('#hasPRODBUCKET').text(), 'true'); - assert.equal($('#hasACCESS').text(), 'true'); - }); - - it('adds KV mocking', async () => { - const res = await fixture.fetch('/kv'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#hasKV').text(), 'true'); - assert.equal($('#hasPRODKV').text(), 'true'); - assert.equal($('#hasACCESS').text(), 'true'); - }); - - it('Code component works in dev mode (no CommonJS module errors)', async () => { - const res = await fixture.fetch('/code-test'); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - // Verify the page rendered successfully with Code component - assert.equal($('h1').text(), 'Testing Code Component'); - // Verify the code block was rendered - assert.ok($('pre').length > 0, 'Code block should be rendered'); - }); -}); diff --git a/packages/integrations/cloudflare/test/astro-dev-platform.test.ts b/packages/integrations/cloudflare/test/astro-dev-platform.test.ts new file mode 100644 index 000000000000..62440398df03 --- /dev/null +++ b/packages/integrations/cloudflare/test/astro-dev-platform.test.ts @@ -0,0 +1,73 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type DevServer, type Fixture, loadFixture } from './test-utils.ts'; + +describe('AstroDevPlatform', () => { + let fixture: Fixture; + let devServer: DevServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-dev-platform/', + }); + devServer = await fixture.startDevServer(); + // Do an initial request to prime preloading + await fixture.fetch('/'); + }); + + after(async () => { + await devServer.stop(); + }); + + it('adds cf object', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#hasCF').text(), 'true'); + }); + + it('adds cache mocking', async () => { + const res = await fixture.fetch('/caches'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#hasCACHE').text(), 'true'); + }); + + it('adds D1 mocking', async () => { + const res = await fixture.fetch('/d1'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#hasDB').text(), 'true'); + assert.equal($('#hasPRODDB').text(), 'true'); + assert.equal($('#hasACCESS').text(), 'true'); + }); + + it('adds R2 mocking', async () => { + const res = await fixture.fetch('/r2'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#hasBUCKET').text(), 'true'); + assert.equal($('#hasPRODBUCKET').text(), 'true'); + assert.equal($('#hasACCESS').text(), 'true'); + }); + + it('adds KV mocking', async () => { + const res = await fixture.fetch('/kv'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#hasKV').text(), 'true'); + assert.equal($('#hasPRODKV').text(), 'true'); + assert.equal($('#hasACCESS').text(), 'true'); + }); + + it('Code component works in dev mode (no CommonJS module errors)', async () => { + const res = await fixture.fetch('/code-test'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + // Verify the page rendered successfully with Code component + assert.equal($('h1').text(), 'Testing Code Component'); + // Verify the code block was rendered + assert.ok($('pre').length > 0, 'Code block should be rendered'); + }); +}); diff --git a/packages/integrations/cloudflare/test/astro-env.test.js b/packages/integrations/cloudflare/test/astro-env.test.js deleted file mode 100644 index 6259b453ac87..000000000000 --- a/packages/integrations/cloudflare/test/astro-env.test.js +++ /dev/null @@ -1,122 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './_test-utils.js'; - -describe('astro:env', () => { - describe('ssr', () => { - let fixture; - let previewServer; - - before(async () => { - process.env.API_URL = 'https://google.de'; - process.env.PORT = '4322'; - fixture = await loadFixture({ - root: './fixtures/astro-env/', - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('runtime', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal( - $('#runtime').text().includes('https://google.de') && - $('#runtime').text().includes('4322') && - $('#runtime').text().includes('123456789'), - true, - ); - }); - - it('client', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#client').text().includes('https://google.de'), true); - }); - - it('server', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#server').text().includes('4322'), true); - }); - - it('secret', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#secret').text().includes('123456789'), true); - }); - - it('action secret', async () => { - const res = await fixture.fetch('/test'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#secret').text().includes('123456789'), true); - }); - }); - - describe('dev', () => { - let devServer; - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/astro-env/', - }); - devServer = await fixture.startDevServer(); - await fixture.fetch('/'); - }); - - after(async () => { - await devServer.stop(); - }); - - it('runtime', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal( - $('#runtime').text().includes('https://google.de') && - $('#runtime').text().includes('4322') && - $('#runtime').text().includes('123456789'), - true, - ); - }); - - it('client', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#client').text().includes('https://google.de'), true); - }); - - it('server', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#server').text().includes('4322'), true); - }); - - it('secret', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#secret').text().includes('123456789'), true); - }); - - it('action secret', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#secret').text().includes('123456789'), true); - }); - }); -}); diff --git a/packages/integrations/cloudflare/test/astro-env.test.ts b/packages/integrations/cloudflare/test/astro-env.test.ts new file mode 100644 index 000000000000..9a961e399e01 --- /dev/null +++ b/packages/integrations/cloudflare/test/astro-env.test.ts @@ -0,0 +1,120 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type DevServer, type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; + +describe('astro:env', () => { + describe('ssr', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + before(async () => { + process.env.API_URL = 'https://google.de'; + process.env.PORT = '4322'; + fixture = await loadFixture({ + root: './fixtures/astro-env/', + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + }); + + it('runtime', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal( + $('#runtime').text().includes('https://google.de') && + $('#runtime').text().includes('4322') && + $('#runtime').text().includes('123456789'), + true, + ); + }); + + it('client', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#client').text().includes('https://google.de'), true); + }); + + it('server', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#server').text().includes('4322'), true); + }); + + it('secret', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#secret').text().includes('123456789'), true); + }); + + it('action secret', async () => { + const res = await fixture.fetch('/test'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#secret').text().includes('123456789'), true); + }); + }); + + describe('dev', () => { + let devServer: DevServer; + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/astro-env/', + }); + devServer = await fixture.startDevServer(); + await fixture.fetch('/'); + }); + + after(async () => { + await devServer.stop(); + }); + + it('runtime', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal( + $('#runtime').text().includes('https://google.de') && + $('#runtime').text().includes('4322') && + $('#runtime').text().includes('123456789'), + true, + ); + }); + + it('client', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#client').text().includes('https://google.de'), true); + }); + + it('server', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#server').text().includes('4322'), true); + }); + + it('secret', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#secret').text().includes('123456789'), true); + }); + + it('action secret', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#secret').text().includes('123456789'), true); + }); + }); +}); diff --git a/packages/integrations/cloudflare/test/binding-image-service.test.js b/packages/integrations/cloudflare/test/binding-image-service.test.js deleted file mode 100644 index a5504620471d..000000000000 --- a/packages/integrations/cloudflare/test/binding-image-service.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { loadFixture } from './_test-utils.js'; - -describe('BindingImageService', () => { - let fixture; - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/binding-image-service/', - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('returns 403 for missing href parameter', async () => { - const res = await fixture.fetch('/_image?f=webp'); - assert.equal(res.status, 403); - }); - - it('returns 403 for remote images not in allowed domains', async () => { - const res = await fixture.fetch('/_image?href=https://example.com/image.jpg&f=webp'); - assert.equal(res.status, 403); - }); - - it('returns 400 for unsupported format', async () => { - const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=tiff'); - const text = await res.text(); - assert.equal(res.status, 400); - assert.ok(text.includes('Unsupported format')); - }); - - it('transforms local images to png', async () => { - const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=png&w=100'); - assert.equal(res.status, 200); - assert.equal(res.headers.get('content-type'), 'image/png'); - }); - - it('transforms local images to webp', async () => { - const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=webp&w=100'); - assert.equal(res.status, 200); - assert.equal(res.headers.get('content-type'), 'image/webp'); - }); - - it('transforms local images to avif', async () => { - const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=avif&w=100'); - assert.equal(res.status, 200); - assert.equal(res.headers.get('content-type'), 'image/avif'); - }); -}); diff --git a/packages/integrations/cloudflare/test/binding-image-service.test.ts b/packages/integrations/cloudflare/test/binding-image-service.test.ts new file mode 100644 index 000000000000..09b7868188bc --- /dev/null +++ b/packages/integrations/cloudflare/test/binding-image-service.test.ts @@ -0,0 +1,82 @@ +import * as assert from 'node:assert/strict'; +import { createServer, type Server } from 'node:http'; +import { after, before, describe, it } from 'node:test'; +import { type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; + +describe('BindingImageService', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + let redirectServer: Server; + let redirectServerPort: number; + + before(async () => { + // Start a local HTTP server that always responds with a 302 redirect. + // Used to test that the image transform endpoint does not follow redirects. + redirectServer = createServer((_req, res) => { + res.writeHead(302, { Location: 'http://example.com/secret' }); + res.end(); + }); + await new Promise((resolve) => { + redirectServer.listen(0, () => { + const address = redirectServer.address(); + if (typeof address === 'string' || !address) { + throw new TypeError('Unexpected address for testing'); + } + redirectServerPort = address.port; + resolve(); + }); + }); + + fixture = await loadFixture({ + root: './fixtures/binding-image-service/', + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + await new Promise((resolve) => redirectServer.close(resolve)); + }); + + it('returns 403 for missing href parameter', async () => { + const res = await fixture.fetch('/_image?f=webp'); + assert.equal(res.status, 403); + }); + + it('returns 403 for remote images not in allowed domains', async () => { + const res = await fixture.fetch('/_image?href=https://example.com/image.jpg&f=webp'); + assert.equal(res.status, 403); + }); + + it('returns 400 for unsupported format', async () => { + const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=tiff'); + const text = await res.text(); + assert.equal(res.status, 400); + assert.ok(text.includes('Unsupported format')); + }); + + it('transforms local images to png', async () => { + const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=png&w=100'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/png'); + }); + + it('transforms local images to webp', async () => { + const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=webp&w=100'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/webp'); + }); + + it('transforms local images to avif', async () => { + const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=avif&w=100'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/avif'); + }); + + it('does not follow redirects for remote images', async () => { + const href = `http://localhost:${redirectServerPort}/image.jpg`; + const res = await fixture.fetch(`/_image?href=${encodeURIComponent(href)}&f=webp`); + assert.equal(res.status, 404); + }); +}); diff --git a/packages/integrations/cloudflare/test/client-address.test.js b/packages/integrations/cloudflare/test/client-address.test.js deleted file mode 100644 index e905afd61c07..000000000000 --- a/packages/integrations/cloudflare/test/client-address.test.js +++ /dev/null @@ -1,107 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './_test-utils.js'; - -/** - * Tests that the Cloudflare adapter correctly extracts and validates - * clientAddress from the cf-connecting-ip header, ensuring: - * - Only the first value is returned from multi-value headers - * - The value is validated as a syntactically valid IP address - * - Injection payloads are rejected - * - * Regression test for: https://github.com/withastro/astro-security/issues/69 - */ -describe('Cloudflare clientAddress', () => { - let fixture; - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/client-address/', - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - previewServer.stop(); - }); - - it('returns the client IP from cf-connecting-ip header', async () => { - const res = await fixture.fetch('/api/address', { - headers: { 'cf-connecting-ip': '203.0.113.50' }, - }); - assert.equal(res.status, 200); - const data = await res.json(); - assert.equal(data.clientAddress, '203.0.113.50'); - }); - - it('returns only the first IP when cf-connecting-ip contains multiple values', async () => { - const res = await fixture.fetch('/api/address', { - headers: { 'cf-connecting-ip': '203.0.113.50, 70.41.3.18, 150.172.238.178' }, - }); - assert.equal(res.status, 200); - const data = await res.json(); - assert.equal(data.clientAddress, '203.0.113.50'); - }); - - it('trims whitespace around the IP address', async () => { - const res = await fixture.fetch('/api/address', { - headers: { 'cf-connecting-ip': ' 203.0.113.50 ' }, - }); - assert.equal(res.status, 200); - const data = await res.json(); - assert.equal(data.clientAddress, '203.0.113.50'); - }); - - it('handles IPv6 addresses', async () => { - const res = await fixture.fetch('/api/address', { - headers: { 'cf-connecting-ip': '2001:db8::1' }, - }); - assert.equal(res.status, 200); - const data = await res.json(); - assert.equal(data.clientAddress, '2001:db8::1'); - }); - - it('renders the client address in an Astro page', async () => { - const res = await fixture.fetch('/', { - headers: { 'cf-connecting-ip': '198.51.100.42' }, - }); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#address').text(), '198.51.100.42'); - }); - - it('renders only the first IP in an Astro page when header has multiple values', async () => { - const res = await fixture.fetch('/', { - headers: { 'cf-connecting-ip': '198.51.100.42, 10.0.0.1' }, - }); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#address').text(), '198.51.100.42'); - }); - - it('rejects HTML injection in cf-connecting-ip', async () => { - const res = await fixture.fetch('/api/address', { - headers: { 'cf-connecting-ip': '' }, - }); - assert.equal(res.status, 500); - }); - - it('rejects SQL injection in cf-connecting-ip', async () => { - const res = await fixture.fetch('/api/address', { - headers: { 'cf-connecting-ip': "'; DROP TABLE users; --" }, - }); - assert.equal(res.status, 500); - }); - - it('rejects path traversal in cf-connecting-ip', async () => { - const res = await fixture.fetch('/api/address', { - headers: { 'cf-connecting-ip': '../../etc/passwd' }, - }); - assert.equal(res.status, 500); - }); -}); diff --git a/packages/integrations/cloudflare/test/client-address.test.ts b/packages/integrations/cloudflare/test/client-address.test.ts new file mode 100644 index 000000000000..534b089adf61 --- /dev/null +++ b/packages/integrations/cloudflare/test/client-address.test.ts @@ -0,0 +1,106 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; + +/** + * Tests that the Cloudflare adapter correctly extracts and validates + * clientAddress from the cf-connecting-ip header, ensuring: + * - Only the first value is returned from multi-value headers + * - The value is validated as a syntactically valid IP address + * - Injection payloads are rejected + * + * Regression test for: https://github.com/withastro/astro-security/issues/69 + */ +describe('Cloudflare clientAddress', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/client-address/', + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + previewServer.stop(); + }); + + it('returns the client IP from cf-connecting-ip header', async () => { + const res = await fixture.fetch('/api/address', { + headers: { 'cf-connecting-ip': '203.0.113.50' }, + }); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.clientAddress, '203.0.113.50'); + }); + + it('returns only the first IP when cf-connecting-ip contains multiple values', async () => { + const res = await fixture.fetch('/api/address', { + headers: { 'cf-connecting-ip': '203.0.113.50, 70.41.3.18, 150.172.238.178' }, + }); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.clientAddress, '203.0.113.50'); + }); + + it('trims whitespace around the IP address', async () => { + const res = await fixture.fetch('/api/address', { + headers: { 'cf-connecting-ip': ' 203.0.113.50 ' }, + }); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.clientAddress, '203.0.113.50'); + }); + + it('handles IPv6 addresses', async () => { + const res = await fixture.fetch('/api/address', { + headers: { 'cf-connecting-ip': '2001:db8::1' }, + }); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.clientAddress, '2001:db8::1'); + }); + + it('renders the client address in an Astro page', async () => { + const res = await fixture.fetch('/', { + headers: { 'cf-connecting-ip': '198.51.100.42' }, + }); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#address').text(), '198.51.100.42'); + }); + + it('renders only the first IP in an Astro page when header has multiple values', async () => { + const res = await fixture.fetch('/', { + headers: { 'cf-connecting-ip': '198.51.100.42, 10.0.0.1' }, + }); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#address').text(), '198.51.100.42'); + }); + + it('rejects HTML injection in cf-connecting-ip', async () => { + const res = await fixture.fetch('/api/address', { + headers: { 'cf-connecting-ip': '' }, + }); + assert.equal(res.status, 500); + }); + + it('rejects SQL injection in cf-connecting-ip', async () => { + const res = await fixture.fetch('/api/address', { + headers: { 'cf-connecting-ip': "'; DROP TABLE users; --" }, + }); + assert.equal(res.status, 500); + }); + + it('rejects path traversal in cf-connecting-ip', async () => { + const res = await fixture.fetch('/api/address', { + headers: { 'cf-connecting-ip': '../../etc/passwd' }, + }); + assert.equal(res.status, 500); + }); +}); diff --git a/packages/integrations/cloudflare/test/compile-image-service.test.js b/packages/integrations/cloudflare/test/compile-image-service.test.js deleted file mode 100644 index 93ee3b9227f2..000000000000 --- a/packages/integrations/cloudflare/test/compile-image-service.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './_test-utils.js'; - -describe('CompileImageService', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/compile-image-service/', - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - // In dev, the compile service falls back to passthrough because sharp cannot run in workerd. Images are served unoptimized - // through the /_image endpoint. - it('returns 200 for local images via /_image endpoint', async () => { - const html = await fixture.fetch('/blog/post').then((res) => res.text()); - const $ = cheerio.load(html); - const src = $('img').attr('src'); - assert.ok( - src.startsWith('/_image'), - `Expected image src to route through /_image, got: ${src}`, - ); - const res = await fixture.fetch(src); - assert.equal(res.status, 200); - }); - }); - - describe('preview', () => { - let previewServer; - - before(async () => { - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('forbids http://', async () => { - const res = await fixture.fetch('/_image?href=http://placehold.co/600x400'); - const html = await res.text(); - const status = res.status; - assert.equal(html, 'Forbidden'); - assert.equal(status, 403); - }); - - it('forbids https://', async () => { - const res = await fixture.fetch('/_image?href=https://placehold.co/600x400'); - const html = await res.text(); - const status = res.status; - assert.equal(html, 'Forbidden'); - assert.equal(status, 403); - }); - - it('forbids //', async () => { - const res = await fixture.fetch('/_image?href=//placehold.co/600x400'); - const html = await res.text(); - const status = res.status; - assert.equal(html, 'Blocked'); - assert.equal(status, 403); - }); - - it('allows local', async () => { - const res = await fixture.fetch('/_image?href=/_astro/placeholder.gLBdjEDe.jpg&f=jpg'); - assert.equal(res.status, 200); - const blob = await res.blob(); - assert.equal(blob.type, 'image/jpeg'); - }); - }); -}); diff --git a/packages/integrations/cloudflare/test/compile-image-service.test.ts b/packages/integrations/cloudflare/test/compile-image-service.test.ts new file mode 100644 index 000000000000..62652020a73f --- /dev/null +++ b/packages/integrations/cloudflare/test/compile-image-service.test.ts @@ -0,0 +1,81 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type DevServer, type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; + +describe('CompileImageService', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/compile-image-service/', + }); + }); + + describe('dev', () => { + let devServer: DevServer; + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + // In dev, the compile service falls back to passthrough because sharp cannot run in workerd. Images are served unoptimized + // through the /_image endpoint. + it('returns 200 for local images via /_image endpoint', async () => { + const html = await fixture.fetch('/blog/post').then((res) => res.text()); + const $ = cheerio.load(html); + const src = $('img').attr('src')!; + assert.ok( + src.startsWith('/_image'), + `Expected image src to route through /_image, got: ${src}`, + ); + const res = await fixture.fetch(src); + assert.equal(res.status, 200); + }); + }); + + describe('preview', () => { + let previewServer: PreviewServer; + before(async () => { + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + }); + + it('forbids http://', async () => { + const res = await fixture.fetch('/_image?href=http://placehold.co/600x400'); + const html = await res.text(); + const status = res.status; + assert.equal(html, 'Forbidden'); + assert.equal(status, 403); + }); + + it('forbids https://', async () => { + const res = await fixture.fetch('/_image?href=https://placehold.co/600x400'); + const html = await res.text(); + const status = res.status; + assert.equal(html, 'Forbidden'); + assert.equal(status, 403); + }); + + it('forbids //', async () => { + const res = await fixture.fetch('/_image?href=//placehold.co/600x400'); + const html = await res.text(); + const status = res.status; + assert.equal(html, 'Blocked'); + assert.equal(status, 403); + }); + + it('allows local', async () => { + const res = await fixture.fetch('/_image?href=/_astro/placeholder.gLBdjEDe.jpg&f=jpg'); + assert.equal(res.status, 200); + const blob = await res.blob(); + assert.equal(blob.type, 'image/jpeg'); + }); + }); +}); diff --git a/packages/integrations/cloudflare/test/custom-entryfile.test.js b/packages/integrations/cloudflare/test/custom-entryfile.test.js deleted file mode 100644 index 630c4e00016e..000000000000 --- a/packages/integrations/cloudflare/test/custom-entryfile.test.js +++ /dev/null @@ -1,38 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { existsSync } from 'node:fs'; -import { after, before, describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { loadFixture } from './_test-utils.js'; - -describe('Custom entry file', () => { - let fixture; - let previewServer; - const root = new URL('./fixtures/custom-entryfile/', import.meta.url); - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/custom-entryfile/', - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('filters out duplicate "default" export and builds', async () => { - const filePath = fileURLToPath(new URL('dist/server', root)); - const hasBuilt = existsSync(filePath); - assert.equal(hasBuilt, true, `Expected ${filePath} to exist after build`); - }); - - it('uses custom entrypoint', async () => { - const response = await fixture.fetch('/'); - assert.equal( - response.headers.get('X-Custom-Entrypoint'), - 'true', - 'Expected custom entrypoint to add X-Custom-Entrypoint header', - ); - }); -}); diff --git a/packages/integrations/cloudflare/test/custom-entryfile.test.ts b/packages/integrations/cloudflare/test/custom-entryfile.test.ts new file mode 100644 index 000000000000..79d8e4fa6bf6 --- /dev/null +++ b/packages/integrations/cloudflare/test/custom-entryfile.test.ts @@ -0,0 +1,38 @@ +import * as assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; + +describe('Custom entry file', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + const root = new URL('./fixtures/custom-entryfile/', import.meta.url); + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/custom-entryfile/', + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + }); + + it('filters out duplicate "default" export and builds', async () => { + const filePath = fileURLToPath(new URL('dist/server', root)); + const hasBuilt = existsSync(filePath); + assert.equal(hasBuilt, true, `Expected ${filePath} to exist after build`); + }); + + it('uses custom entrypoint', async () => { + const response = await fixture.fetch('/'); + assert.equal( + response.headers.get('X-Custom-Entrypoint'), + 'true', + 'Expected custom entrypoint to add X-Custom-Entrypoint header', + ); + }); +}); diff --git a/packages/integrations/cloudflare/test/dev-image-endpoint.test.js b/packages/integrations/cloudflare/test/dev-image-endpoint.test.js deleted file mode 100644 index bd186ec8aead..000000000000 --- a/packages/integrations/cloudflare/test/dev-image-endpoint.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { loadFixture } from './_test-utils.js'; - -describe('Dev image endpoint', () => { - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/dev-image-endpoint/', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('returns 403 for missing href parameter', async () => { - const res = await fixture.fetch('/_image?f=webp'); - assert.equal(res.status, 403); - }); - - it('returns 403 for disallowed remote images', async () => { - const res = await fixture.fetch('/_image?href=https://example.com/image.jpg&f=webp'); - assert.equal(res.status, 403); - }); - - it('returns 400 for unsupported format', async () => { - const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=tiff'); - const text = await res.text(); - assert.equal(res.status, 400); - assert.ok(text.includes('Unsupported format')); - }); - - it('transforms local images to png', async () => { - const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=png&w=100'); - assert.equal(res.status, 200); - assert.equal(res.headers.get('content-type'), 'image/png'); - }); - - it('transforms local images to webp', async () => { - const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=webp&w=100'); - assert.equal(res.status, 200); - assert.equal(res.headers.get('content-type'), 'image/webp'); - }); - - it('transforms local images to avif', async () => { - const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=avif&w=100'); - assert.equal(res.status, 200); - assert.equal(res.headers.get('content-type'), 'image/avif'); - }); -}); diff --git a/packages/integrations/cloudflare/test/dev-image-endpoint.test.ts b/packages/integrations/cloudflare/test/dev-image-endpoint.test.ts new file mode 100644 index 000000000000..ad33f34d647c --- /dev/null +++ b/packages/integrations/cloudflare/test/dev-image-endpoint.test.ts @@ -0,0 +1,53 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { type DevServer, type Fixture, loadFixture } from './test-utils.ts'; + +describe('Dev image endpoint', () => { + let fixture: Fixture; + let devServer: DevServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/dev-image-endpoint/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('returns 403 for missing href parameter', async () => { + const res = await fixture.fetch('/_image?f=webp'); + assert.equal(res.status, 403); + }); + + it('returns 403 for disallowed remote images', async () => { + const res = await fixture.fetch('/_image?href=https://example.com/image.jpg&f=webp'); + assert.equal(res.status, 403); + }); + + it('returns 400 for unsupported format', async () => { + const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=tiff'); + const text = await res.text(); + assert.equal(res.status, 400); + assert.ok(text.includes('Unsupported format')); + }); + + it('transforms local images to png', async () => { + const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=png&w=100'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/png'); + }); + + it('transforms local images to webp', async () => { + const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=webp&w=100'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/webp'); + }); + + it('transforms local images to avif', async () => { + const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=avif&w=100'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/avif'); + }); +}); diff --git a/packages/integrations/cloudflare/test/external-image-service.test.js b/packages/integrations/cloudflare/test/external-image-service.test.js deleted file mode 100644 index 8662df507130..000000000000 --- a/packages/integrations/cloudflare/test/external-image-service.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { after, before, describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { glob } from 'tinyglobby'; -import { loadFixture } from './_test-utils.js'; - -const root = new URL('./fixtures/external-image-service/', import.meta.url); - -describe('ExternalImageService', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/external-image-service/', - }); - await fixture.build(); - }); - - after(async () => { - // await fixture.clean(); - }); - - it('has correct image service', async () => { - const files = await glob('**/image-service*', { - cwd: fileURLToPath(new URL('dist/server', root)), - filesOnly: true, - absolute: true, - flush: true, - }); - // the image service seems to be bundled inside the entry point - const outFileToCheck = readFileSync(files[0], 'utf-8'); - assert.equal(outFileToCheck.includes('cdn-cgi/image'), true); - }); -}); - -describe('ExternalImageService dev mode', () => { - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/external-image-service/', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('does not generate /cdn-cgi/image/ URLs in dev mode', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - assert.ok(!html.includes('/cdn-cgi/image/'), 'expected no cdn-cgi URL in dev mode'); - }); -}); diff --git a/packages/integrations/cloudflare/test/external-image-service.test.ts b/packages/integrations/cloudflare/test/external-image-service.test.ts new file mode 100644 index 000000000000..eafe69275b64 --- /dev/null +++ b/packages/integrations/cloudflare/test/external-image-service.test.ts @@ -0,0 +1,53 @@ +import * as assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { glob } from 'tinyglobby'; +import { type DevServer, type Fixture, loadFixture } from './test-utils.ts'; + +const root = new URL('./fixtures/external-image-service/', import.meta.url); + +describe('ExternalImageService', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/external-image-service/', + }); + await fixture.build(); + }); + + after(async () => { + // await fixture.clean(); + }); + + it('has correct image service', async () => { + const files = await glob('**/image-service*', { + cwd: fileURLToPath(new URL('dist/server', root)), + absolute: true, + }); + // the image service seems to be bundled inside the entry point + const outFileToCheck = readFileSync(files[0], 'utf-8'); + assert.equal(outFileToCheck.includes('cdn-cgi/image'), true); + }); +}); + +describe('ExternalImageService dev mode', () => { + let fixture: Fixture; + let devServer: DevServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/external-image-service/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('does not generate /cdn-cgi/image/ URLs in dev mode', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + assert.ok(!html.includes('/cdn-cgi/image/'), 'expected no cdn-cgi URL in dev mode'); + }); +}); diff --git a/packages/integrations/cloudflare/test/external-redirects.test.js b/packages/integrations/cloudflare/test/external-redirects.test.js deleted file mode 100644 index 33e841c0c0fe..000000000000 --- a/packages/integrations/cloudflare/test/external-redirects.test.js +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, it } from 'node:test'; -import { loadFixture } from './_test-utils.js'; -import assert from 'node:assert/strict'; -import { existsSync, readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -describe('External Redirects', () => { - let fixture; - - it('should not attempt to prerender external redirect destinations', async () => { - fixture = await loadFixture({ - root: './fixtures/external-redirects', - }); - - // Building should not result in a fetch to the external destination URL. - // If it does, the fetch will throw and the test will fail. - await fixture.build(); - - // Check that the redirect file was created and contains the redirect - const redirectsPath = fileURLToPath(new URL('client/_redirects', fixture.config.outDir)); - const redirectsContent = readFileSync(redirectsPath, 'utf-8'); - assert.match( - redirectsContent, - /\/redirect\s+http:\/\/test.invalid\/destination\s+301/, - '_redirects file should contain the redirect rule', - ); - - // Check that the destination was not prerendered - const prerenderedPath = fileURLToPath( - new URL('client/redirect/index.html', fixture.config.outDir), - ); - assert.ok( - !existsSync(prerenderedPath), - 'Should not create prerendered file for external redirect destination', - ); - }); -}); diff --git a/packages/integrations/cloudflare/test/external-redirects.test.ts b/packages/integrations/cloudflare/test/external-redirects.test.ts new file mode 100644 index 000000000000..e2a8343dfa92 --- /dev/null +++ b/packages/integrations/cloudflare/test/external-redirects.test.ts @@ -0,0 +1,36 @@ +import { describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +describe('External Redirects', () => { + let fixture: Fixture; + it('should not attempt to prerender external redirect destinations', async () => { + fixture = await loadFixture({ + root: './fixtures/external-redirects', + }); + + // Building should not result in a fetch to the external destination URL. + // If it does, the fetch will throw and the test will fail. + await fixture.build(); + + // Check that the redirect file was created and contains the redirect + const redirectsPath = fileURLToPath(new URL('client/_redirects', fixture.config.outDir)); + const redirectsContent = readFileSync(redirectsPath, 'utf-8'); + assert.match( + redirectsContent, + /\/redirect\s+http:\/\/test.invalid\/destination\s+301/, + '_redirects file should contain the redirect rule', + ); + + // Check that the destination was not prerendered + const prerenderedPath = fileURLToPath( + new URL('client/redirect/index.html', fixture.config.outDir), + ); + assert.ok( + !existsSync(prerenderedPath), + 'Should not create prerendered file for external redirect destination', + ); + }); +}); diff --git a/packages/integrations/cloudflare/test/fixtures/binding-image-service/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/binding-image-service/astro.config.mjs index 398d7db71019..421833f961d2 100644 --- a/packages/integrations/cloudflare/test/fixtures/binding-image-service/astro.config.mjs +++ b/packages/integrations/cloudflare/test/fixtures/binding-image-service/astro.config.mjs @@ -5,5 +5,8 @@ export default defineConfig({ adapter: cloudflare({ imageService: 'cloudflare-binding', }), + image: { + domains: ['localhost'], + }, output: 'server', }); diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-node-env/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/astro.config.mjs index 86dbfb924824..cb8168367f51 100644 --- a/packages/integrations/cloudflare/test/fixtures/prerender-node-env/astro.config.mjs +++ b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/astro.config.mjs @@ -1,3 +1,6 @@ import { defineConfig } from 'astro/config'; +import svelte from '@astrojs/svelte'; -export default defineConfig({}); +export default defineConfig({ + integrations: [svelte()], +}); diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg/package.json b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg/package.json new file mode 100644 index 000000000000..5613dd151cde --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg/package.json @@ -0,0 +1,11 @@ +{ + "name": "fake-svelte-pkg", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg/src/FakeComponent.svelte b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg/src/FakeComponent.svelte new file mode 100644 index 000000000000..ec2d63936fd4 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg/src/FakeComponent.svelte @@ -0,0 +1 @@ +

    Hello from fake svelte component

    diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg/src/index.js b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg/src/index.js new file mode 100644 index 000000000000..42c5b2af5f7b --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg/src/index.js @@ -0,0 +1 @@ +export { default as FakeComponent } from './FakeComponent.svelte'; diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-node-env/package.json b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/package.json index 19e3ed1bd820..abda43484202 100644 --- a/packages/integrations/cloudflare/test/fixtures/prerender-node-env/package.json +++ b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/package.json @@ -4,6 +4,9 @@ "private": true, "dependencies": { "@astrojs/cloudflare": "workspace:*", - "astro": "workspace:*" + "@astrojs/svelte": "workspace:*", + "astro": "workspace:*", + "svelte": "^5.0.0", + "fake-svelte-pkg": "file:./fake-svelte-pkg" } } diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-node-env/src/components/SvelteWrapper.svelte b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/src/components/SvelteWrapper.svelte new file mode 100644 index 000000000000..7df170a7e13a --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/src/components/SvelteWrapper.svelte @@ -0,0 +1,7 @@ + + +
    + +
    diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-node-env/src/pages/svelte.astro b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/src/pages/svelte.astro new file mode 100644 index 000000000000..9ef1fc29ad91 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerender-node-env/src/pages/svelte.astro @@ -0,0 +1,15 @@ +--- +export const prerender = true; + +import SvelteWrapper from '../components/SvelteWrapper.svelte'; +--- + + + + + Svelte Prerender Test + + + + + diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/astro.config.mjs new file mode 100644 index 000000000000..339f0e2a49c0 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/astro.config.mjs @@ -0,0 +1,7 @@ +import cloudflare from '@astrojs/cloudflare'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + adapter: cloudflare(), + output: 'server', +}); diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/package.json b/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/package.json new file mode 100644 index 000000000000..5e57f22f1754 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/astro-cloudflare-prerender-queue-consumers", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/cloudflare": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/src/pages/api.ts b/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/src/pages/api.ts new file mode 100644 index 000000000000..3060e9427491 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/src/pages/api.ts @@ -0,0 +1,9 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const GET: APIRoute = async () => { + return new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/src/pages/index.astro new file mode 100644 index 000000000000..55e12f5dd94a --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/src/pages/index.astro @@ -0,0 +1,10 @@ +--- +// This page is prerendered by default (output: 'server' with no opt-out) +// Actually, in output: 'server' mode, pages are server-rendered by default. +// We explicitly mark this as prerendered. +export const prerender = true; +--- + +Prerendered +

    Prerendered Page

    + diff --git a/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/wrangler.jsonc b/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/wrangler.jsonc new file mode 100644 index 000000000000..6ec9e7179b12 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers/wrangler.jsonc @@ -0,0 +1,18 @@ +{ + "name": "prerender-queue-consumers", + "main": "@astrojs/cloudflare/entrypoints/server", + "compatibility_date": "2026-01-28", + "queues": { + "consumers": [ + { + "queue": "my-queue" + } + ], + "producers": [ + { + "binding": "MY_QUEUE", + "queue": "my-queue" + } + ] + } +} diff --git a/packages/integrations/cloudflare/test/internal-redirects.test.js b/packages/integrations/cloudflare/test/internal-redirects.test.js deleted file mode 100644 index 84ae0d251b6b..000000000000 --- a/packages/integrations/cloudflare/test/internal-redirects.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it } from 'node:test'; -import { loadFixture } from './_test-utils.js'; -import assert from 'node:assert/strict'; -import { existsSync, readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -describe('Internal Redirects', () => { - let fixture; - - it('should not create a prerendered file for internal redirects', async () => { - fixture = await loadFixture({ - root: './fixtures/internal-redirects', - }); - - await fixture.build(); - - // Check that the redirect file was created and contains the redirect - const redirectsPath = fileURLToPath(new URL('client/_redirects', fixture.config.outDir)); - const redirectsContent = readFileSync(redirectsPath, 'utf-8'); - assert.match( - redirectsContent, - /\/redirect\s+\/page2\s+301/, - '_redirects file should contain the redirect rule', - ); - - // Check that the destination was not prerendered - const prerenderedPath = fileURLToPath( - new URL('client/redirect/index.html', fixture.config.outDir), - ); - assert.ok( - !existsSync(prerenderedPath), - 'Should not create prerendered file for internal redirect destination', - ); - }); -}); diff --git a/packages/integrations/cloudflare/test/internal-redirects.test.ts b/packages/integrations/cloudflare/test/internal-redirects.test.ts new file mode 100644 index 000000000000..0964ae221387 --- /dev/null +++ b/packages/integrations/cloudflare/test/internal-redirects.test.ts @@ -0,0 +1,34 @@ +import { describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +describe('Internal Redirects', () => { + let fixture: Fixture; + it('should not create a prerendered file for internal redirects', async () => { + fixture = await loadFixture({ + root: './fixtures/internal-redirects', + }); + + await fixture.build(); + + // Check that the redirect file was created and contains the redirect + const redirectsPath = fileURLToPath(new URL('client/_redirects', fixture.config.outDir)); + const redirectsContent = readFileSync(redirectsPath, 'utf-8'); + assert.match( + redirectsContent, + /\/redirect\s+\/page2\s+301/, + '_redirects file should contain the redirect rule', + ); + + // Check that the destination was not prerendered + const prerenderedPath = fileURLToPath( + new URL('client/redirect/index.html', fixture.config.outDir), + ); + assert.ok( + !existsSync(prerenderedPath), + 'Should not create prerendered file for internal redirect destination', + ); + }); +}); diff --git a/packages/integrations/cloudflare/test/prerender-node-env.test.js b/packages/integrations/cloudflare/test/prerender-node-env.test.js deleted file mode 100644 index aa4645ff0e05..000000000000 --- a/packages/integrations/cloudflare/test/prerender-node-env.test.js +++ /dev/null @@ -1,112 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import cloudflare from '../dist/index.js'; -import { loadFixture } from './_test-utils.js'; - -describe('prerenderEnvironment: node', () => { - /** @type {import('../../../astro/test/test-utils').Fixture} */ - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/prerender-node-env/', import.meta.url).toString(), - adapter: cloudflare({ - prerenderEnvironment: 'node', - }), - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer?.stop(); - await fixture.clean(); - }); - - it('renders prerendered page using Node.js APIs', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - assert.ok( - html.includes('id="pkg-name"'), - 'Expected the prerendered page to contain the pkg-name element', - ); - // The package.json name should be read via node:fs — cwd resolves to - // the cloudflare package root, so we check for that name - assert.ok( - html.includes('@astrojs/cloudflare'), - 'Expected node:fs to successfully read a package.json name', - ); - }); - - it('includes styles in prerendered page', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - assert.ok( - html.includes('rebeccapurple'), - 'Expected scoped styles to be included in the prerendered page', - ); - }); - - it('serves server islands for prerendered routes in dev', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - assert.ok( - html.includes('id="deferred-fallback"'), - 'Expected fallback content in prerendered HTML', - ); - - const islandUrlMatch = html.match(/fetch\('(\/_server-islands\/[^']+)'/); - assert.ok(islandUrlMatch, 'Expected prerendered HTML to include a server island fetch URL'); - - const islandRes = await fixture.fetch(islandUrlMatch[1]); - assert.equal(islandRes.status, 200, 'Expected server island endpoint to return 200 in dev'); - const islandHtml = await islandRes.text(); - assert.ok( - islandHtml.includes('id="deferred-content"'), - 'Expected server island response to include deferred island content', - ); - }); - - it('serves server islands through workerd runtime (not Node prerender env)', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - - const islandUrlMatches = [...html.matchAll(/fetch\((['"])(\/_server-islands\/[^'"]+)\1/g)]; - assert.ok( - islandUrlMatches.length > 0, - 'Expected prerendered HTML to include server island fetch URLs', - ); - - let sawWorkerdIsland = false; - for (const islandUrlMatch of islandUrlMatches) { - const islandRes = await fixture.fetch(islandUrlMatch[2]); - assert.equal(islandRes.status, 200, 'Expected server island endpoint to return 200'); - const islandHtml = await islandRes.text(); - - if (islandHtml.includes('id="island-has-cf"')) { - sawWorkerdIsland = true; - assert.ok( - islandHtml.includes('>true<'), - 'Expected server island to have access to Astro.request.cf (runs in workerd, not Node)', - ); - break; - } - } - - assert.ok( - sawWorkerdIsland, - 'Expected at least one server island response to include the WorkerdIsland marker', - ); - }); - - it('renders SSR page through workerd with Astro.request.cf', async () => { - const res = await fixture.fetch('/ssr'); - assert.equal(res.status, 200); - const html = await res.text(); - assert.ok(html.includes('id="has-cf"'), 'Expected the SSR page to contain the has-cf element'); - assert.ok(html.includes('>true<'), 'Expected Astro.request.cf to be available in the SSR page'); - }); -}); diff --git a/packages/integrations/cloudflare/test/prerender-node-env.test.ts b/packages/integrations/cloudflare/test/prerender-node-env.test.ts new file mode 100644 index 000000000000..61e5dfa03318 --- /dev/null +++ b/packages/integrations/cloudflare/test/prerender-node-env.test.ts @@ -0,0 +1,124 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import cloudflare from '../dist/index.js'; +import { type DevServer, type Fixture, loadFixture } from './test-utils.ts'; + +describe('prerenderEnvironment: node', () => { + let fixture: Fixture; + let devServer: DevServer; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/prerender-node-env/', import.meta.url).toString(), + adapter: cloudflare({ + prerenderEnvironment: 'node', + }), + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer?.stop(); + await fixture.clean(); + }); + + it('renders prerendered page using Node.js APIs', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + assert.ok( + html.includes('id="pkg-name"'), + 'Expected the prerendered page to contain the pkg-name element', + ); + // The package.json name should be read via node:fs — cwd resolves to + // the cloudflare package root, so we check for that name + assert.ok( + html.includes('@astrojs/cloudflare'), + 'Expected node:fs to successfully read a package.json name', + ); + }); + + it('renders svelte component that imports .svelte files from node_modules', async () => { + const res = await fixture.fetch('/svelte'); + assert.equal(res.status, 200); + const html = await res.text(); + assert.ok( + html.includes('id="svelte-wrapper"'), + 'Expected the prerendered page to contain the svelte wrapper', + ); + assert.ok( + html.includes('Hello from fake svelte component'), + 'Expected the fake svelte component to be rendered', + ); + }); + + it('includes styles in prerendered page', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + assert.ok( + html.includes('rebeccapurple'), + 'Expected scoped styles to be included in the prerendered page', + ); + }); + + it('serves server islands for prerendered routes in dev', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + assert.ok( + html.includes('id="deferred-fallback"'), + 'Expected fallback content in prerendered HTML', + ); + + const islandUrlMatch = /fetch\('(\/_server-islands\/[^']+)'/.exec(html); + assert.ok(islandUrlMatch, 'Expected prerendered HTML to include a server island fetch URL'); + + const islandRes = await fixture.fetch(islandUrlMatch[1]); + assert.equal(islandRes.status, 200, 'Expected server island endpoint to return 200 in dev'); + const islandHtml = await islandRes.text(); + assert.ok( + islandHtml.includes('id="deferred-content"'), + 'Expected server island response to include deferred island content', + ); + }); + + it('serves server islands through workerd runtime (not Node prerender env)', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + + const islandUrlMatches = [...html.matchAll(/fetch\((['"])(\/_server-islands\/[^'"]+)\1/g)]; + assert.ok( + islandUrlMatches.length > 0, + 'Expected prerendered HTML to include server island fetch URLs', + ); + + let sawWorkerdIsland = false; + for (const islandUrlMatch of islandUrlMatches) { + const islandRes = await fixture.fetch(islandUrlMatch[2]); + assert.equal(islandRes.status, 200, 'Expected server island endpoint to return 200'); + const islandHtml = await islandRes.text(); + + if (islandHtml.includes('id="island-has-cf"')) { + sawWorkerdIsland = true; + assert.ok( + islandHtml.includes('>true<'), + 'Expected server island to have access to Astro.request.cf (runs in workerd, not Node)', + ); + break; + } + } + + assert.ok( + sawWorkerdIsland, + 'Expected at least one server island response to include the WorkerdIsland marker', + ); + }); + + it('renders SSR page through workerd with Astro.request.cf', async () => { + const res = await fixture.fetch('/ssr'); + assert.equal(res.status, 200); + const html = await res.text(); + assert.ok(html.includes('id="has-cf"'), 'Expected the SSR page to contain the has-cf element'); + assert.ok(html.includes('>true<'), 'Expected Astro.request.cf to be available in the SSR page'); + }); +}); diff --git a/packages/integrations/cloudflare/test/prerender-queue-consumers.test.ts b/packages/integrations/cloudflare/test/prerender-queue-consumers.test.ts new file mode 100644 index 000000000000..0518b194caad --- /dev/null +++ b/packages/integrations/cloudflare/test/prerender-queue-consumers.test.ts @@ -0,0 +1,32 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; + +describe('Prerender with queue consumers', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/prerender-queue-consumers/', + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + previewServer.stop(); + }); + + it('builds and previews without ERR_MULTIPLE_CONSUMERS', async () => { + // The prerendered page should be accessible + const res = await fixture.fetch('/'); + const html = await res.text(); + assert.ok(html.includes('Prerendered Page')); + }); + + it('serves the SSR endpoint', async () => { + const res = await fixture.fetch('/api'); + const json = await res.json(); + assert.deepEqual(json, { ok: true }); + }); +}); diff --git a/packages/integrations/cloudflare/test/prerender-styles.test.js b/packages/integrations/cloudflare/test/prerender-styles.test.js deleted file mode 100644 index 77df3fb8b270..000000000000 --- a/packages/integrations/cloudflare/test/prerender-styles.test.js +++ /dev/null @@ -1,111 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import cloudflare from '../dist/index.js'; -import { loadFixture } from './_test-utils.js'; - -describe('Prerendered page styles', () => { - /** @type {import('../../../astro/test/test-utils').Fixture} */ - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/prerender-styles/', import.meta.url).toString(), - adapter: cloudflare(), - }); - }); - - after(async () => { - await devServer?.stop(); - await fixture.clean(); - }); - - describe('dev', () => { - before(async () => { - devServer = await fixture.startDevServer(); - }); - - it('includes Tailwind styles in prerendered page', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - // Check that the bg-amber-500 class has its styles included - assert.ok(html.includes('.bg-amber-500'), 'Expected .bg-amber-500 class to be in the HTML'); - }); - }); - - describe('build', () => { - before(async () => { - await devServer?.stop(); - devServer = undefined; - await fixture.build(); - }); - - it('includes Tailwind styles in prerendered page', async () => { - // With cloudflare adapter, prerendered pages are in dist/client/ - const html = await fixture.readFile('/client/index.html'); - // Tailwind CSS is emitted as an external stylesheet linked from the HTML. - // Verify the HTML references a stylesheet and that the stylesheet contains the expected class. - assert.ok(html.includes('rel="stylesheet"'), 'Expected the HTML to reference a stylesheet'); - const cssFiles = await fixture.glob('client/_astro/*.css'); - assert.ok(cssFiles.length > 0, 'Expected at least one CSS file in _astro/'); - let foundClass = false; - for (const cssFile of cssFiles) { - const css = await fixture.readFile('/' + cssFile); - if (css.includes('.bg-amber-500')) { - foundClass = true; - break; - } - } - assert.ok(foundClass, 'Expected .bg-amber-500 class to be in a generated CSS file'); - }); - }); -}); - -describe('Styles from Astro components imported in MDX content collections', () => { - /** @type {import('../../../astro/test/test-utils').Fixture} */ - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/prerender-styles/', import.meta.url).toString(), - adapter: cloudflare(), - }); - }); - - after(async () => { - await devServer?.stop(); - await fixture.clean(); - }); - - describe('dev', () => { - before(async () => { - devServer = await fixture.startDevServer(); - }); - - it('includes styles from an Astro component imported in an MDX content collection entry', async () => { - const res = await fixture.fetch('/posts/styled'); - const html = await res.text(); - assert.ok( - html.includes('.mdx-styled-card'), - 'Expected .mdx-styled-card styles from StyledCard.astro to be injected in the MDX page', - ); - }); - }); - - describe('build', () => { - before(async () => { - await devServer?.stop(); - devServer = undefined; - await fixture.build(); - }); - - it('includes styles from an Astro component imported in an MDX content collection entry', async () => { - const html = await fixture.readFile('/client/posts/styled/index.html'); - assert.ok( - html.includes('.mdx-styled-card'), - 'Expected .mdx-styled-card styles from StyledCard.astro to be in the built MDX page', - ); - }); - }); -}); diff --git a/packages/integrations/cloudflare/test/prerender-styles.test.ts b/packages/integrations/cloudflare/test/prerender-styles.test.ts new file mode 100644 index 000000000000..eab3bee011fb --- /dev/null +++ b/packages/integrations/cloudflare/test/prerender-styles.test.ts @@ -0,0 +1,107 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import cloudflare from '../dist/index.js'; +import { type DevServer, type Fixture, loadFixture } from './test-utils.ts'; + +describe('Prerendered page styles', () => { + let fixture: Fixture; + let devServer: DevServer | undefined; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/prerender-styles/', import.meta.url).toString(), + adapter: cloudflare(), + }); + }); + + after(async () => { + await devServer?.stop(); + await fixture.clean(); + }); + + describe('dev', () => { + before(async () => { + devServer = await fixture.startDevServer(); + }); + + it('includes Tailwind styles in prerendered page', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + // Check that the bg-amber-500 class has its styles included + assert.ok(html.includes('.bg-amber-500'), 'Expected .bg-amber-500 class to be in the HTML'); + }); + }); + + describe('build', () => { + before(async () => { + await devServer?.stop(); + devServer = undefined; + await fixture.build(); + }); + + it('includes Tailwind styles in prerendered page', async () => { + // With cloudflare adapter, prerendered pages are in dist/client/ + const html = await fixture.readFile('/client/index.html'); + // Tailwind CSS is emitted as an external stylesheet linked from the HTML. + // Verify the HTML references a stylesheet and that the stylesheet contains the expected class. + assert.ok(html.includes('rel="stylesheet"'), 'Expected the HTML to reference a stylesheet'); + const cssFiles = await fixture.glob('client/_astro/*.css'); + assert.ok(cssFiles.length > 0, 'Expected at least one CSS file in _astro/'); + let foundClass = false; + for (const cssFile of cssFiles) { + const css = await fixture.readFile('/' + cssFile); + if (css.includes('.bg-amber-500')) { + foundClass = true; + break; + } + } + assert.ok(foundClass, 'Expected .bg-amber-500 class to be in a generated CSS file'); + }); + }); +}); + +describe('Styles from Astro components imported in MDX content collections', () => { + let fixture: Fixture; + let devServer: DevServer | undefined; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/prerender-styles/', import.meta.url).toString(), + adapter: cloudflare(), + }); + }); + + after(async () => { + await devServer?.stop(); + await fixture.clean(); + }); + + describe('dev', () => { + before(async () => { + devServer = await fixture.startDevServer(); + }); + + it('includes styles from an Astro component imported in an MDX content collection entry', async () => { + const res = await fixture.fetch('/posts/styled'); + const html = await res.text(); + assert.ok( + html.includes('.mdx-styled-card'), + 'Expected .mdx-styled-card styles from StyledCard.astro to be injected in the MDX page', + ); + }); + }); + + describe('build', () => { + before(async () => { + await devServer?.stop(); + devServer = undefined; + await fixture.build(); + }); + + it('includes styles from an Astro component imported in an MDX content collection entry', async () => { + const html = await fixture.readFile('/client/posts/styled/index.html'); + assert.ok( + html.includes('.mdx-styled-card'), + 'Expected .mdx-styled-card styles from StyledCard.astro to be in the built MDX page', + ); + }); + }); +}); diff --git a/packages/integrations/cloudflare/test/prerenderer-errors.test.js b/packages/integrations/cloudflare/test/prerenderer-errors.test.js deleted file mode 100644 index 329b08eaaa88..000000000000 --- a/packages/integrations/cloudflare/test/prerenderer-errors.test.js +++ /dev/null @@ -1,36 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { loadFixture } from '../../../astro/test/test-utils.js'; -import cloudflare from '../dist/index.js'; - -describe('Cloudflare prerenderer errors', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/prerenderer-errors/', import.meta.url).toString(), - adapter: cloudflare(), - }); - }); - - after(async () => { - await fixture.clean(); - }); - - it('includes workerd error details when getStaticPaths fails', async () => { - await assert.rejects( - async () => { - await fixture.build({}, { teardownCompiler: true }); - }, - (error) => { - assert.ok(error instanceof Error); - assert.match( - error.message, - /Failed to get static paths from the Cloudflare prerender server/, - ); - assert.match(error.message, /getStaticPaths\(\).*required for dynamic routes/); - return true; - }, - ); - }); -}); diff --git a/packages/integrations/cloudflare/test/prerenderer-errors.test.ts b/packages/integrations/cloudflare/test/prerenderer-errors.test.ts new file mode 100644 index 000000000000..e90c2152343c --- /dev/null +++ b/packages/integrations/cloudflare/test/prerenderer-errors.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from '../../../astro/test/test-utils.js'; +import cloudflare from '../dist/index.js'; + +describe('Cloudflare prerenderer errors', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/prerenderer-errors/', import.meta.url).toString(), + adapter: cloudflare(), + }); + }); + + after(async () => { + await fixture.clean(); + }); + + it('includes workerd error details when getStaticPaths fails', async () => { + await assert.rejects( + async () => { + await fixture.build({}, { teardownCompiler: true }); + }, + (error) => { + assert.ok(error instanceof Error); + assert.match( + error.message, + /Failed to get static paths from the Cloudflare prerender server/, + ); + assert.match(error.message, /getStaticPaths\(\).*required for dynamic routes/); + return true; + }, + ); + }); +}); diff --git a/packages/integrations/cloudflare/test/routing-priority.test.js b/packages/integrations/cloudflare/test/routing-priority.test.js deleted file mode 100644 index 36935d8a0ef0..000000000000 --- a/packages/integrations/cloudflare/test/routing-priority.test.js +++ /dev/null @@ -1,270 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from './_test-utils.js'; - -const routes = [ - { - description: 'matches / to index.astro', - url: '/', - h1: 'index.astro', - }, - { - description: 'matches /slug-1 to [slug].astro', - url: '/slug-1', - h1: '[slug].astro', - p: 'slug-1', - }, - { - description: 'matches /slug-2 to [slug].astro', - url: '/slug-2', - h1: '[slug].astro', - p: 'slug-2', - }, - { - description: 'matches /page-1 to [page].astro', - url: '/page-1', - h1: '[page].astro', - p: 'page-1', - }, - { - description: 'matches /page-2 to [page].astro', - url: '/page-2', - h1: '[page].astro', - p: 'page-2', - }, - { - description: 'matches /posts/post-1 to posts/[pid].astro', - url: '/posts/post-1', - h1: 'posts/[pid].astro', - p: 'post-1', - }, - { - description: 'matches /posts/post-2 to posts/[pid].astro', - url: '/posts/post-2', - h1: 'posts/[pid].astro', - p: 'post-2', - }, - { - description: 'matches /posts/1/2 to posts/[...slug].astro', - url: '/posts/1/2', - h1: 'posts/[...slug].astro', - p: '1/2', - }, - { - description: 'matches /de to de/index.astro', - url: '/de', - h1: 'de/index.astro (priority)', - }, - { - description: 'matches /en to [lang]/index.astro', - url: '/en', - h1: '[lang]/index.astro', - p: 'en', - }, - { - description: 'matches /de/1/2 to [lang]/[...catchall].astro', - url: '/de/1/2', - h1: '[lang]/[...catchall].astro', - p: 'de | 1/2', - }, - { - description: 'matches /en/1/2 to [lang]/[...catchall].astro', - url: '/en/1/2', - h1: '[lang]/[...catchall].astro', - p: 'en | 1/2', - }, - { - description: 'matches /injected to to-inject.astro', - url: '/injected', - h1: 'to-inject.astro', - }, - { - description: 'matches /_injected to to-inject.astro', - url: '/_injected', - h1: 'to-inject.astro', - }, - { - description: 'matches /injected-1 to [id].astro', - url: '/injected-1', - h1: '[id].astro', - p: 'injected-1', - }, - { - description: 'matches /injected-2 to [id].astro', - url: '/injected-2', - h1: '[id].astro', - p: 'injected-2', - }, - { - description: 'matches /empty-slug to empty-slug/[...slug].astro', - url: '/empty-slug', - h1: 'empty-slug/[...slug].astro', - p: 'slug: ', - }, - { - description: 'do not match /empty-slug/undefined to empty-slug/[...slug].astro', - url: '/empty-slug/undefined', - fourOhFour: true, - }, - { - description: 'do not match /empty-paths/hello to empty-paths/[...slug].astro', - url: '/empty-paths/hello', - fourOhFour: true, - }, - { - description: 'matches /api/catch/a.json to api/catch/[...slug].json.ts', - url: '/api/catch/a.json', - htmlMatch: JSON.stringify({ path: 'a' }), - }, - { - description: 'matches /api/catch/b/c.json to api/catch/[...slug].json.ts', - url: '/api/catch/b/c.json', - htmlMatch: JSON.stringify({ path: 'b/c' }), - }, - { - description: 'matches /api/catch/a-b.json to api/catch/[foo]-[bar].json.ts', - url: '/api/catch/a-b.json', - htmlMatch: JSON.stringify({ foo: 'a', bar: 'b' }), - }, -]; - -function appendForwardSlash(path) { - return path.endsWith('/') ? path : path + '/'; -} - -describe('Routing priority', () => { - describe('build', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/routing-priority/', - }); - await fixture.build(); - }); - - routes.forEach(({ description, url, fourOhFour, h1, p, htmlMatch }) => { - const isEndpoint = htmlMatch && !h1 && !p; - - it(description, async () => { - const htmlFile = `/client/${isEndpoint ? url : `${appendForwardSlash(url)}index.html`}`; - - if (fourOhFour) { - assert.equal(fixture.pathExists(htmlFile), false); - return; - } - - const html = await fixture.readFile(htmlFile); - const $ = cheerioLoad(html); - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - }); - }); - - describe('dev', () => { - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/routing-priority/', - }); - - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - routes.forEach(({ description, url, fourOhFour, h1, p, htmlMatch }) => { - const isEndpoint = htmlMatch && !h1 && !p; - - // checks URLs as written above - it(description, async () => { - const html = await fixture.fetch(url).then((res) => res.text()); - const $ = cheerioLoad(html); - - if (fourOhFour) { - assert.equal($('title').text(), '404: Not Found'); - return; - } - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - - // skip for endpoint page test - if (isEndpoint) return; - - // checks with trailing slashes, ex: '/de/' instead of '/de' - it(`${description} (trailing slash)`, async () => { - const html = await fixture.fetch(appendForwardSlash(url)).then((res) => res.text()); - const $ = cheerioLoad(html); - - if (fourOhFour) { - assert.equal($('title').text(), '404: Not Found'); - return; - } - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - - // checks with index.html, ex: '/de/index.html' instead of '/de' - it(`${description} (index.html)`, async () => { - const html = await fixture - .fetch(`${appendForwardSlash(url)}index.html`) - .then((res) => res.text()); - const $ = cheerioLoad(html); - - if (fourOhFour) { - assert.equal($('title').text(), '404: Not Found'); - return; - } - - if (h1) { - assert.equal($('h1').text(), h1); - } - - if (p) { - assert.equal($('p').text(), p); - } - - if (htmlMatch) { - assert.equal(html, htmlMatch); - } - }); - }); - }); -}); diff --git a/packages/integrations/cloudflare/test/routing-priority.test.ts b/packages/integrations/cloudflare/test/routing-priority.test.ts new file mode 100644 index 000000000000..68a13639d437 --- /dev/null +++ b/packages/integrations/cloudflare/test/routing-priority.test.ts @@ -0,0 +1,268 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { type DevServer, type Fixture, loadFixture } from './test-utils.ts'; + +const routes = [ + { + description: 'matches / to index.astro', + url: '/', + h1: 'index.astro', + }, + { + description: 'matches /slug-1 to [slug].astro', + url: '/slug-1', + h1: '[slug].astro', + p: 'slug-1', + }, + { + description: 'matches /slug-2 to [slug].astro', + url: '/slug-2', + h1: '[slug].astro', + p: 'slug-2', + }, + { + description: 'matches /page-1 to [page].astro', + url: '/page-1', + h1: '[page].astro', + p: 'page-1', + }, + { + description: 'matches /page-2 to [page].astro', + url: '/page-2', + h1: '[page].astro', + p: 'page-2', + }, + { + description: 'matches /posts/post-1 to posts/[pid].astro', + url: '/posts/post-1', + h1: 'posts/[pid].astro', + p: 'post-1', + }, + { + description: 'matches /posts/post-2 to posts/[pid].astro', + url: '/posts/post-2', + h1: 'posts/[pid].astro', + p: 'post-2', + }, + { + description: 'matches /posts/1/2 to posts/[...slug].astro', + url: '/posts/1/2', + h1: 'posts/[...slug].astro', + p: '1/2', + }, + { + description: 'matches /de to de/index.astro', + url: '/de', + h1: 'de/index.astro (priority)', + }, + { + description: 'matches /en to [lang]/index.astro', + url: '/en', + h1: '[lang]/index.astro', + p: 'en', + }, + { + description: 'matches /de/1/2 to [lang]/[...catchall].astro', + url: '/de/1/2', + h1: '[lang]/[...catchall].astro', + p: 'de | 1/2', + }, + { + description: 'matches /en/1/2 to [lang]/[...catchall].astro', + url: '/en/1/2', + h1: '[lang]/[...catchall].astro', + p: 'en | 1/2', + }, + { + description: 'matches /injected to to-inject.astro', + url: '/injected', + h1: 'to-inject.astro', + }, + { + description: 'matches /_injected to to-inject.astro', + url: '/_injected', + h1: 'to-inject.astro', + }, + { + description: 'matches /injected-1 to [id].astro', + url: '/injected-1', + h1: '[id].astro', + p: 'injected-1', + }, + { + description: 'matches /injected-2 to [id].astro', + url: '/injected-2', + h1: '[id].astro', + p: 'injected-2', + }, + { + description: 'matches /empty-slug to empty-slug/[...slug].astro', + url: '/empty-slug', + h1: 'empty-slug/[...slug].astro', + p: 'slug: ', + }, + { + description: 'do not match /empty-slug/undefined to empty-slug/[...slug].astro', + url: '/empty-slug/undefined', + fourOhFour: true, + }, + { + description: 'do not match /empty-paths/hello to empty-paths/[...slug].astro', + url: '/empty-paths/hello', + fourOhFour: true, + }, + { + description: 'matches /api/catch/a.json to api/catch/[...slug].json.ts', + url: '/api/catch/a.json', + htmlMatch: JSON.stringify({ path: 'a' }), + }, + { + description: 'matches /api/catch/b/c.json to api/catch/[...slug].json.ts', + url: '/api/catch/b/c.json', + htmlMatch: JSON.stringify({ path: 'b/c' }), + }, + { + description: 'matches /api/catch/a-b.json to api/catch/[foo]-[bar].json.ts', + url: '/api/catch/a-b.json', + htmlMatch: JSON.stringify({ foo: 'a', bar: 'b' }), + }, +]; + +function appendForwardSlash(path: string) { + return path.endsWith('/') ? path : path + '/'; +} + +describe('Routing priority', () => { + describe('build', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/routing-priority/', + }); + await fixture.build(); + }); + + routes.forEach(({ description, url, fourOhFour, h1, p, htmlMatch }) => { + const isEndpoint = htmlMatch && !h1 && !p; + + it(description, async () => { + const htmlFile = `/client/${isEndpoint ? url : `${appendForwardSlash(url)}index.html`}`; + + if (fourOhFour) { + assert.equal(fixture.pathExists(htmlFile), false); + return; + } + + const html = await fixture.readFile(htmlFile); + const $ = cheerioLoad(html); + + if (h1) { + assert.equal($('h1').text(), h1); + } + + if (p) { + assert.equal($('p').text(), p); + } + + if (htmlMatch) { + assert.equal(html, htmlMatch); + } + }); + }); + }); + + describe('dev', () => { + let fixture: Fixture; + let devServer: DevServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/routing-priority/', + }); + + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + routes.forEach(({ description, url, fourOhFour, h1, p, htmlMatch }) => { + const isEndpoint = htmlMatch && !h1 && !p; + + // checks URLs as written above + it(description, async () => { + const html = await fixture.fetch(url).then((res) => res.text()); + const $ = cheerioLoad(html); + + if (fourOhFour) { + assert.equal($('title').text(), '404: Not Found'); + return; + } + + if (h1) { + assert.equal($('h1').text(), h1); + } + + if (p) { + assert.equal($('p').text(), p); + } + + if (htmlMatch) { + assert.equal(html, htmlMatch); + } + }); + + // skip for endpoint page test + if (isEndpoint) return; + + // checks with trailing slashes, ex: '/de/' instead of '/de' + it(`${description} (trailing slash)`, async () => { + const html = await fixture.fetch(appendForwardSlash(url)).then((res) => res.text()); + const $ = cheerioLoad(html); + + if (fourOhFour) { + assert.equal($('title').text(), '404: Not Found'); + return; + } + + if (h1) { + assert.equal($('h1').text(), h1); + } + + if (p) { + assert.equal($('p').text(), p); + } + + if (htmlMatch) { + assert.equal(html, htmlMatch); + } + }); + + // checks with index.html, ex: '/de/index.html' instead of '/de' + it(`${description} (index.html)`, async () => { + const html = await fixture + .fetch(`${appendForwardSlash(url)}index.html`) + .then((res) => res.text()); + const $ = cheerioLoad(html); + + if (fourOhFour) { + assert.equal($('title').text(), '404: Not Found'); + return; + } + + if (h1) { + assert.equal($('h1').text(), h1); + } + + if (p) { + assert.equal($('p').text(), p); + } + + if (htmlMatch) { + assert.equal(html, htmlMatch); + } + }); + }); + }); +}); diff --git a/packages/integrations/cloudflare/test/server-entry.test.js b/packages/integrations/cloudflare/test/server-entry.test.js deleted file mode 100644 index 6c4c8e34ee9b..000000000000 --- a/packages/integrations/cloudflare/test/server-entry.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, it } from 'node:test'; -import { loadFixture } from './_test-utils.js'; -import assert from 'node:assert/strict'; -import { existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -describe('Server entry', () => { - let fixture; - it('should load the custom entry when using legacy entrypoint', async () => { - fixture = await loadFixture({ - root: './fixtures/server-entry', - output: 'server', - }); - - await fixture.build(); - - const itExits = existsSync(fileURLToPath(new URL('server/custom.mjs', fixture.config.outDir))); - - assert.ok(itExits); - }); -}); diff --git a/packages/integrations/cloudflare/test/server-entry.test.ts b/packages/integrations/cloudflare/test/server-entry.test.ts new file mode 100644 index 000000000000..0e662f4a8836 --- /dev/null +++ b/packages/integrations/cloudflare/test/server-entry.test.ts @@ -0,0 +1,21 @@ +import { describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +describe('Server entry', () => { + let fixture: Fixture; + it('should load the custom entry when using legacy entrypoint', async () => { + fixture = await loadFixture({ + root: './fixtures/server-entry', + output: 'server', + }); + + await fixture.build(); + + const itExits = existsSync(fileURLToPath(new URL('server/custom.mjs', fixture.config.outDir))); + + assert.ok(itExits); + }); +}); diff --git a/packages/integrations/cloudflare/test/server-island-prerender-deps.test.js b/packages/integrations/cloudflare/test/server-island-prerender-deps.test.js deleted file mode 100644 index b541f13f8908..000000000000 --- a/packages/integrations/cloudflare/test/server-island-prerender-deps.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import assert from 'node:assert/strict'; -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { loadFixture } from './_test-utils.js'; - -async function readFilesRecursive(dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }); - const files = await Promise.all( - entries.map(async (entry) => { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - return readFilesRecursive(fullPath); - } - return [fullPath]; - }), - ); - return files.flat(); -} - -describe('Cloudflare server island prerender dependencies', () => { - it('bundles third-party imports for prerender-only server islands', async () => { - const fixture = await loadFixture({ - root: './fixtures/server-island-prerender-deps/', - }); - - await fixture.build(); - - const serverOutputDir = fileURLToPath(fixture.config.build.server); - const outputFiles = await readFilesRecursive(serverOutputDir); - const islandChunkPath = outputFiles.find((file) => { - const normalized = file.replaceAll(path.sep, '/'); - return normalized.includes('/chunks/Island_') && normalized.endsWith('.mjs'); - }); - - assert.ok(islandChunkPath, 'Server island chunk should be emitted'); - - const islandChunkCode = await fs.readFile(islandChunkPath, 'utf-8'); - assert.equal( - islandChunkCode.includes("from 'devalue'") || islandChunkCode.includes('from "devalue"'), - false, - `Server island chunk should not keep bare devalue imports:\n${islandChunkCode}`, - ); - }); - - it('renders framework components in prerender-only server islands', async () => { - const fixture = await loadFixture({ - root: './fixtures/server-island-prerender-framework/', - }); - - await fixture.build(); - const previewServer = await fixture.preview({ - server: { - host: '127.0.0.1', - port: 48081, - }, - }); - - try { - const pageRes = await fixture.fetch('/'); - assert.equal(pageRes.status, 200); - const pageHtml = await pageRes.text(); - - const islandUrlMatch = /fetch\((["'])(\/_server-islands\/[^"']+)\1/.exec(pageHtml); - assert.ok( - islandUrlMatch, - `Expected prerendered HTML to include server island fetch URL, got:\n${pageHtml}`, - ); - - const islandRes = await fixture.fetch(islandUrlMatch[2]); - assert.equal(islandRes.status, 200); - const islandHtml = await islandRes.text(); - - assert.ok( - islandHtml.includes('id="framework-content"'), - `Expected framework content in server island response, got:\n${islandHtml}`, - ); - } finally { - await previewServer.stop(); - await fixture.clean(); - } - }); -}); diff --git a/packages/integrations/cloudflare/test/server-island-prerender-deps.test.ts b/packages/integrations/cloudflare/test/server-island-prerender-deps.test.ts new file mode 100644 index 000000000000..b099ebfb4b27 --- /dev/null +++ b/packages/integrations/cloudflare/test/server-island-prerender-deps.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { loadFixture } from './test-utils.ts'; + +async function readFilesRecursive(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files: string[][] = await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + return readFilesRecursive(fullPath); + } + return [fullPath]; + }), + ); + return files.flat(); +} + +describe('Cloudflare server island prerender dependencies', () => { + it('bundles third-party imports for prerender-only server islands', async () => { + const fixture = await loadFixture({ + root: './fixtures/server-island-prerender-deps/', + }); + + await fixture.build(); + + const serverOutputDir = fileURLToPath(fixture.config.build.server); + const outputFiles = await readFilesRecursive(serverOutputDir); + const islandChunkPath = outputFiles.find((file) => { + const normalized = file.replaceAll(path.sep, '/'); + return normalized.includes('/chunks/Island_') && normalized.endsWith('.mjs'); + }); + + assert.ok(islandChunkPath, 'Server island chunk should be emitted'); + + const islandChunkCode = await fs.readFile(islandChunkPath, 'utf-8'); + assert.equal( + islandChunkCode.includes("from 'devalue'") || islandChunkCode.includes('from "devalue"'), + false, + `Server island chunk should not keep bare devalue imports:\n${islandChunkCode}`, + ); + }); + + it('renders framework components in prerender-only server islands', async () => { + const fixture = await loadFixture({ + root: './fixtures/server-island-prerender-framework/', + }); + + await fixture.build(); + const previewServer = await fixture.preview({ + server: { + host: '127.0.0.1', + port: 48081, + }, + }); + + try { + const pageRes = await fixture.fetch('/'); + assert.equal(pageRes.status, 200); + const pageHtml = await pageRes.text(); + + const islandUrlMatch = /fetch\((["'])(\/_server-islands\/[^"']+)\1/.exec(pageHtml); + assert.ok( + islandUrlMatch, + `Expected prerendered HTML to include server island fetch URL, got:\n${pageHtml}`, + ); + + const islandRes = await fixture.fetch(islandUrlMatch[2]); + assert.equal(islandRes.status, 200); + const islandHtml = await islandRes.text(); + + assert.ok( + islandHtml.includes('id="framework-content"'), + `Expected framework content in server island response, got:\n${islandHtml}`, + ); + } finally { + await previewServer.stop(); + await fixture.clean(); + } + }); +}); diff --git a/packages/integrations/cloudflare/test/sessions.test.js b/packages/integrations/cloudflare/test/sessions.test.js deleted file mode 100644 index cd2a3de04fcc..000000000000 --- a/packages/integrations/cloudflare/test/sessions.test.js +++ /dev/null @@ -1,147 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as devalue from 'devalue'; -import cloudflare from '../dist/index.js'; -import { loadFixture } from './_test-utils.js'; - -describe('sessions', () => { - let fixture; - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/sessions/', - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('can regenerate session cookies upon request', async () => { - const firstResponse = await fixture.fetch('/regenerate', { method: 'GET' }); - const firstHeaders = firstResponse.headers.get('set-cookie').split(','); - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - - const secondResponse = await fixture.fetch('/regenerate', { - method: 'GET', - headers: { - cookie: `astro-session=${firstSessionId}`, - }, - }); - const secondHeaders = secondResponse.headers.get('set-cookie').split(','); - const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; - assert.notEqual(firstSessionId, secondSessionId); - }); - - it('can save session data by value', async () => { - const firstResponse = await fixture.fetch('/update', { method: 'GET' }); - const firstValue = await firstResponse.json(); - assert.equal(firstValue.previousValue, 'none'); - - const firstHeaders = firstResponse.headers.get('set-cookie').split(','); - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - const secondResponse = await fixture.fetch('/update', { - method: 'GET', - headers: { - cookie: `astro-session=${firstSessionId}`, - }, - }); - const secondValue = await secondResponse.json(); - assert.equal(secondValue.previousValue, 'expected'); - }); - - it('can save and restore URLs in session data', async () => { - const firstResponse = await fixture.fetch('/_actions/addUrl', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), - }); - - assert.equal(firstResponse.ok, true); - const firstHeaders = firstResponse.headers.get('set-cookie').split(','); - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - - const data = devalue.parse(await firstResponse.text()); - assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); - const secondResponse = await fixture.fetch('/_actions/addUrl', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - cookie: `astro-session=${firstSessionId}`, - }, - body: JSON.stringify({ favoriteUrl: 'https://example.com' }), - }); - const secondData = devalue.parse(await secondResponse.text()); - assert.equal( - secondData.message, - 'Favorite URL set to https://example.com/ from https://domain.invalid/', - ); - }); -}); - -describe('sessions with custom binding name', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/sessions/', - adapter: cloudflare({ - sessionKVBindingName: 'CUSTOM_SESSION', - }), - }); - }); - - it('can build with custom session binding name', async () => { - await assert.doesNotReject( - async () => { - await fixture.build(); - }, - undefined, - 'Building with custom session binding name should not throw an error', - ); - }); -}); - -describe('session wrangler config', () => { - it('does not include the SESSION KV binding when sessions are disabled', async () => { - const fixture = await loadFixture({ - root: './fixtures/static/', - }); - - await fixture.build({ - session: { - driver: { - entrypoint: 'unstorage/drivers/null', - }, - }, - }); - - const wrangler = JSON.parse(await fixture.readFile('/server/wrangler.json')); - assert.equal( - wrangler.kv_namespaces?.some(({ binding }) => binding === 'SESSION'), - false, - ); - }); - - it('includes the SESSION KV binding when Cloudflare KV is configured explicitly', async () => { - const fixture = await loadFixture({ - root: './fixtures/static/', - }); - - await fixture.build({ - session: { - driver: { - entrypoint: 'unstorage/drivers/cloudflare-kv-binding', - }, - }, - }); - - const wrangler = JSON.parse(await fixture.readFile('/server/wrangler.json')); - assert.deepEqual(wrangler.kv_namespaces, [{ binding: 'SESSION' }]); - }); -}); diff --git a/packages/integrations/cloudflare/test/sessions.test.ts b/packages/integrations/cloudflare/test/sessions.test.ts new file mode 100644 index 000000000000..d651a4810026 --- /dev/null +++ b/packages/integrations/cloudflare/test/sessions.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as devalue from 'devalue'; +import cloudflare from '../dist/index.js'; +import { type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; +import type { AstroInlineConfig } from 'astro'; + +describe('sessions', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/sessions/', + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + }); + + it('can regenerate session cookies upon request', async () => { + const firstResponse = await fixture.fetch('/regenerate', { method: 'GET' }); + const firstHeaders = firstResponse.headers.get('set-cookie')!.split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const secondResponse = await fixture.fetch('/regenerate', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondHeaders = secondResponse.headers.get('set-cookie')!.split(','); + const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; + assert.notEqual(firstSessionId, secondSessionId); + }); + + it('can save session data by value', async () => { + const firstResponse = await fixture.fetch('/update', { method: 'GET' }); + const firstValue = await firstResponse.json(); + assert.equal(firstValue.previousValue, 'none'); + + const firstHeaders = firstResponse.headers.get('set-cookie')!.split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + const secondResponse = await fixture.fetch('/update', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondValue = await secondResponse.json(); + assert.equal(secondValue.previousValue, 'expected'); + }); + + it('can save and restore URLs in session data', async () => { + const firstResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), + }); + + assert.equal(firstResponse.ok, true); + const firstHeaders = firstResponse.headers.get('set-cookie')!.split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const data = devalue.parse(await firstResponse.text()); + assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); + const secondResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: `astro-session=${firstSessionId}`, + }, + body: JSON.stringify({ favoriteUrl: 'https://example.com' }), + }); + const secondData = devalue.parse(await secondResponse.text()); + assert.equal( + secondData.message, + 'Favorite URL set to https://example.com/ from https://domain.invalid/', + ); + }); +}); + +describe('sessions with custom binding name', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/sessions/', + adapter: cloudflare({ + sessionKVBindingName: 'CUSTOM_SESSION', + }), + }); + }); + + it('can build with custom session binding name', async () => { + await assert.doesNotReject(async () => { + await fixture.build(); + }, 'Building with custom session binding name should not throw an error'); + }); +}); + +describe('session wrangler config', () => { + it('does not include the SESSION KV binding when sessions are disabled', async () => { + const fixture = await loadFixture({ + root: './fixtures/static/', + }); + + const config: AstroInlineConfig = { + session: { + // @ts-expect-error: the default type of the TDriver in AstroUserConfig must be changed so that this can pass + driver: { + entrypoint: 'unstorage/drivers/null', + }, + }, + }; + await fixture.build(config); + + const wrangler = JSON.parse(await fixture.readFile('/server/wrangler.json')) as { + kv_namespaces?: Array<{ binding: string }>; + }; + assert.equal( + wrangler.kv_namespaces?.some(({ binding }) => binding === 'SESSION'), + false, + ); + }); + + it('includes the SESSION KV binding when Cloudflare KV is configured explicitly', async () => { + const fixture = await loadFixture({ + root: './fixtures/static/', + }); + + const config: AstroInlineConfig = { + session: { + // @ts-expect-error: the default type of the TDriver in AstroUserConfig must be changed so that this can pass + driver: { + entrypoint: 'unstorage/drivers/cloudflare-kv-binding', + }, + }, + }; + await fixture.build(config); + + const wrangler = JSON.parse(await fixture.readFile('/server/wrangler.json')) as { + kv_namespaces?: Array<{ binding: string }>; + }; + assert.deepEqual(wrangler.kv_namespaces, [{ binding: 'SESSION' }]); + }); +}); diff --git a/packages/integrations/cloudflare/test/sql-import.test.js b/packages/integrations/cloudflare/test/sql-import.test.js deleted file mode 100644 index 29d6528db27b..000000000000 --- a/packages/integrations/cloudflare/test/sql-import.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './_test-utils.js'; - -describe('SQL Import', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/sql-import/', - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('can import .sql files', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#query').text().includes('SELECT * FROM users'), true); - }); - }); - - describe('build', () => { - let previewServer; - - before(async () => { - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('can import .sql files', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#query').text().includes('SELECT * FROM users'), true); - }); - }); -}); diff --git a/packages/integrations/cloudflare/test/sql-import.test.ts b/packages/integrations/cloudflare/test/sql-import.test.ts new file mode 100644 index 000000000000..fc72a668a3bb --- /dev/null +++ b/packages/integrations/cloudflare/test/sql-import.test.ts @@ -0,0 +1,52 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type DevServer, type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; + +describe('SQL Import', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/sql-import/', + }); + }); + + describe('dev', () => { + let devServer: DevServer; + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('can import .sql files', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#query').text().includes('SELECT * FROM users'), true); + }); + }); + + describe('build', () => { + let previewServer: PreviewServer; + before(async () => { + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + }); + + it('can import .sql files', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#query').text().includes('SELECT * FROM users'), true); + }); + }); +}); diff --git a/packages/integrations/cloudflare/test/ssr-deps.test.js b/packages/integrations/cloudflare/test/ssr-deps.test.js deleted file mode 100644 index 92d2973e2f01..000000000000 --- a/packages/integrations/cloudflare/test/ssr-deps.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { rmSync } from 'node:fs'; -import { Writable } from 'node:stream'; -import { after, before, describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { Logger } from '../../../astro/dist/core/logger/core.js'; -import { loadFixture } from './_test-utils.js'; - -describe('SSR dependencies', () => { - let fixture; - let devServer; - const logs = []; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/ssr-deps/', - }); - - // Clear Vite cache to ensure dependencies are discovered fresh - const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); - rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); - - devServer = await fixture.startDevServer({ - logger: new Logger({ - level: 'info', - dest: new Writable({ - objectMode: true, - write(event, _, callback) { - logs.push(event); - callback(); - }, - }), - }), - }); - }); - - after(async () => { - await devServer?.stop(); - }); - - it('should discover server-side dependencies ahead of time', async () => { - // Make a request to trigger SSR rendering which uses the `ms` dependency - const res = await fixture.fetch('/'); - const html = await res.text(); - - // Verify the page rendered correctly with the dependency - assert.ok(html.includes('172800000'), 'Expected ms() to compute 2 days in milliseconds'); - - // Check that we didn't get the "new dependencies optimized" warning - // This message indicates dependencies weren't discovered ahead of time - const optimizedLog = logs.find( - (log) => log.message && log.message.includes('new dependencies optimized'), - ); - - assert.ok( - !optimizedLog, - `Should not see "new dependencies optimized" message, but got: ${optimizedLog?.message}`, - ); - }); -}); diff --git a/packages/integrations/cloudflare/test/ssr-deps.test.ts b/packages/integrations/cloudflare/test/ssr-deps.test.ts new file mode 100644 index 000000000000..1087b7b3fe16 --- /dev/null +++ b/packages/integrations/cloudflare/test/ssr-deps.test.ts @@ -0,0 +1,62 @@ +import * as assert from 'node:assert/strict'; +import { rmSync } from 'node:fs'; +import { Writable } from 'node:stream'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { AstroLogger } from '../../../astro/dist/core/logger/core.js'; +import { type DevServer, type Fixture, loadFixture } from './test-utils.ts'; + +describe('SSR dependencies', () => { + let fixture: Fixture; + let devServer: DevServer; + const logs: Array<{ message?: string }> = []; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr-deps/', + }); + + // Clear Vite cache to ensure dependencies are discovered fresh + const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); + rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); + + const logger = new AstroLogger({ + level: 'info', + destination: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }); + devServer = await fixture.startDevServer({ + // @ts-expect-error: logger is internal API + logger, + }); + }); + + after(async () => { + await devServer?.stop(); + }); + + it('should discover server-side dependencies ahead of time', async () => { + // Make a request to trigger SSR rendering which uses the `ms` dependency + const res = await fixture.fetch('/'); + const html = await res.text(); + + // Verify the page rendered correctly with the dependency + assert.ok(html.includes('172800000'), 'Expected ms() to compute 2 days in milliseconds'); + + // Check that we didn't get the "new dependencies optimized" warning + // This message indicates dependencies weren't discovered ahead of time + const optimizedLog = logs.find( + (log) => log.message && log.message.includes('new dependencies optimized'), + ); + + assert.ok( + !optimizedLog, + `Should not see "new dependencies optimized" message, but got: ${optimizedLog?.message}`, + ); + }); +}); diff --git a/packages/integrations/cloudflare/test/static.test.js b/packages/integrations/cloudflare/test/static.test.js deleted file mode 100644 index cc7def3167a7..000000000000 --- a/packages/integrations/cloudflare/test/static.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, it } from 'node:test'; -import { loadFixture } from './_test-utils.js'; -import assert from 'node:assert/strict'; -import { existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -describe('Static output', () => { - let fixture; - - it('should not output a _worker.js directory for fully static sites', async () => { - fixture = await loadFixture({ - root: './fixtures/static', - }); - - await fixture.build(); - - const workerExists = existsSync(fileURLToPath(new URL('_worker.js', fixture.config.outDir))); - - assert.ok(!workerExists, '_worker.js directory should not exist for static sites'); - }); -}); diff --git a/packages/integrations/cloudflare/test/static.test.ts b/packages/integrations/cloudflare/test/static.test.ts new file mode 100644 index 000000000000..216730fc93b0 --- /dev/null +++ b/packages/integrations/cloudflare/test/static.test.ts @@ -0,0 +1,20 @@ +import { describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +describe('Static output', () => { + let fixture: Fixture; + it('should not output a _worker.js directory for fully static sites', async () => { + fixture = await loadFixture({ + root: './fixtures/static', + }); + + await fixture.build(); + + const workerExists = existsSync(fileURLToPath(new URL('_worker.js', fixture.config.outDir))); + + assert.ok(!workerExists, '_worker.js directory should not exist for static sites'); + }); +}); diff --git a/packages/integrations/cloudflare/test/svelte-rune-deps.test.js b/packages/integrations/cloudflare/test/svelte-rune-deps.test.js deleted file mode 100644 index b5430440153f..000000000000 --- a/packages/integrations/cloudflare/test/svelte-rune-deps.test.js +++ /dev/null @@ -1,52 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { after, before, describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { loadFixture } from './_test-utils.js'; - -describe('Svelte rune dependencies', () => { - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/svelte-rune-deps/', - }); - - const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); - rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); - - const depDir = new URL('./node_modules/rune-dep/', fixture.config.root); - mkdirSync(fileURLToPath(depDir), { recursive: true }); - writeFileSync( - fileURLToPath(new URL('./package.json', depDir)), - JSON.stringify({ name: 'rune-dep', type: 'module', exports: './index.svelte.js' }), - ); - writeFileSync( - fileURLToPath(new URL('./index.svelte.js', depDir)), - `let count = $state(0); - -export function getCount() { - return count; -} -`, - ); - - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer?.stop(); - await fixture.clean(); - const depDir = new URL('./node_modules/rune-dep/', fixture.config.root); - rmSync(fileURLToPath(depDir), { recursive: true, force: true }); - }); - - it('compiles .svelte.js dependencies in cloudflare dev', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - - const html = await res.text(); - assert.ok(html.includes('Rune Count: 0')); - }); -}); diff --git a/packages/integrations/cloudflare/test/svelte-rune-deps.test.ts b/packages/integrations/cloudflare/test/svelte-rune-deps.test.ts new file mode 100644 index 000000000000..c28cdcb1df93 --- /dev/null +++ b/packages/integrations/cloudflare/test/svelte-rune-deps.test.ts @@ -0,0 +1,51 @@ +import * as assert from 'node:assert/strict'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { type DevServer, type Fixture, loadFixture } from './test-utils.ts'; + +describe('Svelte rune dependencies', () => { + let fixture: Fixture; + let devServer: DevServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/svelte-rune-deps/', + }); + + const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); + rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); + + const depDir = new URL('./node_modules/rune-dep/', fixture.config.root); + mkdirSync(fileURLToPath(depDir), { recursive: true }); + writeFileSync( + fileURLToPath(new URL('./package.json', depDir)), + JSON.stringify({ name: 'rune-dep', type: 'module', exports: './index.svelte.js' }), + ); + writeFileSync( + fileURLToPath(new URL('./index.svelte.js', depDir)), + `let count = $state(0); + +export function getCount() { + return count; +} +`, + ); + + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer?.stop(); + await fixture.clean(); + const depDir = new URL('./node_modules/rune-dep/', fixture.config.root); + rmSync(fileURLToPath(depDir), { recursive: true, force: true }); + }); + + it('compiles .svelte.js dependencies in cloudflare dev', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + + const html = await res.text(); + assert.ok(html.includes('Rune Count: 0')); + }); +}); diff --git a/packages/integrations/cloudflare/test/test-utils.ts b/packages/integrations/cloudflare/test/test-utils.ts new file mode 100644 index 000000000000..8cd5dafda333 --- /dev/null +++ b/packages/integrations/cloudflare/test/test-utils.ts @@ -0,0 +1,42 @@ +import type { DevServer } from '../../../astro/src/core/dev/dev.js'; +import type { PreviewServer } from '../../../astro/src/types/public/preview.js'; +import { + type AstroInlineConfig, + type Fixture, + loadFixture as baseLoadFixture, +} from '../../../astro/test/test-utils.js'; + +export type { AstroInlineConfig, DevServer, Fixture, PreviewServer }; + +export async function loadFixture(inlineConfig: AstroInlineConfig): Promise { + if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }"); + + // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath + // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test` + const fixture = await baseLoadFixture({ + ...inlineConfig, + root: new URL(inlineConfig.root as string, import.meta.url).toString(), + }); + + // For unknown reasons, the error below could raise during testing. We add a retry mechanism to handle it. + // Some further investigation is needed to understand the root cause. + // + // Unable to build fixture for the attempt 1: Error: There is a new version of the pre-bundle for "/astro/packages/integrations/cloudflare/test/fixtures/with-svelte/node_modules/.vite/deps_ssr/svelte_server.js?v=9924cddf", a page reload is going to ask for it. + const buildWithRetry: Fixture['build'] = async (...args) => { + let err: unknown; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + return await fixture.build(...args); + } catch (error) { + console.error(`Unable to build fixture for the attempt ${attempt}:`, error); + err = error; + } + } + + if (err) { + throw err; + } + }; + + return { ...fixture, build: buildWithRetry }; +} diff --git a/packages/integrations/cloudflare/test/top-level-return.test.js b/packages/integrations/cloudflare/test/top-level-return.test.js deleted file mode 100644 index c801eef880bc..000000000000 --- a/packages/integrations/cloudflare/test/top-level-return.test.js +++ /dev/null @@ -1,62 +0,0 @@ -import { rmSync } from 'node:fs'; -import { describe, before, it } from 'node:test'; -import { Writable } from 'node:stream'; -import { loadFixture } from './_test-utils.js'; -import assert from 'node:assert/strict'; -import { fileURLToPath } from 'node:url'; -import { Logger } from '../../../astro/dist/core/logger/core.js'; - -describe('Top-level Return', () => { - /** @type {import('../../../astro/test/test-utils').Fixture} */ - let fixture; - const logs = []; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/top-level-return/', - }); - - // Clear the Vite cache before testing - const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); - - rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); - - await fixture.build({ - vite: { logLevel: 'error' }, - logger: new Logger({ - level: 'error', - dest: new Writable({ - objectMode: true, - write(event, _, callback) { - logs.push(event); - callback(); - }, - }), - }), - }); - }); - - it('should avoid esbuild top-level return error by replacing with void', async () => { - const topLevelReturnErrorLog = logs.find( - (log) => - log.message && - log.message.includes('Top-level return cannot be used inside an ECMAScript module'), - ); - - assert.ok( - !topLevelReturnErrorLog, - `Should not see "Top-level return cannot be used inside an ECMAScript module" message, but got: ${topLevelReturnErrorLog?.message}`, - ); - }); - - it('should not break JS syntax and should complete dependency scanning successfully', async () => { - const dependencyScanFailedLog = logs.find( - (log) => log.message && log.message.includes('Failed to run dependency scan'), - ); - - assert.ok( - !dependencyScanFailedLog, - `Should not see "Failed to run dependency scan" message, but got: ${dependencyScanFailedLog?.message}`, - ); - }); -}); diff --git a/packages/integrations/cloudflare/test/top-level-return.test.ts b/packages/integrations/cloudflare/test/top-level-return.test.ts new file mode 100644 index 000000000000..d5a9132d3690 --- /dev/null +++ b/packages/integrations/cloudflare/test/top-level-return.test.ts @@ -0,0 +1,64 @@ +import { rmSync } from 'node:fs'; +import { describe, before, it } from 'node:test'; +import { Writable } from 'node:stream'; +import { type Fixture, loadFixture } from './test-utils.ts'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { AstroLogger } from '../../../astro/dist/core/logger/core.js'; + +describe('Top-level Return', () => { + let fixture: Fixture; + const logs: Array<{ message?: string }> = []; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/top-level-return/', + }); + + // Clear the Vite cache before testing + const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); + + rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); + + const logger = new AstroLogger({ + level: 'error', + destination: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }); + + await fixture.build({ + vite: { logLevel: 'error' }, + // @ts-expect-error: logger is internal API + logger, + }); + }); + + it('should avoid esbuild top-level return error by replacing with void', async () => { + const topLevelReturnErrorLog = logs.find( + (log) => + log.message && + log.message.includes('Top-level return cannot be used inside an ECMAScript module'), + ); + + assert.ok( + !topLevelReturnErrorLog, + `Should not see "Top-level return cannot be used inside an ECMAScript module" message, but got: ${topLevelReturnErrorLog?.message}`, + ); + }); + + it('should not break JS syntax and should complete dependency scanning successfully', async () => { + const dependencyScanFailedLog = logs.find( + (log) => log.message && log.message.includes('Failed to run dependency scan'), + ); + + assert.ok( + !dependencyScanFailedLog, + `Should not see "Failed to run dependency scan" message, but got: ${dependencyScanFailedLog?.message}`, + ); + }); +}); diff --git a/packages/integrations/cloudflare/test/with-base.test.js b/packages/integrations/cloudflare/test/with-base.test.js deleted file mode 100644 index 998d630dfc96..000000000000 --- a/packages/integrations/cloudflare/test/with-base.test.js +++ /dev/null @@ -1,47 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { rmSync } from 'node:fs'; -import { Writable } from 'node:stream'; -import { after, before, describe, it } from 'node:test'; -import { loadFixture } from './_test-utils.js'; -import { Logger } from '../../../astro/dist/core/logger/core.js'; -import { fileURLToPath } from 'node:url'; - -describe('base', () => { - let fixture; - const logs = []; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-base/', - }); - - // Clear the Vite cache before testing - const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); - - rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); - - await fixture.build({ - vite: { logLevel: 'debug' }, - logger: new Logger({ - level: 'debug', - dest: new Writable({ - objectMode: true, - write(event, _, callback) { - logs.push(event); - callback(); - }, - }), - }), - }); - }); - - after(async () => { - await fixture.clean(); - }); - - it('correctly prints redirects', async () => { - const fileContent = await fixture.readFile('client/_redirects'); - assert.match(fileContent, /\/a\/redirect\s+\/\s+301/); - assert.match(fileContent, /\/a\/redirect\/\s+\/\s+301/); - }); -}); diff --git a/packages/integrations/cloudflare/test/with-base.test.ts b/packages/integrations/cloudflare/test/with-base.test.ts new file mode 100644 index 000000000000..738c2cd3bcd7 --- /dev/null +++ b/packages/integrations/cloudflare/test/with-base.test.ts @@ -0,0 +1,49 @@ +import * as assert from 'node:assert/strict'; +import { rmSync } from 'node:fs'; +import { Writable } from 'node:stream'; +import { after, before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; +import { AstroLogger } from '../../../astro/dist/core/logger/core.js'; +import { fileURLToPath } from 'node:url'; + +describe('base', () => { + let fixture: Fixture; + const logs: Array<{ message?: string }> = []; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/with-base/', + }); + + // Clear the Vite cache before testing + const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); + + rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); + + const logger = new AstroLogger({ + level: 'debug', + destination: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }); + await fixture.build({ + vite: { logLevel: 'info' }, + // @ts-expect-error: logger is internal API + logger, + }); + }); + + after(async () => { + await fixture.clean(); + }); + + it('correctly prints redirects', async () => { + const fileContent = await fixture.readFile('client/_redirects'); + assert.match(fileContent, /\/a\/redirect\s+\/\s+301/); + assert.match(fileContent, /\/a\/redirect\/\s+\/\s+301/); + }); +}); diff --git a/packages/integrations/cloudflare/test/with-react.test.js b/packages/integrations/cloudflare/test/with-react.test.js deleted file mode 100644 index e57388c36897..000000000000 --- a/packages/integrations/cloudflare/test/with-react.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { rmSync } from 'node:fs'; -import { Writable } from 'node:stream'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './_test-utils.js'; -import { Logger } from '../../../astro/dist/core/logger/core.js'; -import { fileURLToPath } from 'node:url'; - -describe('React', () => { - let fixture; - let previewServer; - const logs = []; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-react/', - }); - - // Clear the Vite cache before testing - const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); - - rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); - - await fixture.build({ - vite: { logLevel: 'debug' }, - logger: new Logger({ - level: 'debug', - dest: new Writable({ - objectMode: true, - write(event, _, callback) { - logs.push(event); - callback(); - }, - }), - }), - }); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - await fixture.clean(); - }); - - it('renders the react component', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('.react').text(), 'React Content'); - }); - - // ref: https://github.com/withastro/astro/issues/15796 - // without pre-optimizing picomatch, a build error occurs in standard repositories, but it's not triggered in this monorepo. - // as a workaround, we verify the fix by checking if the "new dependencies optimized" log is output. - it('picomatch should be pre-optimized', async () => { - const picomatchDependenciesOptimizedLog = logs.find( - (log) => - log.message && - log.message.includes('new dependencies optimized') && - log.message.includes('picomatch'), - ); - - assert.ok( - !picomatchDependenciesOptimizedLog, - `Should not see "new dependencies optimized: picomatch" message, but got: ${picomatchDependenciesOptimizedLog?.message}`, - ); - }); -}); diff --git a/packages/integrations/cloudflare/test/with-react.test.ts b/packages/integrations/cloudflare/test/with-react.test.ts new file mode 100644 index 000000000000..4a91a6c796bb --- /dev/null +++ b/packages/integrations/cloudflare/test/with-react.test.ts @@ -0,0 +1,72 @@ +import * as assert from 'node:assert/strict'; +import { rmSync } from 'node:fs'; +import { Writable } from 'node:stream'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; +import { AstroLogger } from '../../../astro/dist/core/logger/core.js'; +import { fileURLToPath } from 'node:url'; + +describe('React', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + const logs: Array<{ message?: string }> = []; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/with-react/', + }); + + // Clear the Vite cache before testing + const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); + + rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); + + const logger = new AstroLogger({ + level: 'debug', + destination: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }); + await fixture.build({ + vite: { logLevel: 'info' }, + // @ts-expect-error: logger is internal API + logger, + }); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + await fixture.clean(); + }); + + it('renders the react component', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('.react').text(), 'React Content'); + }); + + // ref: https://github.com/withastro/astro/issues/15796 + // without pre-optimizing picomatch, a build error occurs in standard repositories, but it's not triggered in this monorepo. + // as a workaround, we verify the fix by checking if the "new dependencies optimized" log is output. + it('picomatch should be pre-optimized', async () => { + const picomatchDependenciesOptimizedLog = logs.find( + (log) => + log.message && + log.message.includes('new dependencies optimized') && + log.message.includes('picomatch'), + ); + + assert.ok( + !picomatchDependenciesOptimizedLog, + `Should not see "new dependencies optimized: picomatch" message, but got: ${picomatchDependenciesOptimizedLog?.message}`, + ); + }); +}); diff --git a/packages/integrations/cloudflare/test/with-solid-js.test.js b/packages/integrations/cloudflare/test/with-solid-js.test.js deleted file mode 100644 index fc8a27cc4713..000000000000 --- a/packages/integrations/cloudflare/test/with-solid-js.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './_test-utils.js'; - -describe('SolidJS', () => { - let fixture; - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-solid-js/', - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - await fixture.clean(); - }); - - it('renders the solid component', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('.solid').text(), 'Solid Content'); - }); -}); diff --git a/packages/integrations/cloudflare/test/with-solid-js.test.ts b/packages/integrations/cloudflare/test/with-solid-js.test.ts new file mode 100644 index 000000000000..b407af4b4c8a --- /dev/null +++ b/packages/integrations/cloudflare/test/with-solid-js.test.ts @@ -0,0 +1,29 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; + +describe('SolidJS', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/with-solid-js/', + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + await fixture.clean(); + }); + + it('renders the solid component', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('.solid').text(), 'Solid Content'); + }); +}); diff --git a/packages/integrations/cloudflare/test/with-svelte.test.js b/packages/integrations/cloudflare/test/with-svelte.test.js deleted file mode 100644 index 7b654a7dcda2..000000000000 --- a/packages/integrations/cloudflare/test/with-svelte.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './_test-utils.js'; - -describe('Svelte', () => { - let fixture; - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-svelte/', - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - await fixture.clean(); - }); - - it('renders the svelte component', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('.svelte').text(), 'Svelte Content'); - }); -}); diff --git a/packages/integrations/cloudflare/test/with-svelte.test.ts b/packages/integrations/cloudflare/test/with-svelte.test.ts new file mode 100644 index 000000000000..f359aec41b3e --- /dev/null +++ b/packages/integrations/cloudflare/test/with-svelte.test.ts @@ -0,0 +1,29 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; + +describe('Svelte', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/with-svelte/', + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + await fixture.clean(); + }); + + it('renders the svelte component', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('.svelte').text(), 'Svelte Content'); + }); +}); diff --git a/packages/integrations/cloudflare/test/with-vue.test.js b/packages/integrations/cloudflare/test/with-vue.test.js deleted file mode 100644 index 3c8136f36616..000000000000 --- a/packages/integrations/cloudflare/test/with-vue.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './_test-utils.js'; - -describe('Vue', () => { - let fixture; - let previewServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-vue/', - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - await previewServer.stop(); - }); - - it('renders the vue component', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('.vue').text(), 'Vue Content'); - }); -}); diff --git a/packages/integrations/cloudflare/test/with-vue.test.ts b/packages/integrations/cloudflare/test/with-vue.test.ts new file mode 100644 index 000000000000..75b856fe0fb7 --- /dev/null +++ b/packages/integrations/cloudflare/test/with-vue.test.ts @@ -0,0 +1,28 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; + +describe('Vue', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/with-vue/', + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + }); + + it('renders the vue component', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('.vue').text(), 'Vue Content'); + }); +}); diff --git a/packages/integrations/cloudflare/test/wrangler-preview-platform.test.js b/packages/integrations/cloudflare/test/wrangler-preview-platform.test.js deleted file mode 100644 index 9bb1150aaa46..000000000000 --- a/packages/integrations/cloudflare/test/wrangler-preview-platform.test.js +++ /dev/null @@ -1,48 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './_test-utils.js'; - -describe('WranglerPreviewPlatform', () => { - let fixture; - let previewServer; - before(async () => { - fixture = await loadFixture({ - root: './fixtures/wrangler-preview-platform/', - }); - await fixture.build(); - previewServer = await fixture.preview(); - }); - - after(async () => { - previewServer.stop(); - }); - - it('exists', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#hasRuntime').text().includes('true'), true); - }); - - it('has environment variables', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#hasENV').text().includes('true'), true); - }); - - it('has Cloudflare request object', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#hasCF').text().includes('true'), true); - }); - - it('has Cloudflare cache', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#hasCACHES').text().includes('true'), true); - }); -}); diff --git a/packages/integrations/cloudflare/test/wrangler-preview-platform.test.ts b/packages/integrations/cloudflare/test/wrangler-preview-platform.test.ts new file mode 100644 index 000000000000..1d0dfaad90c3 --- /dev/null +++ b/packages/integrations/cloudflare/test/wrangler-preview-platform.test.ts @@ -0,0 +1,48 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type Fixture, loadFixture, type PreviewServer } from './test-utils.ts'; + +describe('WranglerPreviewPlatform', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/wrangler-preview-platform/', + }); + await fixture.build(); + previewServer = await fixture.preview(); + }); + + after(async () => { + previewServer.stop(); + }); + + it('exists', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#hasRuntime').text().includes('true'), true); + }); + + it('has environment variables', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#hasENV').text().includes('true'), true); + }); + + it('has Cloudflare request object', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#hasCF').text().includes('true'), true); + }); + + it('has Cloudflare cache', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('#hasCACHES').text().includes('true'), true); + }); +}); diff --git a/packages/integrations/cloudflare/test/wrangler.test.js b/packages/integrations/cloudflare/test/wrangler.test.ts similarity index 100% rename from packages/integrations/cloudflare/test/wrangler.test.js rename to packages/integrations/cloudflare/test/wrangler.test.ts diff --git a/packages/integrations/cloudflare/tsconfig.test.json b/packages/integrations/cloudflare/tsconfig.test.json new file mode 100644 index 000000000000..fa4f11e4ae8b --- /dev/null +++ b/packages/integrations/cloudflare/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [{ "path": "../../astro/tsconfig.test.json" }] +} diff --git a/packages/integrations/markdoc/package.json b/packages/integrations/markdoc/package.json index 6c8f16da46c3..21d6f611e1e5 100644 --- a/packages/integrations/markdoc/package.json +++ b/packages/integrations/markdoc/package.json @@ -59,7 +59,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test --timeout 100000 \"test/**/*.test.js\"" + "test": "astro-scripts test --timeout 100000 \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "dependencies": { "@astrojs/internal-helpers": "workspace:*", diff --git a/packages/integrations/markdoc/test/content-collections.test.js b/packages/integrations/markdoc/test/content-collections.test.js deleted file mode 100644 index d9e88c868e65..000000000000 --- a/packages/integrations/markdoc/test/content-collections.test.js +++ /dev/null @@ -1,117 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parse as parseDevalue } from 'devalue'; -import { fixLineEndings, loadFixture } from '../../../astro/test/test-utils.js'; -import markdoc from '../dist/index.js'; - -function formatPost(post) { - return { - ...post, - body: fixLineEndings(post.body), - }; -} - -const root = new URL('./fixtures/content-collections/', import.meta.url); - -const sortById = (a, b) => a.id.localeCompare(b.id); - -describe('Markdoc - Content Collections', () => { - let baseFixture; - - before(async () => { - baseFixture = await loadFixture({ - root, - integrations: [markdoc()], - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await baseFixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('loads entry', async () => { - const res = await baseFixture.fetch('/entry.json'); - const post = parseDevalue(await res.text()); - assert.deepEqual(formatPost(post), post1Entry); - }); - - it('loads collection', async () => { - const res = await baseFixture.fetch('/collection.json'); - const posts = parseDevalue(await res.text()); - assert.notEqual(posts, null); - - assert.deepEqual( - posts.sort(sortById).map((post) => formatPost(post)), - [post1Entry, post2Entry, post3Entry], - ); - }); - }); - - describe('build', () => { - before(async () => { - await baseFixture.build(); - }); - - it('loads entry', async () => { - const res = await baseFixture.readFile('/entry.json'); - const post = parseDevalue(res); - assert.deepEqual(formatPost(post), post1Entry); - }); - - it('loads collection', async () => { - const res = await baseFixture.readFile('/collection.json'); - const posts = parseDevalue(res); - assert.notEqual(posts, null); - assert.deepEqual( - posts.sort(sortById).map((post) => formatPost(post)), - [post1Entry, post2Entry, post3Entry], - ); - }); - }); -}); - -const post1Entry = { - id: 'post-1', - collection: 'blog', - data: { - schemaWorks: true, - title: 'Post 1', - }, - body: '## Post 1\n\nThis is the contents of post 1.', - deferredRender: true, - filePath: 'src/content/blog/post-1.mdoc', - digest: '5d5bd98d949e2b9a', -}; - -const post2Entry = { - id: 'post-2', - collection: 'blog', - data: { - schemaWorks: true, - title: 'Post 2', - }, - body: '## Post 2\n\nThis is the contents of post 2.', - deferredRender: true, - filePath: 'src/content/blog/post-2.mdoc', - digest: '595af4b93a4af072', -}; - -const post3Entry = { - id: 'post-3', - collection: 'blog', - data: { - schemaWorks: true, - title: 'Post 3', - }, - body: '## Post 3\n\nThis is the contents of post 3.', - deferredRender: true, - filePath: 'src/content/blog/post-3.mdoc', - digest: 'ef589606e542247e', -}; diff --git a/packages/integrations/markdoc/test/content-collections.test.ts b/packages/integrations/markdoc/test/content-collections.test.ts new file mode 100644 index 000000000000..265db0997991 --- /dev/null +++ b/packages/integrations/markdoc/test/content-collections.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parse as parseDevalue } from 'devalue'; +import { + fixLineEndings, + loadFixture, + type Fixture, + type DevServer, +} from '../../../astro/test/test-utils.js'; +import markdoc from '../dist/index.js'; + +function formatPost(post: T): T { + return { + ...post, + body: fixLineEndings(post.body), + }; +} + +const root = new URL('./fixtures/content-collections/', import.meta.url); + +const sortById = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id); + +describe('Markdoc - Content Collections', () => { + let baseFixture: Fixture; + + before(async () => { + baseFixture = await loadFixture({ + root, + integrations: [markdoc()], + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await baseFixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('loads entry', async () => { + const res = await baseFixture.fetch('/entry.json'); + const post = parseDevalue(await res.text()); + assert.deepEqual(formatPost(post), post1Entry); + }); + + it('loads collection', async () => { + const res = await baseFixture.fetch('/collection.json'); + const posts = parseDevalue(await res.text()); + assert.notEqual(posts, null); + + assert.deepEqual( + posts.sort(sortById).map((post: { id: string; body: string }) => formatPost(post)), + [post1Entry, post2Entry, post3Entry], + ); + }); + }); + + describe('build', () => { + before(async () => { + await baseFixture.build(); + }); + + it('loads entry', async () => { + const res = await baseFixture.readFile('/entry.json'); + const post = parseDevalue(res); + assert.deepEqual(formatPost(post), post1Entry); + }); + + it('loads collection', async () => { + const res = await baseFixture.readFile('/collection.json'); + const posts = parseDevalue(res); + assert.notEqual(posts, null); + assert.deepEqual( + posts.sort(sortById).map((post: { id: string; body: string }) => formatPost(post)), + [post1Entry, post2Entry, post3Entry], + ); + }); + }); +}); + +const post1Entry = { + id: 'post-1', + collection: 'blog', + data: { + schemaWorks: true, + title: 'Post 1', + }, + body: '## Post 1\n\nThis is the contents of post 1.', + deferredRender: true, + filePath: 'src/content/blog/post-1.mdoc', + digest: '5d5bd98d949e2b9a', +}; + +const post2Entry = { + id: 'post-2', + collection: 'blog', + data: { + schemaWorks: true, + title: 'Post 2', + }, + body: '## Post 2\n\nThis is the contents of post 2.', + deferredRender: true, + filePath: 'src/content/blog/post-2.mdoc', + digest: '595af4b93a4af072', +}; + +const post3Entry = { + id: 'post-3', + collection: 'blog', + data: { + schemaWorks: true, + title: 'Post 3', + }, + body: '## Post 3\n\nThis is the contents of post 3.', + deferredRender: true, + filePath: 'src/content/blog/post-3.mdoc', + digest: 'ef589606e542247e', +}; diff --git a/packages/integrations/markdoc/test/content-layer.test.js b/packages/integrations/markdoc/test/content-layer.test.js deleted file mode 100644 index 2c2af3150d8f..000000000000 --- a/packages/integrations/markdoc/test/content-layer.test.js +++ /dev/null @@ -1,90 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const root = new URL('./fixtures/content-layer/', import.meta.url); - -describe('Markdoc - Content Layer', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root, - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('renders content - with components', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - - renderComponentsChecks(html); - }); - - it('renders content - with components inside partials', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - - renderComponentsInsidePartialsChecks(html); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('renders content - with components', async () => { - const html = await fixture.readFile('/index.html'); - - renderComponentsChecks(html); - }); - - it('renders content - with components inside partials', async () => { - const html = await fixture.readFile('/index.html'); - - renderComponentsInsidePartialsChecks(html); - }); - }); -}); - -/** @param {string} html */ -function renderComponentsChecks(html) { - const { document } = parseHTML(html); - const h2 = document.querySelector('h2'); - assert.equal(h2.textContent, 'Post with components'); - - // Renders custom shortcode component - const marquee = document.querySelector('marquee'); - assert.notEqual(marquee, null); - assert.equal(marquee.hasAttribute('data-custom-marquee'), true); - - // Renders Astro Code component - const pre = document.querySelector('pre'); - assert.notEqual(pre, null); - assert.ok(pre.classList.contains('github-dark')); - assert.ok(pre.classList.contains('astro-code')); -} - -/** @param {string} html */ -function renderComponentsInsidePartialsChecks(html) { - const { document } = parseHTML(html); - // renders Counter.tsx - const button = document.querySelector('#counter'); - assert.equal(button.textContent, '1'); - - // renders DeeplyNested.astro - const deeplyNested = document.querySelector('#deeply-nested'); - assert.equal(deeplyNested.textContent, 'Deeply nested partial'); -} diff --git a/packages/integrations/markdoc/test/content-layer.test.ts b/packages/integrations/markdoc/test/content-layer.test.ts new file mode 100644 index 000000000000..d92d28a06d5c --- /dev/null +++ b/packages/integrations/markdoc/test/content-layer.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +const root = new URL('./fixtures/content-layer/', import.meta.url); + +describe('Markdoc - Content Layer', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root, + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('renders content - with components', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + + renderComponentsChecks(html); + }); + + it('renders content - with components inside partials', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + + renderComponentsInsidePartialsChecks(html); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('renders content - with components', async () => { + const html = await fixture.readFile('/index.html'); + + renderComponentsChecks(html); + }); + + it('renders content - with components inside partials', async () => { + const html = await fixture.readFile('/index.html'); + + renderComponentsInsidePartialsChecks(html); + }); + }); +}); + +function renderComponentsChecks(html: string) { + const { document } = parseHTML(html); + const h2 = document.querySelector('h2'); + assert.equal(h2!.textContent, 'Post with components'); + + // Renders custom shortcode component + const marquee = document.querySelector('marquee'); + assert.notEqual(marquee, null); + assert.equal(marquee!.hasAttribute('data-custom-marquee'), true); + + // Renders Astro Code component + const pre = document.querySelector('pre'); + assert.notEqual(pre, null); + assert.ok(pre!.classList.contains('github-dark')); + assert.ok(pre!.classList.contains('astro-code')); +} + +function renderComponentsInsidePartialsChecks(html: string) { + const { document } = parseHTML(html); + // renders Counter.tsx + const button = document.querySelector('#counter'); + assert.equal(button!.textContent, '1'); + + // renders DeeplyNested.astro + const deeplyNested = document.querySelector('#deeply-nested'); + assert.equal(deeplyNested!.textContent, 'Deeply nested partial'); +} diff --git a/packages/integrations/markdoc/test/headings.test.js b/packages/integrations/markdoc/test/headings.test.js deleted file mode 100644 index b39fb9485b12..000000000000 --- a/packages/integrations/markdoc/test/headings.test.js +++ /dev/null @@ -1,232 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -async function getFixture(name) { - return await loadFixture({ - root: new URL(`./fixtures/${name}/`, import.meta.url), - }); -} - -describe('Markdoc - Headings', () => { - let fixture; - - before(async () => { - fixture = await getFixture('headings'); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('applies IDs to headings', async () => { - const res = await fixture.fetch('/headings'); - const html = await res.text(); - const { document } = parseHTML(html); - - idTest(document); - }); - - it('applies IDs to headings containing special characters', async () => { - const res = await fixture.fetch('/headings-with-special-characters'); - const html = await res.text(); - const { document } = parseHTML(html); - - assert.equal(document.querySelector('h2')?.id, 'picture-'); - assert.equal(document.querySelector('h3')?.id, '-sacrebleu--'); - }); - - it('generates the same IDs for other documents with the same headings', async () => { - const res = await fixture.fetch('/headings-stale-cache-check'); - const html = await res.text(); - const { document } = parseHTML(html); - - idTest(document); - }); - - it('generates a TOC with correct info', async () => { - const res = await fixture.fetch('/headings'); - const html = await res.text(); - const { document } = parseHTML(html); - - tocTest(document); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('applies IDs to headings', async () => { - const html = await fixture.readFile('/headings/index.html'); - const { document } = parseHTML(html); - - idTest(document); - }); - - it('generates the same IDs for other documents with the same headings', async () => { - const html = await fixture.readFile('/headings-stale-cache-check/index.html'); - const { document } = parseHTML(html); - - idTest(document); - }); - - it('generates a TOC with correct info', async () => { - const html = await fixture.readFile('/headings/index.html'); - const { document } = parseHTML(html); - - tocTest(document); - }); - }); -}); - -describe('Markdoc - Headings with custom Astro renderer', () => { - let fixture; - - before(async () => { - fixture = await getFixture('headings-custom'); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('applies IDs to headings', async () => { - const res = await fixture.fetch('/headings'); - const html = await res.text(); - const { document } = parseHTML(html); - - idTest(document); - }); - - it('generates the same IDs for other documents with the same headings', async () => { - const res = await fixture.fetch('/headings-stale-cache-check'); - const html = await res.text(); - const { document } = parseHTML(html); - - idTest(document); - }); - - it('generates a TOC with correct info', async () => { - const res = await fixture.fetch('/headings'); - const html = await res.text(); - const { document } = parseHTML(html); - - tocTest(document); - }); - - it('renders Astro component for each heading', async () => { - const res = await fixture.fetch('/headings'); - const html = await res.text(); - const { document } = parseHTML(html); - - astroComponentTest(document); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('applies IDs to headings', async () => { - const html = await fixture.readFile('/headings/index.html'); - const { document } = parseHTML(html); - - idTest(document); - }); - - it('generates the same IDs for other documents with the same headings', async () => { - const html = await fixture.readFile('/headings-stale-cache-check/index.html'); - const { document } = parseHTML(html); - - idTest(document); - }); - - it('generates a TOC with correct info', async () => { - const html = await fixture.readFile('/headings/index.html'); - const { document } = parseHTML(html); - - tocTest(document); - }); - - it('renders Astro component for each heading', async () => { - const html = await fixture.readFile('/headings/index.html'); - const { document } = parseHTML(html); - - astroComponentTest(document); - }); - }); -}); - -const depthToHeadingMap = { - 1: { - slug: 'level-1-heading', - text: 'Level 1 heading', - }, - 2: { - slug: 'level-2-heading', - text: 'Level 2 heading', - }, - 3: { - slug: 'level-3-heading', - text: 'Level 3 heading', - }, - 4: { - slug: 'level-4-heading', - text: 'Level 4 heading', - }, - 5: { - slug: 'id-override', - text: 'Level 5 heading with override', - }, - 6: { - slug: 'level-6-heading', - text: 'Level 6 heading', - }, -}; - -/** @param {Document} document */ -function idTest(document) { - for (const [depth, info] of Object.entries(depthToHeadingMap)) { - assert.equal(document.querySelector(`h${depth}`)?.getAttribute('id'), info.slug); - } -} - -/** @param {Document} document */ -function tocTest(document) { - const toc = document.querySelector('[data-toc] > ul'); - assert.equal(toc.children.length, Object.keys(depthToHeadingMap).length); - - for (const [depth, info] of Object.entries(depthToHeadingMap)) { - const linkEl = toc.querySelector(`a[href="#${info.slug}"]`); - assert.ok(linkEl); - assert.equal(linkEl.getAttribute('data-depth'), depth); - assert.equal(linkEl.textContent.trim(), info.text); - } -} - -/** @param {Document} document */ -function astroComponentTest(document) { - const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); - - for (const heading of headings) { - assert.equal(heading.hasAttribute('data-custom-heading'), true); - } -} diff --git a/packages/integrations/markdoc/test/headings.test.ts b/packages/integrations/markdoc/test/headings.test.ts new file mode 100644 index 000000000000..689dcd65d6ed --- /dev/null +++ b/packages/integrations/markdoc/test/headings.test.ts @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +async function getFixture(name: string) { + return await loadFixture({ + root: new URL(`./fixtures/${name}/`, import.meta.url), + }); +} + +describe('Markdoc - Headings', () => { + let fixture: Fixture; + + before(async () => { + fixture = await getFixture('headings'); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('applies IDs to headings', async () => { + const res = await fixture.fetch('/headings'); + const html = await res.text(); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('applies IDs to headings containing special characters', async () => { + const res = await fixture.fetch('/headings-with-special-characters'); + const html = await res.text(); + const { document } = parseHTML(html); + + assert.equal(document.querySelector('h2')?.id, 'picture-'); + assert.equal(document.querySelector('h3')?.id, '-sacrebleu--'); + }); + + it('generates the same IDs for other documents with the same headings', async () => { + const res = await fixture.fetch('/headings-stale-cache-check'); + const html = await res.text(); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('generates a TOC with correct info', async () => { + const res = await fixture.fetch('/headings'); + const html = await res.text(); + const { document } = parseHTML(html); + + tocTest(document); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('applies IDs to headings', async () => { + const html = await fixture.readFile('/headings/index.html'); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('generates the same IDs for other documents with the same headings', async () => { + const html = await fixture.readFile('/headings-stale-cache-check/index.html'); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('generates a TOC with correct info', async () => { + const html = await fixture.readFile('/headings/index.html'); + const { document } = parseHTML(html); + + tocTest(document); + }); + }); +}); + +describe('Markdoc - Headings with custom Astro renderer', () => { + let fixture: Fixture; + + before(async () => { + fixture = await getFixture('headings-custom'); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('applies IDs to headings', async () => { + const res = await fixture.fetch('/headings'); + const html = await res.text(); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('generates the same IDs for other documents with the same headings', async () => { + const res = await fixture.fetch('/headings-stale-cache-check'); + const html = await res.text(); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('generates a TOC with correct info', async () => { + const res = await fixture.fetch('/headings'); + const html = await res.text(); + const { document } = parseHTML(html); + + tocTest(document); + }); + + it('renders Astro component for each heading', async () => { + const res = await fixture.fetch('/headings'); + const html = await res.text(); + const { document } = parseHTML(html); + + astroComponentTest(document); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('applies IDs to headings', async () => { + const html = await fixture.readFile('/headings/index.html'); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('generates the same IDs for other documents with the same headings', async () => { + const html = await fixture.readFile('/headings-stale-cache-check/index.html'); + const { document } = parseHTML(html); + + idTest(document); + }); + + it('generates a TOC with correct info', async () => { + const html = await fixture.readFile('/headings/index.html'); + const { document } = parseHTML(html); + + tocTest(document); + }); + + it('renders Astro component for each heading', async () => { + const html = await fixture.readFile('/headings/index.html'); + const { document } = parseHTML(html); + + astroComponentTest(document); + }); + }); +}); + +const depthToHeadingMap = { + 1: { + slug: 'level-1-heading', + text: 'Level 1 heading', + }, + 2: { + slug: 'level-2-heading', + text: 'Level 2 heading', + }, + 3: { + slug: 'level-3-heading', + text: 'Level 3 heading', + }, + 4: { + slug: 'level-4-heading', + text: 'Level 4 heading', + }, + 5: { + slug: 'id-override', + text: 'Level 5 heading with override', + }, + 6: { + slug: 'level-6-heading', + text: 'Level 6 heading', + }, +}; + +function idTest(document: Document) { + for (const [depth, info] of Object.entries(depthToHeadingMap)) { + assert.equal(document.querySelector(`h${depth}`)?.getAttribute('id'), info.slug); + } +} + +function tocTest(document: Document) { + const toc = document.querySelector('[data-toc] > ul'); + assert.equal(toc!.children.length, Object.keys(depthToHeadingMap).length); + + for (const [depth, info] of Object.entries(depthToHeadingMap)) { + const linkEl = toc!.querySelector(`a[href="#${info.slug}"]`); + assert.ok(linkEl); + assert.equal(linkEl.getAttribute('data-depth'), depth); + assert.equal(linkEl.textContent.trim(), info.text); + } +} + +function astroComponentTest(document: Document) { + const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); + + for (const heading of headings) { + assert.equal(heading.hasAttribute('data-custom-heading'), true); + } +} diff --git a/packages/integrations/markdoc/test/image-assets.test.js b/packages/integrations/markdoc/test/image-assets.test.js deleted file mode 100644 index 17fd944134d9..000000000000 --- a/packages/integrations/markdoc/test/image-assets.test.js +++ /dev/null @@ -1,106 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const imageAssetsFixture = new URL('./fixtures/image-assets/', import.meta.url); -const imageAssetsCustomFixture = new URL('./fixtures/image-assets-custom/', import.meta.url); - -describe('Markdoc - Image assets', () => { - const configurations = [ - [imageAssetsFixture, 'Standard default image node rendering'], - [imageAssetsCustomFixture, 'Custom default image node component'], - ]; - - for (const [root, description] of configurations) { - describe(description, () => { - let baseFixture; - - before(async () => { - baseFixture = await loadFixture({ - root, - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await baseFixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('uses public/ image paths unchanged', async () => { - const res = await baseFixture.fetch('/'); - const html = await res.text(); - const { document } = parseHTML(html); - assert.equal(document.querySelector('#public > img')?.src, '/favicon.svg'); - }); - - it('transforms relative image paths to optimized path', async () => { - const res = await baseFixture.fetch('/'); - const html = await res.text(); - const { document } = parseHTML(html); - assert.match( - document.querySelector('#relative > img')?.src, - /\/_image\?href=.*%2Fsrc%2Fassets%2Frelative%2Foar.jpg%3ForigWidth%3D420%26origHeight%3D630%26origFormat%3Djpg&w=420&h=630&f=webp/, - ); - }); - - it('transforms aliased image paths to optimized path', async () => { - const res = await baseFixture.fetch('/'); - const html = await res.text(); - const { document } = parseHTML(html); - assert.match( - document.querySelector('#alias > img')?.src, - /\/_image\?href=.*%2Fsrc%2Fassets%2Falias%2Fcityscape.jpg%3ForigWidth%3D420%26origHeight%3D280%26origFormat%3Djpg&w=420&h=280&f=webp/, - ); - }); - - it('passes images inside image tags to configured image component', async () => { - const res = await baseFixture.fetch('/'); - const html = await res.text(); - const { document } = parseHTML(html); - assert.equal(document.querySelector('#component > img')?.className, 'custom-styles'); - }); - }); - - describe('build', () => { - before(async () => { - await baseFixture.build(); - }); - - it('uses public/ image paths unchanged', async () => { - const html = await baseFixture.readFile('/index.html'); - const { document } = parseHTML(html); - assert.equal(document.querySelector('#public > img')?.src, '/favicon.svg'); - }); - - it('transforms relative image paths to optimized path', async () => { - const html = await baseFixture.readFile('/index.html'); - const { document } = parseHTML(html); - assert.match(document.querySelector('#relative > img')?.src, /^\/_astro\/oar.*\.webp$/); - }); - - it('transforms aliased image paths to optimized path', async () => { - const html = await baseFixture.readFile('/index.html'); - const { document } = parseHTML(html); - assert.match( - document.querySelector('#alias > img')?.src, - /^\/_astro\/cityscape.*\.webp$/, - ); - }); - - it('passes images inside image tags to configured image component', async () => { - const html = await baseFixture.readFile('/index.html'); - const { document } = parseHTML(html); - assert.equal(document.querySelector('#component > img')?.className, 'custom-styles'); - assert.match(document.querySelector('#component > img')?.src, /^\/_astro\/oar.*\.webp$/); - }); - }); - }); - } -}); diff --git a/packages/integrations/markdoc/test/image-assets.test.ts b/packages/integrations/markdoc/test/image-assets.test.ts new file mode 100644 index 000000000000..9d125c96d1a4 --- /dev/null +++ b/packages/integrations/markdoc/test/image-assets.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +const imageAssetsFixture = new URL('./fixtures/image-assets/', import.meta.url); +const imageAssetsCustomFixture = new URL('./fixtures/image-assets-custom/', import.meta.url); + +describe('Markdoc - Image assets', () => { + const configurations: [URL, string][] = [ + [imageAssetsFixture, 'Standard default image node rendering'], + [imageAssetsCustomFixture, 'Custom default image node component'], + ]; + + for (const [root, description] of configurations) { + describe(description, () => { + let baseFixture: Fixture; + + before(async () => { + baseFixture = await loadFixture({ + root, + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await baseFixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('uses public/ image paths unchanged', async () => { + const res = await baseFixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + assert.equal( + document.querySelector('#public > img')?.src, + '/favicon.svg', + ); + }); + + it('transforms relative image paths to optimized path', async () => { + const res = await baseFixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + assert.match( + document.querySelector('#relative > img')!.src, + /\/_image\?href=.*%2Fsrc%2Fassets%2Frelative%2Foar.jpg%3ForigWidth%3D420%26origHeight%3D630%26origFormat%3Djpg&w=420&h=630&f=webp/, + ); + }); + + it('transforms aliased image paths to optimized path', async () => { + const res = await baseFixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + assert.match( + document.querySelector('#alias > img')!.src, + /\/_image\?href=.*%2Fsrc%2Fassets%2Falias%2Fcityscape.jpg%3ForigWidth%3D420%26origHeight%3D280%26origFormat%3Djpg&w=420&h=280&f=webp/, + ); + }); + + it('passes images inside image tags to configured image component', async () => { + const res = await baseFixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + assert.equal( + document.querySelector('#component > img')?.className, + 'custom-styles', + ); + }); + }); + + describe('build', () => { + before(async () => { + await baseFixture.build(); + }); + + it('uses public/ image paths unchanged', async () => { + const html = await baseFixture.readFile('/index.html'); + const { document } = parseHTML(html); + assert.equal( + document.querySelector('#public > img')?.src, + '/favicon.svg', + ); + }); + + it('transforms relative image paths to optimized path', async () => { + const html = await baseFixture.readFile('/index.html'); + const { document } = parseHTML(html); + assert.match( + document.querySelector('#relative > img')!.src, + /^\/_astro\/oar.*\.webp$/, + ); + }); + + it('transforms aliased image paths to optimized path', async () => { + const html = await baseFixture.readFile('/index.html'); + const { document } = parseHTML(html); + assert.match( + document.querySelector('#alias > img')!.src, + /^\/_astro\/cityscape.*\.webp$/, + ); + }); + + it('passes images inside image tags to configured image component', async () => { + const html = await baseFixture.readFile('/index.html'); + const { document } = parseHTML(html); + assert.equal( + document.querySelector('#component > img')?.className, + 'custom-styles', + ); + assert.match( + document.querySelector('#component > img')!.src, + /^\/_astro\/oar.*\.webp$/, + ); + }); + }); + }); + } +}); diff --git a/packages/integrations/markdoc/test/propagated-assets.test.js b/packages/integrations/markdoc/test/propagated-assets.test.js deleted file mode 100644 index a0768448f1d9..000000000000 --- a/packages/integrations/markdoc/test/propagated-assets.test.js +++ /dev/null @@ -1,69 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('Markdoc - propagated assets', () => { - let fixture; - let devServer; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/propagated-assets/', import.meta.url), - // test suite was authored when inlineStylesheets defaulted to never - build: { inlineStylesheets: 'never' }, - }); - }); - - const modes = ['dev', 'prod']; - - for (const mode of modes) { - describe(mode, () => { - /** @type {Document} */ - let stylesDocument; - /** @type {Document} */ - let scriptsDocument; - - before(async () => { - if (mode === 'prod') { - await fixture.build(); - stylesDocument = parseHTML(await fixture.readFile('/styles/index.html')).document; - scriptsDocument = parseHTML(await fixture.readFile('/scripts/index.html')).document; - } else if (mode === 'dev') { - devServer = await fixture.startDevServer(); - const styleRes = await fixture.fetch('/styles'); - const scriptRes = await fixture.fetch('/scripts'); - stylesDocument = parseHTML(await styleRes.text()).document; - scriptsDocument = parseHTML(await scriptRes.text()).document; - } - }); - - after(async () => { - if (mode === 'dev') devServer?.stop(); - }); - - it('Bundles styles', async () => { - let styleContents; - if (mode === 'dev') { - const styles = stylesDocument.querySelectorAll('style'); - assert.equal(styles.length, 1); - styleContents = styles[0].textContent; - } else { - const links = stylesDocument.querySelectorAll('link[rel="stylesheet"]'); - assert.equal(links.length, 1); - styleContents = await fixture.readFile(links[0].href); - } - assert.equal(styleContents.includes('--color-base-purple: 269, 79%;'), true); - }); - - it('[fails] Does not bleed styles to other page', async () => { - if (mode === 'dev') { - const styles = scriptsDocument.querySelectorAll('style'); - assert.equal(styles.length, 0); - } else { - const links = scriptsDocument.querySelectorAll('link[rel="stylesheet"]'); - assert.equal(links.length, 0); - } - }); - }); - } -}); diff --git a/packages/integrations/markdoc/test/propagated-assets.test.ts b/packages/integrations/markdoc/test/propagated-assets.test.ts new file mode 100644 index 000000000000..afe4e817e5d9 --- /dev/null +++ b/packages/integrations/markdoc/test/propagated-assets.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +describe('Markdoc - propagated assets', () => { + let fixture: Fixture; + let devServer: DevServer; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/propagated-assets/', import.meta.url), + // test suite was authored when inlineStylesheets defaulted to never + build: { inlineStylesheets: 'never' }, + }); + }); + + const modes = ['dev', 'prod']; + + for (const mode of modes) { + describe(mode, () => { + let stylesDocument: Document; + let scriptsDocument: Document; + + before(async () => { + if (mode === 'prod') { + await fixture.build(); + stylesDocument = parseHTML(await fixture.readFile('/styles/index.html')).document; + scriptsDocument = parseHTML(await fixture.readFile('/scripts/index.html')).document; + } else if (mode === 'dev') { + devServer = await fixture.startDevServer(); + const styleRes = await fixture.fetch('/styles'); + const scriptRes = await fixture.fetch('/scripts'); + stylesDocument = parseHTML(await styleRes.text()).document; + scriptsDocument = parseHTML(await scriptRes.text()).document; + } + }); + + after(async () => { + if (mode === 'dev') devServer?.stop(); + }); + + it('Bundles styles', async () => { + let styleContents; + if (mode === 'dev') { + const styles = stylesDocument.querySelectorAll('style'); + assert.equal(styles.length, 1); + styleContents = styles[0].textContent; + } else { + const links = stylesDocument.querySelectorAll('link[rel="stylesheet"]'); + assert.equal(links.length, 1); + styleContents = await fixture.readFile(links[0].href); + } + assert.equal(styleContents.includes('--color-base-purple: 269, 79%;'), true); + }); + + it('[fails] Does not bleed styles to other page', async () => { + if (mode === 'dev') { + const styles = scriptsDocument.querySelectorAll('style'); + assert.equal(styles.length, 0); + } else { + const links = scriptsDocument.querySelectorAll('link[rel="stylesheet"]'); + assert.equal(links.length, 0); + } + }); + }); + } +}); diff --git a/packages/integrations/markdoc/test/render-components.test.js b/packages/integrations/markdoc/test/render-components.test.js deleted file mode 100644 index e8ddec90976a..000000000000 --- a/packages/integrations/markdoc/test/render-components.test.js +++ /dev/null @@ -1,94 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const root = new URL('./fixtures/render-with-components/', import.meta.url); - -describe('Markdoc - render components', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root, - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('renders content - with components', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - - renderComponentsChecks(html); - }); - - it('renders content - with components inside partials', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - - renderComponentsInsidePartialsChecks(html); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('renders content - with components', async () => { - const html = await fixture.readFile('/index.html'); - - renderComponentsChecks(html); - }); - - it('renders content - with components inside partials', async () => { - const html = await fixture.readFile('/index.html'); - - renderComponentsInsidePartialsChecks(html); - }); - }); -}); - -/** @param {string} html */ -function renderComponentsChecks(html) { - const { document } = parseHTML(html); - const h2 = document.querySelector('h2'); - assert.equal(h2.textContent, 'Post with components'); - - // Renders custom shortcode component - const marquee = document.querySelector('marquee'); - assert.notEqual(marquee, null); - assert.equal(marquee.hasAttribute('data-custom-marquee'), true); - - // Renders Astro Code component - const pre = document.querySelector('pre'); - assert.notEqual(pre, null); - assert.equal(pre.className, 'astro-code github-dark'); - - // Renders 2nd Astro Code component inside if tag - const pre2 = document.querySelectorAll('pre')[1]; - assert.notEqual(pre2, null); - assert.equal(pre2.className, 'astro-code github-dark'); -} - -/** @param {string} html */ -function renderComponentsInsidePartialsChecks(html) { - const { document } = parseHTML(html); - // renders Counter.tsx - const button = document.querySelector('#counter'); - assert.equal(button.textContent, '1'); - - // renders DeeplyNested.astro - const deeplyNested = document.querySelector('#deeply-nested'); - assert.equal(deeplyNested.textContent, 'Deeply nested partial'); -} diff --git a/packages/integrations/markdoc/test/render-components.test.ts b/packages/integrations/markdoc/test/render-components.test.ts new file mode 100644 index 000000000000..74d49c4de798 --- /dev/null +++ b/packages/integrations/markdoc/test/render-components.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +const root = new URL('./fixtures/render-with-components/', import.meta.url); + +describe('Markdoc - render components', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root, + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('renders content - with components', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + + renderComponentsChecks(html); + }); + + it('renders content - with components inside partials', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + + renderComponentsInsidePartialsChecks(html); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('renders content - with components', async () => { + const html = await fixture.readFile('/index.html'); + + renderComponentsChecks(html); + }); + + it('renders content - with components inside partials', async () => { + const html = await fixture.readFile('/index.html'); + + renderComponentsInsidePartialsChecks(html); + }); + }); +}); + +function renderComponentsChecks(html: string) { + const { document } = parseHTML(html); + const h2 = document.querySelector('h2'); + assert.equal(h2!.textContent, 'Post with components'); + + // Renders custom shortcode component + const marquee = document.querySelector('marquee'); + assert.notEqual(marquee, null); + assert.equal(marquee!.hasAttribute('data-custom-marquee'), true); + + // Renders Astro Code component + const pre = document.querySelector('pre'); + assert.notEqual(pre, null); + assert.equal(pre!.className, 'astro-code github-dark'); + + // Renders 2nd Astro Code component inside if tag + const pre2 = document.querySelectorAll('pre')[1]; + assert.notEqual(pre2, null); + assert.equal(pre2!.className, 'astro-code github-dark'); +} + +function renderComponentsInsidePartialsChecks(html: string) { + const { document } = parseHTML(html); + // renders Counter.tsx + const button = document.querySelector('#counter'); + assert.equal(button!.textContent, '1'); + + // renders DeeplyNested.astro + const deeplyNested = document.querySelector('#deeply-nested'); + assert.equal(deeplyNested!.textContent, 'Deeply nested partial'); +} diff --git a/packages/integrations/markdoc/test/render-extends-components.test.js b/packages/integrations/markdoc/test/render-extends-components.test.js deleted file mode 100644 index f5f1454c8e1b..000000000000 --- a/packages/integrations/markdoc/test/render-extends-components.test.js +++ /dev/null @@ -1,69 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const root = new URL('./fixtures/render-with-extends-components/', import.meta.url); - -describe('Markdoc - render components defined in `extends`', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root, - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('renders content - with components', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - - renderComponentsChecks(html); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('renders content - with components', async () => { - const html = await fixture.readFile('/index.html'); - - renderComponentsChecks(html); - }); - }); -}); - -/** @param {string} html */ -function renderComponentsChecks(html) { - const { document } = parseHTML(html); - const h2 = document.querySelector('h2'); - assert.equal(h2.textContent, 'Post with components'); - - // Renders custom shortcode component - const marquee = document.querySelector('marquee'); - assert.notEqual(marquee, null); - assert.equal(marquee.hasAttribute('data-custom-marquee'), true); - - // Renders Astro Code component - const pre = document.querySelector('pre'); - assert.notEqual(pre, null); - assert.equal(pre.className, 'astro-code github-dark'); - - // Renders 2nd Astro Code component inside if tag - const pre2 = document.querySelectorAll('pre')[1]; - assert.notEqual(pre2, null); - assert.equal(pre2.className, 'astro-code github-dark'); -} diff --git a/packages/integrations/markdoc/test/render-extends-components.test.ts b/packages/integrations/markdoc/test/render-extends-components.test.ts new file mode 100644 index 000000000000..7ae4a3349117 --- /dev/null +++ b/packages/integrations/markdoc/test/render-extends-components.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +const root = new URL('./fixtures/render-with-extends-components/', import.meta.url); + +describe('Markdoc - render components defined in `extends`', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root, + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('renders content - with components', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + + renderComponentsChecks(html); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('renders content - with components', async () => { + const html = await fixture.readFile('/index.html'); + + renderComponentsChecks(html); + }); + }); +}); + +function renderComponentsChecks(html: string) { + const { document } = parseHTML(html); + const h2 = document.querySelector('h2'); + assert.equal(h2!.textContent, 'Post with components'); + + // Renders custom shortcode component + const marquee = document.querySelector('marquee'); + assert.notEqual(marquee, null); + assert.equal(marquee!.hasAttribute('data-custom-marquee'), true); + + // Renders Astro Code component + const pre = document.querySelector('pre'); + assert.notEqual(pre, null); + assert.equal(pre!.className, 'astro-code github-dark'); + + // Renders 2nd Astro Code component inside if tag + const pre2 = document.querySelectorAll('pre')[1]; + assert.notEqual(pre2, null); + assert.equal(pre2.className, 'astro-code github-dark'); +} diff --git a/packages/integrations/markdoc/test/render-html.test.js b/packages/integrations/markdoc/test/render-html.test.js deleted file mode 100644 index bb5135cccb12..000000000000 --- a/packages/integrations/markdoc/test/render-html.test.js +++ /dev/null @@ -1,316 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -async function getFixture(name) { - return await loadFixture({ - root: new URL(`./fixtures/${name}/`, import.meta.url), - }); -} - -describe('Markdoc - render html', () => { - let fixture; - - before(async () => { - fixture = await getFixture('render-html'); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('renders content - simple', async () => { - const res = await fixture.fetch('/simple'); - const html = await res.text(); - - renderSimpleChecks(html); - }); - - it('renders content - nested-html', async () => { - const res = await fixture.fetch('/nested-html'); - const html = await res.text(); - - renderNestedHTMLChecks(html); - }); - - it('renders content - components interleaved with html', async () => { - const res = await fixture.fetch('/components'); - const html = await res.text(); - - renderComponentsHTMLChecks(html); - }); - - it('renders content - randomly cased html attributes', async () => { - const res = await fixture.fetch('/randomly-cased-html-attributes'); - const html = await res.text(); - - renderRandomlyCasedHTMLAttributesChecks(html); - }); - - it('renders content - html within partials', async () => { - const res = await fixture.fetch('/with-partial'); - const html = await res.text(); - - renderHTMLWithinPartialChecks(html); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('renders content - simple', async () => { - const html = await fixture.readFile('/simple/index.html'); - - renderSimpleChecks(html); - }); - - it('renders content - nested-html', async () => { - const html = await fixture.readFile('/nested-html/index.html'); - - renderNestedHTMLChecks(html); - }); - - it('renders content - components interleaved with html', async () => { - const html = await fixture.readFile('/components/index.html'); - - renderComponentsHTMLChecks(html); - }); - - it('renders content - randomly cased html attributes', async () => { - const html = await fixture.readFile('/randomly-cased-html-attributes/index.html'); - - renderRandomlyCasedHTMLAttributesChecks(html); - }); - - it('renders content - html within partials', async () => { - const html = await fixture.readFile('/with-partial/index.html'); - - renderHTMLWithinPartialChecks(html); - }); - }); -}); - -/** @param {string} html */ -function renderSimpleChecks(html) { - const { document } = parseHTML(html); - - const h2 = document.querySelector('h2'); - assert.equal(h2.textContent, 'Simple post header'); - - const spanInsideH2 = document.querySelector('h2 > span'); - assert.equal(spanInsideH2.textContent, 'post'); - assert.equal(spanInsideH2.className, 'inside-h2'); - assert.equal(spanInsideH2.style.color, 'fuscia'); - - const p1 = document.querySelector('article > p:nth-of-type(1)'); - assert.equal(p1.children.length, 1); - assert.equal(p1.textContent, 'This is a simple Markdoc post.'); - - const p2 = document.querySelector('article > p:nth-of-type(2)'); - assert.equal(p2.children.length, 0); - assert.equal(p2.textContent, 'This is a paragraph!'); - - const p3 = document.querySelector('article > p:nth-of-type(3)'); - assert.equal(p3.children.length, 1); - assert.equal(p3.textContent, 'This is a span inside a paragraph!'); - - const video = document.querySelector('video'); - assert.ok(video, 'A video element should exist'); - assert.ok(video.hasAttribute('autoplay'), 'The video element should have the autoplay attribute'); - assert.ok(video.hasAttribute('muted'), 'The video element should have the muted attribute'); -} - -/** @param {string} html */ -function renderNestedHTMLChecks(html) { - const { document } = parseHTML(html); - - const p1 = document.querySelector('p:nth-of-type(1)'); - assert.equal(p1.id, 'p1'); - assert.equal(p1.textContent, 'before inner after'); - assert.equal(p1.children.length, 1); - - const p1Span1 = p1.querySelector('span'); - assert.equal(p1Span1.textContent, 'inner'); - assert.equal(p1Span1.id, 'inner1'); - assert.equal(p1Span1.className, 'inner-class'); - assert.equal(p1Span1.style.color, 'hotpink'); - - const p2 = document.querySelector('p:nth-of-type(2)'); - assert.equal(p2.id, 'p2'); - assert.equal(p2.textContent, '\n before\n inner\n after\n'); - assert.equal(p2.children.length, 1); - - const divL1 = document.querySelector('div:nth-of-type(1)'); - assert.equal(divL1.id, 'div-l1'); - assert.equal(divL1.children.length, 2); - - const divL2_1 = divL1.querySelector('div:nth-of-type(1)'); - assert.equal(divL2_1.id, 'div-l2-1'); - assert.equal(divL2_1.children.length, 1); - - const p3 = divL2_1.querySelector('p:nth-of-type(1)'); - assert.equal(p3.id, 'p3'); - assert.equal(p3.textContent, 'before inner after'); - assert.equal(p3.children.length, 1); - - const divL2_2 = divL1.querySelector('div:nth-of-type(2)'); - assert.equal(divL2_2.id, 'div-l2-2'); - assert.equal(divL2_2.children.length, 2); - - const p4 = divL2_2.querySelector('p:nth-of-type(1)'); - assert.equal(p4.id, 'p4'); - assert.equal(p4.textContent, 'before inner after'); - assert.equal(p4.children.length, 1); - - const p5 = divL2_2.querySelector('p:nth-of-type(2)'); - assert.equal(p5.id, 'p5'); - assert.equal(p5.textContent, 'before inner after'); - assert.equal(p5.children.length, 1); -} - -/** - * - * @param {string} html */ -function renderRandomlyCasedHTMLAttributesChecks(html) { - const { document } = parseHTML(html); - - const td1 = document.querySelector('#td1'); - const td2 = document.querySelector('#td1'); - const td3 = document.querySelector('#td1'); - const td4 = document.querySelector('#td1'); - - // all four 's which had randomly cased variants of colspan/rowspan should all be rendered lowercased at this point - - assert.equal(td1.getAttribute('colspan'), '3'); - assert.equal(td1.getAttribute('rowspan'), '2'); - - assert.equal(td2.getAttribute('colspan'), '3'); - assert.equal(td2.getAttribute('rowspan'), '2'); - - assert.equal(td3.getAttribute('colspan'), '3'); - assert.equal(td3.getAttribute('rowspan'), '2'); - - assert.equal(td4.getAttribute('colspan'), '3'); - assert.equal(td4.getAttribute('rowspan'), '2'); -} - -/** - * @param {string} html - */ -function renderHTMLWithinPartialChecks(html) { - const { document } = parseHTML(html); - - const li = document.querySelector('ul > li#partial'); - assert.equal(li.textContent, 'List item'); -} - -/** - * Asserts that the rendered HTML tags with interleaved Markdoc tags (both block and inline) rendered in the expected nested graph of elements - * - * @param {string} html */ -function renderComponentsHTMLChecks(html) { - const { document } = parseHTML(html); - - const naturalP1 = document.querySelector('article > p:nth-of-type(1)'); - assert.equal(naturalP1.textContent, 'This is an inline mark in regular Markdown markup.'); - assert.equal(naturalP1.children.length, 1); - - const p1 = document.querySelector('article > p:nth-of-type(2)'); - assert.equal(p1.id, 'p1'); - assert.equal(p1.textContent, 'This is an inline mark under some HTML'); - assert.equal(p1.children.length, 1); - assertInlineMark(p1.children[0]); - - const div1p1 = document.querySelector('article > #div1 > p:nth-of-type(1)'); - assert.equal(div1p1.id, 'div1-p1'); - assert.equal(div1p1.textContent, 'This is an inline mark under some HTML'); - assert.equal(div1p1.children.length, 1); - assertInlineMark(div1p1.children[0]); - - const div1p2 = document.querySelector('article > #div1 > p:nth-of-type(2)'); - assert.equal(div1p2.id, 'div1-p2'); - assert.equal(div1p2.textContent, 'This is an inline mark under some HTML'); - assert.equal(div1p2.children.length, 1); - - const div1p2span1 = div1p2.querySelector('span'); - assert.equal(div1p2span1.id, 'div1-p2-span1'); - assert.equal(div1p2span1.textContent, 'inline mark'); - assert.equal(div1p2span1.children.length, 1); - assertInlineMark(div1p2span1.children[0]); - - const aside1 = document.querySelector('article > aside:nth-of-type(1)'); - const aside1Title = aside1.querySelector('p.title'); - assert.equal(aside1Title.textContent.trim(), 'Aside One'); - const aside1Section = aside1.querySelector('section'); - const aside1SectionP1 = aside1Section.querySelector('p:nth-of-type(1)'); - assert.equal( - aside1SectionP1.textContent, - "I'm a Markdown paragraph inside a top-level aside tag", - ); - const aside1H2_1 = aside1Section.querySelector('h2:nth-of-type(1)'); - assert.equal(aside1H2_1.id, 'im-an-h2-via-markdown-markup'); // automatic slug - assert.equal(aside1H2_1.textContent, "I'm an H2 via Markdown markup"); - const aside1H2_2 = aside1Section.querySelector('h2:nth-of-type(2)'); - assert.equal(aside1H2_2.id, 'h-two'); - assert.equal(aside1H2_2.textContent, "I'm an H2 via HTML markup"); - const aside1SectionP2 = aside1Section.querySelector('p:nth-of-type(2)'); - assert.equal(aside1SectionP2.textContent, 'Markdown bold vs HTML bold'); - assert.equal(aside1SectionP2.children.length, 2); - const aside1SectionP2Strong1 = aside1SectionP2.querySelector('strong:nth-of-type(1)'); - assert.equal(aside1SectionP2Strong1.textContent, 'Markdown bold'); - const aside1SectionP2Strong2 = aside1SectionP2.querySelector('strong:nth-of-type(2)'); - assert.equal(aside1SectionP2Strong2.textContent, 'HTML bold'); - - const article = document.querySelector('article'); - assert.equal(article.textContent.includes('RENDERED'), true); - assert.notEqual(article.textContent.includes('NOT RENDERED'), true); - - const section1 = document.querySelector('article > #section1'); - const section1div1 = section1.querySelector('#div1'); - const section1Aside1 = section1div1.querySelector('aside:nth-of-type(1)'); - const section1Aside1Title = section1Aside1.querySelector('p.title'); - assert.equal(section1Aside1Title.textContent.trim(), 'Nested un-indented Aside'); - const section1Aside1Section = section1Aside1.querySelector('section'); - const section1Aside1SectionP1 = section1Aside1Section.querySelector('p:nth-of-type(1)'); - assert.equal(section1Aside1SectionP1.textContent, 'regular Markdown markup'); - const section1Aside1SectionP4 = section1Aside1Section.querySelector('p:nth-of-type(2)'); - assert.equal(section1Aside1SectionP4.textContent, 'nested inline mark content'); - assert.equal(section1Aside1SectionP4.children.length, 1); - assertInlineMark(section1Aside1SectionP4.children[0]); - - const section1div2 = section1.querySelector('#div2'); - const section1Aside2 = section1div2.querySelector('aside:nth-of-type(1)'); - const section1Aside2Title = section1Aside2.querySelector('p.title'); - assert.equal(section1Aside2Title.textContent.trim(), 'Nested indented Aside 💀'); - const section1Aside2Section = section1Aside2.querySelector('section'); - const section1Aside2SectionP1 = section1Aside2Section.querySelector('p:nth-of-type(1)'); - assert.equal(section1Aside2SectionP1.textContent, 'regular Markdown markup'); - const section1Aside1SectionP5 = section1Aside2Section.querySelector('p:nth-of-type(2)'); - assert.equal(section1Aside1SectionP5.id, 'p5'); - assert.equal(section1Aside1SectionP5.children.length, 1); - const section1Aside1SectionP5Span1 = section1Aside1SectionP5.children[0]; - assert.equal(section1Aside1SectionP5Span1.textContent, 'inline mark'); - assert.equal(section1Aside1SectionP5Span1.children.length, 1); - const section1Aside1SectionP5Span1Span1 = section1Aside1SectionP5Span1.children[0]; - assert.equal(section1Aside1SectionP5Span1Span1.textContent, ' mark'); -} - -/** @param {HTMLElement | null | undefined} el */ - -function assertInlineMark(el) { - assert.ok(el); - assert.equal(el.children.length, 0); - assert.equal(el.textContent, 'inline mark'); - assert.equal(el.className, 'mark'); - assert.equal(el.style.color, 'hotpink'); -} diff --git a/packages/integrations/markdoc/test/render-html.test.ts b/packages/integrations/markdoc/test/render-html.test.ts new file mode 100644 index 000000000000..204f35e8fdf1 --- /dev/null +++ b/packages/integrations/markdoc/test/render-html.test.ts @@ -0,0 +1,305 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +async function getFixture(name: string) { + return await loadFixture({ + root: new URL(`./fixtures/${name}/`, import.meta.url), + }); +} + +describe('Markdoc - render html', () => { + let fixture: Fixture; + + before(async () => { + fixture = await getFixture('render-html'); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('renders content - simple', async () => { + const res = await fixture.fetch('/simple'); + const html = await res.text(); + + renderSimpleChecks(html); + }); + + it('renders content - nested-html', async () => { + const res = await fixture.fetch('/nested-html'); + const html = await res.text(); + + renderNestedHTMLChecks(html); + }); + + it('renders content - components interleaved with html', async () => { + const res = await fixture.fetch('/components'); + const html = await res.text(); + + renderComponentsHTMLChecks(html); + }); + + it('renders content - randomly cased html attributes', async () => { + const res = await fixture.fetch('/randomly-cased-html-attributes'); + const html = await res.text(); + + renderRandomlyCasedHTMLAttributesChecks(html); + }); + + it('renders content - html within partials', async () => { + const res = await fixture.fetch('/with-partial'); + const html = await res.text(); + + renderHTMLWithinPartialChecks(html); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('renders content - simple', async () => { + const html = await fixture.readFile('/simple/index.html'); + + renderSimpleChecks(html); + }); + + it('renders content - nested-html', async () => { + const html = await fixture.readFile('/nested-html/index.html'); + + renderNestedHTMLChecks(html); + }); + + it('renders content - components interleaved with html', async () => { + const html = await fixture.readFile('/components/index.html'); + + renderComponentsHTMLChecks(html); + }); + + it('renders content - randomly cased html attributes', async () => { + const html = await fixture.readFile('/randomly-cased-html-attributes/index.html'); + + renderRandomlyCasedHTMLAttributesChecks(html); + }); + + it('renders content - html within partials', async () => { + const html = await fixture.readFile('/with-partial/index.html'); + + renderHTMLWithinPartialChecks(html); + }); + }); +}); + +function renderSimpleChecks(html: string) { + const { document } = parseHTML(html); + + const h2 = document.querySelector('h2')!; + assert.equal(h2.textContent, 'Simple post header'); + + const spanInsideH2 = document.querySelector('h2 > span')!; + assert.equal(spanInsideH2.textContent, 'post'); + assert.equal(spanInsideH2.className, 'inside-h2'); + assert.equal(spanInsideH2.style.color, 'fuscia'); + + const p1 = document.querySelector('article > p:nth-of-type(1)')!; + assert.equal(p1.children.length, 1); + assert.equal(p1.textContent, 'This is a simple Markdoc post.'); + + const p2 = document.querySelector('article > p:nth-of-type(2)')!; + assert.equal(p2.children.length, 0); + assert.equal(p2.textContent, 'This is a paragraph!'); + + const p3 = document.querySelector('article > p:nth-of-type(3)')!; + assert.equal(p3.children.length, 1); + assert.equal(p3.textContent, 'This is a span inside a paragraph!'); + + const video = document.querySelector('video'); + assert.ok(video, 'A video element should exist'); + assert.ok(video.hasAttribute('autoplay'), 'The video element should have the autoplay attribute'); + assert.ok(video.hasAttribute('muted'), 'The video element should have the muted attribute'); +} + +function renderNestedHTMLChecks(html: string) { + const { document } = parseHTML(html); + + const p1 = document.querySelector('p:nth-of-type(1)')!; + assert.equal(p1.id, 'p1'); + assert.equal(p1.textContent, 'before inner after'); + assert.equal(p1.children.length, 1); + + const p1Span1 = p1.querySelector('span')!; + assert.equal(p1Span1.textContent, 'inner'); + assert.equal(p1Span1.id, 'inner1'); + assert.equal(p1Span1.className, 'inner-class'); + assert.equal(p1Span1.style.color, 'hotpink'); + + const p2 = document.querySelector('p:nth-of-type(2)')!; + assert.equal(p2.id, 'p2'); + assert.equal(p2.textContent, '\n before\n inner\n after\n'); + assert.equal(p2.children.length, 1); + + const divL1 = document.querySelector('div:nth-of-type(1)')!; + assert.equal(divL1.id, 'div-l1'); + assert.equal(divL1.children.length, 2); + + const divL2_1 = divL1.querySelector('div:nth-of-type(1)')!; + assert.equal(divL2_1.id, 'div-l2-1'); + assert.equal(divL2_1.children.length, 1); + + const p3 = divL2_1.querySelector('p:nth-of-type(1)')!; + assert.equal(p3.id, 'p3'); + assert.equal(p3.textContent, 'before inner after'); + assert.equal(p3.children.length, 1); + + const divL2_2 = divL1.querySelector('div:nth-of-type(2)')!; + assert.equal(divL2_2.id, 'div-l2-2'); + assert.equal(divL2_2.children.length, 2); + + const p4 = divL2_2.querySelector('p:nth-of-type(1)')!; + assert.equal(p4.id, 'p4'); + assert.equal(p4.textContent, 'before inner after'); + assert.equal(p4.children.length, 1); + + const p5 = divL2_2.querySelector('p:nth-of-type(2)')!; + assert.equal(p5.id, 'p5'); + assert.equal(p5.textContent, 'before inner after'); + assert.equal(p5.children.length, 1); +} + +function renderRandomlyCasedHTMLAttributesChecks(html: string) { + const { document } = parseHTML(html); + + const td1 = document.querySelector('#td1')!; + const td2 = document.querySelector('#td1')!; + const td3 = document.querySelector('#td1')!; + const td4 = document.querySelector('#td1')!; + + // all four 's which had randomly cased variants of colspan/rowspan should all be rendered lowercased at this point + + assert.equal(td1.getAttribute('colspan'), '3'); + assert.equal(td1.getAttribute('rowspan'), '2'); + + assert.equal(td2.getAttribute('colspan'), '3'); + assert.equal(td2.getAttribute('rowspan'), '2'); + + assert.equal(td3.getAttribute('colspan'), '3'); + assert.equal(td3.getAttribute('rowspan'), '2'); + + assert.equal(td4.getAttribute('colspan'), '3'); + assert.equal(td4.getAttribute('rowspan'), '2'); +} + +function renderHTMLWithinPartialChecks(html: string) { + const { document } = parseHTML(html); + + const li = document.querySelector('ul > li#partial')!; + assert.equal(li.textContent, 'List item'); +} + +/** + * Asserts that the rendered HTML tags with interleaved Markdoc tags (both block and inline) rendered in the expected nested graph of elements + */ +function renderComponentsHTMLChecks(html: string) { + const { document } = parseHTML(html); + + const naturalP1 = document.querySelector('article > p:nth-of-type(1)')!; + assert.equal(naturalP1.textContent, 'This is an inline mark in regular Markdown markup.'); + assert.equal(naturalP1.children.length, 1); + + const p1 = document.querySelector('article > p:nth-of-type(2)')!; + assert.equal(p1.id, 'p1'); + assert.equal(p1.textContent, 'This is an inline mark under some HTML'); + assert.equal(p1.children.length, 1); + assertInlineMark(p1.children[0] as HTMLElement); + + const div1p1 = document.querySelector('article > #div1 > p:nth-of-type(1)')!; + assert.equal(div1p1.id, 'div1-p1'); + assert.equal(div1p1.textContent, 'This is an inline mark under some HTML'); + assert.equal(div1p1.children.length, 1); + assertInlineMark(div1p1.children[0] as HTMLElement); + + const div1p2 = document.querySelector('article > #div1 > p:nth-of-type(2)')!; + assert.equal(div1p2.id, 'div1-p2'); + assert.equal(div1p2.textContent, 'This is an inline mark under some HTML'); + assert.equal(div1p2.children.length, 1); + + const div1p2span1 = div1p2.querySelector('span')!; + assert.equal(div1p2span1.id, 'div1-p2-span1'); + assert.equal(div1p2span1.textContent, 'inline mark'); + assert.equal(div1p2span1.children.length, 1); + assertInlineMark(div1p2span1.children[0] as HTMLElement); + + const aside1 = document.querySelector('article > aside:nth-of-type(1)')!; + const aside1Title = aside1.querySelector('p.title')!; + assert.equal(aside1Title.textContent.trim(), 'Aside One'); + const aside1Section = aside1.querySelector('section')!; + const aside1SectionP1 = aside1Section.querySelector('p:nth-of-type(1)')!; + assert.equal( + aside1SectionP1.textContent, + "I'm a Markdown paragraph inside a top-level aside tag", + ); + const aside1H2_1 = aside1Section.querySelector('h2:nth-of-type(1)')!; + assert.equal(aside1H2_1.id, 'im-an-h2-via-markdown-markup'); // automatic slug + assert.equal(aside1H2_1.textContent, "I'm an H2 via Markdown markup"); + const aside1H2_2 = aside1Section.querySelector('h2:nth-of-type(2)')!; + assert.equal(aside1H2_2.id, 'h-two'); + assert.equal(aside1H2_2.textContent, "I'm an H2 via HTML markup"); + const aside1SectionP2 = aside1Section.querySelector('p:nth-of-type(2)')!; + assert.equal(aside1SectionP2.textContent, 'Markdown bold vs HTML bold'); + assert.equal(aside1SectionP2.children.length, 2); + const aside1SectionP2Strong1 = aside1SectionP2.querySelector('strong:nth-of-type(1)')!; + assert.equal(aside1SectionP2Strong1.textContent, 'Markdown bold'); + const aside1SectionP2Strong2 = aside1SectionP2.querySelector('strong:nth-of-type(2)')!; + assert.equal(aside1SectionP2Strong2.textContent, 'HTML bold'); + + const article = document.querySelector('article')!; + assert.equal(article.textContent.includes('RENDERED'), true); + assert.notEqual(article.textContent.includes('NOT RENDERED'), true); + + const section1 = document.querySelector('article > #section1')!; + const section1div1 = section1.querySelector('#div1')!; + const section1Aside1 = section1div1.querySelector('aside:nth-of-type(1)')!; + const section1Aside1Title = section1Aside1.querySelector('p.title')!; + assert.equal(section1Aside1Title.textContent.trim(), 'Nested un-indented Aside'); + const section1Aside1Section = section1Aside1.querySelector('section')!; + const section1Aside1SectionP1 = section1Aside1Section.querySelector('p:nth-of-type(1)')!; + assert.equal(section1Aside1SectionP1.textContent, 'regular Markdown markup'); + const section1Aside1SectionP4 = section1Aside1Section.querySelector('p:nth-of-type(2)')!; + assert.equal(section1Aside1SectionP4.textContent, 'nested inline mark content'); + assert.equal(section1Aside1SectionP4.children.length, 1); + assertInlineMark(section1Aside1SectionP4.children[0] as HTMLElement); + + const section1div2 = section1.querySelector('#div2')!; + const section1Aside2 = section1div2.querySelector('aside:nth-of-type(1)')!; + const section1Aside2Title = section1Aside2.querySelector('p.title')!; + assert.equal(section1Aside2Title.textContent.trim(), 'Nested indented Aside 💀'); + const section1Aside2Section = section1Aside2.querySelector('section')!; + const section1Aside2SectionP1 = section1Aside2Section.querySelector('p:nth-of-type(1)')!; + assert.equal(section1Aside2SectionP1.textContent, 'regular Markdown markup'); + const section1Aside1SectionP5 = section1Aside2Section.querySelector('p:nth-of-type(2)')!; + assert.equal(section1Aside1SectionP5.id, 'p5'); + assert.equal(section1Aside1SectionP5.children.length, 1); + const section1Aside1SectionP5Span1 = section1Aside1SectionP5.children[0]; + assert.equal(section1Aside1SectionP5Span1.textContent, 'inline mark'); + assert.equal(section1Aside1SectionP5Span1.children.length, 1); + const section1Aside1SectionP5Span1Span1 = section1Aside1SectionP5Span1.children[0]; + assert.equal(section1Aside1SectionP5Span1Span1.textContent, ' mark'); +} + +function assertInlineMark(el: HTMLElement | null | undefined) { + assert.ok(el); + assert.equal(el.children.length, 0); + assert.equal(el.textContent, 'inline mark'); + assert.equal(el.className, 'mark'); + assert.equal(el.style.color, 'hotpink'); +} diff --git a/packages/integrations/markdoc/test/render-indented-components.test.js b/packages/integrations/markdoc/test/render-indented-components.test.js deleted file mode 100644 index ac47e72f9116..000000000000 --- a/packages/integrations/markdoc/test/render-indented-components.test.js +++ /dev/null @@ -1,67 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const root = new URL('./fixtures/render-with-indented-components/', import.meta.url); - -describe('Markdoc - render indented components', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root, - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('renders content - with indented components', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - - renderIndentedComponentsChecks(html); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('renders content - with indented components', async () => { - const html = await fixture.readFile('/index.html'); - - renderIndentedComponentsChecks(html); - }); - }); -}); - -/** @param {string} html */ -function renderIndentedComponentsChecks(html) { - const { document } = parseHTML(html); - const h2 = document.querySelector('h2'); - assert.equal(h2.textContent, 'Post with indented components'); - - // Renders custom shortcode components - const marquees = document.querySelectorAll('marquee'); - assert.equal(marquees.length, 2); - - // Renders h3 - const h3 = document.querySelector('h3'); - assert.equal(h3.textContent, 'I am an h3!'); - - // Renders Astro Code component - const pre = document.querySelector('pre'); - assert.notEqual(pre, null); - assert.equal(pre.className, 'astro-code github-dark'); -} diff --git a/packages/integrations/markdoc/test/render-indented-components.test.ts b/packages/integrations/markdoc/test/render-indented-components.test.ts new file mode 100644 index 000000000000..2440bb9b23a3 --- /dev/null +++ b/packages/integrations/markdoc/test/render-indented-components.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +const root = new URL('./fixtures/render-with-indented-components/', import.meta.url); + +describe('Markdoc - render indented components', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root, + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('renders content - with indented components', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + + renderIndentedComponentsChecks(html); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('renders content - with indented components', async () => { + const html = await fixture.readFile('/index.html'); + + renderIndentedComponentsChecks(html); + }); + }); +}); + +function renderIndentedComponentsChecks(html: string) { + const { document } = parseHTML(html); + const h2 = document.querySelector('h2'); + assert.equal(h2!.textContent, 'Post with indented components'); + + // Renders custom shortcode components + const marquees = document.querySelectorAll('marquee'); + assert.equal(marquees.length, 2); + + // Renders h3 + const h3 = document.querySelector('h3'); + assert.equal(h3!.textContent, 'I am an h3!'); + + // Renders Astro Code component + const pre = document.querySelector('pre'); + assert.notEqual(pre, null); + assert.equal(pre!.className, 'astro-code github-dark'); +} diff --git a/packages/integrations/markdoc/test/render-table-attrs.test.js b/packages/integrations/markdoc/test/render-table-attrs.test.js deleted file mode 100644 index b1dd7e3f83ed..000000000000 --- a/packages/integrations/markdoc/test/render-table-attrs.test.js +++ /dev/null @@ -1,49 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -async function getFixture() { - return await loadFixture({ - root: new URL('./fixtures/render-table-attrs/', import.meta.url), - }); -} - -describe('Markdoc - table attributes', () => { - describe('build', () => { - it('renders table with custom attributes without validation errors', async () => { - const fixture = await getFixture(); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const th = document.querySelector('th'); - assert.ok(th, 'table header should exist'); - assert.equal(th.textContent, 'Feature'); - - const td = document.querySelector('td'); - assert.equal(td.textContent, 'Custom attributes'); - }); - }); - - describe('dev', () => { - it('renders table with custom attributes without validation errors', async () => { - const fixture = await getFixture(); - const server = await fixture.startDevServer(); - - const res = await fixture.fetch('/'); - const html = await res.text(); - const { document } = parseHTML(html); - - const th = document.querySelector('th'); - assert.ok(th, 'table header should exist'); - assert.equal(th.textContent, 'Feature'); - - const td = document.querySelector('td'); - assert.equal(td.textContent, 'Custom attributes'); - - await server.stop(); - }); - }); -}); diff --git a/packages/integrations/markdoc/test/render-table-attrs.test.ts b/packages/integrations/markdoc/test/render-table-attrs.test.ts new file mode 100644 index 000000000000..472a6c526524 --- /dev/null +++ b/packages/integrations/markdoc/test/render-table-attrs.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture } from '../../../astro/test/test-utils.js'; + +async function getFixture() { + return await loadFixture({ + root: new URL('./fixtures/render-table-attrs/', import.meta.url), + }); +} + +describe('Markdoc - table attributes', () => { + describe('build', () => { + it('renders table with custom attributes without validation errors', async () => { + const fixture = await getFixture(); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const th = document.querySelector('th'); + assert.ok(th, 'table header should exist'); + assert.equal(th.textContent, 'Feature'); + + const td = document.querySelector('td'); + assert.equal(td!.textContent, 'Custom attributes'); + }); + }); + + describe('dev', () => { + it('renders table with custom attributes without validation errors', async () => { + const fixture = await getFixture(); + const server = await fixture.startDevServer(); + + const res = await fixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + + const th = document.querySelector('th'); + assert.ok(th, 'table header should exist'); + assert.equal(th.textContent, 'Feature'); + + const td = document.querySelector('td'); + assert.equal(td!.textContent, 'Custom attributes'); + + await server.stop(); + }); + }); +}); diff --git a/packages/integrations/markdoc/test/render-with-transform.test.js b/packages/integrations/markdoc/test/render-with-transform.test.js deleted file mode 100644 index fdf068455b1b..000000000000 --- a/packages/integrations/markdoc/test/render-with-transform.test.js +++ /dev/null @@ -1,69 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const root = new URL('./fixtures/render-with-transform/', import.meta.url); - -/** - * Tests for issue #9708: Markdoc `transform()` function overrides custom Astro component - * - * When spreading a built-in node config (e.g., `...Markdoc.nodes.fence`) and specifying - * a custom `render` component, the `render` should win over the built-in `transform()`. - */ -describe('Markdoc - render with transform override', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ root }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('uses custom render component instead of built-in transform', async () => { - const res = await fixture.fetch('/'); - const html = await res.text(); - assertCustomFenceRendered(html); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('uses custom render component instead of built-in transform', async () => { - const html = await fixture.readFile('/index.html'); - assertCustomFenceRendered(html); - }); - }); -}); - -function assertCustomFenceRendered(html) { - const { document } = parseHTML(html); - - // The custom component should render a div with data-custom-fence - const customFence = document.querySelector('[data-custom-fence]'); - assert.notEqual( - customFence, - null, - 'Expected custom fence component to be rendered (div[data-custom-fence])', - ); - - // Verify it has the language attribute - assert.equal(customFence.getAttribute('data-language'), 'js', 'Expected data-language="js"'); - - // The content should be inside a pre > code - const code = customFence.querySelector('pre code'); - assert.notEqual(code, null, 'Expected pre > code inside custom fence'); - assert.ok(code.textContent.includes('hello'), 'Expected code content to include "hello"'); -} diff --git a/packages/integrations/markdoc/test/render-with-transform.test.ts b/packages/integrations/markdoc/test/render-with-transform.test.ts new file mode 100644 index 000000000000..ad2adda991c4 --- /dev/null +++ b/packages/integrations/markdoc/test/render-with-transform.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +const root = new URL('./fixtures/render-with-transform/', import.meta.url); + +/** + * Tests for issue #9708: Markdoc `transform()` function overrides custom Astro component + * + * When spreading a built-in node config (e.g., `...Markdoc.nodes.fence`) and specifying + * a custom `render` component, the `render` should win over the built-in `transform()`. + */ +describe('Markdoc - render with transform override', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ root }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('uses custom render component instead of built-in transform', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + assertCustomFenceRendered(html); + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('uses custom render component instead of built-in transform', async () => { + const html = await fixture.readFile('/index.html'); + assertCustomFenceRendered(html); + }); + }); +}); + +function assertCustomFenceRendered(html: string) { + const { document } = parseHTML(html); + + // The custom component should render a div with data-custom-fence + const customFence = document.querySelector('[data-custom-fence]'); + assert.notEqual( + customFence, + null, + 'Expected custom fence component to be rendered (div[data-custom-fence])', + ); + + // Verify it has the language attribute + assert.equal(customFence!.getAttribute('data-language'), 'js', 'Expected data-language="js"'); + + // The content should be inside a pre > code + const code = customFence!.querySelector('pre code'); + assert.notEqual(code, null, 'Expected pre > code inside custom fence'); + assert.ok(code!.textContent.includes('hello'), 'Expected code content to include "hello"'); +} diff --git a/packages/integrations/markdoc/test/render.test.js b/packages/integrations/markdoc/test/render.test.js deleted file mode 100644 index 4c9293288bb9..000000000000 --- a/packages/integrations/markdoc/test/render.test.js +++ /dev/null @@ -1,199 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -async function getFixture(name) { - return await loadFixture({ - root: new URL(`./fixtures/${name}/`, import.meta.url), - }); -} - -describe('Markdoc - render', () => { - describe('dev', () => { - it('renders content - simple', async () => { - const fixture = await getFixture('render-simple'); - const server = await fixture.startDevServer(); - - const res = await fixture.fetch('/'); - const html = await res.text(); - - renderSimpleChecks(html); - - await server.stop(); - }); - - it('renders content - with partials', async () => { - const fixture = await getFixture('render-partials'); - const server = await fixture.startDevServer(); - - const res = await fixture.fetch('/'); - const html = await res.text(); - - renderPartialsChecks(html); - - await server.stop(); - }); - - it('renders content - with config', async () => { - const fixture = await getFixture('render-with-config'); - const server = await fixture.startDevServer(); - - const res = await fixture.fetch('/'); - const html = await res.text(); - - renderConfigChecks(html); - - await server.stop(); - }); - - it('renders content - with `render: null` in document', async () => { - const fixture = await getFixture('render-null'); - const server = await fixture.startDevServer(); - - const res = await fixture.fetch('/'); - const html = await res.text(); - - renderNullChecks(html); - - await server.stop(); - }); - - it('renders content - with root folder containing space', async () => { - const fixture = await getFixture('render with-space'); - const server = await fixture.startDevServer(); - - const res = await fixture.fetch('/'); - const html = await res.text(); - - renderWithRootFolderContainingSpace(html); - - await server.stop(); - }); - }); - - describe('build', () => { - it('renders content - simple', async () => { - const fixture = await getFixture('render-simple'); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - - renderSimpleChecks(html); - }); - - it('renders content - with partials', async () => { - const fixture = await getFixture('render-partials'); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - - renderPartialsChecks(html); - }); - - it('renders content - with config', async () => { - const fixture = await getFixture('render-with-config'); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - - renderConfigChecks(html); - }); - - it('renders content - with `render: null` in document', async () => { - const fixture = await getFixture('render-null'); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - - renderNullChecks(html); - }); - - it('renders content - with root folder containing space', async () => { - const fixture = await getFixture('render with-space'); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - - renderWithRootFolderContainingSpace(html); - }); - - it('renders content - with typographer option', async () => { - const fixture = await getFixture('render-typographer'); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - - renderTypographerChecks(html); - }); - }); -}); - -/** - * @param {string} html - */ -function renderNullChecks(html) { - const { document } = parseHTML(html); - const h2 = document.querySelector('h2'); - assert.equal(h2.textContent, 'Post with render null'); - assert.equal(h2.parentElement?.tagName, 'BODY'); - const divWrapper = document.querySelector('.div-wrapper'); - assert.equal(divWrapper.textContent, "I'm inside a div wrapper"); -} - -/** @param {string} html */ -function renderPartialsChecks(html) { - const { document } = parseHTML(html); - const top = document.querySelector('#top'); - assert.ok(top); - const nested = document.querySelector('#nested'); - assert.ok(nested); - const configured = document.querySelector('#configured'); - assert.ok(configured); -} - -/** @param {string} html */ -function renderConfigChecks(html) { - const { document } = parseHTML(html); - const h2 = document.querySelector('h2'); - assert.equal(h2.textContent, 'Post with config'); - const textContent = html; - - assert.notEqual(textContent.includes('Hello'), true); - assert.equal(textContent.includes('Hola'), true); - assert.equal(textContent.includes('Konnichiwa'), true); - - const runtimeVariable = document.querySelector('#runtime-variable'); - assert.equal(runtimeVariable?.textContent?.trim(), 'working!'); -} - -/** @param {string} html */ -function renderSimpleChecks(html) { - const { document } = parseHTML(html); - const h2 = document.querySelector('h2'); - assert.equal(h2.textContent, 'Simple post'); - const p = document.querySelector('p'); - assert.equal(p.textContent, 'This is a simple Markdoc post.'); -} - -/** @param {string} html */ -function renderWithRootFolderContainingSpace(html) { - const { document } = parseHTML(html); - const h2 = document.querySelector('h2'); - assert.equal(h2.textContent, 'Simple post with root folder containing a space'); - const p = document.querySelector('p'); - assert.equal(p.textContent, 'This is a simple Markdoc post with root folder containing a space.'); -} - -/** - * @param {string} html - */ -function renderTypographerChecks(html) { - const { document } = parseHTML(html); - - const h2 = document.querySelector('h2'); - assert.equal(h2.textContent, 'Typographer’s post'); - - const p = document.querySelector('p'); - assert.equal(p.textContent, 'This is a post to test the “typographer” option.'); -} diff --git a/packages/integrations/markdoc/test/render.test.ts b/packages/integrations/markdoc/test/render.test.ts new file mode 100644 index 000000000000..47ae8f097546 --- /dev/null +++ b/packages/integrations/markdoc/test/render.test.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture } from '../../../astro/test/test-utils.js'; + +async function getFixture(name: string) { + return await loadFixture({ + root: new URL(`./fixtures/${name}/`, import.meta.url), + }); +} + +describe('Markdoc - render', () => { + describe('dev', () => { + it('renders content - simple', async () => { + const fixture = await getFixture('render-simple'); + const server = await fixture.startDevServer(); + + const res = await fixture.fetch('/'); + const html = await res.text(); + + renderSimpleChecks(html); + + await server.stop(); + }); + + it('renders content - with partials', async () => { + const fixture = await getFixture('render-partials'); + const server = await fixture.startDevServer(); + + const res = await fixture.fetch('/'); + const html = await res.text(); + + renderPartialsChecks(html); + + await server.stop(); + }); + + it('renders content - with config', async () => { + const fixture = await getFixture('render-with-config'); + const server = await fixture.startDevServer(); + + const res = await fixture.fetch('/'); + const html = await res.text(); + + renderConfigChecks(html); + + await server.stop(); + }); + + it('renders content - with `render: null` in document', async () => { + const fixture = await getFixture('render-null'); + const server = await fixture.startDevServer(); + + const res = await fixture.fetch('/'); + const html = await res.text(); + + renderNullChecks(html); + + await server.stop(); + }); + + it('renders content - with root folder containing space', async () => { + const fixture = await getFixture('render with-space'); + const server = await fixture.startDevServer(); + + const res = await fixture.fetch('/'); + const html = await res.text(); + + renderWithRootFolderContainingSpace(html); + + await server.stop(); + }); + }); + + describe('build', () => { + it('renders content - simple', async () => { + const fixture = await getFixture('render-simple'); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + + renderSimpleChecks(html); + }); + + it('renders content - with partials', async () => { + const fixture = await getFixture('render-partials'); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + + renderPartialsChecks(html); + }); + + it('renders content - with config', async () => { + const fixture = await getFixture('render-with-config'); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + + renderConfigChecks(html); + }); + + it('renders content - with `render: null` in document', async () => { + const fixture = await getFixture('render-null'); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + + renderNullChecks(html); + }); + + it('renders content - with root folder containing space', async () => { + const fixture = await getFixture('render with-space'); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + + renderWithRootFolderContainingSpace(html); + }); + + it('renders content - with typographer option', async () => { + const fixture = await getFixture('render-typographer'); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + + renderTypographerChecks(html); + }); + }); +}); + +function renderNullChecks(html: string) { + const { document } = parseHTML(html); + const h2 = document.querySelector('h2'); + assert.equal(h2!.textContent, 'Post with render null'); + assert.equal(h2!.parentElement?.tagName, 'BODY'); + const divWrapper = document.querySelector('.div-wrapper'); + assert.equal(divWrapper!.textContent, "I'm inside a div wrapper"); +} + +function renderPartialsChecks(html: string) { + const { document } = parseHTML(html); + const top = document.querySelector('#top'); + assert.ok(top); + const nested = document.querySelector('#nested'); + assert.ok(nested); + const configured = document.querySelector('#configured'); + assert.ok(configured); +} + +function renderConfigChecks(html: string) { + const { document } = parseHTML(html); + const h2 = document.querySelector('h2'); + assert.equal(h2!.textContent, 'Post with config'); + const textContent = html; + + assert.notEqual(textContent.includes('Hello'), true); + assert.equal(textContent.includes('Hola'), true); + assert.equal(textContent.includes('Konnichiwa'), true); + + const runtimeVariable = document.querySelector('#runtime-variable'); + assert.equal(runtimeVariable?.textContent?.trim(), 'working!'); +} + +function renderSimpleChecks(html: string) { + const { document } = parseHTML(html); + const h2 = document.querySelector('h2'); + assert.equal(h2!.textContent, 'Simple post'); + const p = document.querySelector('p'); + assert.equal(p!.textContent, 'This is a simple Markdoc post.'); +} + +function renderWithRootFolderContainingSpace(html: string) { + const { document } = parseHTML(html); + const h2 = document.querySelector('h2'); + assert.equal(h2!.textContent, 'Simple post with root folder containing a space'); + const p = document.querySelector('p')!; + assert.equal(p.textContent, 'This is a simple Markdoc post with root folder containing a space.'); +} + +function renderTypographerChecks(html: string) { + const { document } = parseHTML(html); + + const h2 = document.querySelector('h2'); + assert.equal(h2!.textContent, 'Typographer’s post'); + + const p = document.querySelector('p')!; + assert.equal(p.textContent, 'This is a post to test the “typographer” option.'); +} diff --git a/packages/integrations/markdoc/test/syntax-highlighting.test.js b/packages/integrations/markdoc/test/syntax-highlighting.test.js deleted file mode 100644 index 6ea841ae1249..000000000000 --- a/packages/integrations/markdoc/test/syntax-highlighting.test.js +++ /dev/null @@ -1,136 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import Markdoc from '@markdoc/markdoc'; -import { isHTMLString } from 'astro/runtime/server/index.js'; -import { parseHTML } from 'linkedom'; -import prism from '../dist/extensions/prism.js'; -import shiki from '../dist/extensions/shiki.js'; -import { setupConfig } from '../dist/runtime.js'; - -const entry = ` -\`\`\`ts -const highlighting = true; -\`\`\` - -\`\`\`css -.highlighting { - color: red; -} -\`\`\` -`; - -describe('Markdoc - syntax highlighting', () => { - describe('shiki', () => { - it('transforms with defaults', async () => { - const ast = Markdoc.parse(entry); - const content = await Markdoc.transform(ast, await getConfigExtendingShiki()); - - assert.equal(content.children.length, 2); - for (const codeBlock of content.children) { - assert.equal(isHTMLString(codeBlock), true); - - const pre = parsePreTag(codeBlock); - assert.equal(pre.classList.contains('astro-code'), true); - assert.equal(pre.classList.contains('github-dark'), true); - } - }); - it('transforms with `theme` property', async () => { - const ast = Markdoc.parse(entry); - const content = await Markdoc.transform( - ast, - await getConfigExtendingShiki({ - theme: 'dracula', - }), - ); - assert.equal(content.children.length, 2); - for (const codeBlock of content.children) { - assert.equal(isHTMLString(codeBlock), true); - - const pre = parsePreTag(codeBlock); - assert.equal(pre.classList.contains('astro-code'), true); - assert.equal(pre.classList.contains('dracula'), true); - } - }); - it('transforms with `wrap` property', async () => { - const ast = Markdoc.parse(entry); - const content = await Markdoc.transform( - ast, - await getConfigExtendingShiki({ - wrap: true, - }), - ); - assert.equal(content.children.length, 2); - for (const codeBlock of content.children) { - assert.equal(isHTMLString(codeBlock), true); - - const pre = parsePreTag(codeBlock); - assert.equal(pre.getAttribute('style').includes('white-space: pre-wrap'), true); - assert.equal(pre.getAttribute('style').includes('word-wrap: break-word'), true); - } - }); - it('transform within if tags', async () => { - const ast = Markdoc.parse(` -{% if equals("true", "true") %} -Inside truthy - -\`\`\`js -const hello = "yes"; -\`\`\` - -{% /if %}`); - const content = await Markdoc.transform(ast, await getConfigExtendingShiki()); - assert.equal(content.children.length, 1); - assert.equal(content.children[0].length, 2); - const pTag = content.children[0][0]; - assert.equal(pTag.name, 'p'); - const codeBlock = content.children[0][1]; - assert.equal(isHTMLString(codeBlock), true); - const pre = parsePreTag(codeBlock); - assert.equal(pre.classList.contains('astro-code'), true); - assert.equal(pre.classList.contains('github-dark'), true); - }); - }); - - describe('prism', () => { - it('transforms', async () => { - const ast = Markdoc.parse(entry); - const config = await setupConfig({ - extends: [prism()], - }); - const content = await Markdoc.transform(ast, config); - - assert.equal(content.children.length, 2); - const [tsBlock, cssBlock] = content.children; - - assert.equal(isHTMLString(tsBlock), true); - assert.equal(isHTMLString(cssBlock), true); - - const preTs = parsePreTag(tsBlock); - assert.equal(preTs.classList.contains('language-ts'), true); - - const preCss = parsePreTag(cssBlock); - assert.equal(preCss.classList.contains('language-css'), true); - }); - }); -}); - -/** - * @param {import('astro').ShikiConfig} config - * @returns {import('../src/config.js').AstroMarkdocConfig} - */ -async function getConfigExtendingShiki(config) { - return await setupConfig({ - extends: [shiki(config)], - }); -} - -/** - * @param {string} html - * @returns {HTMLPreElement} - */ -function parsePreTag(html) { - const { document } = parseHTML(html); - const pre = document.querySelector('pre'); - assert.ok(pre); - return pre; -} diff --git a/packages/integrations/markdoc/test/syntax-highlighting.test.ts b/packages/integrations/markdoc/test/syntax-highlighting.test.ts new file mode 100644 index 000000000000..c06c5a4dd1a6 --- /dev/null +++ b/packages/integrations/markdoc/test/syntax-highlighting.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import Markdoc, { type Tag } from '@markdoc/markdoc'; +import { isHTMLString } from 'astro/runtime/server/index.js'; +import { parseHTML } from 'linkedom'; +import prism from '../dist/extensions/prism.js'; +import shiki from '../dist/extensions/shiki.js'; +import { setupConfig } from '../dist/runtime.js'; + +const entry = ` +\`\`\`ts +const highlighting = true; +\`\`\` + +\`\`\`css +.highlighting { + color: red; +} +\`\`\` +`; + +describe('Markdoc - syntax highlighting', () => { + describe('shiki', () => { + it('transforms with defaults', async () => { + const ast = Markdoc.parse(entry); + // @ts-expect-error AstroMarkdocConfig is incompatible with Markdoc.Config + const config: Markdoc.Config = await getConfigExtendingShiki(); + const content = (await Markdoc.transform(ast, config)) as Tag; + + assert.equal(content.children.length, 2); + for (const codeBlock of content.children) { + assert.equal(isHTMLString(codeBlock), true); + + const pre = parsePreTag(codeBlock as string); + assert.equal(pre.classList.contains('astro-code'), true); + assert.equal(pre.classList.contains('github-dark'), true); + } + }); + it('transforms with `theme` property', async () => { + const ast = Markdoc.parse(entry); + // @ts-expect-error AstroMarkdocConfig is incompatible with Markdoc.Config + const config: Markdoc.Config = await getConfigExtendingShiki({ theme: 'dracula' }); + const content = (await Markdoc.transform(ast, config)) as Tag; + assert.equal(content.children.length, 2); + for (const codeBlock of content.children) { + assert.equal(isHTMLString(codeBlock), true); + + const pre = parsePreTag(codeBlock as string); + assert.equal(pre.classList.contains('astro-code'), true); + assert.equal(pre.classList.contains('dracula'), true); + } + }); + it('transforms with `wrap` property', async () => { + const ast = Markdoc.parse(entry); + // @ts-expect-error AstroMarkdocConfig is incompatible with Markdoc.Config + const config: Markdoc.Config = await getConfigExtendingShiki({ wrap: true }); + const content = (await Markdoc.transform(ast, config)) as Tag; + assert.equal(content.children.length, 2); + for (const codeBlock of content.children) { + assert.equal(isHTMLString(codeBlock), true); + + const pre = parsePreTag(codeBlock as string); + assert.equal(pre.getAttribute('style')!.includes('white-space: pre-wrap'), true); + assert.equal(pre.getAttribute('style')!.includes('word-wrap: break-word'), true); + } + }); + it('transform within if tags', async () => { + const ast = Markdoc.parse(` +{% if equals("true", "true") %} +Inside truthy + +\`\`\`js +const hello = "yes"; +\`\`\` + +{% /if %}`); + // @ts-expect-error AstroMarkdocConfig is incompatible with Markdoc.Config + const config: Markdoc.Config = await getConfigExtendingShiki(); + const content = (await Markdoc.transform(ast, config)) as Tag; + assert.equal(content.children.length, 1); + const innerChildren = content.children[0] as unknown as Tag[]; + assert.equal(innerChildren.length, 2); + const pTag = innerChildren[0] as Tag; + assert.equal(pTag.name, 'p'); + const codeBlock = innerChildren[1]; + assert.equal(isHTMLString(codeBlock), true); + const pre = parsePreTag(codeBlock as unknown as string); + assert.equal(pre.classList.contains('astro-code'), true); + assert.equal(pre.classList.contains('github-dark'), true); + }); + }); + + describe('prism', () => { + it('transforms', async () => { + const ast = Markdoc.parse(entry); + // @ts-expect-error AstroMarkdocConfig is incompatible with Markdoc.Config + const config: Markdoc.Config = await setupConfig( + { + extends: [prism()], + }, + undefined, + ); + const content = (await Markdoc.transform(ast, config)) as Tag; + + assert.equal(content.children.length, 2); + const [tsBlock, cssBlock] = content.children; + + assert.equal(isHTMLString(tsBlock), true); + assert.equal(isHTMLString(cssBlock), true); + + const preTs = parsePreTag(tsBlock as string); + assert.equal(preTs.classList.contains('language-ts'), true); + + const preCss = parsePreTag(cssBlock as string); + assert.equal(preCss.classList.contains('language-css'), true); + }); + }); +}); + +async function getConfigExtendingShiki(config?: Parameters[0]) { + return await setupConfig( + { + extends: [shiki(config)], + }, + undefined, + ); +} + +function parsePreTag(html: string): HTMLPreElement { + const { document } = parseHTML(html); + const pre = document.querySelector('pre'); + assert.ok(pre); + return pre; +} diff --git a/packages/integrations/markdoc/test/variables.test.js b/packages/integrations/markdoc/test/variables.test.js deleted file mode 100644 index 2225f19c8d86..000000000000 --- a/packages/integrations/markdoc/test/variables.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; -import markdoc from '../dist/index.js'; - -const root = new URL('./fixtures/variables/', import.meta.url); - -describe('Markdoc - Variables', () => { - let baseFixture; - - before(async () => { - baseFixture = await loadFixture({ - root, - integrations: [markdoc()], - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await baseFixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('has expected entry properties', async () => { - const res = await baseFixture.fetch('/'); - const html = await res.text(); - const { document } = parseHTML(html); - assert.equal(document.querySelector('h1')?.textContent, 'Processed by schema: Test entry'); - assert.equal(document.getElementById('id')?.textContent?.trim(), 'id: entry'); - assert.equal(document.getElementById('collection')?.textContent?.trim(), 'collection: blog'); - }); - }); - - describe('build', () => { - before(async () => { - await baseFixture.build(); - }); - - it('has expected entry properties', async () => { - const html = await baseFixture.readFile('/index.html'); - const { document } = parseHTML(html); - assert.equal(document.querySelector('h1')?.textContent, 'Processed by schema: Test entry'); - assert.equal(document.getElementById('id')?.textContent?.trim(), 'id: entry'); - assert.equal(document.getElementById('collection')?.textContent?.trim(), 'collection: blog'); - }); - }); -}); diff --git a/packages/integrations/markdoc/test/variables.test.ts b/packages/integrations/markdoc/test/variables.test.ts new file mode 100644 index 000000000000..073124fc8db2 --- /dev/null +++ b/packages/integrations/markdoc/test/variables.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; +import markdoc from '../dist/index.js'; + +const root = new URL('./fixtures/variables/', import.meta.url); + +describe('Markdoc - Variables', () => { + let baseFixture: Fixture; + + before(async () => { + baseFixture = await loadFixture({ + root, + integrations: [markdoc()], + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await baseFixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('has expected entry properties', async () => { + const res = await baseFixture.fetch('/'); + const html = await res.text(); + const { document } = parseHTML(html); + assert.equal(document.querySelector('h1')?.textContent, 'Processed by schema: Test entry'); + assert.equal(document.getElementById('id')?.textContent?.trim(), 'id: entry'); + assert.equal(document.getElementById('collection')?.textContent?.trim(), 'collection: blog'); + }); + }); + + describe('build', () => { + before(async () => { + await baseFixture.build(); + }); + + it('has expected entry properties', async () => { + const html = await baseFixture.readFile('/index.html'); + const { document } = parseHTML(html); + assert.equal(document.querySelector('h1')?.textContent, 'Processed by schema: Test entry'); + assert.equal(document.getElementById('id')?.textContent?.trim(), 'id: entry'); + assert.equal(document.getElementById('collection')?.textContent?.trim(), 'collection: blog'); + }); + }); +}); diff --git a/packages/integrations/markdoc/tsconfig.test.json b/packages/integrations/markdoc/tsconfig.test.json new file mode 100644 index 000000000000..27c89c5fe7a7 --- /dev/null +++ b/packages/integrations/markdoc/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../../astro/tsconfig.test.json" + } + ] +} diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 95fe5f8d5aab..bfae15c3f5a6 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -31,7 +31,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test --timeout 70000 \"test/**/*.test.js\"" + "test": "astro-scripts test --timeout 70000 \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "dependencies": { "@astrojs/markdown-remark": "workspace:*", diff --git a/packages/integrations/mdx/src/server.ts b/packages/integrations/mdx/src/server.ts index 0d728d7a3b53..0671fd55442f 100644 --- a/packages/integrations/mdx/src/server.ts +++ b/packages/integrations/mdx/src/server.ts @@ -3,7 +3,8 @@ import { AstroError } from 'astro/errors'; import { AstroJSX, jsx } from 'astro/jsx-runtime'; import { renderJSX } from 'astro/runtime/server/index.js'; -const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase()); +export const slotName = (str: string) => + str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase()); // NOTE: In practice, MDX components are always tagged with `__astro_tag_component__`, so the right renderer // is used directly, and this check is not often used to return true. diff --git a/packages/integrations/mdx/src/utils.ts b/packages/integrations/mdx/src/utils.ts index 2fe729f8cad9..b2e798212d34 100644 --- a/packages/integrations/mdx/src/utils.ts +++ b/packages/integrations/mdx/src/utils.ts @@ -6,7 +6,7 @@ import type { MdxjsEsm } from 'mdast-util-mdx'; import colors from 'piccolore'; import type { PluggableList } from 'unified'; -function appendForwardSlash(path: string) { +export function appendForwardSlash(path: string) { return path.endsWith('/') ? path : path + '/'; } diff --git a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts index 6ef792ffbcd1..c401f25e5abf 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts @@ -45,7 +45,7 @@ export function vitePluginMdxPostprocess(astroConfig: AstroConfig): Plugin { /** * Inject `Fragment` identifier import if not already present. */ -function injectUnderscoreFragmentImport(code: string, imports: readonly ImportSpecifier[]) { +export function injectUnderscoreFragmentImport(code: string, imports: readonly ImportSpecifier[]) { if (!isSpecifierImported(code, imports, underscoreFragmentImportRegex, 'astro/jsx-runtime')) { code += `\nimport { Fragment as _Fragment } from 'astro/jsx-runtime';`; } @@ -55,7 +55,7 @@ function injectUnderscoreFragmentImport(code: string, imports: readonly ImportSp /** * Inject MDX metadata as exports of the module. */ -function injectMetadataExports( +export function injectMetadataExports( code: string, exports: readonly ExportSpecifier[], fileInfo: FileInfo, @@ -73,7 +73,7 @@ function injectMetadataExports( * Transforms the `MDXContent` default export as `Content`, which wraps `MDXContent` and * passes additional `components` props. */ -function transformContentExport(code: string, exports: readonly ExportSpecifier[]) { +export function transformContentExport(code: string, exports: readonly ExportSpecifier[]) { if (exports.find(({ n }) => n === 'Content')) return code; // If have `export const components`, pass that as props to `Content` as fallback @@ -105,7 +105,7 @@ export default Content;`; /** * Add properties to the `Content` export. */ -function annotateContentExport( +export function annotateContentExport( code: string, id: string, ssr: boolean, @@ -139,7 +139,7 @@ function annotateContentExport( /** * Check whether the `specifierRegex` matches for an import of `source` in the `code`. */ -function isSpecifierImported( +export function isSpecifierImported( code: string, imports: readonly ImportSpecifier[], specifierRegex: RegExp, diff --git a/packages/integrations/mdx/test/css-head-mdx.test.js b/packages/integrations/mdx/test/css-head-mdx.test.js deleted file mode 100644 index 81f1ee49170e..000000000000 --- a/packages/integrations/mdx/test/css-head-mdx.test.js +++ /dev/null @@ -1,100 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import mdx from '@astrojs/mdx'; -import * as cheerio from 'cheerio'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('Head injection w/ MDX', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/css-head-mdx/', import.meta.url), - integrations: [mdx()], - // test suite was authored when inlineStylesheets defaulted to never - build: { inlineStylesheets: 'never' }, - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('injects content styles into head', async () => { - const html = await fixture.readFile('/indexThree/index.html'); - const { document } = parseHTML(html); - - const links = document.querySelectorAll('head link[rel=stylesheet]'); - assert.equal(links.length, 1); - - const scripts = document.querySelectorAll('script[type=module]'); - assert.equal(scripts.length, 1); - }); - - it('injects into the head for content collections', async () => { - const html = await fixture.readFile('/posts/test/index.html'); - const { document } = parseHTML(html); - - const links = document.querySelectorAll('head link[rel=stylesheet]'); - assert.equal(links.length, 1); - }); - - it('injects content from a component using Content#render()', async () => { - const html = await fixture.readFile('/DirectContentUsage/index.html'); - const { document } = parseHTML(html); - - const links = document.querySelectorAll('head link[rel=stylesheet]'); - assert.equal(links.length, 1); - - const scripts = document.querySelectorAll('script[type=module]'); - assert.equal(scripts.length, 1); - }); - - it('Using component using slots.render() API', async () => { - const html = await fixture.readFile('/remote/index.html'); - const { document } = parseHTML(html); - - const links = document.querySelectorAll('head link[rel=stylesheet]'); - assert.equal(links.length, 1); - }); - - it('Using component but no layout', async () => { - const html = await fixture.readFile('/noLayoutWithComponent/index.html'); - // Using cheerio here because linkedom doesn't support head tag injection - const $ = cheerio.load(html); - - const headLinks = $('head link[rel=stylesheet]'); - assert.equal(headLinks.length, 1); - - const bodyLinks = $('body link[rel=stylesheet]'); - assert.equal(bodyLinks.length, 0); - }); - - it('JSX component rendering Astro children within head buffering phase', async () => { - const html = await fixture.readFile('/posts/using-component/index.html'); - // Using cheerio here because linkedom doesn't support head tag injection - const $ = cheerio.load(html); - - const headLinks = $('head link[rel=stylesheet]'); - assert.equal(headLinks.length, 1); - - const bodyLinks = $('body link[rel=stylesheet]'); - assert.equal(bodyLinks.length, 0); - }); - - it('Injection caused by delayed slots', async () => { - const html = await fixture.readFile('/componentwithtext/index.html'); - - // Using cheerio here because linkedom doesn't support head tag injection - const $ = cheerio.load(html); - - const headLinks = $('head link[rel=stylesheet]'); - assert.equal(headLinks.length, 1); - - const bodyLinks = $('body link[rel=stylesheet]'); - assert.equal(bodyLinks.length, 0); - }); - }); -}); diff --git a/packages/integrations/mdx/test/css-head-mdx.test.ts b/packages/integrations/mdx/test/css-head-mdx.test.ts new file mode 100644 index 000000000000..94ae75527b85 --- /dev/null +++ b/packages/integrations/mdx/test/css-head-mdx.test.ts @@ -0,0 +1,100 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import mdx from '@astrojs/mdx'; +import * as cheerio from 'cheerio'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture } from '../../../astro/test/test-utils.js'; + +describe('Head injection w/ MDX', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/css-head-mdx/', import.meta.url), + integrations: [mdx()], + // test suite was authored when inlineStylesheets defaulted to never + build: { inlineStylesheets: 'never' }, + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('injects content styles into head', async () => { + const html = await fixture.readFile('/indexThree/index.html'); + const { document } = parseHTML(html); + + const links = document.querySelectorAll('head link[rel=stylesheet]'); + assert.equal(links.length, 1); + + const scripts = document.querySelectorAll('script[type=module]'); + assert.equal(scripts.length, 1); + }); + + it('injects into the head for content collections', async () => { + const html = await fixture.readFile('/posts/test/index.html'); + const { document } = parseHTML(html); + + const links = document.querySelectorAll('head link[rel=stylesheet]'); + assert.equal(links.length, 1); + }); + + it('injects content from a component using Content#render()', async () => { + const html = await fixture.readFile('/DirectContentUsage/index.html'); + const { document } = parseHTML(html); + + const links = document.querySelectorAll('head link[rel=stylesheet]'); + assert.equal(links.length, 1); + + const scripts = document.querySelectorAll('script[type=module]'); + assert.equal(scripts.length, 1); + }); + + it('Using component using slots.render() API', async () => { + const html = await fixture.readFile('/remote/index.html'); + const { document } = parseHTML(html); + + const links = document.querySelectorAll('head link[rel=stylesheet]'); + assert.equal(links.length, 1); + }); + + it('Using component but no layout', async () => { + const html = await fixture.readFile('/noLayoutWithComponent/index.html'); + // Using cheerio here because linkedom doesn't support head tag injection + const $ = cheerio.load(html); + + const headLinks = $('head link[rel=stylesheet]'); + assert.equal(headLinks.length, 1); + + const bodyLinks = $('body link[rel=stylesheet]'); + assert.equal(bodyLinks.length, 0); + }); + + it('JSX component rendering Astro children within head buffering phase', async () => { + const html = await fixture.readFile('/posts/using-component/index.html'); + // Using cheerio here because linkedom doesn't support head tag injection + const $ = cheerio.load(html); + + const headLinks = $('head link[rel=stylesheet]'); + assert.equal(headLinks.length, 1); + + const bodyLinks = $('body link[rel=stylesheet]'); + assert.equal(bodyLinks.length, 0); + }); + + it('Injection caused by delayed slots', async () => { + const html = await fixture.readFile('/componentwithtext/index.html'); + + // Using cheerio here because linkedom doesn't support head tag injection + const $ = cheerio.load(html); + + const headLinks = $('head link[rel=stylesheet]'); + assert.equal(headLinks.length, 1); + + const bodyLinks = $('body link[rel=stylesheet]'); + assert.equal(bodyLinks.length, 0); + }); + }); +}); diff --git a/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/astro.config.mjs b/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/astro.config.mjs new file mode 100644 index 000000000000..2d0b541506a3 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/astro.config.mjs @@ -0,0 +1,6 @@ +import mdx from '@astrojs/mdx'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + integrations: [mdx()], +}); diff --git a/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/package.json b/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/package.json new file mode 100644 index 000000000000..a7ec46b27d66 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/package.json @@ -0,0 +1,12 @@ +{ + "name": "@test/mdx-astro-container-escape", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/mdx": "workspace:*", + "astro": "workspace:*" + }, + "scripts": { + "dev": "astro dev" + } +} diff --git a/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/src/components/Div.astro b/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/src/components/Div.astro new file mode 100644 index 000000000000..61945625ae84 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/src/components/Div.astro @@ -0,0 +1 @@ +
    diff --git a/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/src/pages/index.astro b/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/src/pages/index.astro new file mode 100644 index 000000000000..ad97478445be --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +import { experimental_AstroContainer } from "astro/container"; +import { loadRenderers } from "astro:container"; +import { getContainerRenderer } from "@astrojs/mdx"; +import { Content } from '../posts/post.mdx' + +const renderers = await loadRenderers([getContainerRenderer()]); +const contentContainer = await experimental_AstroContainer.create({ renderers }); +const html = await contentContainer.renderToString(Content); +--- + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/src/posts/post.mdx b/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/src/posts/post.mdx new file mode 100644 index 000000000000..33ebb46a05d6 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-astro-container-escape/src/posts/post.mdx @@ -0,0 +1,9 @@ +--- +title: Example +--- + +import Div from '../components/Div.astro' + +
    + Hello, World! +
    diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/components/Test.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/components/component/Test.mdx similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-component/src/components/Test.mdx rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/components/component/Test.mdx diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/components/WithFragment.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/components/component/WithFragment.mdx similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-component/src/components/WithFragment.mdx rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/components/component/WithFragment.mdx diff --git a/packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Slotted.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/components/slots/Slotted.astro similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Slotted.astro rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/components/slots/Slotted.astro diff --git a/packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Test.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/components/slots/Test.mdx similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Test.mdx rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/components/slots/Test.mdx diff --git a/packages/integrations/mdx/test/fixtures/mdx-get-static-paths/src/content/1.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/content/1.mdx similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-get-static-paths/src/content/1.mdx rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/content/1.mdx diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/layouts/Base.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/layouts/Base.astro similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/layouts/Base.astro rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/layouts/Base.astro diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/glob.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/glob.astro new file mode 100644 index 000000000000..62e96523db49 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/glob.astro @@ -0,0 +1,20 @@ +--- +import { parse } from 'node:path'; +const components = Object.values(import.meta.glob('../../components/component/*.mdx', { eager: true })); +--- + +
    + {components.map(Component => ( +
    + +
    + ))} +
    + +
    + {components.map(({ Content, file }) => ( +
    + +
    + ))} +
    diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/index.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/index.astro new file mode 100644 index 000000000000..b91c608eb5d4 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/index.astro @@ -0,0 +1,5 @@ +--- +import Test from '../../components/component/Test.mdx'; +--- + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/w-fragment.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/w-fragment.astro new file mode 100644 index 000000000000..3a8ca98240be --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/w-fragment.astro @@ -0,0 +1,5 @@ +--- +import WithFragment from '../../components/component/WithFragment.mdx'; +--- + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/glob.json.js b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/glob.json.js similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/glob.json.js rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/glob.json.js diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/index.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/index.mdx new file mode 100644 index 000000000000..a5f12f5af94d --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/index.mdx @@ -0,0 +1,10 @@ +--- +title: 'Using YAML frontmatter' +layout: '../../layouts/Base.astro' +illThrowIfIDontExist: "Oh no, that's scary!" +--- + +{frontmatter.illThrowIfIDontExist} + +> Note: newline intentionally missing from the end of this file. +> Useful since that can be the source of bugs in our compile step. \ No newline at end of file diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/with-headings.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/with-headings.mdx new file mode 100644 index 000000000000..9fa414968938 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/with-headings.mdx @@ -0,0 +1,7 @@ +--- +layout: '../../layouts/Base.astro' +--- + +## Section 1 + +## Section 2 diff --git a/packages/integrations/mdx/test/fixtures/mdx-script-style-raw/src/pages/index.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/script-style-raw.mdx similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-script-style-raw/src/pages/index.mdx rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/script-style-raw.mdx diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/glob.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/glob.astro new file mode 100644 index 000000000000..74a9f043d5bb --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/glob.astro @@ -0,0 +1,11 @@ +--- +const components = Object.values(import.meta.glob('../../components/slots/*.mdx', { eager: true })); +--- + +
    + {components.map(Component => )} +
    + +
    + {components.map(({ Content }) => )} +
    diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/index.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/index.astro new file mode 100644 index 000000000000..0817e6a673aa --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/index.astro @@ -0,0 +1,5 @@ +--- +import Test from '../../components/slots/Test.mdx'; +--- + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/static-paths/[slug].astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/static-paths/[slug].astro new file mode 100644 index 000000000000..4e5e6b464ec6 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/static-paths/[slug].astro @@ -0,0 +1,34 @@ +--- +export const getStaticPaths = async () => { + const content = Object.values(import.meta.glob('../../content/*.mdx', { eager: true })); + + return content + .filter((page) => !page.frontmatter.draft) // skip drafts + .map(({ default: MdxContent, frontmatter, url, file }) => { + return { + params: { slug: frontmatter.slug || "index" }, + props: { + MdxContent, + file, + frontmatter, + url + } + } + }) +} + +const { MdxContent, frontmatter, url, file } = Astro.props; +--- + + + + Page + + + + +
    {frontmatter.one}
    +
    {url}
    +
    {file}
    + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/pages.json.js b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/pages.json.js similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/pages.json.js rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/pages.json.js diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-1.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-1.mdx new file mode 100644 index 000000000000..68ac2a064e03 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-1.mdx @@ -0,0 +1 @@ +# I'm a page with a url of "/url-export/test-1!" diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-2.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-2.mdx new file mode 100644 index 000000000000..745ffee0d953 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-2.mdx @@ -0,0 +1 @@ +# I'm a page with a url of "/url-export/test-2!" diff --git a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/with-url-override.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/with-url-override.mdx similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/with-url-override.mdx rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/with-url-override.mdx diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/glob.astro b/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/glob.astro deleted file mode 100644 index b18f65fd383a..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/glob.astro +++ /dev/null @@ -1,20 +0,0 @@ ---- -import { parse } from 'node:path'; -const components = Object.values(import.meta.glob('../components/*.mdx', { eager: true })); ---- - -
    - {components.map(Component => ( -
    - -
    - ))} -
    - -
    - {components.map(({ Content, file }) => ( -
    - -
    - ))} -
    diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/index.astro b/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/index.astro deleted file mode 100644 index ed5ae98a3487..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/index.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import Test from '../components/Test.mdx'; ---- - - diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/w-fragment.astro b/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/w-fragment.astro deleted file mode 100644 index d394413f0903..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/w-fragment.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import WithFragment from '../components/WithFragment.mdx'; ---- - - diff --git a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Em.astro b/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Em.astro deleted file mode 100644 index 8166c0586b60..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Em.astro +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/P.astro b/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/P.astro deleted file mode 100644 index e29ac6d8f0d3..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/P.astro +++ /dev/null @@ -1 +0,0 @@ -

    diff --git a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Title.astro b/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Title.astro deleted file mode 100644 index 333ec04a2c3f..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Title.astro +++ /dev/null @@ -1 +0,0 @@ -

    diff --git a/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/html-tag.mdx b/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/html-tag.mdx deleted file mode 100644 index e668c0dc7b1e..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/html-tag.mdx +++ /dev/null @@ -1,5 +0,0 @@ -import P from '../components/P.astro'; -import Em from '../components/Em.astro'; - -

    Render Me

    -

    Me

    diff --git a/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/index.mdx b/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/index.mdx deleted file mode 100644 index d1c6cec9d9ec..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/index.mdx +++ /dev/null @@ -1,13 +0,0 @@ -import P from '../components/P.astro'; -import Em from '../components/Em.astro'; -import Title from '../components/Title.astro'; - -export const components = { p: P, em: Em, h1: Title }; - -# Hello _there_ - -# _there_ - -Hello _there_ - -_there_ diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/index.mdx b/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/index.mdx deleted file mode 100644 index e6f9c8f4a689..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/index.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: 'Using YAML frontmatter' -layout: '../layouts/Base.astro' -illThrowIfIDontExist: "Oh no, that's scary!" ---- - -{frontmatter.illThrowIfIDontExist} - -> Note: newline intentionally missing from the end of this file. -> Useful since that can be the source of bugs in our compile step. \ No newline at end of file diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/with-headings.mdx b/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/with-headings.mdx deleted file mode 100644 index cc4db9582f83..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/with-headings.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -layout: '../layouts/Base.astro' ---- - -## Section 1 - -## Section 2 diff --git a/packages/integrations/mdx/test/fixtures/mdx-get-static-paths/src/pages/[slug].astro b/packages/integrations/mdx/test/fixtures/mdx-get-static-paths/src/pages/[slug].astro deleted file mode 100644 index 01fc3a2573f6..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-get-static-paths/src/pages/[slug].astro +++ /dev/null @@ -1,34 +0,0 @@ ---- -export const getStaticPaths = async () => { - const content = Object.values(import.meta.glob('../content/*.mdx', { eager: true })); - - return content - .filter((page) => !page.frontmatter.draft) // skip drafts - .map(({ default: MdxContent, frontmatter, url, file }) => { - return { - params: { slug: frontmatter.slug || "index" }, - props: { - MdxContent, - file, - frontmatter, - url - } - } - }) -} - -const { MdxContent, frontmatter, url, file } = Astro.props; ---- - - - - Page - - - - -
    {frontmatter.one}
    -
    {url}
    -
    {file}
    - - diff --git a/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/glob.astro b/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/glob.astro deleted file mode 100644 index 2bd8e613c113..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/glob.astro +++ /dev/null @@ -1,11 +0,0 @@ ---- -const components = Object.values(import.meta.glob('../components/*.mdx', { eager: true })); ---- - -
    - {components.map(Component => )} -
    - -
    - {components.map(({ Content }) => )} -
    diff --git a/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/index.astro b/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/index.astro deleted file mode 100644 index ed5ae98a3487..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/index.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -import Test from '../components/Test.mdx'; ---- - - diff --git a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-1.mdx b/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-1.mdx deleted file mode 100644 index c9b984787ff2..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-1.mdx +++ /dev/null @@ -1 +0,0 @@ -# I'm a page with a url of "/test-1!" diff --git a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-2.mdx b/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-2.mdx deleted file mode 100644 index 360f72fc351a..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-2.mdx +++ /dev/null @@ -1 +0,0 @@ -# I'm a page with a url of "/test-2!" diff --git a/packages/integrations/mdx/test/invalid-mdx-component.test.js b/packages/integrations/mdx/test/invalid-mdx-component.test.js deleted file mode 100644 index b8152e89c718..000000000000 --- a/packages/integrations/mdx/test/invalid-mdx-component.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from '../../../astro/test/test-utils.js'; -import mdx from '../dist/index.js'; - -const FIXTURE_ROOT = new URL('./fixtures/invalid-mdx-component/', import.meta.url); - -describe('MDX component with runtime error', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: FIXTURE_ROOT, - integrations: [mdx()], - }); - }); - - describe('build', () => { - /** @type {Error | null} */ - let error; - - before(async () => { - error = null; - try { - await fixture.build(); - } catch (e) { - error = e; - } - }); - - it('Throws the right error', async () => { - assert.ok(error); - assert.match( - error?.hint, - /This issue often occurs when your MDX component encounters runtime errors/, - ); - }); - }); -}); diff --git a/packages/integrations/mdx/test/invalid-mdx-component.test.ts b/packages/integrations/mdx/test/invalid-mdx-component.test.ts new file mode 100644 index 000000000000..e6bfb248576c --- /dev/null +++ b/packages/integrations/mdx/test/invalid-mdx-component.test.ts @@ -0,0 +1,38 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture, type Fixture } from '../../../astro/test/test-utils.js'; +import mdx from '../dist/index.js'; + +const FIXTURE_ROOT = new URL('./fixtures/invalid-mdx-component/', import.meta.url); + +describe('MDX component with runtime error', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: FIXTURE_ROOT, + integrations: [mdx()], + }); + }); + + describe('build', () => { + let error: Error | null = null; + + before(async () => { + error = null; + try { + await fixture.build(); + } catch (e) { + error = e as Error; + } + }); + + it('Throws the right error', async () => { + assert.ok(error); + assert.match( + (error as Error & { hint?: string })?.hint ?? '', + /This issue often occurs when your MDX component encounters runtime errors/, + ); + }); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-astro-container-escape.test.ts b/packages/integrations/mdx/test/mdx-astro-container-escape.test.ts new file mode 100644 index 000000000000..106a10cdac79 --- /dev/null +++ b/packages/integrations/mdx/test/mdx-astro-container-escape.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { loadFixture, type Fixture } from '../../../astro/test/test-utils.js'; + +describe('MDX Component & Astro Container escape issue', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-astro-container-escape/', import.meta.url), + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('should render elements inside component without escaping', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + + assert.equal($('.div').text().includes('

    '), false); + }); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-astro-markdown-remarkRehype.test.js b/packages/integrations/mdx/test/mdx-astro-markdown-remarkRehype.test.js deleted file mode 100644 index f8cb3c98ff9a..000000000000 --- a/packages/integrations/mdx/test/mdx-astro-markdown-remarkRehype.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import mdx from '@astrojs/mdx'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('MDX with Astro Markdown remark-rehype config', () => { - it('Renders footnotes with values from the default configuration', async () => { - const fixture = await loadFixture({ - root: new URL('./fixtures/mdx-astro-markdown-remarkRehype/', import.meta.url), - integrations: [mdx()], - markdown: { - remarkRehype: { - footnoteLabel: 'Catatan kaki', - footnoteBackLabel: 'Kembali ke konten', - }, - }, - }); - - await fixture.build(); - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - assert.equal(document.querySelector('#footnote-label').textContent, 'Catatan kaki'); - assert.equal( - document.querySelector('.data-footnote-backref').getAttribute('aria-label'), - 'Kembali ke konten', - ); - }); - - it('Renders footnotes with values from custom configuration extending the default', async () => { - const fixture = await loadFixture({ - root: new URL('./fixtures/mdx-astro-markdown-remarkRehype/', import.meta.url), - integrations: [ - mdx({ - remarkRehype: { - footnoteLabel: 'Catatan kaki', - footnoteBackLabel: 'Kembali ke konten', - }, - }), - ], - markdown: { - remarkRehype: { - footnoteBackLabel: 'Replace me', - }, - }, - }); - - await fixture.build(); - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - assert.equal(document.querySelector('#footnote-label').textContent, 'Catatan kaki'); - assert.equal( - document.querySelector('.data-footnote-backref').getAttribute('aria-label'), - 'Kembali ke konten', - ); - }); - - it('Renders footnotes with values from custom configuration without extending the default', async () => { - const fixture = await loadFixture({ - root: new URL('./fixtures/mdx-astro-markdown-remarkRehype/', import.meta.url), - integrations: [ - mdx({ - extendPlugins: 'astroDefaults', - remarkRehype: { - footnoteLabel: 'Catatan kaki', - }, - }), - ], - markdown: { - remarkRehype: { - footnoteBackLabel: 'Kembali ke konten', - }, - }, - }); - - await fixture.build(); - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - assert.equal(document.querySelector('#footnote-label').textContent, 'Catatan kaki'); - assert.equal( - document.querySelector('.data-footnote-backref').getAttribute('aria-label'), - 'Back to reference 1', - ); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-astro-markdown-remarkRehype.test.ts b/packages/integrations/mdx/test/mdx-astro-markdown-remarkRehype.test.ts new file mode 100644 index 000000000000..b63c049c5252 --- /dev/null +++ b/packages/integrations/mdx/test/mdx-astro-markdown-remarkRehype.test.ts @@ -0,0 +1,87 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import mdx from '@astrojs/mdx'; +import { parseHTML } from 'linkedom'; +import { loadFixture } from '../../../astro/test/test-utils.js'; + +describe('MDX with Astro Markdown remark-rehype config', () => { + it('Renders footnotes with values from the default configuration', async () => { + const fixture = await loadFixture({ + root: new URL('./fixtures/mdx-astro-markdown-remarkRehype/', import.meta.url), + integrations: [mdx()], + markdown: { + remarkRehype: { + footnoteLabel: 'Catatan kaki', + footnoteBackLabel: 'Kembali ke konten', + }, + }, + }); + + await fixture.build(); + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + assert.equal(document.querySelector('#footnote-label')!.textContent, 'Catatan kaki'); + assert.equal( + document.querySelector('.data-footnote-backref')!.getAttribute('aria-label'), + 'Kembali ke konten', + ); + }); + + it('Renders footnotes with values from custom configuration extending the default', async () => { + const fixture = await loadFixture({ + root: new URL('./fixtures/mdx-astro-markdown-remarkRehype/', import.meta.url), + integrations: [ + mdx({ + remarkRehype: { + footnoteLabel: 'Catatan kaki', + footnoteBackLabel: 'Kembali ke konten', + }, + }), + ], + markdown: { + remarkRehype: { + footnoteBackLabel: 'Replace me', + }, + }, + }); + + await fixture.build(); + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + assert.equal(document.querySelector('#footnote-label')!.textContent, 'Catatan kaki'); + assert.equal( + document.querySelector('.data-footnote-backref')!.getAttribute('aria-label'), + 'Kembali ke konten', + ); + }); + + it('Renders footnotes with values from custom configuration without extending the default', async () => { + const fixture = await loadFixture({ + root: new URL('./fixtures/mdx-astro-markdown-remarkRehype/', import.meta.url), + integrations: [ + mdx({ + remarkRehype: { + footnoteLabel: 'Catatan kaki', + }, + }), + ], + markdown: { + remarkRehype: { + footnoteBackLabel: 'Kembali ke konten', + }, + }, + }); + + await fixture.build(); + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + assert.equal(document.querySelector('#footnote-label')!.textContent, 'Catatan kaki'); + assert.equal( + document.querySelector('.data-footnote-backref')!.getAttribute('aria-label'), + 'Back to reference 1', + ); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-basics.test.ts b/packages/integrations/mdx/test/mdx-basics.test.ts new file mode 100644 index 000000000000..064c2bb0062a --- /dev/null +++ b/packages/integrations/mdx/test/mdx-basics.test.ts @@ -0,0 +1,460 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import mdx from '@astrojs/mdx'; +import * as cheerio from 'cheerio'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +// Merged fixture: combines mdx-component, mdx-slots, mdx-frontmatter, +// mdx-url-export, mdx-get-static-paths, and mdx-script-style-raw. +// All use the same config: integrations: [mdx()], sharing one build and one dev server. +const FIXTURE_ROOT = new URL('./fixtures/mdx-basics/', import.meta.url); + +describe('MDX basics (merged fixture)', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: FIXTURE_ROOT, + integrations: [mdx()], + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + // --- MDX Component tests (was mdx-component.test.js) --- + + describe('component', () => { + it('supports top-level imports', async () => { + const html = await fixture.readFile('/component/index.html'); + const { document } = parseHTML(html); + + const h1 = document.querySelector('h1')!; + const foo = document.querySelector('#foo')!; + + assert.equal(h1.textContent, 'Hello component!'); + assert.equal(foo.textContent, 'bar'); + }); + + it('supports glob imports - ', async () => { + const html = await fixture.readFile('/component/glob/index.html'); + const { document } = parseHTML(html); + + const h1 = document.querySelector('[data-default-export] h1')!; + const foo = document.querySelector('[data-default-export] #foo')!; + + assert.equal(h1.textContent, 'Hello component!'); + assert.equal(foo.textContent, 'bar'); + }); + + it('supports glob imports - ', async () => { + const html = await fixture.readFile('/component/glob/index.html'); + const { document } = parseHTML(html); + + const h1 = document.querySelector('[data-content-export] h1')!; + const foo = document.querySelector('[data-content-export] #foo')!; + + assert.equal(h1.textContent, 'Hello component!'); + assert.equal(foo.textContent, 'bar'); + }); + + describe('with ', () => { + it('supports top-level imports', async () => { + const html = await fixture.readFile('/component/w-fragment/index.html'); + const { document } = parseHTML(html); + + const h1 = document.querySelector('h1')!; + const p = document.querySelector('p')!; + + assert.equal(h1.textContent, 'MDX containing '); + assert.equal(p.textContent, 'bar'); + }); + + it('supports glob imports - ', async () => { + const html = await fixture.readFile('/component/glob/index.html'); + const { document } = parseHTML(html); + + const h = document.querySelector( + '[data-default-export] [data-file="WithFragment.mdx"] h1', + )!; + const p = document.querySelector( + '[data-default-export] [data-file="WithFragment.mdx"] p', + )!; + + assert.equal(h.textContent, 'MDX containing '); + assert.equal(p.textContent, 'bar'); + }); + + it('supports glob imports - ', async () => { + const html = await fixture.readFile('/component/glob/index.html'); + const { document } = parseHTML(html); + + const h = document.querySelector( + '[data-content-export] [data-file="WithFragment.mdx"] h1', + )!; + const p = document.querySelector( + '[data-content-export] [data-file="WithFragment.mdx"] p', + )!; + + assert.equal(h.textContent, 'MDX containing '); + assert.equal(p.textContent, 'bar'); + }); + }); + }); + + // --- MDX Slots tests (was mdx-slots.test.js) --- + + describe('slots', () => { + it('supports top-level imports', async () => { + const html = await fixture.readFile('/slots/index.html'); + const { document } = parseHTML(html); + + const h1 = document.querySelector('h1')!; + const defaultSlot = document.querySelector('[data-default-slot]')!; + const namedSlot = document.querySelector('[data-named-slot]')!; + + assert.equal(h1.textContent, 'Hello slotted component!'); + assert.equal(defaultSlot.textContent, 'Default content.'); + assert.equal(namedSlot.textContent, 'Content for named slot.'); + }); + + it('supports glob imports - ', async () => { + const html = await fixture.readFile('/slots/glob/index.html'); + const { document } = parseHTML(html); + + const h1 = document.querySelector('[data-default-export] h1')!; + const defaultSlot = document.querySelector('[data-default-export] [data-default-slot]')!; + const namedSlot = document.querySelector('[data-default-export] [data-named-slot]')!; + + assert.equal(h1.textContent, 'Hello slotted component!'); + assert.equal(defaultSlot.textContent, 'Default content.'); + assert.equal(namedSlot.textContent, 'Content for named slot.'); + }); + + it('supports glob imports - ', async () => { + const html = await fixture.readFile('/slots/glob/index.html'); + const { document } = parseHTML(html); + + const h1 = document.querySelector('[data-content-export] h1')!; + const defaultSlot = document.querySelector('[data-content-export] [data-default-slot]')!; + const namedSlot = document.querySelector('[data-content-export] [data-named-slot]')!; + + assert.equal(h1.textContent, 'Hello slotted component!'); + assert.equal(defaultSlot.textContent, 'Default content.'); + assert.equal(namedSlot.textContent, 'Content for named slot.'); + }); + }); + + // --- MDX Frontmatter tests (was mdx-frontmatter.test.js) --- + + describe('frontmatter', () => { + it('builds when "frontmatter.property" is in JSX expression', async () => { + assert.equal(true, true); + }); + + it('extracts frontmatter to "frontmatter" export', async () => { + const { titles } = JSON.parse(await fixture.readFile('/frontmatter/glob.json')); + assert.equal(titles.includes('Using YAML frontmatter'), true); + }); + + it('renders layout from "layout" frontmatter property', async () => { + const html = await fixture.readFile('/frontmatter/index.html'); + const { document } = parseHTML(html); + + const layoutParagraph = document.querySelector('[data-layout-rendered]'); + + assert.notEqual(layoutParagraph, null); + }); + + it('passes frontmatter to layout via "content" and "frontmatter" props', async () => { + const html = await fixture.readFile('/frontmatter/index.html'); + const { document } = parseHTML(html); + + const contentTitle = document.querySelector('[data-content-title]')!; + const frontmatterTitle = document.querySelector('[data-frontmatter-title]')!; + + assert.equal(contentTitle.textContent, 'Using YAML frontmatter'); + assert.equal(frontmatterTitle.textContent, 'Using YAML frontmatter'); + }); + + it('passes headings to layout via "headings" prop', async () => { + const html = await fixture.readFile('/frontmatter/with-headings/index.html'); + const { document } = parseHTML(html); + + const headingSlugs = [...document.querySelectorAll('[data-headings] > li')].map( + (el) => el.textContent, + ); + + assert.equal(headingSlugs.length > 0, true); + assert.equal(headingSlugs.includes('section-1'), true); + assert.equal(headingSlugs.includes('section-2'), true); + }); + + it('passes "file" and "url" to layout', async () => { + const html = await fixture.readFile('/frontmatter/with-headings/index.html'); + const { document } = parseHTML(html); + + const frontmatterFile = document.querySelector('[data-frontmatter-file]')?.textContent; + const frontmatterUrl = document.querySelector('[data-frontmatter-url]')?.textContent; + const file = document.querySelector('[data-file]')?.textContent; + const url = document.querySelector('[data-url]')?.textContent; + + assert.equal( + frontmatterFile?.endsWith('with-headings.mdx'), + true, + '"file" prop does not end with correct path or is undefined', + ); + assert.equal(frontmatterUrl, '/frontmatter/with-headings'); + assert.equal(file, frontmatterFile); + assert.equal(url, frontmatterUrl); + }); + }); + + // --- MDX URL Export tests (was mdx-url-export.test.js) --- + + describe('url export', () => { + it('generates correct urls in glob result', async () => { + const { urls } = JSON.parse(await fixture.readFile('/url-export/pages.json')); + assert.equal(urls.includes('/url-export/test-1'), true); + assert.equal(urls.includes('/url-export/test-2'), true); + }); + + it('respects "export url" overrides in glob result', async () => { + const { urls } = JSON.parse(await fixture.readFile('/url-export/pages.json')); + assert.equal(urls.includes('/AH!'), true); + }); + }); + + // --- getStaticPaths tests (was mdx-get-static-paths.test.js) --- + + describe('getStaticPaths', () => { + it('Provides file and url', async () => { + const html = await fixture.readFile('/static-paths/one/index.html'); + + const $ = cheerio.load(html); + assert.equal($('p').text(), 'First mdx file'); + assert.equal($('#one').text(), 'hello', 'Frontmatter included'); + assert.equal($('#url').text(), 'src/content/1.mdx', 'url is included'); + assert.equal( + $('#file').text().includes('fixtures/mdx-basics/src/content/1.mdx'), + true, + 'file is included', + ); + }); + }); + + // --- MDX script/style raw tests (was mdx-script-style-raw.test.js, build part) --- + + describe('script-style-raw', () => { + it('works with raw script and style strings', async () => { + const html = await fixture.readFile('/script-style-raw/index.html'); + const { document } = parseHTML(html); + + const scriptContent = document.getElementById('test-script')!.innerHTML; + assert.equal( + scriptContent.includes("console.log('raw script')"), + true, + 'script should not be html-escaped', + ); + + const styleContent = document.getElementById('test-style')!.innerHTML; + assert.equal( + styleContent.includes('h1[id="script-style-raw"]'), + true, + 'style should not be html-escaped', + ); + }); + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + // --- MDX Component dev tests --- + + describe('component', () => { + it('supports top-level imports', async () => { + const res = await fixture.fetch('/component'); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const h1 = document.querySelector('h1')!; + const foo = document.querySelector('#foo')!; + + assert.equal(h1.textContent, 'Hello component!'); + assert.equal(foo.textContent, 'bar'); + }); + + it('supports glob imports - ', async () => { + const res = await fixture.fetch('/component/glob'); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const h1 = document.querySelector('[data-default-export] h1')!; + const foo = document.querySelector('[data-default-export] #foo')!; + + assert.equal(h1.textContent, 'Hello component!'); + assert.equal(foo.textContent, 'bar'); + }); + + it('supports glob imports - ', async () => { + const res = await fixture.fetch('/component/glob'); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const h1 = document.querySelector('[data-content-export] h1')!; + const foo = document.querySelector('[data-content-export] #foo')!; + + assert.equal(h1.textContent, 'Hello component!'); + assert.equal(foo.textContent, 'bar'); + }); + + describe('with ', () => { + it('supports top-level imports', async () => { + const res = await fixture.fetch('/component/w-fragment'); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const h1 = document.querySelector('h1')!; + const p = document.querySelector('p')!; + + assert.equal(h1.textContent, 'MDX containing '); + assert.equal(p.textContent, 'bar'); + }); + + it('supports glob imports - ', async () => { + const res = await fixture.fetch('/component/glob'); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const h = document.querySelector( + '[data-default-export] [data-file="WithFragment.mdx"] h1', + )!; + const p = document.querySelector( + '[data-default-export] [data-file="WithFragment.mdx"] p', + )!; + + assert.equal(h.textContent, 'MDX containing '); + assert.equal(p.textContent, 'bar'); + }); + + it('supports glob imports - ', async () => { + const res = await fixture.fetch('/component/glob'); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const h = document.querySelector( + '[data-content-export] [data-file="WithFragment.mdx"] h1', + )!; + const p = document.querySelector( + '[data-content-export] [data-file="WithFragment.mdx"] p', + )!; + + assert.equal(h.textContent, 'MDX containing '); + assert.equal(p.textContent, 'bar'); + }); + }); + }); + + // --- MDX Slots dev tests --- + + describe('slots', () => { + it('supports top-level imports', async () => { + const res = await fixture.fetch('/slots'); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const h1 = document.querySelector('h1')!; + const defaultSlot = document.querySelector('[data-default-slot]')!; + const namedSlot = document.querySelector('[data-named-slot]')!; + + assert.equal(h1.textContent, 'Hello slotted component!'); + assert.equal(defaultSlot.textContent, 'Default content.'); + assert.equal(namedSlot.textContent, 'Content for named slot.'); + }); + + it('supports glob imports - ', async () => { + const res = await fixture.fetch('/slots/glob'); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const h1 = document.querySelector('[data-default-export] h1')!; + const defaultSlot = document.querySelector('[data-default-export] [data-default-slot]')!; + const namedSlot = document.querySelector('[data-default-export] [data-named-slot]')!; + + assert.equal(h1.textContent, 'Hello slotted component!'); + assert.equal(defaultSlot.textContent, 'Default content.'); + assert.equal(namedSlot.textContent, 'Content for named slot.'); + }); + + it('supports glob imports - ', async () => { + const res = await fixture.fetch('/slots/glob'); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const h1 = document.querySelector('[data-content-export] h1')!; + const defaultSlot = document.querySelector('[data-content-export] [data-default-slot]')!; + const namedSlot = document.querySelector('[data-content-export] [data-named-slot]')!; + + assert.equal(h1.textContent, 'Hello slotted component!'); + assert.equal(defaultSlot.textContent, 'Default content.'); + assert.equal(namedSlot.textContent, 'Content for named slot.'); + }); + }); + + // --- MDX script/style raw dev tests --- + + describe('script-style-raw', () => { + it('works with raw script and style strings', async () => { + const res = await fixture.fetch('/script-style-raw'); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const scriptContent = document.getElementById('test-script')!.innerHTML; + assert.equal( + scriptContent.includes("console.log('raw script')"), + true, + 'script should not be html-escaped', + ); + + const styleContent = document.getElementById('test-style')!.innerHTML; + assert.equal( + styleContent.includes('h1[id="script-style-raw"]'), + true, + 'style should not be html-escaped', + ); + }); + }); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-component.test.js b/packages/integrations/mdx/test/mdx-component.test.js deleted file mode 100644 index 895d83008244..000000000000 --- a/packages/integrations/mdx/test/mdx-component.test.js +++ /dev/null @@ -1,194 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import mdx from '@astrojs/mdx'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('MDX Component', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-component/', import.meta.url), - integrations: [mdx()], - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('supports top-level imports', async () => { - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const h1 = document.querySelector('h1'); - const foo = document.querySelector('#foo'); - - assert.equal(h1.textContent, 'Hello component!'); - assert.equal(foo.textContent, 'bar'); - }); - - it('supports glob imports - ', async () => { - const html = await fixture.readFile('/glob/index.html'); - const { document } = parseHTML(html); - - const h1 = document.querySelector('[data-default-export] h1'); - const foo = document.querySelector('[data-default-export] #foo'); - - assert.equal(h1.textContent, 'Hello component!'); - assert.equal(foo.textContent, 'bar'); - }); - - it('supports glob imports - ', async () => { - const html = await fixture.readFile('/glob/index.html'); - const { document } = parseHTML(html); - - const h1 = document.querySelector('[data-content-export] h1'); - const foo = document.querySelector('[data-content-export] #foo'); - - assert.equal(h1.textContent, 'Hello component!'); - assert.equal(foo.textContent, 'bar'); - }); - - describe('with ', () => { - it('supports top-level imports', async () => { - const html = await fixture.readFile('/w-fragment/index.html'); - const { document } = parseHTML(html); - - const h1 = document.querySelector('h1'); - const p = document.querySelector('p'); - - assert.equal(h1.textContent, 'MDX containing '); - assert.equal(p.textContent, 'bar'); - }); - - it('supports glob imports - ', async () => { - const html = await fixture.readFile('/glob/index.html'); - const { document } = parseHTML(html); - - const h = document.querySelector('[data-default-export] [data-file="WithFragment.mdx"] h1'); - const p = document.querySelector('[data-default-export] [data-file="WithFragment.mdx"] p'); - - assert.equal(h.textContent, 'MDX containing '); - assert.equal(p.textContent, 'bar'); - }); - - it('supports glob imports - ', async () => { - const html = await fixture.readFile('/glob/index.html'); - const { document } = parseHTML(html); - - const h = document.querySelector('[data-content-export] [data-file="WithFragment.mdx"] h1'); - const p = document.querySelector('[data-content-export] [data-file="WithFragment.mdx"] p'); - - assert.equal(h.textContent, 'MDX containing '); - assert.equal(p.textContent, 'bar'); - }); - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('supports top-level imports', async () => { - const res = await fixture.fetch('/'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const h1 = document.querySelector('h1'); - const foo = document.querySelector('#foo'); - - assert.equal(h1.textContent, 'Hello component!'); - assert.equal(foo.textContent, 'bar'); - }); - - it('supports glob imports - ', async () => { - const res = await fixture.fetch('/glob'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const h1 = document.querySelector('[data-default-export] h1'); - const foo = document.querySelector('[data-default-export] #foo'); - - assert.equal(h1.textContent, 'Hello component!'); - assert.equal(foo.textContent, 'bar'); - }); - - it('supports glob imports - ', async () => { - const res = await fixture.fetch('/glob'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const h1 = document.querySelector('[data-content-export] h1'); - const foo = document.querySelector('[data-content-export] #foo'); - - assert.equal(h1.textContent, 'Hello component!'); - assert.equal(foo.textContent, 'bar'); - }); - - describe('with ', () => { - it('supports top-level imports', async () => { - const res = await fixture.fetch('/w-fragment'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const h1 = document.querySelector('h1'); - const p = document.querySelector('p'); - - assert.equal(h1.textContent, 'MDX containing '); - assert.equal(p.textContent, 'bar'); - }); - - it('supports glob imports - ', async () => { - const res = await fixture.fetch('/glob'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const h = document.querySelector('[data-default-export] [data-file="WithFragment.mdx"] h1'); - const p = document.querySelector('[data-default-export] [data-file="WithFragment.mdx"] p'); - - assert.equal(h.textContent, 'MDX containing '); - assert.equal(p.textContent, 'bar'); - }); - - it('supports glob imports - ', async () => { - const res = await fixture.fetch('/glob'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const h = document.querySelector('[data-content-export] [data-file="WithFragment.mdx"] h1'); - const p = document.querySelector('[data-content-export] [data-file="WithFragment.mdx"] p'); - - assert.equal(h.textContent, 'MDX containing '); - assert.equal(p.textContent, 'bar'); - }); - }); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-content-layer.test.js b/packages/integrations/mdx/test/mdx-content-layer.test.js deleted file mode 100644 index c6998b26f725..000000000000 --- a/packages/integrations/mdx/test/mdx-content-layer.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('Content Layer MDX rendering dev', () => { - /** @type {import("../../../astro/test/test-utils.js").Fixture} */ - let fixture; - - let devServer; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/content-layer/', import.meta.url), - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer?.stop(); - }); - - it('Render an MDX file', async () => { - const html = await fixture.fetch('/reptiles/iguana').then((r) => r.text()); - - assert.match(html, /Iguana/); - assert.match(html, /This is a rendered entry/); - }); -}); - -describe('Content Layer MDX rendering build', () => { - /** @type {import("../../../astro/test/test-utils.js").Fixture} */ - let fixture; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/content-layer/', import.meta.url), - }); - await fixture.build(); - }); - - it('Render an MDX file', async () => { - const html = await fixture.readFile('/reptiles/iguana/index.html'); - - assert.match(html, /Iguana/); - assert.match(html, /This is a rendered entry/); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-content-layer.test.ts b/packages/integrations/mdx/test/mdx-content-layer.test.ts new file mode 100644 index 000000000000..887699750839 --- /dev/null +++ b/packages/integrations/mdx/test/mdx-content-layer.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +describe('Content Layer MDX rendering dev', () => { + let fixture: Fixture; + + let devServer: DevServer; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/content-layer/', import.meta.url), + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer?.stop(); + }); + + it('Render an MDX file', async () => { + const html = await fixture.fetch('/reptiles/iguana').then((r: Response) => r.text()); + + assert.match(html, /Iguana/); + assert.match(html, /This is a rendered entry/); + }); +}); + +describe('Content Layer MDX rendering build', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/content-layer/', import.meta.url), + }); + await fixture.build(); + }); + + it('Render an MDX file', async () => { + const html = await fixture.readFile('/reptiles/iguana/index.html'); + + assert.match(html, /Iguana/); + assert.match(html, /This is a rendered entry/); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-escape.test.js b/packages/integrations/mdx/test/mdx-escape.test.js deleted file mode 100644 index 9770128384d1..000000000000 --- a/packages/integrations/mdx/test/mdx-escape.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import mdx from '@astrojs/mdx'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const FIXTURE_ROOT = new URL('./fixtures/mdx-escape/', import.meta.url); - -describe('MDX frontmatter', () => { - let fixture; - before(async () => { - fixture = await loadFixture({ - root: FIXTURE_ROOT, - integrations: [mdx()], - }); - await fixture.build(); - }); - - it('does not have unescaped HTML at top-level', async () => { - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - assert.equal(document.body.textContent.includes(' { - const html = await fixture.readFile('/html-tag/index.html'); - const { document } = parseHTML(html); - - assert.equal(document.body.textContent.includes(' { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: FIXTURE_ROOT, - }); - await fixture.build(); - }); - - it('remark supports custom vfile data - get title', async () => { - const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json')); - const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title); - assert.equal(titles.includes('Page 1'), true); - assert.equal(titles.includes('Page 2'), true); - }); - - it('rehype supports custom vfile data - reading time', async () => { - const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json')); - const readingTimes = frontmatterByPage.map( - (frontmatter = {}) => frontmatter.injectedReadingTime, - ); - assert.equal(readingTimes.length > 0, true); - for (let readingTime of readingTimes) { - assert.notEqual(readingTime, null); - assert.match(readingTime.text, /^\d+ min read/); - } - }); - - it('allow user frontmatter mutation', async () => { - const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json')); - const descriptions = frontmatterByPage.map((frontmatter = {}) => frontmatter.description); - assert.equal( - descriptions.includes('Processed by remarkDescription plugin: Page 1 description'), - true, - ); - assert.equal( - descriptions.includes('Processed by remarkDescription plugin: Page 2 description'), - true, - ); - }); - - it('passes injected frontmatter to layouts', async () => { - const html1 = await fixture.readFile('/page-1/index.html'); - const html2 = await fixture.readFile('/page-2/index.html'); - - const title1 = parseHTML(html1).document.querySelector('title'); - const title2 = parseHTML(html2).document.querySelector('title'); - - assert.equal(title1.innerHTML, 'Page 1'); - assert.equal(title2.innerHTML, 'Page 2'); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-frontmatter-injection.test.ts b/packages/integrations/mdx/test/mdx-frontmatter-injection.test.ts new file mode 100644 index 000000000000..d82e7e4f3c4f --- /dev/null +++ b/packages/integrations/mdx/test/mdx-frontmatter-injection.test.ts @@ -0,0 +1,69 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture } from '../../../astro/test/test-utils.js'; + +const FIXTURE_ROOT = new URL('./fixtures/mdx-frontmatter-injection/', import.meta.url); + +type FrontmatterEntry = { + layout: string; + title: string; + description: string; + injectedReadingTime: { text: string; minutes: number; time: number; words: number } | null; +}; + +describe('MDX frontmatter injection', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: FIXTURE_ROOT, + }); + await fixture.build(); + }); + + const readFrontmatterByPage = async (): Promise => { + return JSON.parse(await fixture.readFile('/glob.json')) as FrontmatterEntry[]; + }; + + it('remark supports custom vfile data - get title', async () => { + const frontmatterByPage = await readFrontmatterByPage(); + const titles = frontmatterByPage.map((frontmatter) => frontmatter.title); + assert.equal(titles.includes('Page 1'), true); + assert.equal(titles.includes('Page 2'), true); + }); + + it('rehype supports custom vfile data - reading time', async () => { + const frontmatterByPage = await readFrontmatterByPage(); + const readingTimes = frontmatterByPage.map((frontmatter) => frontmatter.injectedReadingTime); + assert.equal(readingTimes.length > 0, true); + for (const readingTime of readingTimes) { + assert.notEqual(readingTime, null); + assert.match(readingTime!.text, /^\d+ min read/); + } + }); + + it('allow user frontmatter mutation', async () => { + const frontmatterByPage = await readFrontmatterByPage(); + const descriptions = frontmatterByPage.map((frontmatter) => frontmatter.description); + assert.equal( + descriptions.includes('Processed by remarkDescription plugin: Page 1 description'), + true, + ); + assert.equal( + descriptions.includes('Processed by remarkDescription plugin: Page 2 description'), + true, + ); + }); + + it('passes injected frontmatter to layouts', async () => { + const html1 = await fixture.readFile('/page-1/index.html'); + const html2 = await fixture.readFile('/page-2/index.html'); + + const title1 = parseHTML(html1).document.querySelector('title')!; + const title2 = parseHTML(html2).document.querySelector('title')!; + + assert.equal(title1.innerHTML, 'Page 1'); + assert.equal(title2.innerHTML, 'Page 2'); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-frontmatter.test.js b/packages/integrations/mdx/test/mdx-frontmatter.test.js deleted file mode 100644 index 5f7398bac800..000000000000 --- a/packages/integrations/mdx/test/mdx-frontmatter.test.js +++ /dev/null @@ -1,78 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import mdx from '@astrojs/mdx'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const FIXTURE_ROOT = new URL('./fixtures/mdx-frontmatter/', import.meta.url); - -describe('MDX frontmatter', () => { - let fixture; - before(async () => { - fixture = await loadFixture({ - root: FIXTURE_ROOT, - integrations: [mdx()], - }); - await fixture.build(); - }); - it('builds when "frontmatter.property" is in JSX expression', async () => { - assert.equal(true, true); - }); - - it('extracts frontmatter to "frontmatter" export', async () => { - const { titles } = JSON.parse(await fixture.readFile('/glob.json')); - assert.equal(titles.includes('Using YAML frontmatter'), true); - }); - - it('renders layout from "layout" frontmatter property', async () => { - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const layoutParagraph = document.querySelector('[data-layout-rendered]'); - - assert.notEqual(layoutParagraph, null); - }); - - it('passes frontmatter to layout via "content" and "frontmatter" props', async () => { - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const contentTitle = document.querySelector('[data-content-title]'); - const frontmatterTitle = document.querySelector('[data-frontmatter-title]'); - - assert.equal(contentTitle.textContent, 'Using YAML frontmatter'); - assert.equal(frontmatterTitle.textContent, 'Using YAML frontmatter'); - }); - - it('passes headings to layout via "headings" prop', async () => { - const html = await fixture.readFile('/with-headings/index.html'); - const { document } = parseHTML(html); - - const headingSlugs = [...document.querySelectorAll('[data-headings] > li')].map( - (el) => el.textContent, - ); - - assert.equal(headingSlugs.length > 0, true); - assert.equal(headingSlugs.includes('section-1'), true); - assert.equal(headingSlugs.includes('section-2'), true); - }); - - it('passes "file" and "url" to layout', async () => { - const html = await fixture.readFile('/with-headings/index.html'); - const { document } = parseHTML(html); - - const frontmatterFile = document.querySelector('[data-frontmatter-file]')?.textContent; - const frontmatterUrl = document.querySelector('[data-frontmatter-url]')?.textContent; - const file = document.querySelector('[data-file]')?.textContent; - const url = document.querySelector('[data-url]')?.textContent; - - assert.equal( - frontmatterFile?.endsWith('with-headings.mdx'), - true, - '"file" prop does not end with correct path or is undefined', - ); - assert.equal(frontmatterUrl, '/with-headings'); - assert.equal(file, frontmatterFile); - assert.equal(url, frontmatterUrl); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-get-headings.test.js b/packages/integrations/mdx/test/mdx-get-headings.test.js deleted file mode 100644 index 2776cfc7c3d6..000000000000 --- a/packages/integrations/mdx/test/mdx-get-headings.test.js +++ /dev/null @@ -1,203 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { rehypeHeadingIds } from '@astrojs/markdown-remark'; -import mdx from '@astrojs/mdx'; -import { parseHTML } from 'linkedom'; -import { visit } from 'unist-util-visit'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('MDX getHeadings', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-get-headings/', import.meta.url), - integrations: [mdx()], - }); - - await fixture.build(); - }); - - it('adds anchor IDs to headings', async () => { - const html = await fixture.readFile('/test/index.html'); - const { document } = parseHTML(html); - - const h2Ids = document.querySelectorAll('h2').map((el) => el?.id); - const h3Ids = document.querySelectorAll('h3').map((el) => el?.id); - assert.equal(document.querySelector('h1').id, 'heading-test'); - assert.equal(h2Ids.includes('section-1'), true); - assert.equal(h2Ids.includes('section-2'), true); - assert.equal(h3Ids.includes('subsection-1'), true); - assert.equal(h3Ids.includes('subsection-2'), true); - }); - - it('generates correct getHeadings() export', async () => { - const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json')); - // TODO: make this a snapshot test :) - assert.equal( - JSON.stringify(headingsByPage['./test.mdx']), - JSON.stringify([ - { depth: 1, slug: 'heading-test', text: 'Heading test' }, - { depth: 2, slug: 'section-1', text: 'Section 1' }, - { depth: 3, slug: 'subsection-1', text: 'Subsection 1' }, - { depth: 3, slug: 'subsection-2', text: 'Subsection 2' }, - { depth: 2, slug: 'section-2', text: 'Section 2' }, - { depth: 2, slug: 'picture-', text: '' }, - { depth: 3, slug: '-sacrebleu--', text: '« Sacrebleu ! »' }, - ]), - ); - }); - - it('generates correct getHeadings() export for JSX expressions', async () => { - const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json')); - assert.equal( - JSON.stringify(headingsByPage['./test-with-jsx-expressions.mdx']), - JSON.stringify([ - { - depth: 1, - slug: 'heading-test-with-jsx-expressions', - text: 'Heading test with JSX expressions', - }, - { depth: 2, slug: 'h2title', text: 'h2Title' }, - { depth: 3, slug: 'h3title', text: 'h3Title' }, - ]), - ); - }); -}); - -describe('MDX heading IDs can be customized by user plugins', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-get-headings/', import.meta.url), - integrations: [mdx()], - markdown: { - rehypePlugins: [ - () => (tree) => { - let count = 0; - visit(tree, 'element', (node) => { - if (!/^h\d$/.test(node.tagName)) return; - if (!node.properties?.id) { - node.properties = { ...node.properties, id: String(count++) }; - } - }); - }, - ], - }, - }); - - await fixture.build(); - }); - - it('adds user-specified IDs to HTML output', async () => { - const html = await fixture.readFile('/test/index.html'); - const { document } = parseHTML(html); - - const h1 = document.querySelector('h1'); - assert.equal(h1?.textContent, 'Heading test'); - assert.equal(h1?.getAttribute('id'), '0'); - - const headingIDs = document.querySelectorAll('h1,h2,h3').map((el) => el.id); - assert.equal( - JSON.stringify(headingIDs), - JSON.stringify(Array.from({ length: headingIDs.length }, (_, idx) => String(idx))), - ); - }); - - it('generates correct getHeadings() export', async () => { - const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json')); - assert.equal( - JSON.stringify(headingsByPage['./test.mdx']), - JSON.stringify([ - { depth: 1, slug: '0', text: 'Heading test' }, - { depth: 2, slug: '1', text: 'Section 1' }, - { depth: 3, slug: '2', text: 'Subsection 1' }, - { depth: 3, slug: '3', text: 'Subsection 2' }, - { depth: 2, slug: '4', text: 'Section 2' }, - { depth: 2, slug: '5', text: '' }, - { depth: 3, slug: '6', text: '« Sacrebleu ! »' }, - ]), - ); - }); -}); - -describe('MDX heading IDs can be injected before user plugins', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-get-headings/', import.meta.url), - integrations: [ - mdx({ - rehypePlugins: [ - rehypeHeadingIds, - () => (tree) => { - visit(tree, 'element', (node) => { - if (!/^h\d$/.test(node.tagName)) return; - if (node.properties?.id) { - node.children.push({ type: 'text', value: ' ' + node.properties.id }); - } - }); - }, - ], - }), - ], - }); - - await fixture.build(); - }); - - it('adds user-specified IDs to HTML output', async () => { - const html = await fixture.readFile('/test/index.html'); - const { document } = parseHTML(html); - - const h1 = document.querySelector('h1'); - assert.equal(h1?.textContent, 'Heading test heading-test'); - assert.equal(h1?.id, 'heading-test'); - }); -}); - -describe('MDX headings with frontmatter', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-get-headings/', import.meta.url), - integrations: [mdx()], - }); - - await fixture.build(); - }); - - it('adds anchor IDs to headings', async () => { - const html = await fixture.readFile('/test-with-frontmatter/index.html'); - const { document } = parseHTML(html); - - const h3Ids = document.querySelectorAll('h3').map((el) => el?.id); - - assert.equal(document.querySelector('h1').id, 'the-frontmatter-title'); - assert.equal(document.querySelector('h2').id, 'frontmattertitle'); - assert.equal(h3Ids.includes('keyword-2'), true); - assert.equal(h3Ids.includes('tag-1'), true); - assert.equal(document.querySelector('h4').id, 'item-2'); - assert.equal(document.querySelector('h5').id, 'nested-item-3'); - assert.equal(document.querySelector('h6').id, 'frontmatterunknown'); - }); - - it('generates correct getHeadings() export', async () => { - const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json')); - assert.equal( - JSON.stringify(headingsByPage['./test-with-frontmatter.mdx']), - JSON.stringify([ - { depth: 1, slug: 'the-frontmatter-title', text: 'The Frontmatter Title' }, - { depth: 2, slug: 'frontmattertitle', text: 'frontmatter.title' }, - { depth: 3, slug: 'keyword-2', text: 'Keyword 2' }, - { depth: 3, slug: 'tag-1', text: 'Tag 1' }, - { depth: 4, slug: 'item-2', text: 'Item 2' }, - { depth: 5, slug: 'nested-item-3', text: 'Nested Item 3' }, - { depth: 6, slug: 'frontmatterunknown', text: 'frontmatter.unknown' }, - ]), - ); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-get-headings.test.ts b/packages/integrations/mdx/test/mdx-get-headings.test.ts new file mode 100644 index 000000000000..f889c5140153 --- /dev/null +++ b/packages/integrations/mdx/test/mdx-get-headings.test.ts @@ -0,0 +1,193 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { rehypeHeadingIds } from '@astrojs/markdown-remark'; +import mdx from '@astrojs/mdx'; +import { parseHTML } from 'linkedom'; +import { visit } from 'unist-util-visit'; +import { loadFixture, type Fixture } from '../../../astro/test/test-utils.js'; + +describe('MDX getHeadings', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-get-headings/', import.meta.url), + integrations: [mdx()], + }); + + await fixture.build(); + }); + + it('adds anchor IDs to headings', async () => { + const html = await fixture.readFile('/test/index.html'); + const { document } = parseHTML(html); + + const h2Ids = Array.from(document.querySelectorAll('h2')).map((el) => el?.id); + const h3Ids = Array.from(document.querySelectorAll('h3')).map((el) => el?.id); + assert.equal(document.querySelector('h1')!.id, 'heading-test'); + assert.equal(h2Ids.includes('section-1'), true); + assert.equal(h2Ids.includes('section-2'), true); + assert.equal(h3Ids.includes('subsection-1'), true); + assert.equal(h3Ids.includes('subsection-2'), true); + }); + + it('generates correct getHeadings() export', async () => { + const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json')); + // TODO: make this a snapshot test :) + assert.equal( + JSON.stringify(headingsByPage['./test.mdx']), + JSON.stringify([ + { depth: 1, slug: 'heading-test', text: 'Heading test' }, + { depth: 2, slug: 'section-1', text: 'Section 1' }, + { depth: 3, slug: 'subsection-1', text: 'Subsection 1' }, + { depth: 3, slug: 'subsection-2', text: 'Subsection 2' }, + { depth: 2, slug: 'section-2', text: 'Section 2' }, + { depth: 2, slug: 'picture-', text: '' }, + { depth: 3, slug: '-sacrebleu--', text: '« Sacrebleu ! »' }, + ]), + ); + }); + + it('generates correct getHeadings() export for JSX expressions', async () => { + const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json')); + assert.equal( + JSON.stringify(headingsByPage['./test-with-jsx-expressions.mdx']), + JSON.stringify([ + { + depth: 1, + slug: 'heading-test-with-jsx-expressions', + text: 'Heading test with JSX expressions', + }, + { depth: 2, slug: 'h2title', text: 'h2Title' }, + { depth: 3, slug: 'h3title', text: 'h3Title' }, + ]), + ); + }); + + // These tests use the same config (integrations: [mdx()]) and share the build above + describe('with frontmatter', () => { + it('adds anchor IDs to headings', async () => { + const html = await fixture.readFile('/test-with-frontmatter/index.html'); + const { document } = parseHTML(html); + + const h3Ids = Array.from(document.querySelectorAll('h3')).map((el) => el?.id); + + assert.equal(document.querySelector('h1')!.id, 'the-frontmatter-title'); + assert.equal(document.querySelector('h2')!.id, 'frontmattertitle'); + assert.equal(h3Ids.includes('keyword-2'), true); + assert.equal(h3Ids.includes('tag-1'), true); + assert.equal(document.querySelector('h4')!.id, 'item-2'); + assert.equal(document.querySelector('h5')!.id, 'nested-item-3'); + assert.equal(document.querySelector('h6')!.id, 'frontmatterunknown'); + }); + + it('generates correct getHeadings() export', async () => { + const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json')); + assert.equal( + JSON.stringify(headingsByPage['./test-with-frontmatter.mdx']), + JSON.stringify([ + { depth: 1, slug: 'the-frontmatter-title', text: 'The Frontmatter Title' }, + { depth: 2, slug: 'frontmattertitle', text: 'frontmatter.title' }, + { depth: 3, slug: 'keyword-2', text: 'Keyword 2' }, + { depth: 3, slug: 'tag-1', text: 'Tag 1' }, + { depth: 4, slug: 'item-2', text: 'Item 2' }, + { depth: 5, slug: 'nested-item-3', text: 'Nested Item 3' }, + { depth: 6, slug: 'frontmatterunknown', text: 'frontmatter.unknown' }, + ]), + ); + }); + }); +}); + +describe('MDX heading IDs can be customized by user plugins', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-get-headings/', import.meta.url), + integrations: [mdx()], + markdown: { + rehypePlugins: [ + () => (tree) => { + let count = 0; + visit(tree, 'element', (node) => { + if (!/^h\d$/.test(node.tagName)) return; + if (!node.properties?.id) { + node.properties = { ...node.properties, id: String(count++) }; + } + }); + }, + ], + }, + }); + + await fixture.build(); + }); + + it('adds user-specified IDs to HTML output', async () => { + const html = await fixture.readFile('/test/index.html'); + const { document } = parseHTML(html); + + const h1 = document.querySelector('h1'); + assert.equal(h1?.textContent, 'Heading test'); + assert.equal(h1?.getAttribute('id'), '0'); + + const headingIDs = Array.from(document.querySelectorAll('h1,h2,h3')).map((el) => el.id); + assert.equal( + JSON.stringify(headingIDs), + JSON.stringify(Array.from({ length: headingIDs.length }, (_, idx) => String(idx))), + ); + }); + + it('generates correct getHeadings() export', async () => { + const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json')); + assert.equal( + JSON.stringify(headingsByPage['./test.mdx']), + JSON.stringify([ + { depth: 1, slug: '0', text: 'Heading test' }, + { depth: 2, slug: '1', text: 'Section 1' }, + { depth: 3, slug: '2', text: 'Subsection 1' }, + { depth: 3, slug: '3', text: 'Subsection 2' }, + { depth: 2, slug: '4', text: 'Section 2' }, + { depth: 2, slug: '5', text: '' }, + { depth: 3, slug: '6', text: '« Sacrebleu ! »' }, + ]), + ); + }); +}); + +describe('MDX heading IDs can be injected before user plugins', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-get-headings/', import.meta.url), + integrations: [ + mdx({ + rehypePlugins: [ + rehypeHeadingIds, + () => (tree) => { + visit(tree, 'element', (node) => { + if (!/^h\d$/.test(node.tagName)) return; + if (node.properties?.id) { + node.children.push({ type: 'text', value: ' ' + node.properties.id }); + } + }); + }, + ], + }), + ], + }); + + await fixture.build(); + }); + + it('adds user-specified IDs to HTML output', async () => { + const html = await fixture.readFile('/test/index.html'); + const { document } = parseHTML(html); + + const h1 = document.querySelector('h1'); + assert.equal(h1?.textContent, 'Heading test heading-test'); + assert.equal(h1?.id, 'heading-test'); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-get-static-paths.test.js b/packages/integrations/mdx/test/mdx-get-static-paths.test.js deleted file mode 100644 index 74959ccd13bf..000000000000 --- a/packages/integrations/mdx/test/mdx-get-static-paths.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import mdx from '@astrojs/mdx'; -import * as cheerio from 'cheerio'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const FIXTURE_ROOT = new URL('./fixtures/mdx-get-static-paths', import.meta.url); - -describe('getStaticPaths', () => { - /** @type {import('astro/test/test-utils').Fixture} */ - let fixture; - before(async () => { - fixture = await loadFixture({ - root: FIXTURE_ROOT, - integrations: [mdx()], - }); - await fixture.build(); - }); - - it('Provides file and url', async () => { - const html = await fixture.readFile('/one/index.html'); - - const $ = cheerio.load(html); - assert.equal($('p').text(), 'First mdx file'); - assert.equal($('#one').text(), 'hello', 'Frontmatter included'); - assert.equal($('#url').text(), 'src/content/1.mdx', 'url is included'); - assert.equal( - $('#file').text().includes('fixtures/mdx-get-static-paths/src/content/1.mdx'), - true, - 'file is included', - ); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-images.test.js b/packages/integrations/mdx/test/mdx-images.test.js deleted file mode 100644 index 543b9021ebe2..000000000000 --- a/packages/integrations/mdx/test/mdx-images.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const imageTestRoutes = ['with-components', 'esm-import', 'content-collection']; - -describe('MDX Page', () => { - let devServer; - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-images/', import.meta.url), - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - describe('Optimized images in MDX', () => { - it('works', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const imgs = document.getElementsByTagName('img'); - assert.equal(imgs.length, 6); - // Image using a relative path - assert.equal(imgs.item(0).src.startsWith('/_image'), true); - // Image using an aliased path - assert.equal(imgs.item(1).src.startsWith('/_image'), true); - // Image with title - assert.equal(imgs.item(2).title, 'Houston title'); - // Image with spaces in the path - assert.equal(imgs.item(3).src.startsWith('/_image'), true); - // Image using a relative path with no slashes - assert.equal(imgs.item(4).src.startsWith('/_image'), true); - // Image using a relative path with nested directory - assert.equal(imgs.item(5).src.startsWith('/_image'), true); - }); - - for (const route of imageTestRoutes) { - it(`supports img component - ${route}`, async () => { - const res = await fixture.fetch(`/${route}`); - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const imgs = document.getElementsByTagName('img'); - assert.equal(imgs.length, 2); - - const assetsImg = imgs.item(0); - assert.equal(assetsImg.src.startsWith('/_image'), true); - assert.equal(assetsImg.hasAttribute('data-my-image'), true); - - const publicImg = imgs.item(1); - assert.equal(publicImg.src, '/favicon.svg'); - assert.equal(publicImg.hasAttribute('data-my-image'), true); - }); - } - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - it('includes responsive styles', async () => { - const code = await fixture.readFile('/index.html'); - assert.ok(code.includes('[data-astro-image]')); - }); - it("doesn't include styles on pages without images", async () => { - const code = await fixture.readFile('/no-image/index.html'); - assert.ok(!code.includes('[data-astro-image]')); - }); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-images.test.ts b/packages/integrations/mdx/test/mdx-images.test.ts new file mode 100644 index 000000000000..304dcc163fa6 --- /dev/null +++ b/packages/integrations/mdx/test/mdx-images.test.ts @@ -0,0 +1,82 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +const imageTestRoutes = ['with-components', 'esm-import', 'content-collection']; + +describe('MDX Page', () => { + let devServer: DevServer; + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-images/', import.meta.url), + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Optimized images in MDX', () => { + it('works', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const imgs = document.getElementsByTagName('img'); + assert.equal(imgs.length, 6); + // Image using a relative path + assert.equal(imgs.item(0)!.src.startsWith('/_image'), true); + // Image using an aliased path + assert.equal(imgs.item(1)!.src.startsWith('/_image'), true); + // Image with title + assert.equal(imgs.item(2)!.title, 'Houston title'); + // Image with spaces in the path + assert.equal(imgs.item(3)!.src.startsWith('/_image'), true); + // Image using a relative path with no slashes + assert.equal(imgs.item(4)!.src.startsWith('/_image'), true); + // Image using a relative path with nested directory + assert.equal(imgs.item(5)!.src.startsWith('/_image'), true); + }); + + for (const route of imageTestRoutes) { + it(`supports img component - ${route}`, async () => { + const res = await fixture.fetch(`/${route}`); + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const imgs = document.getElementsByTagName('img'); + assert.equal(imgs.length, 2); + + const assetsImg = imgs.item(0)!; + assert.equal(assetsImg.src.startsWith('/_image'), true); + assert.equal(assetsImg.hasAttribute('data-my-image'), true); + + const publicImg = imgs.item(1)!; + assert.equal(publicImg.src, '/favicon.svg'); + assert.equal(publicImg.hasAttribute('data-my-image'), true); + }); + } + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + it('includes responsive styles', async () => { + const code = await fixture.readFile('/index.html'); + assert.ok(code.includes('[data-astro-image]')); + }); + it("doesn't include styles on pages without images", async () => { + const code = await fixture.readFile('/no-image/index.html'); + assert.ok(!code.includes('[data-astro-image]')); + }); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-infinite-loop.test.js b/packages/integrations/mdx/test/mdx-infinite-loop.test.js deleted file mode 100644 index a4c78fcfff1e..000000000000 --- a/packages/integrations/mdx/test/mdx-infinite-loop.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import mdx from '@astrojs/mdx'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('MDX Infinite Loop', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-infinite-loop/', import.meta.url), - integrations: [mdx()], - }); - }); - - describe('build', () => { - let err; - before(async () => { - try { - await fixture.build(); - } catch (e) { - err = e; - } - }); - - it('does not hang forever if an error is thrown', async () => { - assert.equal(!!err, true); - }); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-infinite-loop.test.ts b/packages/integrations/mdx/test/mdx-infinite-loop.test.ts new file mode 100644 index 000000000000..383c89d9a52c --- /dev/null +++ b/packages/integrations/mdx/test/mdx-infinite-loop.test.ts @@ -0,0 +1,30 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import mdx from '@astrojs/mdx'; +import { loadFixture, type Fixture } from '../../../astro/test/test-utils.js'; + +describe('MDX Infinite Loop', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-infinite-loop/', import.meta.url), + integrations: [mdx()], + }); + }); + + describe('build', () => { + let err: unknown; + before(async () => { + try { + await fixture.build(); + } catch (e) { + err = e; + } + }); + + it('does not hang forever if an error is thrown', async () => { + assert.equal(!!err, true); + }); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-math.test.js b/packages/integrations/mdx/test/mdx-math.test.ts similarity index 100% rename from packages/integrations/mdx/test/mdx-math.test.js rename to packages/integrations/mdx/test/mdx-math.test.ts diff --git a/packages/integrations/mdx/test/mdx-namespace.test.js b/packages/integrations/mdx/test/mdx-namespace.test.js deleted file mode 100644 index 13137e40e98c..000000000000 --- a/packages/integrations/mdx/test/mdx-namespace.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('MDX Namespace', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-namespace/', import.meta.url), - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('works for object', async () => { - const html = await fixture.readFile('/object/index.html'); - const { document } = parseHTML(html); - - const island = document.querySelector('astro-island'); - const component = document.querySelector('#component'); - - assert.notEqual(island, undefined); - assert.equal(component.textContent, 'Hello world'); - }); - - it('works for star', async () => { - const html = await fixture.readFile('/star/index.html'); - const { document } = parseHTML(html); - - const island = document.querySelector('astro-island'); - const component = document.querySelector('#component'); - - assert.notEqual(island, undefined); - assert.equal(component.textContent, 'Hello world'); - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('works for object', async () => { - const res = await fixture.fetch('/object'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const island = document.querySelector('astro-island'); - const component = document.querySelector('#component'); - - assert.notEqual(island, undefined); - assert.equal(component.textContent, 'Hello world'); - }); - - it('works for star', async () => { - const res = await fixture.fetch('/star'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const island = document.querySelector('astro-island'); - const component = document.querySelector('#component'); - - assert.notEqual(island, undefined); - assert.equal(component.textContent, 'Hello world'); - }); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-namespace.test.ts b/packages/integrations/mdx/test/mdx-namespace.test.ts new file mode 100644 index 000000000000..3f7cd47bb375 --- /dev/null +++ b/packages/integrations/mdx/test/mdx-namespace.test.ts @@ -0,0 +1,84 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +describe('MDX Namespace', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-namespace/', import.meta.url), + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('works for object', async () => { + const html = await fixture.readFile('/object/index.html'); + const { document } = parseHTML(html); + + const island = document.querySelector('astro-island'); + const component = document.querySelector('#component')!; + + assert.notEqual(island, undefined); + assert.equal(component.textContent, 'Hello world'); + }); + + it('works for star', async () => { + const html = await fixture.readFile('/star/index.html'); + const { document } = parseHTML(html); + + const island = document.querySelector('astro-island'); + const component = document.querySelector('#component')!; + + assert.notEqual(island, undefined); + assert.equal(component.textContent, 'Hello world'); + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('works for object', async () => { + const res = await fixture.fetch('/object'); + + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const island = document.querySelector('astro-island'); + const component = document.querySelector('#component')!; + + assert.notEqual(island, undefined); + assert.equal(component.textContent, 'Hello world'); + }); + + it('works for star', async () => { + const res = await fixture.fetch('/star'); + + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const island = document.querySelector('astro-island'); + const component = document.querySelector('#component')!; + + assert.notEqual(island, undefined); + assert.equal(component.textContent, 'Hello world'); + }); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-optimize.test.js b/packages/integrations/mdx/test/mdx-optimize.test.js deleted file mode 100644 index bc66a5732c91..000000000000 --- a/packages/integrations/mdx/test/mdx-optimize.test.js +++ /dev/null @@ -1,97 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const FIXTURE_ROOT = new URL('./fixtures/mdx-optimize/', import.meta.url); - -describe('MDX optimize', () => { - /** @type {import('../../../astro/test/test-utils').Fixture} */ - let fixture; - before(async () => { - fixture = await loadFixture({ - root: FIXTURE_ROOT, - }); - await fixture.build(); - }); - - it('renders an MDX page fine', async () => { - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - assert.equal(document.querySelector('h1').textContent.includes('MDX page'), true); - assert.equal( - document.querySelector('p').textContent.includes('I once heard a very inspirational quote:'), - true, - ); - - const blockquote = document.querySelector('blockquote.custom-blockquote'); - assert.notEqual(blockquote, null); - assert.equal(blockquote.textContent.includes('I like pancakes'), true); - - const code = document.querySelector('pre.astro-code'); - assert.notEqual(code, null); - assert.equal(code.textContent.includes(`const pancakes = 'yummy'`), true); - }); - - it('renders an Astro page that imports MDX fine', async () => { - const html = await fixture.readFile('/import/index.html'); - const { document } = parseHTML(html); - - assert.equal(document.querySelector('h1').textContent.includes('Astro page'), true); - assert.equal( - document.querySelector('p').textContent.includes('I once heard a very inspirational quote:'), - true, - ); - - const blockquote = document.querySelector('blockquote.custom-blockquote'); - assert.notEqual(blockquote, null); - assert.equal(blockquote.textContent.includes('I like pancakes'), true); - }); - - it('supports components passed to the MDX ` component if in the ignoreElementNames config', async () => { - const html = await fixture.readFile('/content-component/index.html'); - const { document } = parseHTML(html); - - const strong = document.querySelector('strong.custom-strong'); - assert.ok(strong); - assert.equal(strong.textContent.trim(), 'inspirational'); - }); - - // This is skipped because we currently do support this (for top-level elements only). This is - // unintentional but it would be a breaking change to remove support, so leaving as-is for now. - it.skip('does not support components passed to the MDX ` component not in the ignoreElementNames config', async () => { - const html = await fixture.readFile('/content-component/index.html'); - const { document } = parseHTML(html); - - const blockquote = document.querySelector('blockquote'); - assert.ok(blockquote); - assert.ok(!blockquote.classList.contains('custom-blockquote')); - }); - - it('extracts components export from more complex MDX nodes', async () => { - const html = await fixture.readFile('/import-export-block/index.html'); - const { document } = parseHTML(html); - - // Strong is expected because its in the `ignoreElementNames` config. - const strong = document.querySelector('strong.custom-strong'); - assert.ok(strong); - assert.equal(strong.textContent.trim(), 'Bold bullet point'); - - // Blockquote is specified in the test document, and should also be extracted correctly. - const blockquote = document.querySelector('blockquote.custom-blockquote'); - assert.ok(blockquote); - assert.equal(blockquote.textContent.trim(), 'This is a blockquote'); - }); - - it('renders MDX with rehype plugin that incorrectly injects root hast node', async () => { - const html = await fixture.readFile('/import/index.html'); - const { document } = parseHTML(html); - - assert.doesNotMatch(html, /set:html=/); - assert.equal( - document.getElementById('injected-root-hast').textContent, - 'Injected root hast from rehype plugin', - ); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-optimize.test.ts b/packages/integrations/mdx/test/mdx-optimize.test.ts new file mode 100644 index 000000000000..3f79cf3742ee --- /dev/null +++ b/packages/integrations/mdx/test/mdx-optimize.test.ts @@ -0,0 +1,96 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture } from '../../../astro/test/test-utils.js'; + +const FIXTURE_ROOT = new URL('./fixtures/mdx-optimize/', import.meta.url); + +describe('MDX optimize', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: FIXTURE_ROOT, + }); + await fixture.build(); + }); + + it('renders an MDX page fine', async () => { + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + assert.equal(document.querySelector('h1')!.textContent.includes('MDX page'), true); + assert.equal( + document.querySelector('p')!.textContent.includes('I once heard a very inspirational quote:'), + true, + ); + + const blockquote = document.querySelector('blockquote.custom-blockquote')!; + assert.notEqual(blockquote, null); + assert.equal(blockquote.textContent.includes('I like pancakes'), true); + + const code = document.querySelector('pre.astro-code')!; + assert.notEqual(code, null); + assert.equal(code.textContent.includes(`const pancakes = 'yummy'`), true); + }); + + it('renders an Astro page that imports MDX fine', async () => { + const html = await fixture.readFile('/import/index.html'); + const { document } = parseHTML(html); + + assert.equal(document.querySelector('h1')!.textContent.includes('Astro page'), true); + assert.equal( + document.querySelector('p')!.textContent.includes('I once heard a very inspirational quote:'), + true, + ); + + const blockquote = document.querySelector('blockquote.custom-blockquote')!; + assert.notEqual(blockquote, null); + assert.equal(blockquote.textContent.includes('I like pancakes'), true); + }); + + it('supports components passed to the MDX ` component if in the ignoreElementNames config', async () => { + const html = await fixture.readFile('/content-component/index.html'); + const { document } = parseHTML(html); + + const strong = document.querySelector('strong.custom-strong'); + assert.ok(strong); + assert.equal(strong.textContent.trim(), 'inspirational'); + }); + + // This is skipped because we currently do support this (for top-level elements only). This is + // unintentional but it would be a breaking change to remove support, so leaving as-is for now. + it.skip('does not support components passed to the MDX ` component not in the ignoreElementNames config', async () => { + const html = await fixture.readFile('/content-component/index.html'); + const { document } = parseHTML(html); + + const blockquote = document.querySelector('blockquote'); + assert.ok(blockquote); + assert.ok(!blockquote.classList.contains('custom-blockquote')); + }); + + it('extracts components export from more complex MDX nodes', async () => { + const html = await fixture.readFile('/import-export-block/index.html'); + const { document } = parseHTML(html); + + // Strong is expected because its in the `ignoreElementNames` config. + const strong = document.querySelector('strong.custom-strong'); + assert.ok(strong); + assert.equal(strong.textContent.trim(), 'Bold bullet point'); + + // Blockquote is specified in the test document, and should also be extracted correctly. + const blockquote = document.querySelector('blockquote.custom-blockquote'); + assert.ok(blockquote); + assert.equal(blockquote.textContent.trim(), 'This is a blockquote'); + }); + + it('renders MDX with rehype plugin that incorrectly injects root hast node', async () => { + const html = await fixture.readFile('/import/index.html'); + const { document } = parseHTML(html); + + assert.doesNotMatch(html, /set:html=/); + assert.equal( + document.getElementById('injected-root-hast')!.textContent, + 'Injected root hast from rehype plugin', + ); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-page.test.js b/packages/integrations/mdx/test/mdx-page.test.js deleted file mode 100644 index 0327bcaf0a82..000000000000 --- a/packages/integrations/mdx/test/mdx-page.test.js +++ /dev/null @@ -1,117 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('MDX Page', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-page/', import.meta.url), - // test suite was authored when inlineStylesheets defaulted to never - build: { inlineStylesheets: 'never' }, - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('works', async () => { - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const h1 = document.querySelector('h1'); - - assert.equal(h1.textContent, 'Hello page!'); - }); - - it('injects style imports when layout is not applied', async () => { - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const stylesheet = document.querySelector('link[rel="stylesheet"]'); - - assert.notEqual(stylesheet, null); - }); - - it('Renders MDX in utf-8 by default', async () => { - const html = await fixture.readFile('/chinese-encoding/index.html'); - const $ = cheerio.load(html); - assert.equal($('h1').text(), '我的第一篇博客文章'); - assert.match(html, / { - const html = await fixture.readFile('/chinese-encoding-layout-frontmatter/index.html'); - assert.doesNotMatch(html, / { - const html = await fixture.readFile('/chinese-encoding-layout-manual/index.html'); - assert.doesNotMatch(html, / { - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const keyTest = document.querySelector('#key-test'); - assert.equal(keyTest.textContent, 'oranges'); - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('works', async () => { - const res = await fixture.fetch('/'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const h1 = document.querySelector('h1'); - - assert.equal(h1.textContent, 'Hello page!'); - }); - - it('Renders MDX in utf-8 by default', async () => { - const res = await fixture.fetch('/chinese-encoding/'); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('h1').text(), '我的第一篇博客文章'); - assert.doesNotMatch(res.headers.get('content-type'), /charset=utf-8/); - assert.match(html, / { - const res = await fixture.fetch('/chinese-encoding-layout-frontmatter/'); - assert.equal(res.status, 200); - const html = await res.text(); - assert.doesNotMatch(res.headers.get('content-type'), /charset=utf-8/); - assert.doesNotMatch(html, / { - const res = await fixture.fetch('/chinese-encoding-layout-manual/'); - assert.equal(res.status, 200); - const html = await res.text(); - assert.doesNotMatch(res.headers.get('content-type'), /charset=utf-8/); - assert.doesNotMatch(html, / { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-page/', import.meta.url), + // test suite was authored when inlineStylesheets defaulted to never + build: { inlineStylesheets: 'never' }, + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('works', async () => { + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const h1 = document.querySelector('h1')!; + + assert.equal(h1.textContent, 'Hello page!'); + }); + + it('injects style imports when layout is not applied', async () => { + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const stylesheet = document.querySelector('link[rel="stylesheet"]'); + + assert.notEqual(stylesheet, null); + }); + + it('Renders MDX in utf-8 by default', async () => { + const html = await fixture.readFile('/chinese-encoding/index.html'); + const $ = cheerio.load(html); + assert.equal($('h1').text(), '我的第一篇博客文章'); + assert.match(html, / { + const html = await fixture.readFile('/chinese-encoding-layout-frontmatter/index.html'); + assert.doesNotMatch(html, / { + const html = await fixture.readFile('/chinese-encoding-layout-manual/index.html'); + assert.doesNotMatch(html, / { + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const keyTest = document.querySelector('#key-test')!; + assert.equal(keyTest.textContent, 'oranges'); + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('works', async () => { + const res = await fixture.fetch('/'); + + assert.equal(res.status, 200); + + const html = await res.text(); + const { document } = parseHTML(html); + + const h1 = document.querySelector('h1')!; + + assert.equal(h1.textContent, 'Hello page!'); + }); + + it('Renders MDX in utf-8 by default', async () => { + const res = await fixture.fetch('/chinese-encoding/'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('h1').text(), '我的第一篇博客文章'); + assert.doesNotMatch(res.headers.get('content-type') ?? '', /charset=utf-8/); + assert.match(html, / { + const res = await fixture.fetch('/chinese-encoding-layout-frontmatter/'); + assert.equal(res.status, 200); + const html = await res.text(); + assert.doesNotMatch(res.headers.get('content-type') ?? '', /charset=utf-8/); + assert.doesNotMatch(html, / { + const res = await fixture.fetch('/chinese-encoding-layout-manual/'); + assert.equal(res.status, 200); + const html = await res.text(); + assert.doesNotMatch(res.headers.get('content-type') ?? '', /charset=utf-8/); + assert.doesNotMatch(html, / { - it('supports custom remark plugins - TOC', async () => { - const fixture = await buildFixture({ - integrations: [ - mdx({ - remarkPlugins: [remarkToc], - }), - ], - }); - - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - assert.notEqual(selectTocLink(document), null); - }); - - it('Applies GFM by default', async () => { - const fixture = await buildFixture({ - integrations: [mdx()], - }); - - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - assert.notEqual(selectGfmLink(document), null); - }); - - it('Applies SmartyPants by default', async () => { - const fixture = await buildFixture({ - integrations: [mdx()], - }); - - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - const quote = selectSmartypantsQuote(document); - assert.notEqual(quote, null); - assert.equal(quote.textContent.includes('“Smartypants” is — awesome'), true); - }); - - it('supports custom rehype plugins', async () => { - const fixture = await buildFixture({ - integrations: [ - mdx({ - rehypePlugins: [rehypeExamplePlugin], - }), - ], - }); - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - assert.notEqual(selectRehypeExample(document), null); - }); - - it('supports custom rehype plugins from integrations', async () => { - const fixture = await buildFixture({ - integrations: [ - mdx(), - { - name: 'test', - hooks: { - 'astro:config:setup': ({ updateConfig }) => { - updateConfig({ - markdown: { - rehypePlugins: [rehypeExamplePlugin], - }, - }); - }, - }, - }, - ], - }); - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - assert.notEqual(selectRehypeExample(document), null); - }); - - it('supports custom rehype plugins with namespaced attributes', async () => { - const fixture = await buildFixture({ - integrations: [ - mdx({ - rehypePlugins: [rehypeSvgPlugin], - }), - ], - }); - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - assert.notEqual(selectRehypeSvg(document), null); - }); - - it('extends markdown config by default', async () => { - const fixture = await buildFixture({ - markdown: { - remarkPlugins: [remarkExamplePlugin], - rehypePlugins: [rehypeExamplePlugin], - }, - integrations: [mdx()], - }); - - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - assert.notEqual(selectRemarkExample(document), null); - assert.notEqual(selectRehypeExample(document), null); - }); - - it('ignores string-based plugins in markdown config', async () => { - const fixture = await buildFixture({ - markdown: { - remarkPlugins: [['remark-toc', {}]], - }, - integrations: [mdx()], - }); - - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - assert.equal(selectTocLink(document), null); - }); - - for (const extendMarkdownConfig of [true, false]) { - describe(`extendMarkdownConfig = ${extendMarkdownConfig}`, () => { - let fixture; - before(async () => { - fixture = await buildFixture({ - markdown: { - remarkPlugins: [remarkToc], - gfm: false, - smartypants: false, - }, - integrations: [ - mdx({ - extendMarkdownConfig, - remarkPlugins: [remarkExamplePlugin], - rehypePlugins: [rehypeExamplePlugin], - }), - ], - }); - }); - - it('Handles MDX plugins', async () => { - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - assert.notEqual(selectRemarkExample(document, 'MDX remark plugins not applied.'), null); - assert.notEqual(selectRehypeExample(document, 'MDX rehype plugins not applied.'), null); - }); - - it('Handles Markdown plugins', async () => { - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - assert.equal( - selectTocLink( - document, - '`remarkToc` plugin applied unexpectedly. Should override Markdown config.', - ), - null, - ); - }); - - it('Handles gfm', async () => { - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - if (extendMarkdownConfig === true) { - assert.equal(selectGfmLink(document), null, 'Does not respect `markdown.gfm` option.'); - } else { - assert.notEqual(selectGfmLink(document), null, 'Respects `markdown.gfm` unexpectedly.'); - } - }); - - it('Handles smartypants', async () => { - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - const quote = selectSmartypantsQuote(document); - - if (extendMarkdownConfig === true) { - assert.equal( - quote.textContent.includes('"Smartypants" is -- awesome'), - true, - 'Does not respect `markdown.smartypants` option.', - ); - } else { - assert.equal( - quote.textContent.includes('“Smartypants” is — awesome'), - true, - 'Respects `markdown.smartypants` unexpectedly.', - ); - } - }); - }); - } - - it('supports custom recma plugins', async () => { - const fixture = await buildFixture({ - integrations: [ - mdx({ - recmaPlugins: [recmaExamplePlugin], - }), - ], - }); - - const html = await fixture.readFile(FILE); - const { document } = parseHTML(html); - - assert.notEqual(selectRecmaExample(document), null); - }); -}); - -async function buildFixture(config) { - const fixture = await loadFixture({ - root: FIXTURE_ROOT, - ...config, - }); - await fixture.build(); - return fixture; -} - -function remarkExamplePlugin() { - return (tree) => { - tree.children.push({ - type: 'html', - value: '

    ', - }); - }; -} - -function rehypeExamplePlugin() { - return (tree) => { - tree.children.push({ - type: 'element', - tagName: 'div', - properties: { 'data-rehype-plugin-works': 'true' }, - }); - }; -} - -function rehypeSvgPlugin() { - return (tree) => { - tree.children.push({ - type: 'element', - tagName: 'svg', - properties: { xmlns: 'http://www.w3.org/2000/svg' }, - children: [ - { - type: 'element', - tagName: 'use', - properties: { xLinkHref: '#icon' }, - }, - ], - }); - }; -} - -function recmaExamplePlugin() { - return (tree) => { - estreeVisit(tree, (node) => { - if ( - node.type === 'VariableDeclarator' && - node.id.name === 'recmaPluginWorking' && - node.init?.type === 'Literal' - ) { - node.init = { - ...(node.init ?? {}), - value: true, - raw: 'true', - }; - } - }); - }; -} - -function selectTocLink(document) { - return document.querySelector('ul a[href="#section-1"]'); -} - -function selectGfmLink(document) { - return document.querySelector('a[href="https://handle-me-gfm.com"]'); -} - -function selectSmartypantsQuote(document) { - return document.querySelector('blockquote'); -} - -function selectRemarkExample(document) { - return document.querySelector('div[data-remark-plugin-works]'); -} - -function selectRehypeExample(document) { - return document.querySelector('div[data-rehype-plugin-works]'); -} - -function selectRehypeSvg(document) { - return document.querySelector('svg > use[xlink\\:href]'); -} - -function selectRecmaExample(document) { - return document.querySelector('div[data-recma-plugin-works]'); -} diff --git a/packages/integrations/mdx/test/mdx-plugins.test.ts b/packages/integrations/mdx/test/mdx-plugins.test.ts new file mode 100644 index 000000000000..42ed69b689ae --- /dev/null +++ b/packages/integrations/mdx/test/mdx-plugins.test.ts @@ -0,0 +1,182 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import mdx from '@astrojs/mdx'; +import { parseHTML } from 'linkedom'; +import remarkToc from 'remark-toc'; +import { + loadFixture, + type AstroInlineConfig, + type Fixture, +} from '../../../astro/test/test-utils.js'; +import type { RehypePlugin, RemarkPlugin } from './test-utils.js'; + +const FIXTURE_ROOT = new URL('./fixtures/mdx-plugins/', import.meta.url); +const FILE = '/with-plugins/index.html'; + +describe('MDX plugins - Astro config integration', () => { + it('supports custom rehype plugins from integrations', async () => { + const fixture = await buildFixture({ + integrations: [ + mdx(), + { + name: 'test', + hooks: { + 'astro:config:setup': ({ updateConfig }) => { + updateConfig({ + markdown: { + rehypePlugins: [rehypeExamplePlugin], + }, + }); + }, + }, + }, + ], + }); + const html = await fixture.readFile(FILE); + const { document } = parseHTML(html); + + assert.notEqual(selectRehypeExample(document), null); + }); + + it('extends markdown config by default', async () => { + const fixture = await buildFixture({ + markdown: { + remarkPlugins: [remarkExamplePlugin], + rehypePlugins: [rehypeExamplePlugin], + }, + integrations: [mdx()], + }); + + const html = await fixture.readFile(FILE); + const { document } = parseHTML(html); + + assert.notEqual(selectRemarkExample(document), null); + assert.notEqual(selectRehypeExample(document), null); + }); + + for (const extendMarkdownConfig of [true, false]) { + describe(`extendMarkdownConfig = ${extendMarkdownConfig}`, () => { + let fixture: Fixture; + before(async () => { + fixture = await buildFixture({ + // Use unique outDir to avoid cache pollution between builds with different configs + outDir: `./dist/mdx-plugins-extend-${extendMarkdownConfig}/`, + markdown: { + remarkPlugins: [remarkToc], + gfm: false, + smartypants: false, + }, + integrations: [ + mdx({ + extendMarkdownConfig, + remarkPlugins: [remarkExamplePlugin], + rehypePlugins: [rehypeExamplePlugin], + }), + ], + }); + }); + + it('Handles MDX plugins', async () => { + const html = await fixture.readFile(FILE); + const { document } = parseHTML(html); + + assert.notEqual(selectRemarkExample(document), null, 'MDX remark plugins not applied.'); + assert.notEqual(selectRehypeExample(document), null, 'MDX rehype plugins not applied.'); + }); + + it('Handles Markdown plugins', async () => { + const html = await fixture.readFile(FILE); + const { document } = parseHTML(html); + + assert.equal( + selectTocLink(document), + null, + '`remarkToc` plugin applied unexpectedly. Should override Markdown config.', + ); + }); + + it('Handles gfm', async () => { + const html = await fixture.readFile(FILE); + const { document } = parseHTML(html); + + if (extendMarkdownConfig === true) { + assert.equal(selectGfmLink(document), null, 'Does not respect `markdown.gfm` option.'); + } else { + assert.notEqual(selectGfmLink(document), null, 'Respects `markdown.gfm` unexpectedly.'); + } + }); + + it('Handles smartypants', async () => { + const html = await fixture.readFile(FILE); + const { document } = parseHTML(html); + + const quote = selectSmartypantsQuote(document)!; + + if (extendMarkdownConfig === true) { + // smartypants: false inherited from markdown config — straight quotes and dashes preserved + assert.equal( + quote.textContent.includes('--'), + true, + 'Does not respect `markdown.smartypants` option: dashes should remain as --.', + ); + } else { + // smartypants defaults to ON — converts quotes to curly and -- to em dash + assert.equal( + quote.textContent.includes('\u2014'), + true, + 'Smartypants should be ON when not extending markdown config: -- should become em dash.', + ); + } + }); + }); + } +}); + +async function buildFixture(config: AstroInlineConfig = {}): Promise { + const fixture = await loadFixture({ + root: FIXTURE_ROOT, + ...config, + }); + await fixture.build(); + return fixture; +} + +const remarkExamplePlugin: RemarkPlugin = () => { + return (tree) => { + tree.children.push({ + type: 'html', + value: '
    ', + }); + }; +}; + +const rehypeExamplePlugin: RehypePlugin = () => { + return (tree) => { + tree.children.push({ + type: 'element', + tagName: 'div', + properties: { 'data-rehype-plugin-works': 'true' }, + children: [], + }); + }; +}; + +function selectTocLink(document: Document) { + return document.querySelector('ul a[href="#section-1"]'); +} + +function selectGfmLink(document: Document) { + return document.querySelector('a[href="https://handle-me-gfm.com"]'); +} + +function selectSmartypantsQuote(document: Document) { + return document.querySelector('blockquote'); +} + +function selectRemarkExample(document: Document) { + return document.querySelector('div[data-remark-plugin-works]'); +} + +function selectRehypeExample(document: Document) { + return document.querySelector('div[data-rehype-plugin-works]'); +} diff --git a/packages/integrations/mdx/test/mdx-plus-react-errors.test.js b/packages/integrations/mdx/test/mdx-plus-react-errors.test.js deleted file mode 100644 index 9d87fa8a0045..000000000000 --- a/packages/integrations/mdx/test/mdx-plus-react-errors.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -function hookError() { - const error = console.error; - const errors = []; - console.error = function (...args) { - errors.push(args); - }; - return () => { - console.error = error; - return errors; - }; -} - -describe('MDX and React with build errors', () => { - let fixture; - let unhook; - - it('shows correct error messages on build error', async () => { - try { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-plus-react-errors/', import.meta.url), - }); - unhook = hookError(); - await fixture.build(); - } catch (err) { - assert.equal(err.message, 'a is not defined'); - } - unhook(); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-plus-react-errors.test.ts b/packages/integrations/mdx/test/mdx-plus-react-errors.test.ts new file mode 100644 index 000000000000..67d909f6df1f --- /dev/null +++ b/packages/integrations/mdx/test/mdx-plus-react-errors.test.ts @@ -0,0 +1,32 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { loadFixture } from '../../../astro/test/test-utils.js'; + +function hookError() { + const error = console.error; + const errors: unknown[][] = []; + console.error = function (...args: unknown[]) { + errors.push(args); + }; + return (): unknown[][] => { + console.error = error; + return errors; + }; +} + +describe('MDX and React with build errors', () => { + let unhook: (() => unknown[][]) | undefined; + + it('shows correct error messages on build error', async () => { + try { + const fixture = await loadFixture({ + root: new URL('./fixtures/mdx-plus-react-errors/', import.meta.url), + }); + unhook = hookError(); + await fixture.build(); + } catch (err) { + assert.equal((err as Error).message, 'a is not defined'); + } + unhook?.(); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-plus-react.test.js b/packages/integrations/mdx/test/mdx-plus-react.test.js deleted file mode 100644 index 87f420fc06ef..000000000000 --- a/packages/integrations/mdx/test/mdx-plus-react.test.js +++ /dev/null @@ -1,64 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -function hookError() { - const error = console.error; - const errors = []; - console.error = function (...args) { - errors.push(args); - }; - return () => { - console.error = error; - return errors; - }; -} - -describe('MDX and React', () => { - let fixture; - let unhook; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-plus-react/', import.meta.url), - }); - unhook = hookError(); - await fixture.build(); - }); - - it('can be used in the same project', async () => { - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const p = document.querySelector('p'); - - assert.equal(p.textContent, 'Hello world'); - }); - - it('mdx renders fine', async () => { - const html = await fixture.readFile('/post/index.html'); - const { document } = parseHTML(html); - const h = document.querySelector('#testing'); - assert.equal(h.textContent, 'Testing'); - }); - - it('does not get an invalid hook call warning', () => { - const errors = unhook(); - assert.equal(errors.length === 0, true); - }); - - it('renders inline mdx component', async () => { - const html = await fixture.readFile('/inline-component/index.html'); - assert.match(html, /This is an inline component: Comp<\/span>/); - }); - - it('hydrates React component in Astro.slots.render()', async () => { - const fooHtml = await fixture.readFile('/foo/index.html'); - assert.match(fooHtml, / { + console.error = error; + return errors; + }; +} + +describe('MDX and React', () => { + let fixture: Fixture; + let unhook: () => unknown[][]; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-plus-react/', import.meta.url), + }); + unhook = hookError(); + await fixture.build(); + }); + + it('can be used in the same project', async () => { + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const p = document.querySelector('p')!; + + assert.equal(p.textContent, 'Hello world'); + }); + + it('mdx renders fine', async () => { + const html = await fixture.readFile('/post/index.html'); + const { document } = parseHTML(html); + const h = document.querySelector('#testing')!; + assert.equal(h.textContent, 'Testing'); + }); + + it('does not get an invalid hook call warning', () => { + const errors = unhook(); + assert.equal(errors.length === 0, true); + }); + + it('renders inline mdx component', async () => { + const html = await fixture.readFile('/inline-component/index.html'); + assert.match(html, /This is an inline component: Comp<\/span>/); + }); + + it('hydrates React component in Astro.slots.render()', async () => { + const fooHtml = await fixture.readFile('/foo/index.html'); + assert.match(fooHtml, / { - describe('dev', () => { - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: FIXTURE_ROOT, - integrations: [mdx()], - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('works with raw script and style strings', async () => { - const res = await fixture.fetch('/index.html'); - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const scriptContent = document.getElementById('test-script').innerHTML; - assert.equal( - scriptContent.includes("console.log('raw script')"), - true, - 'script should not be html-escaped', - ); - - const styleContent = document.getElementById('test-style').innerHTML; - assert.equal( - styleContent.includes('h1[id="script-style-raw"]'), - true, - 'style should not be html-escaped', - ); - }); - }); - - describe('build', () => { - it('works with raw script and style strings', async () => { - const fixture = await loadFixture({ - root: FIXTURE_ROOT, - integrations: [mdx()], - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const scriptContent = document.getElementById('test-script').innerHTML; - assert.equal( - scriptContent.includes("console.log('raw script')"), - true, - 'script should not be html-escaped', - ); - - const styleContent = document.getElementById('test-style').innerHTML; - assert.equal( - styleContent.includes('h1[id="script-style-raw"]'), - true, - 'style should not be html-escaped', - ); - }); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-slots.test.js b/packages/integrations/mdx/test/mdx-slots.test.js deleted file mode 100644 index f1ee6a2377ec..000000000000 --- a/packages/integrations/mdx/test/mdx-slots.test.js +++ /dev/null @@ -1,124 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import mdx from '@astrojs/mdx'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('MDX slots', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-slots/', import.meta.url), - integrations: [mdx()], - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('supports top-level imports', async () => { - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const h1 = document.querySelector('h1'); - const defaultSlot = document.querySelector('[data-default-slot]'); - const namedSlot = document.querySelector('[data-named-slot]'); - - assert.equal(h1.textContent, 'Hello slotted component!'); - assert.equal(defaultSlot.textContent, 'Default content.'); - assert.equal(namedSlot.textContent, 'Content for named slot.'); - }); - - it('supports glob imports - ', async () => { - const html = await fixture.readFile('/glob/index.html'); - const { document } = parseHTML(html); - - const h1 = document.querySelector('[data-default-export] h1'); - const defaultSlot = document.querySelector('[data-default-export] [data-default-slot]'); - const namedSlot = document.querySelector('[data-default-export] [data-named-slot]'); - - assert.equal(h1.textContent, 'Hello slotted component!'); - assert.equal(defaultSlot.textContent, 'Default content.'); - assert.equal(namedSlot.textContent, 'Content for named slot.'); - }); - - it('supports glob imports - ', async () => { - const html = await fixture.readFile('/glob/index.html'); - const { document } = parseHTML(html); - - const h1 = document.querySelector('[data-content-export] h1'); - const defaultSlot = document.querySelector('[data-content-export] [data-default-slot]'); - const namedSlot = document.querySelector('[data-content-export] [data-named-slot]'); - - assert.equal(h1.textContent, 'Hello slotted component!'); - assert.equal(defaultSlot.textContent, 'Default content.'); - assert.equal(namedSlot.textContent, 'Content for named slot.'); - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('supports top-level imports', async () => { - const res = await fixture.fetch('/'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const h1 = document.querySelector('h1'); - const defaultSlot = document.querySelector('[data-default-slot]'); - const namedSlot = document.querySelector('[data-named-slot]'); - - assert.equal(h1.textContent, 'Hello slotted component!'); - assert.equal(defaultSlot.textContent, 'Default content.'); - assert.equal(namedSlot.textContent, 'Content for named slot.'); - }); - - it('supports glob imports - ', async () => { - const res = await fixture.fetch('/glob'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const h1 = document.querySelector('[data-default-export] h1'); - const defaultSlot = document.querySelector('[data-default-export] [data-default-slot]'); - const namedSlot = document.querySelector('[data-default-export] [data-named-slot]'); - - assert.equal(h1.textContent, 'Hello slotted component!'); - assert.equal(defaultSlot.textContent, 'Default content.'); - assert.equal(namedSlot.textContent, 'Content for named slot.'); - }); - - it('supports glob imports - ', async () => { - const res = await fixture.fetch('/glob'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const { document } = parseHTML(html); - - const h1 = document.querySelector('[data-content-export] h1'); - const defaultSlot = document.querySelector('[data-content-export] [data-default-slot]'); - const namedSlot = document.querySelector('[data-content-export] [data-named-slot]'); - - assert.equal(h1.textContent, 'Hello slotted component!'); - assert.equal(defaultSlot.textContent, 'Default content.'); - assert.equal(namedSlot.textContent, 'Content for named slot.'); - }); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-syntax-highlighting.test.js b/packages/integrations/mdx/test/mdx-syntax-highlighting.test.js deleted file mode 100644 index 2f72d4eb2866..000000000000 --- a/packages/integrations/mdx/test/mdx-syntax-highlighting.test.js +++ /dev/null @@ -1,157 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import mdx from '@astrojs/mdx'; -import rehypeShiki from '@shikijs/rehype'; -import { transformerTwoslash } from '@shikijs/twoslash'; -import { parseHTML } from 'linkedom'; -import rehypePrettyCode from 'rehype-pretty-code'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const FIXTURE_ROOT = new URL('./fixtures/mdx-syntax-highlighting/', import.meta.url); - -describe('MDX syntax highlighting', () => { - describe('shiki', () => { - it('works', async () => { - const fixture = await loadFixture({ - root: FIXTURE_ROOT, - markdown: { - syntaxHighlight: 'shiki', - }, - integrations: [mdx()], - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const shikiCodeBlock = document.querySelector('pre.astro-code'); - assert.notEqual(shikiCodeBlock, null); - assert.equal(shikiCodeBlock.getAttribute('style').includes('background-color:#24292e'), true); - }); - - it('respects markdown.shikiConfig.theme', async () => { - const fixture = await loadFixture({ - root: FIXTURE_ROOT, - markdown: { - syntaxHighlight: 'shiki', - shikiConfig: { - theme: 'dracula', - }, - }, - integrations: [mdx()], - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const shikiCodeBlock = document.querySelector('pre.astro-code'); - assert.notEqual(shikiCodeBlock, null); - assert.equal(shikiCodeBlock.getAttribute('style').includes('background-color:#282A36'), true); - }); - }); - - describe('prism', () => { - it('works', async () => { - const fixture = await loadFixture({ - root: FIXTURE_ROOT, - markdown: { - syntaxHighlight: 'prism', - }, - integrations: [mdx()], - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const prismCodeBlock = document.querySelector('pre.language-astro'); - assert.notEqual(prismCodeBlock, null); - }); - - for (const extendMarkdownConfig of [true, false]) { - it(`respects syntaxHighlight when extendMarkdownConfig = ${extendMarkdownConfig}`, async () => { - const fixture = await loadFixture({ - root: FIXTURE_ROOT, - markdown: { - syntaxHighlight: 'shiki', - }, - integrations: [ - mdx({ - extendMarkdownConfig, - syntaxHighlight: 'prism', - }), - ], - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const shikiCodeBlock = document.querySelector('pre.astro-code'); - assert.equal(shikiCodeBlock, null, 'Markdown config syntaxHighlight used unexpectedly'); - const prismCodeBlock = document.querySelector('pre.language-astro'); - assert.notEqual(prismCodeBlock, null); - }); - } - }); - - it('supports custom highlighter - @shikijs/rehype and @shikijs/twoslash', async () => { - const fixture = await loadFixture({ - root: FIXTURE_ROOT, - markdown: { - syntaxHighlight: false, - }, - integrations: [ - mdx({ - rehypePlugins: [ - [ - rehypeShiki, - { - theme: 'vitesse-light', - transformers: [transformerTwoslash({})], - }, - ], - ], - }), - ], - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - const { document } = parseHTML(html); - - const shikiCodeBlock = document.querySelector('pre.shiki'); - assert.notEqual(shikiCodeBlock, null); - - const twoslashPopup = document.querySelector('div.twoslash-popup-docs'); - assert.notEqual(twoslashPopup, null); - }); - - it('supports custom highlighter - rehype-pretty-code', async () => { - const fixture = await loadFixture({ - root: FIXTURE_ROOT, - markdown: { - syntaxHighlight: false, - }, - integrations: [ - mdx({ - rehypePlugins: [ - [ - rehypePrettyCode, - { - onVisitHighlightedLine(node) { - node.properties.style = 'background-color:#000000'; - }, - }, - ], - ], - }), - ], - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - assert.equal(html.includes('style="background-color:#000000"'), true); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-syntax-highlighting.test.ts b/packages/integrations/mdx/test/mdx-syntax-highlighting.test.ts new file mode 100644 index 000000000000..f490ff15babc --- /dev/null +++ b/packages/integrations/mdx/test/mdx-syntax-highlighting.test.ts @@ -0,0 +1,163 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import mdx from '@astrojs/mdx'; +import rehypeShiki from '@shikijs/rehype'; +import { transformerTwoslash } from '@shikijs/twoslash'; +import { parseHTML } from 'linkedom'; +import rehypePrettyCode from 'rehype-pretty-code'; +import { loadFixture } from '../../../astro/test/test-utils.js'; + +const FIXTURE_ROOT = new URL('./fixtures/mdx-syntax-highlighting/', import.meta.url); + +describe('MDX syntax highlighting', () => { + describe('shiki', () => { + it('works', async () => { + const fixture = await loadFixture({ + root: FIXTURE_ROOT, + markdown: { + syntaxHighlight: 'shiki', + }, + integrations: [mdx()], + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const shikiCodeBlock = document.querySelector('pre.astro-code')!; + assert.notEqual(shikiCodeBlock, null); + assert.equal( + shikiCodeBlock.getAttribute('style')!.includes('background-color:#24292e'), + true, + ); + }); + + it('respects markdown.shikiConfig.theme', async () => { + const fixture = await loadFixture({ + root: FIXTURE_ROOT, + markdown: { + syntaxHighlight: 'shiki', + shikiConfig: { + theme: 'dracula', + }, + }, + integrations: [mdx()], + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const shikiCodeBlock = document.querySelector('pre.astro-code')!; + assert.notEqual(shikiCodeBlock, null); + assert.equal( + shikiCodeBlock.getAttribute('style')!.includes('background-color:#282A36'), + true, + ); + }); + }); + + describe('prism', () => { + it('works', async () => { + const fixture = await loadFixture({ + root: FIXTURE_ROOT, + markdown: { + syntaxHighlight: 'prism', + }, + integrations: [mdx()], + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const prismCodeBlock = document.querySelector('pre.language-astro'); + assert.notEqual(prismCodeBlock, null); + }); + + for (const extendMarkdownConfig of [true, false]) { + it(`respects syntaxHighlight when extendMarkdownConfig = ${extendMarkdownConfig}`, async () => { + const fixture = await loadFixture({ + root: FIXTURE_ROOT, + markdown: { + syntaxHighlight: 'shiki', + }, + integrations: [ + mdx({ + extendMarkdownConfig, + syntaxHighlight: 'prism', + }), + ], + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const shikiCodeBlock = document.querySelector('pre.astro-code'); + assert.equal(shikiCodeBlock, null, 'Markdown config syntaxHighlight used unexpectedly'); + const prismCodeBlock = document.querySelector('pre.language-astro'); + assert.notEqual(prismCodeBlock, null); + }); + } + }); + + it('supports custom highlighter - @shikijs/rehype and @shikijs/twoslash', async () => { + const fixture = await loadFixture({ + root: FIXTURE_ROOT, + markdown: { + syntaxHighlight: false, + }, + integrations: [ + mdx({ + rehypePlugins: [ + [ + rehypeShiki, + { + theme: 'vitesse-light', + transformers: [transformerTwoslash({})], + }, + ], + ], + }), + ], + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const shikiCodeBlock = document.querySelector('pre.shiki'); + assert.notEqual(shikiCodeBlock, null); + + const twoslashPopup = document.querySelector('div.twoslash-popup-docs'); + assert.notEqual(twoslashPopup, null); + }); + + it('supports custom highlighter - rehype-pretty-code', async () => { + const fixture = await loadFixture({ + root: FIXTURE_ROOT, + markdown: { + syntaxHighlight: false, + }, + integrations: [ + mdx({ + rehypePlugins: [ + [ + rehypePrettyCode, + { + onVisitHighlightedLine(node: { properties: Record }) { + node.properties.style = 'background-color:#000000'; + }, + }, + ], + ], + }), + ], + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + assert.equal(html.includes('style="background-color:#000000"'), true); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-url-export.test.js b/packages/integrations/mdx/test/mdx-url-export.test.js deleted file mode 100644 index 66a34db75fc4..000000000000 --- a/packages/integrations/mdx/test/mdx-url-export.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import mdx from '@astrojs/mdx'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('MDX url export', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-url-export/', import.meta.url), - integrations: [mdx()], - }); - - await fixture.build(); - }); - - it('generates correct urls in glob result', async () => { - const { urls } = JSON.parse(await fixture.readFile('/pages.json')); - assert.equal(urls.includes('/test-1'), true); - assert.equal(urls.includes('/test-2'), true); - }); - - it('respects "export url" overrides in glob result', async () => { - const { urls } = JSON.parse(await fixture.readFile('/pages.json')); - assert.equal(urls.includes('/AH!'), true); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-vite-env-vars.test.js b/packages/integrations/mdx/test/mdx-vite-env-vars.test.js deleted file mode 100644 index 213386ceb712..000000000000 --- a/packages/integrations/mdx/test/mdx-vite-env-vars.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -describe('MDX - Vite env vars', () => { - let fixture; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/mdx-vite-env-vars/', import.meta.url), - }); - await fixture.build(); - }); - - it('Avoids transforming `import.meta.env` outside JSX expressions', async () => { - const html = await fixture.readFile('/vite-env-vars/index.html'); - const { document } = parseHTML(html); - - assert.equal(document.querySelector('h1')?.innerHTML.includes('import.meta.env.SITE'), true); - assert.equal(document.querySelector('code')?.innerHTML.includes('import.meta.env.SITE'), true); - assert.equal(document.querySelector('pre')?.innerHTML.includes('import.meta.env.SITE'), true); - }); - it('Allows referencing `import.meta.env` in frontmatter', async () => { - const { title = '' } = JSON.parse(await fixture.readFile('/frontmatter.json')); - assert.equal(title.includes('import.meta.env.SITE'), true); - }); - it('Transforms `import.meta.env` in {JSX expressions}', async () => { - const html = await fixture.readFile('/vite-env-vars/index.html'); - const { document } = parseHTML(html); - - assert.equal( - document - .querySelector('[data-env-site]') - ?.innerHTML.includes('https://mdx-is-neat.com/blog/cool-post'), - true, - ); - }); - it('Transforms `import.meta.env` in variable exports', async () => { - const html = await fixture.readFile('/vite-env-vars/index.html'); - const { document } = parseHTML(html); - - assert.equal( - document.querySelector('[data-env-variable-exports]')?.innerHTML.includes('MODE works'), - true, - ); - assert.equal( - document - .querySelector('[data-env-variable-exports-unknown]') - ?.innerHTML.includes('exports: ""'), - true, - ); - }); - it('Transforms `import.meta.env` in HTML attributes', async () => { - const html = await fixture.readFile('/vite-env-vars/index.html'); - const { document } = parseHTML(html); - - const dataAttrDump = document.querySelector('[data-env-dump]'); - assert.notEqual(dataAttrDump, null); - - assert.equal(dataAttrDump.getAttribute('data-env-prod'), 'true'); - assert.equal(dataAttrDump.getAttribute('data-env-dev'), 'false'); - assert.equal(dataAttrDump.getAttribute('data-env-base-url'), '/'); - assert.equal(dataAttrDump.getAttribute('data-env-mode'), 'production'); - }); -}); diff --git a/packages/integrations/mdx/test/mdx-vite-env-vars.test.ts b/packages/integrations/mdx/test/mdx-vite-env-vars.test.ts new file mode 100644 index 000000000000..393c2b639719 --- /dev/null +++ b/packages/integrations/mdx/test/mdx-vite-env-vars.test.ts @@ -0,0 +1,65 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture, type Fixture } from '../../../astro/test/test-utils.js'; + +describe('MDX - Vite env vars', () => { + let fixture: Fixture; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/mdx-vite-env-vars/', import.meta.url), + }); + await fixture.build(); + }); + + it('Avoids transforming `import.meta.env` outside JSX expressions', async () => { + const html = await fixture.readFile('/vite-env-vars/index.html'); + const { document } = parseHTML(html); + + assert.equal(document.querySelector('h1')?.innerHTML.includes('import.meta.env.SITE'), true); + assert.equal(document.querySelector('code')?.innerHTML.includes('import.meta.env.SITE'), true); + assert.equal(document.querySelector('pre')?.innerHTML.includes('import.meta.env.SITE'), true); + }); + it('Allows referencing `import.meta.env` in frontmatter', async () => { + const { title = '' } = JSON.parse(await fixture.readFile('/frontmatter.json')); + assert.equal(title.includes('import.meta.env.SITE'), true); + }); + it('Transforms `import.meta.env` in {JSX expressions}', async () => { + const html = await fixture.readFile('/vite-env-vars/index.html'); + const { document } = parseHTML(html); + + assert.equal( + document + .querySelector('[data-env-site]') + ?.innerHTML.includes('https://mdx-is-neat.com/blog/cool-post'), + true, + ); + }); + it('Transforms `import.meta.env` in variable exports', async () => { + const html = await fixture.readFile('/vite-env-vars/index.html'); + const { document } = parseHTML(html); + + assert.equal( + document.querySelector('[data-env-variable-exports]')?.innerHTML.includes('MODE works'), + true, + ); + assert.equal( + document + .querySelector('[data-env-variable-exports-unknown]') + ?.innerHTML.includes('exports: ""'), + true, + ); + }); + it('Transforms `import.meta.env` in HTML attributes', async () => { + const html = await fixture.readFile('/vite-env-vars/index.html'); + const { document } = parseHTML(html); + + const dataAttrDump = document.querySelector('[data-env-dump]')!; + assert.notEqual(dataAttrDump, null); + + assert.equal(dataAttrDump.getAttribute('data-env-prod'), 'true'); + assert.equal(dataAttrDump.getAttribute('data-env-dev'), 'false'); + assert.equal(dataAttrDump.getAttribute('data-env-base-url'), '/'); + assert.equal(dataAttrDump.getAttribute('data-env-mode'), 'production'); + }); +}); diff --git a/packages/integrations/mdx/test/remark-imgattr.test.js b/packages/integrations/mdx/test/remark-imgattr.test.js deleted file mode 100644 index 067d18e236c6..000000000000 --- a/packages/integrations/mdx/test/remark-imgattr.test.js +++ /dev/null @@ -1,49 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -const FIXTURE_ROOT = new URL('./fixtures/image-remark-imgattr/', import.meta.url); - -describe('Testing remark plugins for image processing', () => { - /** @type {import('../../../astro/test/test-utils.js').Fixture} */ - let fixture; - - describe('start dev server', () => { - /** @type {import('../../../astro/test/test-utils.js').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: FIXTURE_ROOT, - }); - - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - describe('Test image attributes can be added by remark plugins', () => { - let $; - before(async () => { - let res = await fixture.fetch('/'); - let html = await res.text(); - $ = cheerio.load(html); - }); - - it(' has correct attributes', async () => { - let $img = $('img'); - assert.equal($img.attr('id'), 'test'); - assert.equal($img.attr('sizes'), '(min-width: 600px) 600w, 300w'); - assert.ok($img.attr('srcset')); - }); - - it(' was processed properly', async () => { - let $img = $('img'); - assert.equal(new URL($img.attr('src'), 'http://example.com').searchParams.get('w'), '300'); - }); - }); - }); -}); diff --git a/packages/integrations/mdx/test/remark-imgattr.test.ts b/packages/integrations/mdx/test/remark-imgattr.test.ts new file mode 100644 index 000000000000..50528561ed08 --- /dev/null +++ b/packages/integrations/mdx/test/remark-imgattr.test.ts @@ -0,0 +1,50 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +const FIXTURE_ROOT = new URL('./fixtures/image-remark-imgattr/', import.meta.url); + +describe('Testing remark plugins for image processing', () => { + let fixture: Fixture; + + describe('start dev server', () => { + let devServer: DevServer; + + before(async () => { + fixture = await loadFixture({ + root: FIXTURE_ROOT, + }); + + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('Test image attributes can be added by remark plugins', () => { + let $: ReturnType; + before(async () => { + let res = await fixture.fetch('/'); + let html = await res.text(); + $ = cheerio.load(html); + }); + + it(' has correct attributes', async () => { + let $img = $('img'); + assert.equal($img.attr('id'), 'test'); + assert.equal($img.attr('sizes'), '(min-width: 600px) 600w, 300w'); + assert.ok($img.attr('srcset')); + }); + + it(' was processed properly', async () => { + let $img = $('img'); + assert.equal( + new URL($img.attr('src') ?? '', 'http://example.com').searchParams.get('w'), + '300', + ); + }); + }); + }); +}); diff --git a/packages/integrations/mdx/test/test-utils.ts b/packages/integrations/mdx/test/test-utils.ts new file mode 100644 index 000000000000..2f725e8daa22 --- /dev/null +++ b/packages/integrations/mdx/test/test-utils.ts @@ -0,0 +1,44 @@ +import type * as estree from 'estree'; +import type * as hast from 'hast'; +import type * as mdast from 'mdast'; +import type * as unified from 'unified'; +import { + AstroIntegrationLogger, + type AstroLogMessage, +} from '../../../astro/dist/core/logger/core.js'; + +export type RemarkPlugin = unified.Plugin< + PluginParameters, + mdast.Root +>; + +export type RehypePlugin = unified.Plugin< + PluginParameters, + hast.Root +>; + +export type RecmaPlugin = unified.Plugin< + PluginParameters, + estree.Program +>; + +export class SpyIntegrationLogger extends AstroIntegrationLogger { + readonly messages: AstroLogMessage[]; + + constructor() { + const messages: AstroLogMessage[] = []; + super( + { + destination: { + write(chunk): boolean { + messages.push(chunk); + return true; + }, + }, + level: 'warn', + }, + 'test-spy', + ); + this.messages = messages; + } +} diff --git a/packages/integrations/mdx/test/units/mdx-compilation.test.ts b/packages/integrations/mdx/test/units/mdx-compilation.test.ts new file mode 100644 index 000000000000..b723b951cf32 --- /dev/null +++ b/packages/integrations/mdx/test/units/mdx-compilation.test.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { rehypeHeadingIds } from '@astrojs/markdown-remark'; +import { compile as _compile, type CompileOptions, nodeTypes } from '@mdx-js/mdx'; +import { visit as estreeVisit } from 'estree-util-visit'; +import rehypeRaw from 'rehype-raw'; +import remarkGfm from 'remark-gfm'; +import remarkSmartypants from 'remark-smartypants'; +import { visit } from 'unist-util-visit'; +import { ignoreStringPlugins } from '../../dist/utils.js'; +import { + SpyIntegrationLogger, + type RecmaPlugin, + type RehypePlugin, + type RemarkPlugin, +} from '../test-utils.ts'; + +/** + * Compile MDX to JSX string output for inspection. + */ +async function compile(mdxCode: string, options: Readonly = {}) { + const result = await _compile(mdxCode, { + jsx: true, + ...options, + }); + return result.toString(); +} + +/** + * Compile MDX with rehype-raw (like Astro does) and return the JSX output. + */ +async function compileWithRaw( + mdxCode: string, + options: Readonly = {}, +): Promise { + return compile(mdxCode, { + rehypePlugins: [[rehypeRaw, { passThrough: nodeTypes }], ...(options.rehypePlugins || [])], + remarkPlugins: options.remarkPlugins || [], + recmaPlugins: options.recmaPlugins || [], + ...options, + }); +} + +describe('MDX escape handling', () => { + it('wraps escaped HTML in string expressions, not raw JSX', async () => { + // In MDX, \ is escaped and should be rendered as text, not as an HTML element. + // The compiled JSX wraps it in a string expression like {""} + const code = await compile('\\'); + // The output should have the text as a JSX string expression, not as a JSX element + assert.ok(code.includes('{""}'), 'Escaped HTML should be wrapped in JSX string expression'); + // Should NOT have as an actual JSX element (i.e. outside of string) + assert.ok(!code.includes('{"'), 'Should not have as an actual JSX element'); + }); + + it('preserves angle brackets in inline code', async () => { + const code = await compile('` { + const code = await compile('{`
    `}'); + // JSX expression should contain the string + assert.ok(code.includes('
    '), 'Should contain the escaped string'); + }); +}); + +describe('MDX GFM plugin', () => { + it('converts autolinks when GFM is applied', async () => { + const code = await compile('https://handle-me-gfm.com', { + remarkPlugins: [remarkGfm], + }); + assert.ok(code.includes('https://handle-me-gfm.com'), 'Should contain the URL'); + assert.ok(code.includes('href'), 'GFM should create an anchor element'); + }); + + it('does not convert autolinks without GFM', async () => { + const code = await compile('https://handle-me-gfm.com'); + // Without GFM, the URL should just be text, not wrapped in + assert.ok(code.includes('https://handle-me-gfm.com')); + }); +}); + +describe('MDX SmartyPants plugin', () => { + it('converts quotes and dashes when SmartyPants is applied', async () => { + const code = await compile('> "Smartypants" is -- awesome', { + remarkPlugins: [remarkSmartypants], + }); + // SmartyPants converts straight quotes to curly and -- to em dash + assert.ok( + code.includes('\u201C') || code.includes('\u201D') || code.includes('\u2014'), + 'SmartyPants should convert quotes or dashes to typographic equivalents', + ); + }); + + it('does not convert quotes without SmartyPants', async () => { + const code = await compile('> "Smartypants" is -- awesome'); + // Without SmartyPants, double dashes stay as -- (not converted to em dash \u2014) + assert.ok(code.includes('--'), 'Double dashes should remain unconverted'); + assert.ok(!code.includes('\u2014'), 'Em dash should not appear without SmartyPants'); + }); +}); + +describe('MDX remark plugins', () => { + it('supports custom remark plugins that modify the tree', async () => { + /** Remark plugin that appends a div */ + const remarkAddDiv: RemarkPlugin = () => { + return (tree) => { + tree.children.push({ + type: 'html', + value: '
    ', + }); + }; + }; + + const code = await compileWithRaw('# Hello', { + remarkPlugins: [remarkAddDiv], + }); + assert.ok( + code.includes('data-remark-works'), + 'Custom remark plugin output should be in compiled result', + ); + }); +}); + +describe('MDX rehype plugins', () => { + it('supports custom rehype plugins that modify the tree', async () => { + /** Rehype plugin that appends a div */ + const rehypeAddDiv: RehypePlugin = () => { + return (tree) => { + tree.children.push({ + type: 'element', + tagName: 'div', + properties: { 'data-rehype-works': 'true' }, + children: [], + }); + }; + }; + + const code = await compileWithRaw('# Hello', { + rehypePlugins: [rehypeAddDiv], + }); + assert.ok( + code.includes('data-rehype-works'), + 'Custom rehype plugin output should be in compiled result', + ); + }); + + it('supports rehype plugins with namespaced SVG attributes', async () => { + const rehypeSvg: RehypePlugin = () => { + return (tree) => { + tree.children.push({ + type: 'element', + tagName: 'svg', + properties: { xmlns: 'http://www.w3.org/2000/svg' }, + children: [ + { + type: 'element', + tagName: 'use', + properties: { xlinkHref: '#icon' }, + children: [], + }, + ], + }); + }; + }; + + const code = await compileWithRaw('# Hello', { + rehypePlugins: [rehypeSvg], + }); + assert.ok(code.includes('svg'), 'Should contain SVG element'); + }); +}); + +describe('MDX recma plugins', () => { + it('supports custom recma plugins that transform the estree', async () => { + const recmaExample: RecmaPlugin = () => { + return (tree) => { + estreeVisit(tree, (node) => { + if ( + node.type === 'VariableDeclarator' && + node.id.type === 'Identifier' && + node.id.name === 'recmaPluginWorking' && + node.init?.type === 'Literal' + ) { + node.init = { + ...(node.init ?? {}), + value: true, + raw: 'true', + }; + } + }); + }; + }; + + const mdxCode = `export const recmaPluginWorking = false; + +# Hello`; + const code = await compile(mdxCode, { + recmaPlugins: [recmaExample], + }); + // The recma plugin should have changed false to true + assert.ok(code.includes('true'), 'Recma plugin should transform the value'); + }); +}); + +describe('MDX heading IDs', () => { + it('generates heading IDs with rehypeHeadingIds', async () => { + const mdxCode = `# Hello World + +## Section 1 + +### Subsection 1 +`; + const code = await compileWithRaw(mdxCode, { + rehypePlugins: [rehypeHeadingIds], + }); + assert.ok(code.includes('hello-world'), 'Should generate slug for h1'); + assert.ok(code.includes('section-1'), 'Should generate slug for h2'); + assert.ok(code.includes('subsection-1'), 'Should generate slug for h3'); + }); + + it('generates correct slugs for special characters', async () => { + const mdxCode = `# \`\` + +### « Sacrebleu ! » +`; + const code = await compileWithRaw(mdxCode, { + rehypePlugins: [rehypeHeadingIds], + }); + assert.ok(code.includes('picture-'), 'Should generate slug for code in heading'); + assert.ok(code.includes('-sacrebleu--'), 'Should generate slug for special chars'); + }); + + it('allows user plugins to override heading IDs', async () => { + const customIdPlugin: RehypePlugin = () => { + return (tree) => { + let count = 0; + visit(tree, 'element', (node) => { + if (!/^h\d$/.test(node.tagName)) return; + if (!node.properties?.id) { + node.properties = { ...node.properties, id: String(count++) }; + } + }); + }; + }; + + const mdxCode = `# Hello + +## World +`; + const code = await compileWithRaw(mdxCode, { + rehypePlugins: [customIdPlugin], + }); + // MDX JSX output uses id="0" as a JSX attribute + assert.ok(code.includes('id="0"'), 'Custom plugin should set id="0" on first heading'); + assert.ok(code.includes('id="1"'), 'Custom plugin should set id="1" on second heading'); + }); +}); + +describe('MDX string-based plugin filtering', () => { + it('does not apply string-based remark plugins', async () => { + // When a string-based plugin is provided, the ignoreStringPlugins + // function filters it out. We test the filter function directly in utils.test.js. + // Here we verify that only function plugins affect output. + const logger = new SpyIntegrationLogger(); + + const plugins = ['remark-toc', () => (tree: unknown) => tree]; + const filtered = ignoreStringPlugins(plugins, logger); + + assert.equal(filtered.length, 1, 'Should filter out string plugin'); + assert.equal(typeof filtered[0], 'function', 'Should keep function plugin'); + }); +}); diff --git a/packages/integrations/mdx/test/units/rehype-optimize-static.test.js b/packages/integrations/mdx/test/units/rehype-optimize-static.test.js deleted file mode 100644 index 6121975a405a..000000000000 --- a/packages/integrations/mdx/test/units/rehype-optimize-static.test.js +++ /dev/null @@ -1,89 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { compile as _compile } from '@mdx-js/mdx'; -import { rehypeOptimizeStatic } from '../../dist/rehype-optimize-static.js'; - -/** - * @param {string} mdxCode - * @param {Readonly} options - */ -async function compile(mdxCode, options) { - const result = await _compile(mdxCode, { - jsx: true, - rehypePlugins: [rehypeOptimizeStatic], - ...options, - }); - const code = result.toString(); - // Capture the returned JSX code for testing - const jsx = /return (.+);\n\}\nexport default function MDXContent/s.exec(code)?.[1]; - if (jsx == null) throw new Error('Could not find JSX code in compiled MDX'); - return dedent(jsx); -} - -function dedent(str) { - const lines = str.split('\n'); - if (lines.length <= 1) return str; - // Get last line indent, and dedent this amount for the other lines - const lastLineIndent = lines[lines.length - 1].match(/^\s*/)[0].length; - return lines.map((line, i) => (i === 0 ? line : line.slice(lastLineIndent))).join('\n'); -} - -describe('rehype-optimize-static', () => { - it('works', async () => { - const jsx = await compile(`# hello`); - assert.equal( - jsx, - `\ -<_components.h1 {...{ - "set:html": "hello" -}} />`, - ); - }); - - it('groups sibling nodes as a single Fragment', async () => { - const jsx = await compile(`\ -# hello - -foo bar -`); - assert.equal( - jsx, - `\ -`, - ); - }); - - it('skips optimization of components', async () => { - const jsx = await compile(`\ -import Comp from './Comp.jsx'; - -# hello - -This is a -`); - assert.equal( - jsx, - `\ -<><_components.p>{"This is a "}`, - ); - }); - - it('optimizes explicit html elements', async () => { - const jsx = await compile(`\ -# hello - -foo bar baz - -qux -`); - assert.equal( - jsx, - `\ -`, - ); - }); -}); diff --git a/packages/integrations/mdx/test/units/rehype-optimize-static.test.ts b/packages/integrations/mdx/test/units/rehype-optimize-static.test.ts new file mode 100644 index 000000000000..f93b8d3f3338 --- /dev/null +++ b/packages/integrations/mdx/test/units/rehype-optimize-static.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { compile as _compile, type CompileOptions } from '@mdx-js/mdx'; +import { rehypeOptimizeStatic } from '../../dist/rehype-optimize-static.js'; + +async function compile(mdxCode: string, options?: Readonly) { + const result = await _compile(mdxCode, { + jsx: true, + rehypePlugins: [rehypeOptimizeStatic], + ...options, + }); + const code = result.toString(); + // Capture the returned JSX code for testing + const jsx = /return (.+);\n\}\nexport default function MDXContent/s.exec(code)?.[1]; + if (jsx == null) throw new Error('Could not find JSX code in compiled MDX'); + return dedent(jsx); +} + +function dedent(str: string) { + const lines = str.split('\n'); + if (lines.length <= 1) return str; + // Get last line indent, and dedent this amount for the other lines + const lastLineIndent = /^\s*/.exec(lines[lines.length - 1])![0].length; + return lines + .map((line: string, i: number) => (i === 0 ? line : line.slice(lastLineIndent))) + .join('\n'); +} + +describe('rehype-optimize-static', () => { + it('works', async () => { + const jsx = await compile(`# hello`); + assert.equal( + jsx, + `\ +<_components.h1 {...{ + "set:html": "hello" +}} />`, + ); + }); + + it('groups sibling nodes as a single Fragment', async () => { + const jsx = await compile(`\ +# hello + +foo bar +`); + assert.equal( + jsx, + `\ +`, + ); + }); + + it('skips optimization of components', async () => { + const jsx = await compile(`\ +import Comp from './Comp.jsx'; + +# hello + +This is a +`); + assert.equal( + jsx, + `\ +<><_components.p>{"This is a "}`, + ); + }); + + it('optimizes explicit html elements', async () => { + const jsx = await compile(`\ +# hello + +foo bar baz + +qux +`); + assert.equal( + jsx, + `\ +`, + ); + }); +}); diff --git a/packages/integrations/mdx/test/units/rehype-plugins.test.ts b/packages/integrations/mdx/test/units/rehype-plugins.test.ts new file mode 100644 index 000000000000..89e65acaa53b --- /dev/null +++ b/packages/integrations/mdx/test/units/rehype-plugins.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type * as hast from 'hast'; +import { VFile } from 'vfile'; +import { rehypeInjectHeadingsExport } from '../../dist/rehype-collect-headings.js'; +import rehypeMetaString from '../../dist/rehype-meta-string.js'; + +describe('rehypeMetaString', () => { + function createCodeNode(meta: string | undefined): hast.Element { + return { + type: 'element', + tagName: 'code', + data: meta != null ? { meta } : undefined, + children: [{ type: 'text', value: 'const x = 1;' }], + position: undefined, + properties: {}, + }; + } + + function createTree(children: hast.RootContent[]): hast.Root { + return { type: 'root', children }; + } + + it('copies data.meta to properties.metastring', () => { + const codeNode = createCodeNode('{1:3}'); + const tree = createTree([ + { + type: 'element', + tagName: 'pre', + properties: {}, + children: [codeNode], + }, + ]); + + const transform = rehypeMetaString(); + transform(tree); + + assert.equal(codeNode.properties!.metastring, '{1:3}'); + }); + + it('does not set metastring when no data.meta', () => { + const codeNode = createCodeNode(undefined); + // Ensure no data property at all + delete codeNode.data; + const tree = createTree([codeNode]); + + const transform = rehypeMetaString(); + transform(tree); + + assert.equal(codeNode.properties!.metastring, undefined); + }); + + it('handles code elements without properties', () => { + const codeNode: hast.Element = { + type: 'element', + tagName: 'code', + data: { meta: 'title="test"' }, + children: [], + position: undefined, + properties: {}, + }; + const tree = createTree([codeNode]); + + const transform = rehypeMetaString(); + transform(tree); + + assert.equal(codeNode.properties!.metastring, 'title="test"'); + }); + + it('ignores non-code elements', () => { + const divNode: hast.Element = { + type: 'element', + tagName: 'div', + properties: {}, + data: { meta: 'should-not-copy' }, + children: [], + }; + const tree = createTree([divNode]); + + const transform = rehypeMetaString(); + transform(tree); + + assert.equal(divNode.properties!.metastring, undefined); + }); +}); + +describe('rehypeInjectHeadingsExport', () => { + it('injects getHeadings export from vfile headings data', () => { + const headings = [ + { depth: 1, slug: 'hello1', text: 'Hello2' }, + { depth: 2, slug: 'world3', text: 'World4' }, + ]; + + const tree: hast.Root = { type: 'root', children: [] }; + const vfile = new VFile({ + data: { + astro: { + headings, + }, + }, + }); + + const transform = rehypeInjectHeadingsExport(); + transform(tree, vfile); + + assert.equal(tree.children.length, 1); + const injectedNode = tree.children[0] as hast.Element & { + data: { estree: { type: string; body: unknown } }; + }; + assert.equal(injectedNode.type, 'mdxjsEsm'); + // The node should contain a getHeadings function with our headings data + assert.ok(injectedNode.data.estree); + assert.equal(injectedNode.data.estree.type, 'Program'); + // The function should contain the injected heading data + const functionBody = JSON.stringify(injectedNode.data.estree.body); + assert.match(functionBody, /hello1/); + assert.match(functionBody, /Hello2/); + assert.match(functionBody, /world3/); + assert.match(functionBody, /World4/); + }); + + it('injects empty array when no headings', () => { + const tree: hast.Root = { type: 'root', children: [] }; + const vfile = new VFile({ + data: { + astro: {}, + }, + }); + + const transform = rehypeInjectHeadingsExport(); + transform(tree, vfile); + + assert.equal(tree.children.length, 1); + const injectedNode = tree.children[0]; + assert.equal(injectedNode.type, 'mdxjsEsm'); + }); + + it('prepends to existing children', () => { + const existingChild: hast.Element = { + type: 'element', + tagName: 'p', + children: [], + position: undefined, + properties: {}, + }; + const tree: hast.Root = { type: 'root', children: [existingChild] }; + const vfile = new VFile({ + data: { + astro: { headings: [] }, + }, + }); + + const transform = rehypeInjectHeadingsExport(); + transform(tree, vfile); + + assert.equal(tree.children.length, 2); + assert.equal(tree.children[0].type, 'mdxjsEsm'); + assert.equal(tree.children[1], existingChild); + }); +}); diff --git a/packages/integrations/mdx/test/units/server.test.ts b/packages/integrations/mdx/test/units/server.test.ts new file mode 100644 index 000000000000..520081a288df --- /dev/null +++ b/packages/integrations/mdx/test/units/server.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { slotName } from '../../dist/server.js'; + +describe('server', () => { + describe('slotName', () => { + it('converts kebab-case to camelCase', () => { + assert.equal(slotName('my-slot'), 'mySlot'); + }); + + it('converts snake_case to camelCase', () => { + assert.equal(slotName('my_slot'), 'mySlot'); + }); + + it('handles multiple separators', () => { + assert.equal(slotName('my-long-slot-name'), 'myLongSlotName'); + }); + + it('handles mixed separators', () => { + assert.equal(slotName('my-slot_name'), 'mySlotName'); + }); + + it('trims whitespace', () => { + assert.equal(slotName(' my-slot '), 'mySlot'); + }); + + it('returns simple names unchanged', () => { + assert.equal(slotName('default'), 'default'); + }); + + it('handles single character after separator', () => { + assert.equal(slotName('a-b'), 'aB'); + }); + + it('handles empty string', () => { + assert.equal(slotName(''), ''); + }); + + it('only converts lowercase letters after separators', () => { + // Uppercase letters after separators are not matched by the regex [a-z] + assert.equal(slotName('my-Slot'), 'my-Slot'); + }); + }); +}); diff --git a/packages/integrations/mdx/test/units/utils.test.ts b/packages/integrations/mdx/test/units/utils.test.ts new file mode 100644 index 000000000000..a20db2de1694 --- /dev/null +++ b/packages/integrations/mdx/test/units/utils.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { AstroConfig } from 'astro'; +import { + appendForwardSlash, + getFileInfo, + ignoreStringPlugins, + jsToTreeNode, +} from '../../dist/utils.js'; +import { SpyIntegrationLogger } from '../test-utils.ts'; + +describe('utils', () => { + describe('appendForwardSlash', () => { + it('appends slash when missing', () => { + assert.equal(appendForwardSlash('/foo'), '/foo/'); + }); + + it('does not double-append slash', () => { + assert.equal(appendForwardSlash('/foo/'), '/foo/'); + }); + + it('handles empty string', () => { + assert.equal(appendForwardSlash(''), '/'); + }); + + it('handles root slash', () => { + assert.equal(appendForwardSlash('/'), '/'); + }); + }); + + describe('getFileInfo', () => { + function mockConfig(overrides: Partial = {}): AstroConfig { + return { + root: new URL('file:///project/'), + base: '/', + site: undefined, + trailingSlash: 'ignore', + ...overrides, + } as AstroConfig; + } + + it('computes fileUrl for pages', () => { + const config = mockConfig(); + const result = getFileInfo('/project/src/pages/test.mdx', config); + assert.equal(result.fileId, '/project/src/pages/test.mdx'); + assert.equal(result.fileUrl, '/test'); + }); + + it('computes fileUrl for nested pages', () => { + const config = mockConfig(); + const result = getFileInfo('/project/src/pages/blog/post.mdx', config); + assert.equal(result.fileUrl, '/blog/post'); + }); + + it('strips index from page URLs', () => { + const config = mockConfig(); + const result = getFileInfo('/project/src/pages/index.mdx', config); + // The regex strips /index.mdx leaving an empty string + assert.equal(result.fileUrl, ''); + }); + + it('strips query strings from fileId', () => { + const config = mockConfig(); + const result = getFileInfo('/project/src/pages/test.mdx?astro&lang=mdx', config); + assert.equal(result.fileId, '/project/src/pages/test.mdx'); + }); + + it('uses relative path for non-page files under root', () => { + const config = mockConfig(); + const result = getFileInfo('/project/src/content/post.mdx', config); + assert.equal(result.fileUrl, 'src/content/post.mdx'); + }); + + it('respects trailingSlash=always', () => { + const config = mockConfig({ trailingSlash: 'always' }); + const result = getFileInfo('/project/src/pages/test.mdx', config); + assert.equal(result.fileUrl, '/test/'); + }); + + it('respects site + base config for pages', () => { + const config = mockConfig({ + site: 'https://example.com', + base: '/blog', + }); + const result = getFileInfo('/project/src/pages/test.mdx', config); + assert.equal(result.fileUrl, '/blog/test'); + }); + + it('handles files outside project root', () => { + const config = mockConfig(); + const result = getFileInfo('/other/path/file.mdx', config); + assert.equal(result.fileId, '/other/path/file.mdx'); + assert.equal(result.fileUrl, '/other/path/file.mdx'); + }); + }); + + describe('jsToTreeNode', () => { + it('parses a simple export statement', () => { + const node = jsToTreeNode('export const x = 1;'); + const estree = node.data!.estree!; + assert.equal(node.type, 'mdxjsEsm'); + assert.equal(estree.type, 'Program'); + assert.equal(estree.sourceType, 'module'); + assert.ok(estree.body.length > 0); + }); + + it('parses an import statement', () => { + const node = jsToTreeNode("import foo from 'bar';"); + assert.equal(node.type, 'mdxjsEsm'); + assert.equal(node.data!.estree!.body[0].type, 'ImportDeclaration'); + }); + + it('parses a function export', () => { + const node = jsToTreeNode('export function getHeadings() { return []; }'); + assert.equal(node.type, 'mdxjsEsm'); + const decl = node.data!.estree!.body[0]; + assert.equal(decl.type, 'ExportNamedDeclaration'); + }); + + it('throws on invalid JS', () => { + assert.throws(() => jsToTreeNode('this is not valid javascript {{{'), { + name: 'SyntaxError', + }); + }); + }); + + describe('ignoreStringPlugins', () => { + it('returns function plugins unchanged', () => { + const plugin1 = () => {}; + const plugin2 = () => {}; + const logger = new SpyIntegrationLogger(); + const result = ignoreStringPlugins([plugin1, plugin2], logger); + assert.equal(result.length, 2); + assert.equal(result[0], plugin1); + assert.equal(result[1], plugin2); + assert.equal(logger.messages.filter((m) => m.level === 'warn').length, 0); + }); + + it('filters out string-based plugins', () => { + const fnPlugin = () => {}; + const logger = new SpyIntegrationLogger(); + const result = ignoreStringPlugins(['remark-toc', fnPlugin], logger); + assert.equal(result.length, 1); + assert.equal(result[0], fnPlugin); + }); + + it('filters out array-based string plugins [string, options]', () => { + const fnPlugin = () => {}; + const logger = new SpyIntegrationLogger(); + const result = ignoreStringPlugins([['remark-toc', {}], fnPlugin], logger); + assert.equal(result.length, 1); + assert.equal(result[0], fnPlugin); + }); + + it('logs warnings for string plugins', () => { + const logger = new SpyIntegrationLogger(); + ignoreStringPlugins(['remark-toc', ['rehype-highlight', {}]], logger); + // One warning per string plugin + one summary warning + assert.equal(logger.messages.filter((m) => m.level === 'warn').length, 3); + }); + + it('returns empty array for all string plugins', () => { + const logger = new SpyIntegrationLogger(); + const result = ignoreStringPlugins(['remark-toc'], logger); + assert.equal(result.length, 0); + }); + + it('handles array-based function plugins [function, options]', () => { + const fnPlugin = () => {}; + const logger = new SpyIntegrationLogger(); + const result = ignoreStringPlugins([[fnPlugin, { option: true }]], logger); + assert.equal(result.length, 1); + assert.equal(logger.messages.filter((m) => m.level === 'warn').length, 0); + }); + }); +}); diff --git a/packages/integrations/mdx/test/units/vite-plugin-mdx-postprocess.test.ts b/packages/integrations/mdx/test/units/vite-plugin-mdx-postprocess.test.ts new file mode 100644 index 000000000000..8cbddd643e5e --- /dev/null +++ b/packages/integrations/mdx/test/units/vite-plugin-mdx-postprocess.test.ts @@ -0,0 +1,238 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { init, parse } from 'es-module-lexer'; +import { + annotateContentExport, + injectMetadataExports, + injectUnderscoreFragmentImport, + isSpecifierImported, + transformContentExport, +} from '../../dist/vite-plugin-mdx-postprocess.js'; + +await init; + +/** + * Helper: parse code with es-module-lexer and return [imports, exports] + */ +function parseCode(code: string) { + return parse(code); +} + +describe('vite-plugin-mdx-postprocess', () => { + describe('injectUnderscoreFragmentImport', () => { + it('injects Fragment import when not present', () => { + const code = `import { jsx } from 'astro/jsx-runtime';`; + const [imports] = parseCode(code); + const result = injectUnderscoreFragmentImport(code, imports); + assert.ok(result.includes("import { Fragment as _Fragment } from 'astro/jsx-runtime'")); + }); + + it('does not inject Fragment import when already present', () => { + const code = `import { jsx, Fragment as _Fragment } from 'astro/jsx-runtime';`; + const [imports] = parseCode(code); + const result = injectUnderscoreFragmentImport(code, imports); + // Should not have a second import + const importCount = (result.match(/Fragment as _Fragment/g) || []).length; + assert.equal(importCount, 1); + }); + + it('does not inject when _Fragment is imported with different spacing', () => { + const code = `import { _Fragment } from 'astro/jsx-runtime';`; + const [imports] = parseCode(code); + const result = injectUnderscoreFragmentImport(code, imports); + // _Fragment is in the import statement, regex should match + assert.ok(result.includes("import { _Fragment } from 'astro/jsx-runtime'")); + // Should not add a second Fragment import + const fragmentImports = result.match(/from 'astro\/jsx-runtime'/g) || []; + assert.equal(fragmentImports.length, 1); + }); + + it('injects Fragment import when import is from a different source', () => { + const code = `import { Fragment as _Fragment } from 'react/jsx-runtime';`; + const [imports] = parseCode(code); + const result = injectUnderscoreFragmentImport(code, imports); + assert.ok(result.includes("import { Fragment as _Fragment } from 'astro/jsx-runtime'")); + }); + }); + + describe('injectMetadataExports', () => { + it('injects url and file exports when not present', () => { + const code = `export const frontmatter = {};`; + const [, exports] = parseCode(code); + const result = injectMetadataExports(code, exports, { + fileUrl: '/test-page', + fileId: '/src/pages/test-page.mdx', + }); + assert.ok(result.includes('export const url = "/test-page"')); + assert.ok(result.includes('export const file = "/src/pages/test-page.mdx"')); + }); + + it('does not inject url export when already present', () => { + const code = `export const url = "/custom";`; + const [, exports] = parseCode(code); + const result = injectMetadataExports(code, exports, { + fileUrl: '/test-page', + fileId: '/src/pages/test-page.mdx', + }); + // Should not add a second url export + const urlExports = (result.match(/export const url/g) || []).length; + assert.equal(urlExports, 1); + // But should still add file + assert.ok(result.includes('export const file = "/src/pages/test-page.mdx"')); + }); + + it('does not inject file export when already present', () => { + const code = `export const file = "/custom.mdx";`; + const [, exports] = parseCode(code); + const result = injectMetadataExports(code, exports, { + fileUrl: '/test-page', + fileId: '/src/pages/test-page.mdx', + }); + const fileExports = (result.match(/export const file/g) || []).length; + assert.equal(fileExports, 1); + // But should still add url + assert.ok(result.includes('export const url = "/test-page"')); + }); + + it('escapes special characters in fileUrl and fileId', () => { + const code = `export const frontmatter = {};`; + const [, exports] = parseCode(code); + const result = injectMetadataExports(code, exports, { + fileUrl: '/path/with "quotes"', + fileId: '/src/pages/with "quotes".mdx', + }); + // JSON.stringify handles escaping + assert.ok(result.includes('export const url = "/path/with \\"quotes\\""')); + assert.ok(result.includes('export const file = "/src/pages/with \\"quotes\\".mdx"')); + }); + }); + + describe('transformContentExport', () => { + it('wraps MDXContent as Content export', () => { + const code = `export default function MDXContent(props) { return jsx("div", {}); }`; + const [, exports] = parseCode(code); + const result = transformContentExport(code, exports); + // Should remove "export default" from MDXContent + assert.ok(result.includes('function MDXContent')); + assert.ok(!result.includes('export default function MDXContent')); + // Should create Content wrapper + assert.ok(result.includes('export const Content')); + assert.ok(result.includes('export default Content')); + // Should pass Fragment + assert.ok(result.includes('Fragment: _Fragment')); + }); + + it('skips transformation when Content export already exists', () => { + const code = `export const Content = () => {};\nexport default function MDXContent(props) { return jsx("div", {}); }`; + const [, exports] = parseCode(code); + const result = transformContentExport(code, exports); + // Should return code unchanged + assert.equal(result, code); + }); + + it('includes components spread when components export exists', () => { + const code = [ + `export const components = { h1: CustomH1 };`, + `export default function MDXContent(props) { return jsx("div", {}); }`, + ].join('\n'); + const [, exports] = parseCode(code); + const result = transformContentExport(code, exports); + assert.ok(result.includes('...components')); + }); + + it('does not include components spread when no components export', () => { + const code = `export default function MDXContent(props) { return jsx("div", {}); }`; + const [, exports] = parseCode(code); + const result = transformContentExport(code, exports); + assert.ok(!result.includes('...components,')); + }); + + it('includes astro-image handling when __usesAstroImage flag is exported', () => { + const code = [ + `export const __usesAstroImage = true;`, + `export default function MDXContent(props) { return jsx("div", {}); }`, + ].join('\n'); + const [, exports] = parseCode(code); + const result = transformContentExport(code, exports); + assert.ok(result.includes('astro-image')); + }); + }); + + describe('annotateContentExport', () => { + it('adds mdx-component symbol', () => { + const code = `export const Content = () => {};`; + const [imports] = parseCode(code); + const result = annotateContentExport(code, '/test.mdx', false, imports); + assert.ok(result.includes("Content[Symbol.for('mdx-component')] = true")); + }); + + it('adds needsHeadRendering symbol', () => { + const code = `export const Content = () => {};`; + const [imports] = parseCode(code); + const result = annotateContentExport(code, '/test.mdx', false, imports); + assert.ok(result.includes("Content[Symbol.for('astro.needsHeadRendering')]")); + }); + + it('adds moduleId', () => { + const code = `export const Content = () => {};`; + const [imports] = parseCode(code); + const result = annotateContentExport(code, '/src/pages/test.mdx', false, imports); + assert.ok(result.includes('Content.moduleId = "/src/pages/test.mdx"')); + }); + + it('adds __astro_tag_component__ import and call in SSR mode', () => { + const code = `export const Content = () => {};`; + const [imports] = parseCode(code); + const result = annotateContentExport(code, '/test.mdx', true, imports); + assert.ok(result.includes('import { __astro_tag_component__ }')); + assert.ok(result.includes("__astro_tag_component__(Content, 'astro:jsx')")); + }); + + it('does not add __astro_tag_component__ in non-SSR mode', () => { + const code = `export const Content = () => {};`; + const [imports] = parseCode(code); + const result = annotateContentExport(code, '/test.mdx', false, imports); + assert.ok(!result.includes('__astro_tag_component__')); + }); + + it('does not duplicate __astro_tag_component__ import when already present', () => { + const code = `import { __astro_tag_component__ } from 'astro/runtime/server/index.js';\nexport const Content = () => {};`; + const [imports] = parseCode(code); + const result = annotateContentExport(code, '/test.mdx', true, imports); + const importCount = ( + result.match(/import.*__astro_tag_component__.*astro\/runtime\/server/g) || [] + ).length; + assert.equal(importCount, 1); + }); + }); + + describe('isSpecifierImported', () => { + it('returns true when specifier matches in correct source', () => { + const code = `import { Fragment as _Fragment } from 'astro/jsx-runtime';`; + const [imports] = parseCode(code); + const regex = /[\s,{]_Fragment[\s,}]/; + assert.equal(isSpecifierImported(code, imports, regex, 'astro/jsx-runtime'), true); + }); + + it('returns false when specifier is from different source', () => { + const code = `import { Fragment as _Fragment } from 'react/jsx-runtime';`; + const [imports] = parseCode(code); + const regex = /[\s,{]_Fragment[\s,}]/; + assert.equal(isSpecifierImported(code, imports, regex, 'astro/jsx-runtime'), false); + }); + + it('returns false when specifier is not imported', () => { + const code = `import { jsx } from 'astro/jsx-runtime';`; + const [imports] = parseCode(code); + const regex = /[\s,{]_Fragment[\s,}]/; + assert.equal(isSpecifierImported(code, imports, regex, 'astro/jsx-runtime'), false); + }); + + it('returns false with no imports', () => { + const code = `const x = 1;`; + const [imports] = parseCode(code); + const regex = /[\s,{]_Fragment[\s,}]/; + assert.equal(isSpecifierImported(code, imports, regex, 'astro/jsx-runtime'), false); + }); + }); +}); diff --git a/packages/integrations/mdx/tsconfig.test.json b/packages/integrations/mdx/tsconfig.test.json new file mode 100644 index 000000000000..853326403557 --- /dev/null +++ b/packages/integrations/mdx/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true, + "rootDir": "." + }, + "references": [{ "path": "../../astro/tsconfig.test.json" }] +} diff --git a/packages/integrations/netlify/CHANGELOG.md b/packages/integrations/netlify/CHANGELOG.md index 50b0d84f3b1d..8c5add863ffd 100644 --- a/packages/integrations/netlify/CHANGELOG.md +++ b/packages/integrations/netlify/CHANGELOG.md @@ -1,5 +1,21 @@ # @astrojs/netlify +## 7.0.7 + +### Patch Changes + +- [#16027](https://github.com/withastro/astro/pull/16027) [`c62516b`](https://github.com/withastro/astro/commit/c62516bbbf8fdf95d38293440d28221c048c41f0) Thanks [@fkatsuhiro](https://github.com/fkatsuhiro)! - Fixes a bug where remote image dimensions were not validated during static builds on Netlify. + +- Updated dependencies []: + - @astrojs/underscore-redirects@1.0.3 + +## 7.0.6 + +### Patch Changes + +- Updated dependencies [[`814406d`](https://github.com/withastro/astro/commit/814406de7dc3ea014b47d2d886d55c45e4e1c034)]: + - @astrojs/underscore-redirects@1.0.3 + ## 7.0.5 ### Patch Changes diff --git a/packages/integrations/netlify/package.json b/packages/integrations/netlify/package.json index 4cfd4a79f9bf..1885aa72ac1e 100644 --- a/packages/integrations/netlify/package.json +++ b/packages/integrations/netlify/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/netlify", "description": "Deploy your site to Netlify", - "version": "7.0.5", + "version": "7.0.7", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", @@ -33,10 +33,11 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", "test": "pnpm run test-fn && pnpm run test-static && pnpm run test:dev", - "test-fn": "astro-scripts test \"test/functions/*.test.js\"", - "test:dev": "astro-scripts test \"test/development/*.test.js\"", - "test-static": "astro-scripts test \"test/static/*.test.js\"", - "test:hosted": "astro-scripts test \"test/hosted/*.test.js\"" + "test-fn": "astro-scripts test \"test/functions/*.test.ts\"", + "test:dev": "astro-scripts test \"test/development/*.test.ts\"", + "test-static": "astro-scripts test \"test/static/*.test.ts\"", + "test:hosted": "astro-scripts test \"test/hosted/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "dependencies": { "@astrojs/internal-helpers": "workspace:*", diff --git a/packages/integrations/netlify/src/image-service.ts b/packages/integrations/netlify/src/image-service.ts index 91c3596ad25c..f8e93a05abdc 100644 --- a/packages/integrations/netlify/src/image-service.ts +++ b/packages/integrations/netlify/src/image-service.ts @@ -1,5 +1,6 @@ import type { ExternalImageService } from 'astro'; import { baseService } from 'astro/assets'; +import { verifyOptions } from '../../../astro/dist/assets/internal.js'; import { isESMImportedImage } from 'astro/assets/utils'; import { AstroError } from 'astro/errors'; @@ -51,6 +52,8 @@ const service: ExternalImageService = { getHTMLAttributes: baseService.getHTMLAttributes, getSrcSet: baseService.getSrcSet, validateOptions(options) { + verifyOptions(options); + if (options.format && !SUPPORTED_FORMATS.includes(options.format)) { throw new AstroError( `Unsupported image format "${options.format}"`, diff --git a/packages/integrations/netlify/test/development/primitives.test.js b/packages/integrations/netlify/test/development/primitives.test.js deleted file mode 100644 index 016305e42cae..000000000000 --- a/packages/integrations/netlify/test/development/primitives.test.js +++ /dev/null @@ -1,108 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, afterEach, before, describe, it } from 'node:test'; - -import * as cheerio from 'cheerio'; -import { loadFixture } from '../../../../astro/test/test-utils.js'; -import netlifyAdapter from '../../dist/index.js'; - -describe('Netlify primitives', () => { - describe('Development', () => { - /** @type {import('../../../../astro/test/test-utils').Fixture} */ - let fixture; - let devServer; - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/primitives/', import.meta.url), - output: 'server', - adapter: netlifyAdapter(), - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - afterEach(async () => { - fixture.resetAllFiles(); - }); - - it('loads Astro routes', async () => { - const rootResponse = await fixture.fetch('/'); - const $root = cheerio.load(await rootResponse.text()); - - assert.equal($root('h1').text(), 'Middleware'); - - const imgResponse = await fixture.fetch('/astronaut'); - const $img = cheerio.load(await imgResponse.text()); - - assert.equal($img('h1').text(), 'Hello, Astronaut'); - - // assert($img('img').attr('src').startsWith('/_image?href=')); - }); - - it('loads function routes', async () => { - const firstResponse = await fixture.fetch('/function'); - - assert.equal(await firstResponse.text(), 'Hello from function'); - - await fixture.editFile('netlify/functions/func.mjs', (content) => - content.replace('Hello', 'Hello again'), - ); - - const secondResponse = await fixture.fetch('/function'); - - assert.equal(await secondResponse.text(), 'Hello again from function'); - }); - - it('loads edge function routes', async () => { - const efResponse = await fixture.fetch('/processed/hello'); - const $root = cheerio.load(await efResponse.text()); - - assert.equal($root('h1').text(), 'HELLO THERE, ASTRONAUT.'); - }); - - it('loads images in development', async () => { - const imgResponse = await fixture.fetch('/astronaut'); - const $img = cheerio.load(await imgResponse.text()); - const images = $img('img').map((_i, el) => { - return $img(el).attr('src'); - }); - - for (const imgSrc of images) { - assert(imgSrc.startsWith('/.netlify/images')); - const imageResponse = await fixture.fetch(imgSrc); - assert.equal(imageResponse.status, 200); - // Images are JPEG by default in development - assert.equal(imageResponse.headers.get('content-type'), 'image/jpeg'); - } - }); - - it('respects imageCDN: false in development', async () => { - process.env.DISABLE_IMAGE_CDN = 'true'; - const cdnDisabledFixture = await loadFixture({ - root: new URL('./fixtures/primitives/', import.meta.url), - output: 'server', - adapter: netlifyAdapter({ imageCDN: false }), - }); - const cdnDisabledServer = await cdnDisabledFixture.startDevServer(); - try { - const imgResponse = await cdnDisabledFixture.fetch('/astronaut'); - const $img = cheerio.load(await imgResponse.text()); - const images = $img('img').map((_i, el) => { - return $img(el).attr('src'); - }); - - for (const imgSrc of images) { - assert( - !imgSrc.startsWith('/.netlify/images'), - `Expected image src to not use Netlify CDN, got: ${imgSrc}`, - ); - } - } finally { - await cdnDisabledServer.stop(); - process.env.DISABLE_IMAGE_CDN = undefined; - } - }); - }); -}); diff --git a/packages/integrations/netlify/test/development/primitives.test.ts b/packages/integrations/netlify/test/development/primitives.test.ts new file mode 100644 index 000000000000..2bad2bd8ce36 --- /dev/null +++ b/packages/integrations/netlify/test/development/primitives.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, before, describe, it } from 'node:test'; + +import * as cheerio from 'cheerio'; +import netlifyAdapter from '../../dist/index.js'; +import { type DevServer, type Fixture, loadFixture } from '../test-utils.ts'; + +describe('Netlify primitives', () => { + describe('Development', () => { + let fixture: Fixture; + let devServer: DevServer; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/primitives/', import.meta.url), + output: 'server', + adapter: netlifyAdapter(), + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + afterEach(async () => { + fixture.resetAllFiles(); + }); + + it('loads Astro routes', async () => { + const rootResponse = await fixture.fetch('/'); + const $root = cheerio.load(await rootResponse.text()); + + assert.equal($root('h1').text(), 'Middleware'); + + const imgResponse = await fixture.fetch('/astronaut'); + const $img = cheerio.load(await imgResponse.text()); + + assert.equal($img('h1').text(), 'Hello, Astronaut'); + + // assert($img('img').attr('src').startsWith('/_image?href=')); + }); + + it('loads function routes', async () => { + const firstResponse = await fixture.fetch('/function'); + + assert.equal(await firstResponse.text(), 'Hello from function'); + + await fixture.editFile('netlify/functions/func.mjs', (content) => + content.replace('Hello', 'Hello again'), + ); + + const secondResponse = await fixture.fetch('/function'); + + assert.equal(await secondResponse.text(), 'Hello again from function'); + }); + + it('loads edge function routes', async () => { + const efResponse = await fixture.fetch('/processed/hello'); + const $root = cheerio.load(await efResponse.text()); + + assert.equal($root('h1').text(), 'HELLO THERE, ASTRONAUT.'); + }); + + it('loads images in development', async () => { + const imgResponse = await fixture.fetch('/astronaut'); + const $img = cheerio.load(await imgResponse.text()); + const images = $img('img').map((_i, el) => { + return $img(el).attr('src'); + }); + + for (const imgSrc of images) { + assert(imgSrc.startsWith('/.netlify/images')); + const imageResponse = await fixture.fetch(imgSrc); + assert.equal(imageResponse.status, 200); + // Images are JPEG by default in development + assert.equal(imageResponse.headers.get('content-type'), 'image/jpeg'); + } + }); + + it('respects imageCDN: false in development', async () => { + process.env.DISABLE_IMAGE_CDN = 'true'; + const cdnDisabledFixture = await loadFixture({ + root: new URL('./fixtures/primitives/', import.meta.url), + output: 'server', + adapter: netlifyAdapter({ imageCDN: false }), + }); + const cdnDisabledServer = await cdnDisabledFixture.startDevServer(); + try { + const imgResponse = await cdnDisabledFixture.fetch('/astronaut'); + const $img = cheerio.load(await imgResponse.text()); + const images = $img('img').map((_i, el) => { + return $img(el).attr('src'); + }); + + for (const imgSrc of images) { + assert( + !imgSrc.startsWith('/.netlify/images'), + `Expected image src to not use Netlify CDN, got: ${imgSrc}`, + ); + } + } finally { + await cdnDisabledServer.stop(); + delete process.env.DISABLE_IMAGE_CDN; + } + }); + }); +}); diff --git a/packages/integrations/netlify/test/functions/cookies.test.js b/packages/integrations/netlify/test/functions/cookies.test.js deleted file mode 100644 index 6ef16763ed5d..000000000000 --- a/packages/integrations/netlify/test/functions/cookies.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from '../../../../astro/test/test-utils.js'; - -describe( - 'Cookies', - () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ root: new URL('./fixtures/cookies/', import.meta.url) }); - await fixture.build(); - }); - - it('Can set multiple', async () => { - const entryURL = new URL( - './fixtures/cookies/.netlify/v1/functions/ssr/ssr.mjs', - import.meta.url, - ); - const { default: handler } = await import(entryURL); - const resp = await handler( - new Request('http://example.com/login', { method: 'POST', body: '{}' }), - {}, - ); - assert.equal(resp.status, 301); - assert.equal(resp.headers.get('location'), '/'); - assert.deepEqual(resp.headers.getSetCookie(), ['foo=foo; HttpOnly', 'bar=bar; HttpOnly']); - }); - - it('Can set partitioned cookie', async () => { - const entryURL = new URL( - './fixtures/cookies/.netlify/v1/functions/ssr/ssr.mjs', - import.meta.url, - ); - const { default: handler } = await import(entryURL); - const resp = await handler(new Request('http://example.com/partitioned'), {}); - assert.equal(resp.status, 200); - const cookie = resp.headers.getSetCookie()[0]; - assert.ok(cookie.includes('Partitioned'), 'Cookie should include Partitioned attribute'); - }); - - it('renders dynamic 404 page', async () => { - const entryURL = new URL( - './fixtures/cookies/.netlify/v1/functions/ssr/ssr.mjs', - import.meta.url, - ); - const { default: handler } = await import(entryURL); - const resp = await handler( - new Request('http://example.com/nonexistant-page', { - headers: { - 'x-test': 'bar', - }, - }), - {}, - ); - assert.equal(resp.status, 404); - const text = await resp.text(); - assert.equal(text.includes('This is my custom 404 page'), true); - assert.equal(text.includes('x-test: bar'), true); - }); - }, - { - timeout: 120000, - }, -); diff --git a/packages/integrations/netlify/test/functions/cookies.test.ts b/packages/integrations/netlify/test/functions/cookies.test.ts new file mode 100644 index 000000000000..a412dc6537ec --- /dev/null +++ b/packages/integrations/netlify/test/functions/cookies.test.ts @@ -0,0 +1,59 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from '../test-utils.ts'; + +describe('Cookies', { timeout: 120000 }, () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ root: new URL('./fixtures/cookies/', import.meta.url) }); + await fixture.build(); + }); + + it('Can set multiple', async () => { + const entryURL = new URL( + './fixtures/cookies/.netlify/v1/functions/ssr/ssr.mjs', + import.meta.url, + ); + const { default: handler } = await import(entryURL.href); + const resp = await handler( + new Request('http://example.com/login', { method: 'POST', body: '{}' }), + {}, + ); + assert.equal(resp.status, 301); + assert.equal(resp.headers.get('location'), '/'); + assert.deepEqual(resp.headers.getSetCookie(), ['foo=foo; HttpOnly', 'bar=bar; HttpOnly']); + }); + + it('Can set partitioned cookie', async () => { + const entryURL = new URL( + './fixtures/cookies/.netlify/v1/functions/ssr/ssr.mjs', + import.meta.url, + ); + const { default: handler } = await import(entryURL.href); + const resp = await handler(new Request('http://example.com/partitioned'), {}); + assert.equal(resp.status, 200); + const cookie = resp.headers.getSetCookie()[0]!; + assert.ok(cookie.includes('Partitioned'), 'Cookie should include Partitioned attribute'); + }); + + it('renders dynamic 404 page', async () => { + const entryURL = new URL( + './fixtures/cookies/.netlify/v1/functions/ssr/ssr.mjs', + import.meta.url, + ); + const { default: handler } = await import(entryURL.href); + const resp = await handler( + new Request('http://example.com/nonexistant-page', { + headers: { + 'x-test': 'bar', + }, + }), + {}, + ); + assert.equal(resp.status, 404); + const text = await resp.text(); + assert.equal(text.includes('This is my custom 404 page'), true); + assert.equal(text.includes('x-test: bar'), true); + }); +}); diff --git a/packages/integrations/netlify/test/functions/edge-middleware.test.js b/packages/integrations/netlify/test/functions/edge-middleware.test.js deleted file mode 100644 index 6d255f4dc7f9..000000000000 --- a/packages/integrations/netlify/test/functions/edge-middleware.test.js +++ /dev/null @@ -1,66 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { loadFixture } from '../../../../astro/test/test-utils.js'; - -describe( - 'Middleware', - () => { - const root = new URL('./fixtures/middleware/', import.meta.url); - - describe('middlewareMode: classic', () => { - let fixture; - before(async () => { - process.env.EDGE_MIDDLEWARE = 'false'; - fixture = await loadFixture({ root }); - await fixture.build(); - }); - - it('emits no edge function', async () => { - assert.equal( - fixture.pathExists('../.netlify/v1/edge-functions/middleware/middleware.mjs'), - false, - ); - }); - - it('applies middleware to static files at build-time', async () => { - // prerendered page has middleware applied at build time - const prerenderedPage = await fixture.readFile('prerender/index.html'); - assert.equal(prerenderedPage.includes('Middleware'), true); - }); - - after(async () => { - process.env.EDGE_MIDDLEWARE = undefined; - await fixture.clean(); - }); - }); - - describe('middlewareMode: edge', () => { - let fixture; - before(async () => { - process.env.EDGE_MIDDLEWARE = 'true'; - fixture = await loadFixture({ root }); - await fixture.build(); - }); - - it('emits an edge function', async () => { - const contents = await fixture.readFile( - '../.netlify/v1/edge-functions/middleware/middleware.mjs', - ); - assert.equal(contents.includes('"Hello world"'), false); - }); - - it.skip('does not apply middleware during prerendering', async () => { - const prerenderedPage = await fixture.readFile('prerender/index.html'); - assert.equal(prerenderedPage.includes(''), true); - }); - - after(async () => { - process.env.EDGE_MIDDLEWARE = undefined; - await fixture.clean(); - }); - }); - }, - { - timeout: 120000, - }, -); diff --git a/packages/integrations/netlify/test/functions/edge-middleware.test.ts b/packages/integrations/netlify/test/functions/edge-middleware.test.ts new file mode 100644 index 000000000000..bc6abbe64877 --- /dev/null +++ b/packages/integrations/netlify/test/functions/edge-middleware.test.ts @@ -0,0 +1,60 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from '../test-utils.ts'; + +describe('Middleware', { timeout: 120000 }, () => { + const root = new URL('./fixtures/middleware/', import.meta.url); + + describe('middlewareMode: classic', () => { + let fixture: Fixture; + before(async () => { + process.env.EDGE_MIDDLEWARE = 'false'; + fixture = await loadFixture({ root }); + await fixture.build(); + }); + + it('emits no edge function', async () => { + assert.equal( + fixture.pathExists('../.netlify/v1/edge-functions/middleware/middleware.mjs'), + false, + ); + }); + + it('applies middleware to static files at build-time', async () => { + // prerendered page has middleware applied at build time + const prerenderedPage = await fixture.readFile('prerender/index.html'); + assert.equal(prerenderedPage.includes('Middleware'), true); + }); + + after(async () => { + delete process.env.EDGE_MIDDLEWARE; + await fixture.clean(); + }); + }); + + describe('middlewareMode: edge', () => { + let fixture: Fixture; + before(async () => { + process.env.EDGE_MIDDLEWARE = 'true'; + fixture = await loadFixture({ root }); + await fixture.build(); + }); + + it('emits an edge function', async () => { + const contents = await fixture.readFile( + '../.netlify/v1/edge-functions/middleware/middleware.mjs', + ); + assert.equal(contents.includes('"Hello world"'), false); + }); + + it.skip('does not apply middleware during prerendering', async () => { + const prerenderedPage = await fixture.readFile('prerender/index.html'); + assert.equal(prerenderedPage.includes(''), true); + }); + + after(async () => { + delete process.env.EDGE_MIDDLEWARE; + await fixture.clean(); + }); + }); +}); diff --git a/packages/integrations/netlify/test/functions/image-cdn.test.js b/packages/integrations/netlify/test/functions/image-cdn.test.js deleted file mode 100644 index 8d6196817607..000000000000 --- a/packages/integrations/netlify/test/functions/image-cdn.test.js +++ /dev/null @@ -1,182 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { remotePatternToRegex } from '@astrojs/netlify'; -import { loadFixture } from '../../../../astro/test/test-utils.js'; -import imageService from '../../dist/image-service.js'; - -describe( - 'Image CDN', - () => { - const root = new URL('./fixtures/middleware/', import.meta.url); - - describe('configuration', () => { - after(() => { - process.env.DISABLE_IMAGE_CDN = undefined; - }); - - it('enables Netlify Image CDN', async () => { - const fixture = await loadFixture({ root }); - await fixture.build(); - - const astronautPage = await fixture.readFile('astronaut/index.html'); - assert.equal(astronautPage.includes(`src="/.netlify/image`), true); - }); - - it('respects image CDN opt-out', async () => { - process.env.DISABLE_IMAGE_CDN = 'true'; - const fixture = await loadFixture({ root }); - await fixture.build(); - - const astronautPage = await fixture.readFile('astronaut/index.html'); - assert.equal(astronautPage.includes(`src="/_astro/astronaut.`), true); - }); - }); - - describe('remote image config', () => { - let regexes; - - before(async () => { - const fixture = await loadFixture({ root }); - await fixture.build(); - - const config = await fixture.readFile('../.netlify/v1/config.json'); - if (config) { - regexes = JSON.parse(config).images.remote_images.map((pattern) => new RegExp(pattern)); - } - }); - - it('generates remote image config patterns', async () => { - assert.equal(regexes?.length, 3); - }); - - it('generates correct config for domains', async () => { - const domain = regexes[0]; - assert.equal(domain.test('https://example.net/image.jpg'), true); - assert.equal( - domain.test('https://www.example.net/image.jpg'), - false, - 'subdomain should not match', - ); - assert.equal(domain.test('http://example.net/image.jpg'), true, 'http should match'); - assert.equal( - domain.test('https://example.net/subdomain/image.jpg'), - true, - 'subpath should match', - ); - const subdomain = regexes[1]; - assert.equal( - subdomain.test('https://secret.example.edu/image.jpg'), - true, - 'should match subdomains', - ); - assert.equal( - subdomain.test('https://secretxexample.edu/image.jpg'), - false, - 'should not use dots in domains as wildcards', - ); - }); - - it('generates correct config for remotePatterns', async () => { - const patterns = regexes[2]; - assert.equal( - patterns.test('https://example.org/images/1.jpg'), - true, - 'should match domain', - ); - assert.equal( - patterns.test('https://www.example.org/images/2.jpg'), - true, - 'www subdomain should match', - ); - assert.equal( - patterns.test('https://www.subdomain.example.org/images/2.jpg'), - false, - 'second level subdomain should not match', - ); - assert.equal( - patterns.test('https://example.org/not-images/2.jpg'), - false, - 'wrong path should not match', - ); - }); - - it('warns when remotepatterns generates an invalid regex', async (t) => { - const logger = { - warn: t.mock.fn(), - }; - const regex = remotePatternToRegex( - { - hostname: '*.examp[le.org', - pathname: '/images/*', - }, - logger, - ); - assert.strictEqual(regex, undefined); - const calls = logger.warn.mock.calls; - assert.strictEqual(calls.length, 1); - assert.equal( - calls[0].arguments[0], - 'Could not generate a valid regex from the remotePattern "{"hostname":"*.examp[le.org","pathname":"/images/*"}". Please check the syntax.', - ); - }); - }); - - describe('fit parameter', () => { - it('includes fit parameter in image URL', () => { - const url = imageService.getURL({ - src: 'images/astronaut.jpg', - width: 300, - height: 400, - fit: 'cover', - format: 'webp', - }); - assert.ok(url.includes('fit=cover'), `Expected fit=cover in URL, got: ${url}`); - }); - - it('maps Astro fit values to Netlify equivalents', () => { - const cases = [ - ['contain', 'contain'], - ['cover', 'cover'], - ['fill', 'fill'], - ['inside', 'contain'], - ['outside', 'cover'], - ['scale-down', 'contain'], - ]; - for (const [astroFit, netlifyFit] of cases) { - const url = imageService.getURL({ - src: 'img.jpg', - width: 100, - height: 100, - fit: astroFit, - }); - assert.ok( - url.includes(`fit=${netlifyFit}`), - `Expected fit=${netlifyFit} for astro fit="${astroFit}", got: ${url}`, - ); - } - }); - - it('omits fit parameter when fit is none or unset', () => { - const withNone = imageService.getURL({ - src: 'img.jpg', - width: 100, - height: 100, - fit: 'none', - }); - assert.ok( - !withNone.includes('fit='), - `Expected no fit param for fit="none", got: ${withNone}`, - ); - - const withoutFit = imageService.getURL({ src: 'img.jpg', width: 100, height: 100 }); - assert.ok( - !withoutFit.includes('fit='), - `Expected no fit param when unset, got: ${withoutFit}`, - ); - }); - }); - }, - { - timeout: 120000, - }, -); diff --git a/packages/integrations/netlify/test/functions/image-cdn.test.ts b/packages/integrations/netlify/test/functions/image-cdn.test.ts new file mode 100644 index 000000000000..709c306ec3c1 --- /dev/null +++ b/packages/integrations/netlify/test/functions/image-cdn.test.ts @@ -0,0 +1,184 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { remotePatternToRegex } from '@astrojs/netlify'; +import imageService from '../../dist/image-service.js'; +import { loadFixture, SpyIntegrationLogger } from '../test-utils.ts'; +import type { ImageTransform } from 'astro'; + +async function getURL(options: ImageTransform) { + return await imageService.getURL( + options, + // @ts-expect-error The second argument is not used in the current + // implementation of `imageService.getURL`, but we need to pass it + // to satisfy the type signature. + {}, + ); +} + +describe('Image CDN', { timeout: 120000 }, () => { + const root = new URL('./fixtures/middleware/', import.meta.url); + + describe('configuration', () => { + after(() => { + delete process.env.DISABLE_IMAGE_CDN; + }); + + it('enables Netlify Image CDN', async () => { + const fixture = await loadFixture({ root }); + await fixture.build(); + + const astronautPage = await fixture.readFile('astronaut/index.html'); + assert.equal(astronautPage.includes(`src="/.netlify/image`), true); + }); + + it('respects image CDN opt-out', async () => { + process.env.DISABLE_IMAGE_CDN = 'true'; + const fixture = await loadFixture({ root }); + await fixture.build(); + + const astronautPage = await fixture.readFile('astronaut/index.html'); + assert.equal(astronautPage.includes(`src="/_astro/astronaut.`), true); + }); + }); + + describe('remote image config', () => { + let regexes: RegExp[]; + + before(async () => { + const fixture = await loadFixture({ root }); + await fixture.build(); + + const config = await fixture.readFile('../.netlify/v1/config.json'); + if (config) { + regexes = JSON.parse(config).images.remote_images.map( + (pattern: string) => new RegExp(pattern), + ); + } + }); + + it('generates remote image config patterns', async () => { + assert.equal(regexes?.length, 3); + }); + + it('generates correct config for domains', async () => { + const domain = regexes[0]!; + assert.equal(domain.test('https://example.net/image.jpg'), true); + assert.equal( + domain.test('https://www.example.net/image.jpg'), + false, + 'subdomain should not match', + ); + assert.equal(domain.test('http://example.net/image.jpg'), true, 'http should match'); + assert.equal( + domain.test('https://example.net/subdomain/image.jpg'), + true, + 'subpath should match', + ); + const subdomain = regexes[1]!; + assert.equal( + subdomain.test('https://secret.example.edu/image.jpg'), + true, + 'should match subdomains', + ); + assert.equal( + subdomain.test('https://secretxexample.edu/image.jpg'), + false, + 'should not use dots in domains as wildcards', + ); + }); + + it('generates correct config for remotePatterns', async () => { + const patterns = regexes[2]!; + assert.equal(patterns.test('https://example.org/images/1.jpg'), true, 'should match domain'); + assert.equal( + patterns.test('https://www.example.org/images/2.jpg'), + true, + 'www subdomain should match', + ); + assert.equal( + patterns.test('https://www.subdomain.example.org/images/2.jpg'), + false, + 'second level subdomain should not match', + ); + assert.equal( + patterns.test('https://example.org/not-images/2.jpg'), + false, + 'wrong path should not match', + ); + }); + + it('warns when remotepatterns generates an invalid regex', async () => { + const logger = new SpyIntegrationLogger(); + const regex = remotePatternToRegex( + { + hostname: '*.examp[le.org', + pathname: '/images/*', + }, + logger, + ); + assert.strictEqual(regex, undefined); + assert.strictEqual(logger.messages.length, 1); + const message = logger.messages[0]; + assert.equal(message.level, 'warn'); + assert.equal( + message.message, + 'Could not generate a valid regex from the remotePattern "{"hostname":"*.examp[le.org","pathname":"/images/*"}". Please check the syntax.', + ); + }); + }); + + describe('fit parameter', () => { + it('includes fit parameter in image URL', async () => { + const url = await getURL({ + src: 'images/astronaut.jpg', + width: 300, + height: 400, + fit: 'cover', + format: 'webp', + }); + assert.ok(url.includes('fit=cover'), `Expected fit=cover in URL, got: ${url}`); + }); + + it('maps Astro fit values to Netlify equivalents', async () => { + const cases = [ + ['contain', 'contain'], + ['cover', 'cover'], + ['fill', 'fill'], + ['inside', 'contain'], + ['outside', 'cover'], + ['scale-down', 'contain'], + ]; + for (const [astroFit, netlifyFit] of cases) { + const url = await getURL({ + src: 'img.jpg', + width: 100, + height: 100, + fit: astroFit, + }); + assert.ok( + url.includes(`fit=${netlifyFit}`), + `Expected fit=${netlifyFit} for astro fit="${astroFit}", got: ${url}`, + ); + } + }); + + it('omits fit parameter when fit is none or unset', async () => { + const withNone = await getURL({ + src: 'img.jpg', + width: 100, + height: 100, + fit: 'none', + }); + assert.ok( + !withNone.includes('fit='), + `Expected no fit param for fit="none", got: ${withNone}`, + ); + + const withoutFit = await getURL({ src: 'img.jpg', width: 100, height: 100 }); + assert.ok( + !withoutFit.includes('fit='), + `Expected no fit param when unset, got: ${withoutFit}`, + ); + }); + }); +}); diff --git a/packages/integrations/netlify/test/functions/include-files.test.js b/packages/integrations/netlify/test/functions/include-files.test.js deleted file mode 100644 index e54e116a78c3..000000000000 --- a/packages/integrations/netlify/test/functions/include-files.test.js +++ /dev/null @@ -1,184 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { existsSync } from 'node:fs'; -import { after, before, describe, it } from 'node:test'; -import netlify from '@astrojs/netlify'; -import * as cheerio from 'cheerio'; -import { globSync } from 'tinyglobby'; -import { loadFixture } from '../../../../astro/test/test-utils.js'; - -describe( - 'Included vite assets files', - () => { - let fixture; - - const root = new URL('./fixtures/includes/', import.meta.url); - const expectedCwd = new URL('.netlify/v1/functions/ssr/packages/integrations/netlify/', root); - - const expectedAssetsInclude = ['./*.json']; - const excludedAssets = ['./files/exclude-asset.json']; - - before(async () => { - fixture = await loadFixture({ - root, - vite: { - assetsInclude: expectedAssetsInclude, - }, - adapter: netlify({ - excludeFiles: excludedAssets, - }), - }); - await fixture.build(); - }); - - it('Emits vite assets files', async () => { - for (const pattern of expectedAssetsInclude) { - const files = globSync(pattern); - for (const file of files) { - assert.ok( - existsSync(new URL(file, expectedCwd)), - `Expected file ${pattern} to exist in build`, - ); - } - } - }); - - it('Does not include vite assets files when excluded', async () => { - for (const file of excludedAssets) { - assert.ok( - !existsSync(new URL(file, expectedCwd)), - `Expected file ${file} to not exist in build`, - ); - } - }); - - after(async () => { - await fixture.clean(); - }); - }, - { - timeout: 120000, - }, -); - -describe( - 'Included files', - () => { - let fixture; - - const root = new URL('./fixtures/includes/', import.meta.url); - const expectedCwd = new URL( - '.netlify/v1/functions/ssr/packages/integrations/netlify/test/functions/fixtures/includes/', - root, - ); - - const expectedFiles = [ - './files/include-this.txt', - './files/also-this.csv', - './files/subdirectory/and-this.csv', - ]; - - before(async () => { - fixture = await loadFixture({ - root, - adapter: netlify({ - includeFiles: expectedFiles, - }), - }); - await fixture.build(); - }); - - it('Emits include files', async () => { - for (const file of expectedFiles) { - assert.ok(existsSync(new URL(file, expectedCwd)), `Expected file ${file} to exist`); - } - }); - - it('Can load included files correctly', async () => { - const entryURL = new URL( - './fixtures/includes/.netlify/v1/functions/ssr/ssr.mjs', - import.meta.url, - ); - const { default: handler } = await import(entryURL); - const resp = await handler(new Request('http://example.com/?file=include-this.txt'), {}); - const html = await resp.text(); - const $ = cheerio.load(html); - assert.equal($('h1').text(), 'hello'); - }); - - it('Includes traced node modules with symlinks', async () => { - const expected = new URL( - '.netlify/v1/functions/ssr/node_modules/.pnpm/cowsay@1.6.0/node_modules/cowsay/cows/happy-whale.cow', - root, - ); - assert.ok(existsSync(expected, 'Expected excluded file to exist in default build')); - }); - - after(async () => { - await fixture.clean(); - }); - }, - { - timeout: 120000, - }, -); - -describe( - 'Excluded files', - () => { - let fixture; - - const root = new URL('./fixtures/includes/', import.meta.url); - const expectedCwd = new URL( - '.netlify/v1/functions/ssr/packages/integrations/netlify/test/functions/fixtures/includes/', - root, - ); - - const includeFiles = ['./files/**/*.txt']; - const excludedTxt = ['./files/subdirectory/not-this.txt', './files/subdirectory/or-this.txt']; - const excludeFiles = [...excludedTxt, '../../../../../../../node_modules/.pnpm/cowsay@*/**']; - - before(async () => { - fixture = await loadFixture({ - root, - adapter: netlify({ - includeFiles: includeFiles, - excludeFiles: excludeFiles, - }), - }); - await fixture.build(); - }); - - it('Excludes traced node modules', async () => { - const expected = new URL( - '.netlify/v1/functions/ssr/node_modules/.pnpm/cowsay@1.6.0/node_modules/cowsay/cows/happy-whale.cow', - root, - ); - assert.ok(!existsSync(expected), 'Expected excluded file to not exist in build'); - }); - - it('Does not include files when excluded', async () => { - for (const pattern of includeFiles) { - const files = globSync(pattern, { ignore: excludedTxt }); - for (const file of files) { - assert.ok( - existsSync(new URL(file, expectedCwd)), - `Expected file ${pattern} to exist in build`, - ); - } - } - for (const file of excludedTxt) { - assert.ok( - !existsSync(new URL(file, expectedCwd)), - `Expected file ${file} to not exist in build`, - ); - } - }); - - after(async () => { - await fixture.clean(); - }); - }, - { - timeout: 120000, - }, -); diff --git a/packages/integrations/netlify/test/functions/include-files.test.ts b/packages/integrations/netlify/test/functions/include-files.test.ts new file mode 100644 index 000000000000..5efdd6227869 --- /dev/null +++ b/packages/integrations/netlify/test/functions/include-files.test.ts @@ -0,0 +1,166 @@ +import * as assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { after, before, describe, it } from 'node:test'; +import netlify from '@astrojs/netlify'; +import * as cheerio from 'cheerio'; +import { globSync } from 'tinyglobby'; +import { type Fixture, loadFixture } from '../test-utils.ts'; + +describe('Included vite assets files', { timeout: 120000 }, () => { + let fixture: Fixture; + + const root = new URL('./fixtures/includes/', import.meta.url); + const expectedCwd = new URL('.netlify/v1/functions/ssr/packages/integrations/netlify/', root); + + const expectedAssetsInclude = ['./*.json']; + const excludedAssets = ['./files/exclude-asset.json']; + + before(async () => { + fixture = await loadFixture({ + root, + vite: { + assetsInclude: expectedAssetsInclude, + }, + adapter: netlify({ + excludeFiles: excludedAssets, + }), + }); + await fixture.build(); + }); + + it('Emits vite assets files', async () => { + for (const pattern of expectedAssetsInclude) { + const files = globSync(pattern); + for (const file of files) { + assert.ok( + existsSync(new URL(file, expectedCwd)), + `Expected file ${pattern} to exist in build`, + ); + } + } + }); + + it('Does not include vite assets files when excluded', async () => { + for (const file of excludedAssets) { + assert.ok( + !existsSync(new URL(file, expectedCwd)), + `Expected file ${file} to not exist in build`, + ); + } + }); + + after(async () => { + await fixture.clean(); + }); +}); + +describe('Included files', { timeout: 120000 }, () => { + let fixture: Fixture; + + const root = new URL('./fixtures/includes/', import.meta.url); + const expectedCwd = new URL( + '.netlify/v1/functions/ssr/packages/integrations/netlify/test/functions/fixtures/includes/', + root, + ); + + const expectedFiles = [ + './files/include-this.txt', + './files/also-this.csv', + './files/subdirectory/and-this.csv', + ]; + + before(async () => { + fixture = await loadFixture({ + root, + adapter: netlify({ + includeFiles: expectedFiles, + }), + }); + await fixture.build(); + }); + + it('Emits include files', async () => { + for (const file of expectedFiles) { + assert.ok(existsSync(new URL(file, expectedCwd)), `Expected file ${file} to exist`); + } + }); + + it('Can load included files correctly', async () => { + const entryURL = new URL( + './fixtures/includes/.netlify/v1/functions/ssr/ssr.mjs', + import.meta.url, + ); + const { default: handler } = await import(entryURL.href); + const resp = await handler(new Request('http://example.com/?file=include-this.txt'), {}); + const html = await resp.text(); + const $ = cheerio.load(html); + assert.equal($('h1').text(), 'hello'); + }); + + it('Includes traced node modules with symlinks', async () => { + const expected = new URL( + '.netlify/v1/functions/ssr/node_modules/.pnpm/cowsay@1.6.0/node_modules/cowsay/cows/happy-whale.cow', + root, + ); + assert.ok(existsSync(expected), 'Expected excluded file to exist in default build'); + }); + + after(async () => { + await fixture.clean(); + }); +}); + +describe('Excluded files', { timeout: 120000 }, () => { + let fixture: Fixture; + + const root = new URL('./fixtures/includes/', import.meta.url); + const expectedCwd = new URL( + '.netlify/v1/functions/ssr/packages/integrations/netlify/test/functions/fixtures/includes/', + root, + ); + + const includeFiles = ['./files/**/*.txt']; + const excludedTxt = ['./files/subdirectory/not-this.txt', './files/subdirectory/or-this.txt']; + const excludeFiles = [...excludedTxt, '../../../../../../../node_modules/.pnpm/cowsay@*/**']; + + before(async () => { + fixture = await loadFixture({ + root, + adapter: netlify({ + includeFiles: includeFiles, + excludeFiles: excludeFiles, + }), + }); + await fixture.build(); + }); + + it('Excludes traced node modules', async () => { + const expected = new URL( + '.netlify/v1/functions/ssr/node_modules/.pnpm/cowsay@1.6.0/node_modules/cowsay/cows/happy-whale.cow', + root, + ); + assert.ok(!existsSync(expected), 'Expected excluded file to not exist in build'); + }); + + it('Does not include files when excluded', async () => { + for (const pattern of includeFiles) { + const files = globSync(pattern, { ignore: excludedTxt }); + for (const file of files) { + assert.ok( + existsSync(new URL(file, expectedCwd)), + `Expected file ${pattern} to exist in build`, + ); + } + } + for (const file of excludedTxt) { + assert.ok( + !existsSync(new URL(file, expectedCwd)), + `Expected file ${file} to not exist in build`, + ); + } + }); + + after(async () => { + await fixture.clean(); + }); +}); diff --git a/packages/integrations/netlify/test/functions/redirects.test.js b/packages/integrations/netlify/test/functions/redirects.test.js deleted file mode 100644 index 01bddc4c9a28..000000000000 --- a/packages/integrations/netlify/test/functions/redirects.test.js +++ /dev/null @@ -1,69 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { createServer } from 'node:http'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from '../../../../astro/test/test-utils.js'; - -describe( - 'SSR - Redirects', - () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ root: new URL('./fixtures/redirects/', import.meta.url) }); - await fixture.build(); - }); - - it('Creates a redirects file', async () => { - const redirects = await fixture.readFile('./_redirects'); - const parts = redirects.split(/\s+/); - assert.deepEqual(parts, ['', '/other', '/', '301', '']); - // Snapshots are not supported in Node.js test yet (https://github.com/nodejs/node/issues/48260) - assert.equal(redirects, '\n/other / 301\n'); - }); - - it('Does not create .html files', async () => { - let hasErrored = false; - try { - await fixture.readFile('/other/index.html'); - } catch { - hasErrored = true; - } - assert.equal(hasErrored, true, 'this file should not exist'); - }); - - it('renders static 404 page', async () => { - const entryURL = new URL( - './fixtures/redirects/.netlify/v1/functions/ssr/ssr.mjs', - import.meta.url, - ); - const { default: handler } = await import(entryURL); - const resp = await handler(new Request('http://example.com/nonexistant-page'), {}); - assert.equal(resp.status, 404); - assert.equal(resp.headers.get('content-type'), 'text/html; charset=utf-8'); - const text = await resp.text(); - assert.equal(text.includes('This is my static 404 page'), true); - }); - - it('does not pass through 404 request', async () => { - let testServerCalls = 0; - const testServer = createServer((_req, res) => { - testServerCalls++; - res.writeHead(200); - res.end(); - }); - testServer.listen(5678); - const entryURL = new URL( - './fixtures/redirects/.netlify/v1/functions/ssr/ssr.mjs', - import.meta.url, - ); - const { default: handler } = await import(entryURL); - const resp = await handler(new Request('http://localhost:5678/nonexistant-page'), {}); - assert.equal(resp.status, 404); - assert.equal(testServerCalls, 0); - testServer.close(); - }); - }, - { - timeout: 120000, - }, -); diff --git a/packages/integrations/netlify/test/functions/redirects.test.ts b/packages/integrations/netlify/test/functions/redirects.test.ts new file mode 100644 index 000000000000..553885096486 --- /dev/null +++ b/packages/integrations/netlify/test/functions/redirects.test.ts @@ -0,0 +1,62 @@ +import * as assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from '../test-utils.ts'; + +describe('SSR - Redirects', { timeout: 120000 }, () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ root: new URL('./fixtures/redirects/', import.meta.url) }); + await fixture.build(); + }); + + it('Creates a redirects file', async () => { + const redirects = await fixture.readFile('./_redirects'); + const parts = redirects.split(/\s+/); + // based on https://github.com/withastro/astro/issues/16030 for the default option `trailingSlash: 'ignore'` both variants should be generated + assert.deepEqual(parts, ['', '/other/', '/', '301', '/other', '/', '301', '']); + }); + + it('Does not create .html files', async () => { + let hasErrored = false; + try { + await fixture.readFile('/other/index.html'); + } catch { + hasErrored = true; + } + assert.equal(hasErrored, true, 'this file should not exist'); + }); + + it('renders static 404 page', async () => { + const entryURL = new URL( + './fixtures/redirects/.netlify/v1/functions/ssr/ssr.mjs', + import.meta.url, + ); + const { default: handler } = await import(entryURL.href); + const resp = await handler(new Request('http://example.com/nonexistant-page'), {}); + assert.equal(resp.status, 404); + assert.equal(resp.headers.get('content-type'), 'text/html; charset=utf-8'); + const text = await resp.text(); + assert.equal(text.includes('This is my static 404 page'), true); + }); + + it('does not pass through 404 request', async () => { + let testServerCalls = 0; + const testServer = createServer((_req, res) => { + testServerCalls++; + res.writeHead(200); + res.end(); + }); + testServer.listen(5678); + const entryURL = new URL( + './fixtures/redirects/.netlify/v1/functions/ssr/ssr.mjs', + import.meta.url, + ); + const { default: handler } = await import(entryURL.href); + const resp = await handler(new Request('http://localhost:5678/nonexistant-page'), {}); + assert.equal(resp.status, 404); + assert.equal(testServerCalls, 0); + testServer.close(); + }); +}); diff --git a/packages/integrations/netlify/test/functions/sessions.test.js b/packages/integrations/netlify/test/functions/sessions.test.js deleted file mode 100644 index 7b41cb129648..000000000000 --- a/packages/integrations/netlify/test/functions/sessions.test.js +++ /dev/null @@ -1,132 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { mkdir, rm } from 'node:fs/promises'; -import { after, before, describe, it } from 'node:test'; -import { BlobsServer } from '@netlify/blobs/server'; -import * as devalue from 'devalue'; -import { loadFixture } from '../../../../astro/test/test-utils.js'; -import netlify from '../../dist/index.js'; -import { sessionDrivers } from 'astro/config'; - -const token = 'mock'; -const siteID = '1'; -const dataDir = '.netlify/sessions'; - -describe('Astro.session', () => { - describe('Production', () => { - /** @type {import('../../../../astro/test/test-utils.js').Fixture} */ - let fixture; - - /** @type {BlobsServer} */ - let blobServer; - before(async () => { - process.env.NETLIFY = '1'; - await rm(dataDir, { recursive: true, force: true }).catch(() => {}); - await mkdir(dataDir, { recursive: true }); - blobServer = new BlobsServer({ - directory: dataDir, - token, - port: 8971, - }); - await blobServer.start(); - fixture = await loadFixture({ - // @ts-ignore - root: new URL('./fixtures/sessions/', import.meta.url), - output: 'server', - adapter: netlify(), - session: { - driver: sessionDrivers.netlifyBlobs({ - name: 'test', - uncachedEdgeURL: `http://localhost:8971`, - edgeURL: `http://localhost:8971`, - token, - siteID, - }), - }, - }); - await fixture.build({}); - const entryURL = new URL( - './fixtures/sessions/.netlify/v1/functions/ssr/ssr.mjs', - import.meta.url, - ); - const mod = await import(entryURL.href); - handler = mod.default; - }); - /** @type {(request: Request, options: {}) => Promise} */ - let handler; - after(async () => { - await blobServer.stop(); - delete process.env.NETLIFY; - }); - /** - * @param {string} path - * @param {RequestInit} requestInit - */ - function fetchResponse(path, requestInit) { - return handler(new Request(new URL(path, 'http://example.com'), requestInit), {}); - } - - it('can regenerate session cookies upon request', async () => { - const firstResponse = await fetchResponse('/regenerate', { method: 'GET' }); - const firstHeaders = firstResponse.headers.get('set-cookie')?.split(',') ?? ''; - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - - const secondResponse = await fetchResponse('/regenerate', { - method: 'GET', - headers: { - cookie: `astro-session=${firstSessionId}`, - }, - }); - const secondHeaders = secondResponse.headers.get('set-cookie')?.split(',') ?? ''; - const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; - assert.notEqual(firstSessionId, secondSessionId); - }); - - it('can save session data by value', async () => { - const firstResponse = await fetchResponse('/update', { method: 'GET' }); - const firstValue = await firstResponse.json(); - assert.equal(firstValue.previousValue, 'none'); - - const firstHeaders = firstResponse.headers.get('set-cookie')?.split(',') ?? ''; - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - const secondResponse = await fetchResponse('/update', { - method: 'GET', - headers: { - cookie: `astro-session=${firstSessionId}`, - }, - }); - const secondValue = await secondResponse.json(); - assert.equal(secondValue.previousValue, 'expected'); - }); - - it('can save and restore URLs in session data', async () => { - const firstResponse = await fetchResponse('/_actions/addUrl', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), - }); - - assert.equal(firstResponse.ok, true); - const firstHeaders = firstResponse.headers.get('set-cookie')?.split(',') ?? ''; - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - - const data = devalue.parse(await firstResponse.text()); - assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); - const secondResponse = await fetchResponse('/_actions/addUrl', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - cookie: `astro-session=${firstSessionId}`, - }, - body: JSON.stringify({ favoriteUrl: 'https://example.com' }), - }); - const secondData = devalue.parse(await secondResponse.text()); - assert.equal( - secondData.message, - 'Favorite URL set to https://example.com/ from https://domain.invalid/', - ); - }); - }); -}); diff --git a/packages/integrations/netlify/test/functions/sessions.test.ts b/packages/integrations/netlify/test/functions/sessions.test.ts new file mode 100644 index 000000000000..99c0283ccb76 --- /dev/null +++ b/packages/integrations/netlify/test/functions/sessions.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { mkdir, rm } from 'node:fs/promises'; +import { after, before, describe, it } from 'node:test'; +import { BlobsServer } from '@netlify/blobs/server'; +import { sessionDrivers } from 'astro/config'; +import * as devalue from 'devalue'; +import netlify from '../../dist/index.js'; +import { type Fixture, loadFixture } from '../test-utils.ts'; + +const token = 'mock'; +const siteID = '1'; +const dataDir = '.netlify/sessions'; + +describe('Astro.session', () => { + describe('Production', () => { + let fixture: Fixture; + + let blobServer: BlobsServer; + before(async () => { + process.env.NETLIFY = '1'; + await rm(dataDir, { recursive: true, force: true }).catch(() => {}); + await mkdir(dataDir, { recursive: true }); + blobServer = new BlobsServer({ + directory: dataDir, + token, + port: 8971, + }); + await blobServer.start(); + fixture = await loadFixture({ + root: new URL('./fixtures/sessions/', import.meta.url), + output: 'server', + adapter: netlify(), + session: { + // @ts-expect-error: the default type of the TDriver in AstroUserConfig must be changed so that this can pass + driver: sessionDrivers.netlifyBlobs({ + name: 'test', + uncachedEdgeURL: `http://localhost:8971`, + edgeURL: `http://localhost:8971`, + token, + siteID, + }), + }, + }); + await fixture.build({}); + const entryURL = new URL( + './fixtures/sessions/.netlify/v1/functions/ssr/ssr.mjs', + import.meta.url, + ); + const mod = await import(entryURL.href); + handler = mod.default; + }); + let handler: (request: Request, options: object) => Promise; + after(async () => { + await blobServer.stop(); + delete process.env.NETLIFY; + }); + function fetchResponse(path: string, requestInit: RequestInit) { + return handler(new Request(new URL(path, 'http://example.com'), requestInit), {}); + } + + it('can regenerate session cookies upon request', async () => { + const firstResponse = await fetchResponse('/regenerate', { method: 'GET' }); + const firstHeaders = firstResponse.headers.get('set-cookie')?.split(',') ?? ''; + const firstSessionId = firstHeaders[0]!.split(';')[0]!.split('=')[1]; + + const secondResponse = await fetchResponse('/regenerate', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondHeaders = secondResponse.headers.get('set-cookie')?.split(',') ?? ''; + const secondSessionId = secondHeaders[0]!.split(';')[0]!.split('=')[1]; + assert.notEqual(firstSessionId, secondSessionId); + }); + + it('can save session data by value', async () => { + const firstResponse = await fetchResponse('/update', { method: 'GET' }); + const firstValue = await firstResponse.json(); + assert.equal(firstValue.previousValue, 'none'); + + const firstHeaders = firstResponse.headers.get('set-cookie')?.split(',') ?? ''; + const firstSessionId = firstHeaders[0]!.split(';')[0]!.split('=')[1]; + const secondResponse = await fetchResponse('/update', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondValue = await secondResponse.json(); + assert.equal(secondValue.previousValue, 'expected'); + }); + + it('can save and restore URLs in session data', async () => { + const firstResponse = await fetchResponse('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), + }); + + assert.equal(firstResponse.ok, true); + const firstHeaders = firstResponse.headers.get('set-cookie')?.split(',') ?? ''; + const firstSessionId = firstHeaders[0]!.split(';')[0]!.split('=')[1]; + + const data = devalue.parse(await firstResponse.text()); + assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); + const secondResponse = await fetchResponse('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: `astro-session=${firstSessionId}`, + }, + body: JSON.stringify({ favoriteUrl: 'https://example.com' }), + }); + const secondData = devalue.parse(await secondResponse.text()); + assert.equal( + secondData.message, + 'Favorite URL set to https://example.com/ from https://domain.invalid/', + ); + }); + }); +}); diff --git a/packages/integrations/netlify/test/functions/skew-protection.test.js b/packages/integrations/netlify/test/functions/skew-protection.test.js deleted file mode 100644 index ee5f8c840689..000000000000 --- a/packages/integrations/netlify/test/functions/skew-protection.test.js +++ /dev/null @@ -1,70 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from '../../../../astro/test/test-utils.js'; - -describe( - 'Skew Protection', - () => { - let fixture; - - before(async () => { - // Set DEPLOY_ID env var for the test - process.env.DEPLOY_ID = 'test-deploy-123'; - - fixture = await loadFixture({ - root: new URL('./fixtures/skew-protection/', import.meta.url), - }); - await fixture.build(); - - // Clean up - delete process.env.DEPLOY_ID; - }); - - it('Server islands inline adapter headers', async () => { - // Render a page with server islands and check the HTML contains inline headers - const entryURL = new URL( - './fixtures/skew-protection/.netlify/v1/functions/ssr/ssr.mjs', - import.meta.url, - ); - const { default: handler } = await import(entryURL); - const resp = await handler(new Request('http://example.com/server-island'), {}); - const html = await resp.text(); - - // Check that the HTML contains the inline headers in the server island script - // Should have something like: const headers = new Headers({"X-Netlify-Deploy-ID":"test-deploy-123"}); - assert.ok( - html.includes('test-deploy-123'), - 'Expected server island HTML to include deploy ID in inline script', - ); - }); - - it('Manifest contains internalFetchHeaders', async () => { - // The manifest is embedded in the build output - // Check the manifest file which contains the serialized manifest - const manifestURL = new URL( - './fixtures/skew-protection/.netlify/build/chunks/', - import.meta.url, - ); - - // Find the manifest file (it has a hash in the name) - const { readdir } = await import('node:fs/promises'); - const files = await readdir(manifestURL); - let found = false; - for (const file of files) { - const contents = await readFile(new URL(file, manifestURL), 'utf-8'); - if (contents.includes('"internalFetchHeaders":{"X-Netlify-Deploy-ID":"test-deploy-123"}')) { - found = true; - break; - } - } - assert.ok( - found, - 'Manifest should include internalFetchHeaders field with the correct deploy ID value', - ); - }); - }, - { - timeout: 120000, - }, -); diff --git a/packages/integrations/netlify/test/functions/skew-protection.test.ts b/packages/integrations/netlify/test/functions/skew-protection.test.ts new file mode 100644 index 000000000000..b92f03cb958b --- /dev/null +++ b/packages/integrations/netlify/test/functions/skew-protection.test.ts @@ -0,0 +1,64 @@ +import * as assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from '../test-utils.ts'; + +describe('Skew Protection', { timeout: 120000 }, () => { + let fixture: Fixture; + + before(async () => { + // Set DEPLOY_ID env var for the test + process.env.DEPLOY_ID = 'test-deploy-123'; + + fixture = await loadFixture({ + root: new URL('./fixtures/skew-protection/', import.meta.url), + }); + await fixture.build(); + + // Clean up + delete process.env.DEPLOY_ID; + }); + + it('Server islands inline adapter headers', async () => { + // Render a page with server islands and check the HTML contains inline headers + const entryURL = new URL( + './fixtures/skew-protection/.netlify/v1/functions/ssr/ssr.mjs', + import.meta.url, + ); + const { default: handler } = await import(entryURL.href); + const resp = await handler(new Request('http://example.com/server-island'), {}); + const html = await resp.text(); + + // Check that the HTML contains the inline headers in the server island script + // Should have something like: const headers = new Headers({"X-Netlify-Deploy-ID":"test-deploy-123"}); + assert.ok( + html.includes('test-deploy-123'), + 'Expected server island HTML to include deploy ID in inline script', + ); + }); + + it('Manifest contains internalFetchHeaders', async () => { + // The manifest is embedded in the build output + // Check the manifest file which contains the serialized manifest + const manifestURL = new URL( + './fixtures/skew-protection/.netlify/build/chunks/', + import.meta.url, + ); + + // Find the manifest file (it has a hash in the name) + const { readdir } = await import('node:fs/promises'); + const files = await readdir(manifestURL); + let found = false; + for (const file of files) { + const contents = await readFile(new URL(file, manifestURL), 'utf-8'); + if (contents.includes('"internalFetchHeaders":{"X-Netlify-Deploy-ID":"test-deploy-123"}')) { + found = true; + break; + } + } + assert.ok( + found, + 'Manifest should include internalFetchHeaders field with the correct deploy ID value', + ); + }); +}); diff --git a/packages/integrations/netlify/test/hosted/hosted.test.js b/packages/integrations/netlify/test/hosted/hosted.test.js deleted file mode 100644 index 3bc9349f960b..000000000000 --- a/packages/integrations/netlify/test/hosted/hosted.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -const NETLIFY_TEST_URL = 'https://curious-boba-495d6d.netlify.app'; - -describe('Hosted Netlify Tests', () => { - it('Image endpoint works', async () => { - const image = await fetch( - `${NETLIFY_TEST_URL}/_image?href=%2F_astro%2Fpenguin.e9c64733.png&w=300&f=webp`, - ); - - assert.equal(image.status, 200); - }); - - it('passes context from edge middleware', async () => { - const response = await fetch(`${NETLIFY_TEST_URL}/country`); - const body = await response.text(); - assert.match(body, /has context/); - assert.match(body, /Deno/); - }); - - it('Server returns fresh content', async () => { - const responseOne = await fetch(`${NETLIFY_TEST_URL}/time`).then((res) => res.text()); - - const responseTwo = await fetch(`${NETLIFY_TEST_URL}/time`).then((res) => res.text()); - - assert.notEqual(responseOne.body, responseTwo.body); - }); -}); diff --git a/packages/integrations/netlify/test/hosted/hosted.test.ts b/packages/integrations/netlify/test/hosted/hosted.test.ts new file mode 100644 index 000000000000..3657c17cb7fa --- /dev/null +++ b/packages/integrations/netlify/test/hosted/hosted.test.ts @@ -0,0 +1,29 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const NETLIFY_TEST_URL = 'https://curious-boba-495d6d.netlify.app'; + +describe('Hosted Netlify Tests', () => { + it('Image endpoint works', async () => { + const image = await fetch( + `${NETLIFY_TEST_URL}/_image?href=%2F_astro%2Fpenguin.e9c64733.png&w=300&f=webp`, + ); + + assert.equal(image.status, 200); + }); + + it('passes context from edge middleware', async () => { + const response = await fetch(`${NETLIFY_TEST_URL}/country`); + const body = await response.text(); + assert.match(body, /has context/); + assert.match(body, /Deno/); + }); + + it('Server returns fresh content', async () => { + const responseOne: string = await fetch(`${NETLIFY_TEST_URL}/time`).then((res) => res.text()); + + const responseTwo: string = await fetch(`${NETLIFY_TEST_URL}/time`).then((res) => res.text()); + + assert.notEqual(responseOne, responseTwo); + }); +}); diff --git a/packages/integrations/netlify/test/static/fixtures/image-missing-dimension/astro.config.mjs b/packages/integrations/netlify/test/static/fixtures/image-missing-dimension/astro.config.mjs new file mode 100644 index 000000000000..6f09867119ee --- /dev/null +++ b/packages/integrations/netlify/test/static/fixtures/image-missing-dimension/astro.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from 'astro/config'; +import netlify from '@astrojs/netlify'; + +export default defineConfig({ + adapter: netlify(), + output: 'static', + image: { + service: { + entrypoint: 'astro/assets/services/sharp' + } + } +}); diff --git a/packages/integrations/netlify/test/static/fixtures/image-missing-dimension/package.json b/packages/integrations/netlify/test/static/fixtures/image-missing-dimension/package.json new file mode 100644 index 000000000000..98054c7d11bd --- /dev/null +++ b/packages/integrations/netlify/test/static/fixtures/image-missing-dimension/package.json @@ -0,0 +1,9 @@ +{ + "name": "image-missing-dimention", + "type": "module", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/netlify": "workspace:*" + } +} diff --git a/packages/integrations/netlify/test/static/fixtures/image-missing-dimension/src/pages/index.astro b/packages/integrations/netlify/test/static/fixtures/image-missing-dimension/src/pages/index.astro new file mode 100644 index 000000000000..9402dbf6da2d --- /dev/null +++ b/packages/integrations/netlify/test/static/fixtures/image-missing-dimension/src/pages/index.astro @@ -0,0 +1,8 @@ +--- +import { Image } from 'astro:assets'; +--- + +Astro diff --git a/packages/integrations/netlify/test/static/headers.test.js b/packages/integrations/netlify/test/static/headers.test.js deleted file mode 100644 index 5c1400098b10..000000000000 --- a/packages/integrations/netlify/test/static/headers.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from '../../../../astro/test/test-utils.js'; - -describe('SSG - headers', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ root: new URL('./fixtures/redirects/', import.meta.url) }); - await fixture.build(); - }); - - it('Generates headers for static assets', async () => { - const config = await fixture.readFile('../.netlify/v1/config.json'); - const headers = JSON.parse(config).headers; - assert.deepEqual(headers, [ - { - for: '/_astro/*', - values: { - 'Cache-Control': 'public, max-age=31536000, immutable', - }, - }, - ]); - }); -}); diff --git a/packages/integrations/netlify/test/static/headers.test.ts b/packages/integrations/netlify/test/static/headers.test.ts new file mode 100644 index 000000000000..c5316742d352 --- /dev/null +++ b/packages/integrations/netlify/test/static/headers.test.ts @@ -0,0 +1,25 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from '../test-utils.ts'; + +describe('SSG - headers', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ root: new URL('./fixtures/redirects/', import.meta.url) }); + await fixture.build(); + }); + + it('Generates headers for static assets', async () => { + const config = await fixture.readFile('../.netlify/v1/config.json'); + const headers = JSON.parse(config).headers; + assert.deepEqual(headers, [ + { + for: '/_astro/*', + values: { + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }, + ]); + }); +}); diff --git a/packages/integrations/netlify/test/static/image-missing-dimension.test.ts b/packages/integrations/netlify/test/static/image-missing-dimension.test.ts new file mode 100644 index 000000000000..3e29122228da --- /dev/null +++ b/packages/integrations/netlify/test/static/image-missing-dimension.test.ts @@ -0,0 +1,23 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { loadFixture } from '../test-utils.ts'; + +describe('Image validation when is not size specification in netlify.', () => { + it('throw on missing dimension in static build', async () => { + const fixture = await loadFixture({ + root: new URL('./fixtures/image-missing-dimension/', import.meta.url), + }); + + try { + await fixture.build(); + assert.fail(); + } catch (e) { + // check the error image about missing image dimension + assert.match( + (e as Error).name, + /MissingImageDimension/, + `Build failed but not with the expected "MissingImageDimension"`, + ); + } + }); +}); diff --git a/packages/integrations/netlify/test/static/redirects.test.js b/packages/integrations/netlify/test/static/redirects.test.js deleted file mode 100644 index cab95483143d..000000000000 --- a/packages/integrations/netlify/test/static/redirects.test.js +++ /dev/null @@ -1,34 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from '../../../../astro/test/test-utils.js'; - -describe('SSG - Redirects', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ root: new URL('./fixtures/redirects/', import.meta.url) }); - await fixture.build(); - }); - - it('Creates a redirects file', async () => { - const redirects = await fixture.readFile('./_redirects'); - const parts = redirects.split(/\s+/); - assert.deepEqual(parts, [ - '', - - '/two', - '/', - '302', - - '/other', - '/', - '301', - - '/blog/*', - '/team/articles/*/index.html', - '301', - - '', - ]); - }); -}); diff --git a/packages/integrations/netlify/test/static/redirects.test.ts b/packages/integrations/netlify/test/static/redirects.test.ts new file mode 100644 index 000000000000..ceafb5856720 --- /dev/null +++ b/packages/integrations/netlify/test/static/redirects.test.ts @@ -0,0 +1,43 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from '../test-utils.ts'; + +describe('SSG - Redirects', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ root: new URL('./fixtures/redirects/', import.meta.url) }); + await fixture.build(); + }); + + it('Creates a redirects file', async () => { + const redirects = await fixture.readFile('./_redirects'); + const parts = redirects.split(/\s+/); + // based on https://github.com/withastro/astro/issues/16030 for the default option `trailingSlash: 'ignore'` both variants should be generated + assert.deepEqual(parts, [ + '', + + '/two/', + '/', + '302', + + '/two', + '/', + '302', + + '/other/', + '/', + '301', + + '/other', + '/', + '301', + + '/blog/*', + '/team/articles/*/index.html', + '301', + + '', + ]); + }); +}); diff --git a/packages/integrations/netlify/test/static/static-headers.test.js b/packages/integrations/netlify/test/static/static-headers.test.js deleted file mode 100644 index a5b7014894b0..000000000000 --- a/packages/integrations/netlify/test/static/static-headers.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { existsSync, readdirSync } from 'node:fs'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from '../../../../astro/test/test-utils.js'; - -describe('Static headers', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ root: new URL('./fixtures/static-headers/', import.meta.url) }); - await fixture.build(); - }); - - it('SSR function is generated when server islands are used with output: static', async () => { - const ssrFunctionDir = new URL( - './fixtures/static-headers/.netlify/v1/functions/ssr/', - import.meta.url, - ); - assert.ok(existsSync(ssrFunctionDir), 'SSR function directory should exist'); - assert.ok(readdirSync(ssrFunctionDir).length > 0, 'SSR function directory should not be empty'); - }); - - it('CSP headers are added when CSP is enabled', async () => { - const config = await fixture.readFile('../.netlify/v1/config.json'); - const headers = JSON.parse(config).headers; - const index = headers.find((x) => x.for === '/'); - - assert.notEqual(index, undefined, 'the index must have CSP headers'); - assert.notEqual( - index.values['Content-Security-Policy'], - undefined, - 'the index must have CSP headers', - ); - assert.ok( - index.values['Content-Security-Policy'].includes('script-src'), - 'must contain the script-src directive because of the server island', - ); - }); -}); diff --git a/packages/integrations/netlify/test/static/static-headers.test.ts b/packages/integrations/netlify/test/static/static-headers.test.ts new file mode 100644 index 000000000000..297b0ed57dff --- /dev/null +++ b/packages/integrations/netlify/test/static/static-headers.test.ts @@ -0,0 +1,40 @@ +import * as assert from 'node:assert/strict'; +import { existsSync, readdirSync } from 'node:fs'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from '../test-utils.ts'; + +describe('Static headers', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ root: new URL('./fixtures/static-headers/', import.meta.url) }); + await fixture.build(); + }); + + it('SSR function is generated when server islands are used with output: static', async () => { + const ssrFunctionDir = new URL( + './fixtures/static-headers/.netlify/v1/functions/ssr/', + import.meta.url, + ); + assert.ok(existsSync(ssrFunctionDir), 'SSR function directory should exist'); + assert.ok(readdirSync(ssrFunctionDir).length > 0, 'SSR function directory should not be empty'); + }); + + it('CSP headers are added when CSP is enabled', async () => { + const config = await fixture.readFile('../.netlify/v1/config.json'); + const headers: Array<{ for: string; values: Record }> = + JSON.parse(config).headers; + const index = headers.find((x) => x.for === '/')!; + + assert.notEqual(index, undefined, 'the index must have CSP headers'); + assert.notEqual( + index.values['Content-Security-Policy'], + undefined, + 'the index must have CSP headers', + ); + assert.ok( + index.values['Content-Security-Policy']!.includes('script-src'), + 'must contain the script-src directive because of the server island', + ); + }); +}); diff --git a/packages/integrations/netlify/test/test-utils.ts b/packages/integrations/netlify/test/test-utils.ts new file mode 100644 index 000000000000..3f1f0bbdb101 --- /dev/null +++ b/packages/integrations/netlify/test/test-utils.ts @@ -0,0 +1,37 @@ +import { + type DevServer, + type AstroInlineConfig, + type Fixture, + loadFixture as baseLoadFixture, +} from '../../../astro/test/test-utils.js'; +import { + AstroIntegrationLogger, + type AstroLogMessage, +} from '../../../astro/dist/core/logger/core.js'; + +export type { AstroInlineConfig, DevServer, Fixture }; + +export function loadFixture(config: AstroInlineConfig) { + return baseLoadFixture(config); +} + +export class SpyIntegrationLogger extends AstroIntegrationLogger { + readonly messages: AstroLogMessage[]; + + constructor() { + const messages: AstroLogMessage[] = []; + super( + { + destination: { + write(chunk): boolean { + messages.push(chunk); + return true; + }, + }, + level: 'warn', + }, + 'test-spy', + ); + this.messages = messages; + } +} diff --git a/packages/integrations/netlify/tsconfig.test.json b/packages/integrations/netlify/tsconfig.test.json new file mode 100644 index 000000000000..462c7b7db770 --- /dev/null +++ b/packages/integrations/netlify/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/**/fixtures/**", "test/hosted/hosted-astro-project/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true, + "rootDir": "." + }, + "references": [{ "path": "../../astro/tsconfig.test.json" }] +} diff --git a/packages/integrations/node/CHANGELOG.md b/packages/integrations/node/CHANGELOG.md index b2392492d98d..9c3d168cc240 100644 --- a/packages/integrations/node/CHANGELOG.md +++ b/packages/integrations/node/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/node +## 10.0.5 + +### Patch Changes + +- [#16319](https://github.com/withastro/astro/pull/16319) [`940afd5`](https://github.com/withastro/astro/commit/940afd53040a14e924606b3218a8619c1e2674ee) Thanks [@matthewp](https://github.com/matthewp)! - Fixes static asset error responses incorrectly including immutable cache headers. Conditional request failures (e.g. `If-Match` mismatch) now return the correct status code without far-future cache directives. + ## 10.0.4 ### Patch Changes diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index bbe5e7dc59b3..e07cf38ff22a 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/node", "description": "Deploy your site to a Node.js server", - "version": "10.0.4", + "version": "10.0.5", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", @@ -30,7 +30,8 @@ "dev": "astro-scripts dev \"src/**/*.ts\"", "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "dependencies": { "@astrojs/internal-helpers": "workspace:*", @@ -43,6 +44,7 @@ "devDependencies": { "@fastify/middie": "^9.1.0", "@fastify/static": "^9.0.0", + "@types/express": "^5.0.6", "@types/node": "^22.10.6", "@types/send": "^1.2.1", "@types/server-destroy": "^1.0.4", diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts index 2050c6236e71..4a490e3afedc 100644 --- a/packages/integrations/node/src/serve-static.ts +++ b/packages/integrations/node/src/serve-static.ts @@ -130,26 +130,33 @@ export function createStaticHandler( stream.on('error', (err) => { if (forwardError) { - console.error(err.toString()); - res.writeHead(500); - res.end('Internal server error'); + // The `send` library emits errors with a `statusCode` property + // (e.g. 412 for precondition failures from If-Match / If-Unmodified-Since). + // Use the real status when available instead of always returning 500. + const status = 'statusCode' in err ? (err as any).statusCode : 500; + if (status >= 500) { + console.error(err.toString()); + } + res.writeHead(status); + res.end(status >= 500 ? 'Internal server error' : ''); return; } // File not found, forward to the SSR handler ssr(); }); - stream.on('headers', (_res: ServerResponse) => { - // assets in dist/_astro are hashed and should get the immutable header - if (normalizedPathname.startsWith(`/${app.manifest.assetsDir}/`)) { - // This is the "far future" cache header, used for static files whose name includes their digest hash. - // 1 year (31,536,000 seconds) is convention. - // Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable - _res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - } - }); stream.on('file', () => { forwardError = true; }); + // The `stream` event fires only when `send` is actually going to stream + // the file content (i.e. after all precondition checks like If-Match and + // If-Unmodified-Since have passed). Setting cache headers here instead of + // in the `headers` event ensures error responses (e.g. 412) are never + // sent with immutable cache headers, which would poison CDN caches. + stream.on('stream', () => { + if (normalizedPathname.startsWith(`/${app.manifest.assetsDir}/`)) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } + }); stream.pipe(res); } else { ssr(); diff --git a/packages/integrations/node/test/api-route.test.js b/packages/integrations/node/test/api-route.test.js deleted file mode 100644 index 05cdcd637bdf..000000000000 --- a/packages/integrations/node/test/api-route.test.js +++ /dev/null @@ -1,153 +0,0 @@ -import * as assert from 'node:assert/strict'; -import crypto from 'node:crypto'; -import { after, before, describe, it } from 'node:test'; -import nodejs from '../dist/index.js'; -import { createRequestAndResponse, loadFixture } from './test-utils.js'; - -describe('API routes', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('../../../astro/src/types/public/preview.js').PreviewServer} */ - let previewServer; - /** @type {URL} */ - let baseUri; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/api-route/', - output: 'server', - adapter: nodejs({ mode: 'middleware' }), - }); - await fixture.build(); - previewServer = await fixture.preview(); - baseUri = new URL(`http://${previewServer.host ?? 'localhost'}:${previewServer.port}/`); - }); - - after(() => previewServer.stop()); - - it('Can get the request body', async () => { - const { handler } = await import('./fixtures/api-route/dist/server/entry.mjs'); - const { req, res, done } = createRequestAndResponse({ - method: 'POST', - url: '/recipes', - }); - - req.once('async_iterator', () => { - req.send(JSON.stringify({ id: 2 })); - }); - - handler(req, res); - - const [buffer] = await done; - - const json = JSON.parse(buffer.toString('utf-8')); - - assert.equal(json.length, 1); - - assert.equal(json[0].name, 'Broccoli Soup'); - }); - - it('Can get binary data', async () => { - const { handler } = await import('./fixtures/api-route/dist/server/entry.mjs'); - - const { req, res, done } = createRequestAndResponse({ - method: 'POST', - url: '/binary', - }); - - req.once('async_iterator', () => { - req.send(Buffer.from(new Uint8Array([1, 2, 3, 4, 5]))); - }); - - handler(req, res); - - const [out] = await done; - const arr = Array.from(new Uint8Array(out.buffer)); - assert.deepEqual(arr, [5, 4, 3, 2, 1]); - }); - - it('Can post large binary data', async () => { - const { handler } = await import('./fixtures/api-route/dist/server/entry.mjs'); - - const { req, res, done } = createRequestAndResponse({ - method: 'POST', - url: '/hash', - }); - - handler(req, res); - - let expectedDigest = null; - req.once('async_iterator', () => { - // Send 256MB of garbage data in 256KB chunks. This should be fast (< 1sec). - let remainingBytes = 256 * 1024 * 1024; - const chunkSize = 256 * 1024; - - const hash = crypto.createHash('sha256'); - while (remainingBytes > 0) { - const size = Math.min(remainingBytes, chunkSize); - const chunk = Buffer.alloc(size, Math.floor(Math.random() * 256)); - hash.update(chunk); - req.emit('data', chunk); - remainingBytes -= size; - } - - req.emit('end'); - expectedDigest = hash.digest(); - }); - - const [out] = await done; - assert.deepEqual(new Uint8Array(out.buffer), new Uint8Array(expectedDigest)); - }); - - it('Can bail on streaming', async () => { - const { handler } = await import('./fixtures/api-route/dist/server/entry.mjs'); - const { req, res, done } = createRequestAndResponse({ - url: '/streaming', - }); - - const locals = { cancelledByTheServer: false }; - - handler(req, res, () => {}, locals); - req.send(); - - await new Promise((resolve) => setTimeout(resolve, 500)); - res.emit('close'); - - await done; - - assert.deepEqual(locals, { cancelledByTheServer: true }); - }); - - it('Can respond with SSR redirect', async () => { - const controller = new AbortController(); - setTimeout(() => controller.abort(), 1000); - const response = await fetch(new URL('/redirect', baseUri), { - redirect: 'manual', - signal: controller.signal, - }); - assert.equal(response.status, 302); - assert.equal(response.headers.get('location'), '/destination'); - }); - - it('Can respond with Astro.redirect', async () => { - const controller = new AbortController(); - setTimeout(() => controller.abort(), 1000); - const response = await fetch(new URL('/astro-redirect', baseUri), { - redirect: 'manual', - signal: controller.signal, - }); - assert.equal(response.status, 303); - assert.equal(response.headers.get('location'), '/destination'); - }); - - it('Can respond with Response.redirect', async () => { - const controller = new AbortController(); - setTimeout(() => controller.abort(), 1000); - const response = await fetch(new URL('/response-redirect', baseUri), { - redirect: 'manual', - signal: controller.signal, - }); - assert.equal(response.status, 307); - assert.equal(response.headers.get('location'), String(new URL('/destination', baseUri))); - }); -}); diff --git a/packages/integrations/node/test/api-route.test.ts b/packages/integrations/node/test/api-route.test.ts new file mode 100644 index 000000000000..26e26d0fe8bc --- /dev/null +++ b/packages/integrations/node/test/api-route.test.ts @@ -0,0 +1,151 @@ +import * as assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { after, before, describe, it } from 'node:test'; +import type { PreviewServer } from '../../../astro/src/types/public/preview.js'; +import nodejs from '../dist/index.js'; +import { createRequestAndResponse, type Fixture, loadFixture } from './test-utils.ts'; + +describe('API routes', () => { + let fixture: Fixture; + let previewServer: PreviewServer; + let baseUri: URL; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/api-route/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + }); + await fixture.build(); + previewServer = await fixture.preview(); + baseUri = new URL(`http://${previewServer.host ?? 'localhost'}:${previewServer.port}/`); + }); + + after(() => previewServer.stop()); + + it('Can get the request body', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, done } = createRequestAndResponse({ + method: 'POST', + url: '/recipes', + }); + + req.once('async_iterator', () => { + req.send(JSON.stringify({ id: 2 })); + }); + + handler(req, res); + + const [buffer] = await done; + + const json = JSON.parse(buffer.toString('utf-8')); + + assert.equal(json.length, 1); + + assert.equal(json[0].name, 'Broccoli Soup'); + }); + + it('Can get binary data', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + + const { req, res, done } = createRequestAndResponse({ + method: 'POST', + url: '/binary', + }); + + req.once('async_iterator', () => { + req.send(Buffer.from(new Uint8Array([1, 2, 3, 4, 5]))); + }); + + handler(req, res); + + const [out] = await done; + const arr = Array.from(new Uint8Array(out.buffer)); + assert.deepEqual(arr, [5, 4, 3, 2, 1]); + }); + + it('Can post large binary data', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + + const { req, res, done } = createRequestAndResponse({ + method: 'POST', + url: '/hash', + }); + + handler(req, res); + + let expectedDigest: Buffer | null = null; + req.once('async_iterator', () => { + // Send 256MB of garbage data in 256KB chunks. This should be fast (< 1sec). + let remainingBytes = 256 * 1024 * 1024; + const chunkSize = 256 * 1024; + + const hash = crypto.createHash('sha256'); + while (remainingBytes > 0) { + const size = Math.min(remainingBytes, chunkSize); + const chunk = Buffer.alloc(size, Math.floor(Math.random() * 256)); + hash.update(chunk); + req.emit('data', chunk); + remainingBytes -= size; + } + + req.emit('end'); + expectedDigest = hash.digest(); + }); + + const [out] = await done; + assert.deepEqual(new Uint8Array(out.buffer), new Uint8Array(expectedDigest!)); + }); + + it('Can bail on streaming', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, done } = createRequestAndResponse({ + url: '/streaming', + }); + + const locals = { cancelledByTheServer: false }; + + handler(req, res, () => {}, locals); + req.send(); + + await new Promise((resolve) => setTimeout(resolve, 500)); + res.emit('close'); + + await done; + + assert.deepEqual(locals, { cancelledByTheServer: true }); + }); + + it('Can respond with SSR redirect', async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 1000); + const response = await fetch(new URL('/redirect', baseUri), { + redirect: 'manual', + signal: controller.signal, + }); + assert.equal(response.status, 302); + assert.equal(response.headers.get('location'), '/destination'); + }); + + it('Can respond with Astro.redirect', async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 1000); + const response = await fetch(new URL('/astro-redirect', baseUri), { + redirect: 'manual', + signal: controller.signal, + }); + assert.equal(response.status, 303); + assert.equal(response.headers.get('location'), '/destination'); + }); + + it('Can respond with Response.redirect', async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 1000); + const response = await fetch(new URL('/response-redirect', baseUri), { + redirect: 'manual', + signal: controller.signal, + }); + assert.equal(response.status, 307); + assert.equal(response.headers.get('location'), String(new URL('/destination', baseUri))); + }); +}); diff --git a/packages/integrations/node/test/assets.test.js b/packages/integrations/node/test/assets.test.js deleted file mode 100644 index 758026ecca19..000000000000 --- a/packages/integrations/node/test/assets.test.js +++ /dev/null @@ -1,47 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import nodejs from '../dist/index.js'; -import { loadFixture } from './test-utils.js'; -import { fileURLToPath } from 'node:url'; - -describe('Assets', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let devPreview; - - before(async () => { - const root = new URL('./fixtures/image/', import.meta.url); - fixture = await loadFixture({ - root, - outDir: fileURLToPath(new URL('./dist/assets/', root)), - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - vite: { - build: { - assetsInlineLimit: 0, - }, - }, - }); - await fixture.build(); - devPreview = await fixture.preview(); - }); - - after(async () => { - await devPreview.stop(); - }); - - it('Assets within the _astro folder should be given immutable headers', async () => { - let response = await fixture.fetch('/text-file'); - let cacheControl = response.headers.get('cache-control'); - assert.equal(cacheControl, null); - const html = await response.text(); - const $ = cheerio.load(html); - - // Fetch the asset - const fileURL = $('a').attr('href'); - response = await fixture.fetch(fileURL); - cacheControl = response.headers.get('cache-control'); - assert.equal(cacheControl, 'public, max-age=31536000, immutable'); - }); -}); diff --git a/packages/integrations/node/test/assets.test.ts b/packages/integrations/node/test/assets.test.ts new file mode 100644 index 000000000000..e7682ef7b65e --- /dev/null +++ b/packages/integrations/node/test/assets.test.ts @@ -0,0 +1,69 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import type { PreviewServer } from '../../../astro/src/types/public/preview.js'; +import * as cheerio from 'cheerio'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('Assets', () => { + let fixture: Fixture; + let devPreview: PreviewServer; + + before(async () => { + const root = new URL('./fixtures/image/', import.meta.url); + fixture = await loadFixture({ + root, + outDir: fileURLToPath(new URL('./dist/assets/', root)), + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + vite: { + build: { + assetsInlineLimit: 0, + }, + }, + }); + await fixture.build(); + devPreview = await fixture.preview(); + }); + + after(async () => { + await devPreview.stop(); + }); + + it('Assets within the _astro folder should be given immutable headers', async () => { + let response = await fixture.fetch('/text-file'); + let cacheControl = response.headers.get('cache-control'); + assert.equal(cacheControl, null); + const html = await response.text(); + const $ = cheerio.load(html); + + // Fetch the asset + const fileURL = $('a').attr('href')!; + response = await fixture.fetch(fileURL); + cacheControl = response.headers.get('cache-control'); + assert.equal(cacheControl, 'public, max-age=31536000, immutable'); + }); + + it('Malformed If-Match header should return 412 without immutable cache headers', async () => { + // First, get a valid asset URL from the page + let response = await fixture.fetch('/text-file'); + const html = await response.text(); + const $ = cheerio.load(html); + const fileURL = $('a').attr('href')!; + + // Send a request with a malformed If-Match header that won't match the ETag + response = await fixture.fetch(fileURL, { + headers: { 'If-Match': 'xxx' }, + }); + + // Should return 412 Precondition Failed, not 500 + assert.equal(response.status, 412); + + // Must NOT include the immutable far-future cache header on error responses, + // as that would allow CDN cache poisoning. The `send` library may still set + // its own default `public, max-age=0` which is harmless (not cached). + const cacheControl = response.headers.get('cache-control'); + assert.notEqual(cacheControl, 'public, max-age=31536000, immutable'); + }); +}); diff --git a/packages/integrations/node/test/bad-urls.test.js b/packages/integrations/node/test/bad-urls.test.js deleted file mode 100644 index 2e3e3ad28c18..000000000000 --- a/packages/integrations/node/test/bad-urls.test.js +++ /dev/null @@ -1,63 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import nodejs from '../dist/index.js'; -import { loadFixture } from './test-utils.js'; - -describe('Bad URLs', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let devPreview; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/bad-urls/', - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - devPreview = await fixture.preview(); - }); - - after(async () => { - await devPreview.stop(); - }); - - it('Does not crash on bad urls', async () => { - const weirdURLs = [ - '/\\xfs.bxss.me%3Fastrojs.com/hello-world', - '/asdasdasd@ax_zX=.zxczas🐥%/úadasd000%/', - '%', - '%80', - '%c', - '%c0%80', - '%20foobar%', - ]; - - const statusCodes = [400, 404, 500]; - for (const weirdUrl of weirdURLs) { - const fetchResult = await fixture.fetch(weirdUrl); - assert.equal( - statusCodes.includes(fetchResult.status), - true, - `${weirdUrl} returned something else than 400, 404, or 500`, - ); - } - const stillWork = await fixture.fetch('/'); - const text = await stillWork.text(); - assert.equal(text, 'Hello!'); - }); - - it('Does not crash on URLs with hash fragments', async () => { - // Hash fragments are client-side only and stripped before reaching the server - // so /#foo should resolve to / and return 200 - see issue #14625 - const hashURLs = ['/#', '/#foo', '/#/path']; - for (const hashUrl of hashURLs) { - const fetchResult = await fixture.fetch(hashUrl); - assert.equal( - fetchResult.status, - 200, - `${hashUrl} should return 200 as hash fragments are stripped`, - ); - } - }); -}); diff --git a/packages/integrations/node/test/bad-urls.test.ts b/packages/integrations/node/test/bad-urls.test.ts new file mode 100644 index 000000000000..fc45bef3fa4a --- /dev/null +++ b/packages/integrations/node/test/bad-urls.test.ts @@ -0,0 +1,63 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import type { PreviewServer } from '../../../astro/src/types/public/preview.js'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('Bad URLs', () => { + let fixture: Fixture; + let devPreview: PreviewServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/bad-urls/', + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + devPreview = await fixture.preview(); + }); + + after(async () => { + await devPreview.stop(); + }); + + it('Does not crash on bad urls', async () => { + const weirdURLs = [ + '/\\xfs.bxss.me%3Fastrojs.com/hello-world', + '/asdasdasd@ax_zX=.zxczas🐥%/úadasd000%/', + '%', + '%80', + '%c', + '%c0%80', + '%20foobar%', + ]; + + const statusCodes = [400, 404, 500]; + for (const weirdUrl of weirdURLs) { + const fetchResult = await fixture.fetch(weirdUrl); + assert.equal( + statusCodes.includes(fetchResult.status), + true, + `${weirdUrl} returned something else than 400, 404, or 500`, + ); + } + const stillWork = await fixture.fetch('/'); + const text = await stillWork.text(); + assert.equal(text, 'Hello!'); + }); + + it('Does not crash on URLs with hash fragments', async () => { + // Hash fragments are client-side only and stripped before reaching the server + // so /#foo should resolve to / and return 200 - see issue #14625 + const hashURLs = ['/#', '/#foo', '/#/path']; + for (const hashUrl of hashURLs) { + const fetchResult = await fixture.fetch(hashUrl); + assert.equal( + fetchResult.status, + 200, + `${hashUrl} should return 200 as hash fragments are stripped`, + ); + } + }); +}); diff --git a/packages/integrations/node/test/encoded.test.js b/packages/integrations/node/test/encoded.test.js deleted file mode 100644 index 4fc97cf7fd09..000000000000 --- a/packages/integrations/node/test/encoded.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import nodejs from '../dist/index.js'; -import { createRequestAndResponse, loadFixture } from './test-utils.js'; - -describe('Encoded Pathname', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/encoded/', - output: 'server', - adapter: nodejs({ mode: 'middleware' }), - }); - await fixture.build(); - }); - - it('Can get an Astro file', async () => { - const { handler } = await import('./fixtures/encoded/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - url: '/什么', - }); - - handler(req, res); - req.send(); - - const html = await text(); - assert.equal(html.includes('什么

    '), true); - }); - - it('Can get a Markdown file', async () => { - const { handler } = await import('./fixtures/encoded/dist/server/entry.mjs'); - - const { req, res, text } = createRequestAndResponse({ - url: '/blog/什么', - }); - - handler(req, res); - req.send(); - - const html = await text(); - assert.equal(html.includes('什么

    '), true); - }); -}); diff --git a/packages/integrations/node/test/encoded.test.ts b/packages/integrations/node/test/encoded.test.ts new file mode 100644 index 000000000000..657119e6d0b2 --- /dev/null +++ b/packages/integrations/node/test/encoded.test.ts @@ -0,0 +1,44 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import nodejs from '../dist/index.js'; +import { createRequestAndResponse, type Fixture, loadFixture } from './test-utils.ts'; + +describe('Encoded Pathname', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/encoded/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + }); + await fixture.build(); + }); + + it('Can get an Astro file', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + url: '/什么', + }); + + handler(req, res); + req.send(); + + const html = await text(); + assert.equal(html.includes('什么'), true); + }); + + it('Can get a Markdown file', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + + const { req, res, text } = createRequestAndResponse({ + url: '/blog/什么', + }); + + handler(req, res); + req.send(); + + const html = await text(); + assert.equal(html.includes('什么'), true); + }); +}); diff --git a/packages/integrations/node/test/errors.test.js b/packages/integrations/node/test/errors.test.js deleted file mode 100644 index 9090b162ee4b..000000000000 --- a/packages/integrations/node/test/errors.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import nodejs from '../dist/index.js'; -import { loadFixture } from './test-utils.js'; - -describe('Errors', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/errors/', - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - }); - let devPreview; - - // The two tests that need the server to run are skipped - // before(async () => { - // devPreview = await fixture.preview(); - // }); - after(async () => { - await devPreview?.stop(); - }); - - it( - 'rejected promise in template', - { skip: true, todo: 'Review the response from the in-stream' }, - async () => { - const res = await fixture.fetch('/in-stream'); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal($('p').text().trim(), 'Internal server error'); - }, - ); - - it( - 'generator that throws called in template', - { skip: true, todo: 'Review the response from the generator' }, - async () => { - const result = ['

    Astro

    1', 'Internal server error']; - - /** @type {Response} */ - const res = await fixture.fetch('/generator'); - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - const chunk1 = await reader.read(); - const chunk2 = await reader.read(); - const chunk3 = await reader.read(); - assert.equal(chunk1.done, false); - if (chunk2.done) { - assert.equal(decoder.decode(chunk1.value), result.join('')); - } else if (chunk3.done) { - assert.equal(decoder.decode(chunk1.value), result[0]); - assert.equal(decoder.decode(chunk2.value), result[1]); - } else { - throw new Error('The response should take at most 2 chunks.'); - } - }, - ); -}); diff --git a/packages/integrations/node/test/errors.test.ts b/packages/integrations/node/test/errors.test.ts new file mode 100644 index 000000000000..c9b7fb92d8ea --- /dev/null +++ b/packages/integrations/node/test/errors.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('Errors', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/errors/', + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + }); + + it('rejected promise in template', { + skip: true, + todo: 'Review the response from the in-stream', + }, async () => { + const res = await fixture.fetch('/in-stream'); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal($('p').text().trim(), 'Internal server error'); + }); + + it('generator that throws called in template', { + skip: true, + todo: 'Review the response from the generator', + }, async () => { + const result = ['

    Astro

    1', 'Internal server error']; + + const res: Response = await fixture.fetch('/generator'); + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + const chunk1 = await reader.read(); + const chunk2 = await reader.read(); + const chunk3 = await reader.read(); + assert.equal(chunk1.done, false); + if (chunk2.done) { + assert.equal(decoder.decode(chunk1.value), result.join('')); + } else if (chunk3.done) { + assert.equal(decoder.decode(chunk1.value), result[0]); + assert.equal(decoder.decode(chunk2.value), result[1]); + } else { + throw new Error('The response should take at most 2 chunks.'); + } + }); +}); diff --git a/packages/integrations/node/test/headers.test.js b/packages/integrations/node/test/headers.test.js deleted file mode 100644 index 15f52a434d25..000000000000 --- a/packages/integrations/node/test/headers.test.js +++ /dev/null @@ -1,195 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import nodejs from '../dist/index.js'; -import { createRequestAndResponse, loadFixture } from './test-utils.js'; - -describe('Node Adapter Headers', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - describe('streaming', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/headers/', - output: 'server', - adapter: nodejs({ mode: 'middleware' }), - }); - await fixture.build(); - }); - - it('Endpoint Simple Headers', async () => { - await runTest('/endpoints/simple', { - 'content-type': 'text/plain;charset=utf-8', - 'x-hello': 'world', - }); - }); - - it('Endpoint Astro Single Cookie Header', async () => { - await runTest('/endpoints/astro-cookies-single', { - 'content-type': 'text/plain;charset=utf-8', - 'set-cookie': 'from1=astro1', - }); - }); - - it('Endpoint Astro Multi Cookie Header', async () => { - await runTest('/endpoints/astro-cookies-multi', { - 'content-type': 'text/plain;charset=utf-8', - 'set-cookie': ['from1=astro1', 'from2=astro2'], - }); - }); - - it('Endpoint Response Single Cookie Header', async () => { - await runTest('/endpoints/response-cookies-single', { - 'content-type': 'text/plain;charset=utf-8', - 'set-cookie': 'hello1=world1', - }); - }); - - it('Endpoint Response Multi Cookie Header', async () => { - await runTest('/endpoints/response-cookies-multi', { - 'content-type': 'text/plain;charset=utf-8', - 'set-cookie': ['hello1=world1', 'hello2=world2'], - }); - }); - - it('Endpoint Complex Headers Kitchen Sink', async () => { - await runTest('/endpoints/kitchen-sink', { - 'content-type': 'text/plain;charset=utf-8', - 'x-single': 'single', - 'x-triple': 'one, two, three', - 'set-cookie': ['hello1=world1', 'hello2=world2'], - }); - }); - - it('Endpoint Astro and Response Single Cookie Header', async () => { - await runTest('/endpoints/astro-response-cookie-single', { - 'content-type': 'text/plain;charset=utf-8', - 'set-cookie': ['from1=response1', 'from1=astro1'], - }); - }); - - it('Endpoint Astro and Response Multi Cookie Header', async () => { - await runTest('/endpoints/astro-response-cookie-multi', { - 'content-type': 'text/plain;charset=utf-8', - 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'], - }); - }); - - it('Endpoint Response Empty Headers Object', async () => { - await runTest('/endpoints/response-empty-headers-object', { - 'content-type': 'text/plain;charset=UTF-8', - }); - }); - - it('Endpoint Response undefined Headers Object', async () => { - await runTest('/endpoints/response-undefined-headers-object', { - 'content-type': 'text/plain;charset=UTF-8', - }); - }); - - it('Component Astro Single Cookie Header', async () => { - await runTest('/astro/component-astro-cookies-single', { - 'content-type': 'text/html', - 'set-cookie': 'from1=astro1', - }); - }); - - it('Component Astro Multi Cookie Header', async () => { - await runTest('/astro/component-astro-cookies-multi', { - 'content-type': 'text/html', - 'set-cookie': ['from1=astro1', 'from2=astro2'], - }); - }); - - it('Component Response Single Cookie Header', async () => { - await runTest('/astro/component-response-cookies-single', { - 'content-type': 'text/html', - 'set-cookie': 'from1=value1', - }); - }); - - it('Component Response Multi Cookie Header', async () => { - await runTest('/astro/component-response-cookies-multi', { - 'content-type': 'text/html', - 'set-cookie': ['from1=value1', 'from2=value2'], - }); - }); - - it('Component Astro and Response Single Cookie Header', async () => { - await runTest('/astro/component-astro-response-cookie-single', { - 'content-type': 'text/html', - 'set-cookie': ['from1=response1', 'from1=astro1'], - }); - }); - - it('Component Astro and Response Multi Cookie Header', async () => { - await runTest('/astro/component-astro-response-cookie-multi', { - 'content-type': 'text/html', - 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'], - }); - }); - - // TODO: needs e2e tests to check real headers - it('sends several chunks', async () => { - const { handler } = await import('./fixtures/headers/dist/server/entry.mjs'); - - const { req, res, done } = createRequestAndResponse({ - method: 'GET', - url: '/astro/component-simple', - }); - - handler(req, res); - - req.send(); - - const chunks = await done; - assert.equal(chunks.length, 3); - }); - }); - - describe('without streaming', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/headers/', - output: 'server', - adapter: nodejs({ mode: 'middleware', experimentalDisableStreaming: true }), - }); - await fixture.build(); - }); - - // TODO: needs e2e tests to check real headers - it('sends a single chunk', async () => { - const { handler } = await import('./fixtures/headers/dist/server/entry.mjs?cachebust=0'); - - const { req, res, done } = createRequestAndResponse({ - method: 'GET', - url: '/astro/component-simple', - }); - - handler(req, res); - - req.send(); - - const chunks = await done; - assert.equal(chunks.length, 1); - }); - }); -}); - -async function runTest(url, expectedHeaders) { - const { handler } = await import('./fixtures/headers/dist/server/entry.mjs'); - - const { req, res, done } = createRequestAndResponse({ - method: 'GET', - url, - }); - - handler(req, res); - - req.send(); - - await done; - const headers = res.getHeaders(); - - assert.deepEqual(headers, expectedHeaders); -} diff --git a/packages/integrations/node/test/headers.test.ts b/packages/integrations/node/test/headers.test.ts new file mode 100644 index 000000000000..e9a5faa9c353 --- /dev/null +++ b/packages/integrations/node/test/headers.test.ts @@ -0,0 +1,194 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import nodejs from '../dist/index.js'; +import { createRequestAndResponse, type Fixture, loadFixture } from './test-utils.ts'; + +let fixture: Fixture; + +describe('Node Adapter Headers', () => { + describe('streaming', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/headers/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + }); + await fixture.build(); + }); + + it('Endpoint Simple Headers', async () => { + await runTest('/endpoints/simple', { + 'content-type': 'text/plain;charset=utf-8', + 'x-hello': 'world', + }); + }); + + it('Endpoint Astro Single Cookie Header', async () => { + await runTest('/endpoints/astro-cookies-single', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': 'from1=astro1', + }); + }); + + it('Endpoint Astro Multi Cookie Header', async () => { + await runTest('/endpoints/astro-cookies-multi', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': ['from1=astro1', 'from2=astro2'], + }); + }); + + it('Endpoint Response Single Cookie Header', async () => { + await runTest('/endpoints/response-cookies-single', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': 'hello1=world1', + }); + }); + + it('Endpoint Response Multi Cookie Header', async () => { + await runTest('/endpoints/response-cookies-multi', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': ['hello1=world1', 'hello2=world2'], + }); + }); + + it('Endpoint Complex Headers Kitchen Sink', async () => { + await runTest('/endpoints/kitchen-sink', { + 'content-type': 'text/plain;charset=utf-8', + 'x-single': 'single', + 'x-triple': 'one, two, three', + 'set-cookie': ['hello1=world1', 'hello2=world2'], + }); + }); + + it('Endpoint Astro and Response Single Cookie Header', async () => { + await runTest('/endpoints/astro-response-cookie-single', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': ['from1=response1', 'from1=astro1'], + }); + }); + + it('Endpoint Astro and Response Multi Cookie Header', async () => { + await runTest('/endpoints/astro-response-cookie-multi', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'], + }); + }); + + it('Endpoint Response Empty Headers Object', async () => { + await runTest('/endpoints/response-empty-headers-object', { + 'content-type': 'text/plain;charset=UTF-8', + }); + }); + + it('Endpoint Response undefined Headers Object', async () => { + await runTest('/endpoints/response-undefined-headers-object', { + 'content-type': 'text/plain;charset=UTF-8', + }); + }); + + it('Component Astro Single Cookie Header', async () => { + await runTest('/astro/component-astro-cookies-single', { + 'content-type': 'text/html', + 'set-cookie': 'from1=astro1', + }); + }); + + it('Component Astro Multi Cookie Header', async () => { + await runTest('/astro/component-astro-cookies-multi', { + 'content-type': 'text/html', + 'set-cookie': ['from1=astro1', 'from2=astro2'], + }); + }); + + it('Component Response Single Cookie Header', async () => { + await runTest('/astro/component-response-cookies-single', { + 'content-type': 'text/html', + 'set-cookie': 'from1=value1', + }); + }); + + it('Component Response Multi Cookie Header', async () => { + await runTest('/astro/component-response-cookies-multi', { + 'content-type': 'text/html', + 'set-cookie': ['from1=value1', 'from2=value2'], + }); + }); + + it('Component Astro and Response Single Cookie Header', async () => { + await runTest('/astro/component-astro-response-cookie-single', { + 'content-type': 'text/html', + 'set-cookie': ['from1=response1', 'from1=astro1'], + }); + }); + + it('Component Astro and Response Multi Cookie Header', async () => { + await runTest('/astro/component-astro-response-cookie-multi', { + 'content-type': 'text/html', + 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'], + }); + }); + + // TODO: needs e2e tests to check real headers + it('sends several chunks', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + + const { req, res, done } = createRequestAndResponse({ + method: 'GET', + url: '/astro/component-simple', + }); + + handler(req, res); + + req.send(); + + const chunks = await done; + assert.equal(chunks.length, 3); + }); + }); + + describe('without streaming', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/headers/', + output: 'server', + adapter: nodejs({ mode: 'middleware', experimentalDisableStreaming: true }), + }); + await fixture.build(); + }); + + // TODO: needs e2e tests to check real headers + it('sends a single chunk', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + + const { req, res, done } = createRequestAndResponse({ + method: 'GET', + url: '/astro/component-simple', + }); + + handler(req, res); + + req.send(); + + const chunks = await done; + assert.equal(chunks.length, 1); + }); + }); +}); + +async function runTest(url: string, expectedHeaders: Record) { + const handler = await fixture.loadNodeAdapterHandler(); + + const { req, res, done } = createRequestAndResponse({ + method: 'GET', + url, + }); + + handler(req, res); + + req.send(); + + await done; + const headers = res.getHeaders(); + + assert.deepEqual(headers, expectedHeaders); +} diff --git a/packages/integrations/node/test/image.test.js b/packages/integrations/node/test/image.test.js deleted file mode 100644 index 0426a7605c0f..000000000000 --- a/packages/integrations/node/test/image.test.js +++ /dev/null @@ -1,122 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { cp, rm } from 'node:fs/promises'; -import { after, before, describe, it } from 'node:test'; -import { inferRemoteSize } from 'astro/assets/utils/inferRemoteSize.js'; -import * as cheerio from 'cheerio'; -import nodejs from '../dist/index.js'; -import { loadFixture } from './test-utils.js'; -import { fileURLToPath } from 'node:url'; - -describe('Image endpoint', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let devPreview; - - before(async () => { - const root = new URL('./fixtures/image/', import.meta.url); - fixture = await loadFixture({ - root, - outDir: fileURLToPath(new URL('./dist/image/', root)), - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - image: { - domains: ['images.unsplash.com'], - }, - }); - await fixture.build(); - devPreview = await fixture.preview(); - }); - - after(async () => { - await devPreview.stop(); - }); - - it('it returns local images', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - - const img = $('img[alt=Penguins]').attr('src'); - const host = fixture.config.server.host || 'localhost'; - const port = fixture.config.server.port; - const size = await inferRemoteSize(`http://${host}:${port}${img}`); - assert.equal(size.format, 'webp'); - assert.equal(size.width, 50); - assert.equal(size.height, 33); - }); - - it('it returns remote images', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - const img = $('img[alt=Cornwall]').attr('src'); - const host = fixture.config.server.host || 'localhost'; - const port = fixture.config.server.port; - const size = await inferRemoteSize(`http://${host}:${port}${img}`); - assert.equal(size.format, 'webp'); - assert.equal(size.width, 400); - assert.equal(size.height, 300); - }); - - it('refuses images from unknown domains', async () => { - const res = await fixture.fetch( - '/_image?href=https://example.com/image.jpg&w=100&h=100&f=webp&q=75', - ); - assert.equal(res.status, 403); - }); - - it('refuses common URL bypasses', async () => { - for (const href of [ - 'HTTP://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - 'HttpS://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - '//raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - '//raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg?param=https://example.com', - '/%2fraw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - '/%5craw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - '/\\raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - '///raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - 'http:\\\\raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - '\\\\raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - '\\raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - ' https://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - '\thttps://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - '\nhttps://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - '\rhttps://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', - ]) { - const res = await fixture.fetch(`/_image?href=${encodeURIComponent(href)}&f=svg`); - assert.equal(res.status, 403, `Failed on href: ${href}`); - } - }); - - describe('the dist folder is moved', () => { - const outputDir = new URL('./fixtures/image/output/', import.meta.url); - - before(async () => { - await devPreview.stop(); - await cp(fixture.config.outDir, new URL('./dist', outputDir), { recursive: true }); - await rm(fixture.config.outDir, { recursive: true }); - devPreview = await fixture.preview({ outDir: './output/dist' }); - }); - - after(async () => { - await rm(outputDir, { recursive: true }); - }); - - it('it returns local images', async () => { - const res = await fixture.fetch('/'); - assert.equal(res.status, 200); - const html = await res.text(); - const $ = cheerio.load(html); - - const img = $('img[alt=Penguins]').attr('src'); - const host = fixture.config.server.host || 'localhost'; - const port = fixture.config.server.port; - const size = await inferRemoteSize(`http://${host}:${port}${img}`); - assert.equal(size.format, 'webp'); - assert.equal(size.width, 50); - assert.equal(size.height, 33); - }); - }); -}); diff --git a/packages/integrations/node/test/image.test.ts b/packages/integrations/node/test/image.test.ts new file mode 100644 index 000000000000..f6d4389423f5 --- /dev/null +++ b/packages/integrations/node/test/image.test.ts @@ -0,0 +1,122 @@ +import * as assert from 'node:assert/strict'; +import { cp, rm } from 'node:fs/promises'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import type { PreviewServer } from '../../../astro/src/types/public/preview.js'; +import { inferRemoteSize } from 'astro/assets/utils/inferRemoteSize.js'; +import * as cheerio from 'cheerio'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('Image endpoint', () => { + let fixture: Fixture; + let devPreview: PreviewServer; + + before(async () => { + const root = new URL('./fixtures/image/', import.meta.url); + fixture = await loadFixture({ + root, + outDir: fileURLToPath(new URL('./dist/image/', root)), + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + image: { + domains: ['images.unsplash.com'], + }, + }); + await fixture.build(); + devPreview = await fixture.preview(); + }); + + after(async () => { + await devPreview.stop(); + }); + + it('it returns local images', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + + const img = $('img[alt=Penguins]').attr('src'); + const host = fixture.config.server.host || 'localhost'; + const port = fixture.config.server.port; + const size = await inferRemoteSize(`http://${host}:${port}${img}`); + assert.equal(size.format, 'webp'); + assert.equal(size.width, 50); + assert.equal(size.height, 33); + }); + + it('it returns remote images', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + const img = $('img[alt=Cornwall]').attr('src'); + const host = fixture.config.server.host || 'localhost'; + const port = fixture.config.server.port; + const size = await inferRemoteSize(`http://${host}:${port}${img}`); + assert.equal(size.format, 'webp'); + assert.equal(size.width, 400); + assert.equal(size.height, 300); + }); + + it('refuses images from unknown domains', async () => { + const res = await fixture.fetch( + '/_image?href=https://example.com/image.jpg&w=100&h=100&f=webp&q=75', + ); + assert.equal(res.status, 403); + }); + + it('refuses common URL bypasses', async () => { + for (const href of [ + 'HTTP://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + 'HttpS://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + '//raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + '//raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg?param=https://example.com', + '/%2fraw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + '/%5craw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + '/\\raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + '///raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + 'http:\\\\raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + '\\\\raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + '\\raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + ' https://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + '\thttps://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + '\nhttps://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + '\rhttps://raw.githubusercontent.com/projectdiscovery/nuclei-templates/refs/heads/main/helpers/payloads/retool-xss.svg', + ]) { + const res = await fixture.fetch(`/_image?href=${encodeURIComponent(href)}&f=svg`); + assert.equal(res.status, 403, `Failed on href: ${href}`); + } + }); + + describe('the dist folder is moved', () => { + const outputDir = new URL('./fixtures/image/output/', import.meta.url); + + before(async () => { + await devPreview.stop(); + await cp(fixture.config.outDir, new URL('./dist', outputDir), { recursive: true }); + await rm(fixture.config.outDir, { recursive: true }); + devPreview = await fixture.preview({ outDir: './output/dist' }); + }); + + after(async () => { + await rm(outputDir, { recursive: true }); + }); + + it('it returns local images', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + + const img = $('img[alt=Penguins]').attr('src'); + const host = fixture.config.server.host || 'localhost'; + const port = fixture.config.server.port; + const size = await inferRemoteSize(`http://${host}:${port}${img}`); + assert.equal(size.format, 'webp'); + assert.equal(size.width, 50); + assert.equal(size.height, 33); + }); + }); +}); diff --git a/packages/integrations/node/test/locals.test.js b/packages/integrations/node/test/locals.test.js deleted file mode 100644 index b8e3ed40fc4b..000000000000 --- a/packages/integrations/node/test/locals.test.js +++ /dev/null @@ -1,81 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import nodejs from '../dist/index.js'; -import { createRequestAndResponse, loadFixture } from './test-utils.js'; - -describe('API routes', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/locals/', - output: 'server', - adapter: nodejs({ mode: 'middleware' }), - }); - await fixture.build(); - }); - - it('Can use locals added by node middleware', async () => { - const { handler } = await import('./fixtures/locals/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - url: '/from-node-middleware', - }); - - const locals = { foo: 'bar' }; - - handler(req, res, () => {}, locals); - req.send(); - - const html = await text(); - - assert.equal(html.includes('

    bar

    '), true); - }); - - it('Throws an error when provided non-objects as locals', async () => { - const { handler } = await import('./fixtures/locals/dist/server/entry.mjs'); - const { req, res, done } = createRequestAndResponse({ - url: '/from-node-middleware', - }); - - handler(req, res, undefined, 'locals'); - req.send(); - - await done; - assert.equal(res.statusCode, 500); - }); - - it('Can use locals added by astro middleware', async () => { - const { handler } = await import('./fixtures/locals/dist/server/entry.mjs'); - - const { req, res, text } = createRequestAndResponse({ - url: '/from-astro-middleware', - }); - - handler(req, res, () => {}); - req.send(); - - const html = await text(); - - assert.equal(html.includes('

    baz

    '), true); - }); - - it('Can access locals in API', async () => { - const { handler } = await import('./fixtures/locals/dist/server/entry.mjs'); - const { req, res, done } = createRequestAndResponse({ - method: 'POST', - url: '/api', - }); - - const locals = { foo: 'bar' }; - - handler(req, res, () => {}, locals); - req.send(); - - const [buffer] = await done; - - const json = JSON.parse(buffer.toString('utf-8')); - - assert.equal(json.foo, 'bar'); - }); -}); diff --git a/packages/integrations/node/test/locals.test.ts b/packages/integrations/node/test/locals.test.ts new file mode 100644 index 000000000000..d588d4ac3e69 --- /dev/null +++ b/packages/integrations/node/test/locals.test.ts @@ -0,0 +1,81 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import nodejs from '../dist/index.js'; +import { createRequestAndResponse, type Fixture, loadFixture } from './test-utils.ts'; + +describe('API routes', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/locals/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + }); + await fixture.build(); + }); + + it('Can use locals added by node middleware', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + url: '/from-node-middleware', + }); + + const locals = { foo: 'bar' }; + + handler(req, res, () => {}, locals); + req.send(); + + const html = await text(); + + assert.equal(html.includes('

    bar

    '), true); + }); + + it('Throws an error when provided non-objects as locals', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, done } = createRequestAndResponse({ + url: '/from-node-middleware', + }); + + // @ts-expect-error - intentionally passing a non-object to test error handling + handler(req, res, undefined, 'locals'); + req.send(); + + await done; + assert.equal(res.statusCode, 500); + }); + + it('Can use locals added by astro middleware', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + + const { req, res, text } = createRequestAndResponse({ + url: '/from-astro-middleware', + }); + + handler(req, res, () => {}); + req.send(); + + const html = await text(); + + assert.equal(html.includes('

    baz

    '), true); + }); + + it('Can access locals in API', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, done } = createRequestAndResponse({ + method: 'POST', + url: '/api', + }); + + const locals = { foo: 'bar' }; + + handler(req, res, () => {}, locals); + req.send(); + + const [buffer] = await done; + + const json = JSON.parse(buffer.toString('utf-8')); + + assert.equal(json.foo, 'bar'); + }); +}); diff --git a/packages/integrations/node/test/node-middleware-listener-cleanup.test.js b/packages/integrations/node/test/node-middleware-listener-cleanup.test.js deleted file mode 100644 index a0fffe44b953..000000000000 --- a/packages/integrations/node/test/node-middleware-listener-cleanup.test.js +++ /dev/null @@ -1,78 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import fastifyMiddie from '@fastify/middie'; -import fastifyStatic from '@fastify/static'; -import Fastify from 'fastify'; -import nodejs from '../dist/index.js'; -import { loadFixture } from './test-utils.js'; - -describe('Node middleware socket listener cleanup', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - before(async () => { - process.env.PRERENDER = true; - fixture = await loadFixture({ - root: './fixtures/node-middleware/', - output: 'static', - adapter: nodejs({ mode: 'middleware' }), - }); - await fixture.build(); - const { handler } = await fixture.loadAdapterEntryModule(); - const app = Fastify({ logger: false }); - await app - .register(fastifyStatic, { - root: fileURLToPath(new URL('./fixtures/node-middleware/dist/client', import.meta.url)), - }) - .register(fastifyMiddie); - app.use(handler); - - await app.listen({ port: 8890 }); - server = app; - }); - - after(async () => { - server.close(); - await fixture.clean(); - delete process.env.PRERENDER; - }); - - it('should not leak socket listeners when serving static files', async () => { - const agent = new (await import('node:http')).Agent({ - keepAlive: true, - }); - - let listenerWarningEmitted = false; - const warningListener = (warning) => { - if (warning.name === 'MaxListenersExceededWarning') { - listenerWarningEmitted = true; - } - }; - process.on('warning', warningListener); - - try { - // Make multiple back-to-back requests to a static page - for (let i = 0; i < 30; i++) { - const response = await fetch('http://localhost:8890', { - agent, - headers: { - Connection: 'keep-alive', - }, - }); - - await response.text(); - } - } finally { - process.off('warning', warningListener); - agent.destroy(); - } - - assert.equal( - listenerWarningEmitted, - false, - 'MaxListenersExceededWarning should not be emitted', - ); - }); -}); diff --git a/packages/integrations/node/test/node-middleware-listener-cleanup.test.ts b/packages/integrations/node/test/node-middleware-listener-cleanup.test.ts new file mode 100644 index 000000000000..5684b426a7cd --- /dev/null +++ b/packages/integrations/node/test/node-middleware-listener-cleanup.test.ts @@ -0,0 +1,76 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import fastifyMiddie from '@fastify/middie'; +import fastifyStatic from '@fastify/static'; +import Fastify, { type FastifyInstance } from 'fastify'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('Node middleware socket listener cleanup', () => { + let fixture: Fixture; + let server: FastifyInstance; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/node-middleware/', + output: 'static', + adapter: nodejs({ mode: 'middleware' }), + }); + await fixture.build(); + const { handler } = await fixture.loadAdapterEntryModule(); + const app = Fastify({ logger: false }); + await app + .register(fastifyStatic, { + root: fileURLToPath(new URL('./fixtures/node-middleware/dist/client', import.meta.url)), + }) + .register(fastifyMiddie); + app.use(handler); + + await app.listen({ port: 8890 }); + server = app; + }); + + after(async () => { + server.close(); + await fixture.clean(); + }); + + it('should not leak socket listeners when serving static files', async () => { + const agent = new (await import('node:http')).Agent({ + keepAlive: true, + }); + + let listenerWarningEmitted = false; + const warningListener = (warning: Error) => { + if (warning.name === 'MaxListenersExceededWarning') { + listenerWarningEmitted = true; + } + }; + process.on('warning', warningListener); + + try { + // Make multiple back-to-back requests to a static page + for (let i = 0; i < 30; i++) { + const response = await fetch('http://localhost:8890', { + // @ts-expect-error: it seems that Node.js `fetch` doesn't accept `agent` here. Should we use dispatcher instead? https://stackoverflow.com/a/76069981 + agent, + headers: { + Connection: 'keep-alive', + }, + }); + + await response.text(); + } + } finally { + process.off('warning', warningListener); + agent.destroy(); + } + + assert.equal( + listenerWarningEmitted, + false, + 'MaxListenersExceededWarning should not be emitted', + ); + }); +}); diff --git a/packages/integrations/node/test/node-middleware.test.js b/packages/integrations/node/test/node-middleware.test.js deleted file mode 100644 index e1603e34b93e..000000000000 --- a/packages/integrations/node/test/node-middleware.test.js +++ /dev/null @@ -1,285 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import fastifyMiddie from '@fastify/middie'; -import fastifyStatic from '@fastify/static'; -import * as cheerio from 'cheerio'; -import express from 'express'; -import Fastify from 'fastify'; -import nodejs from '../dist/index.js'; -import { loadFixture, waitServerListen } from './test-utils.js'; - -/** - * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture - */ - -describe('behavior from middleware, standalone', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - before(async () => { - process.env.PRERENDER = false; - fixture = await loadFixture({ - root: './fixtures/node-middleware/', - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - describe('404', async () => { - it('when mode is standalone', async () => { - const res = await fetch(`http://${server.host}:${server.port}/error-page`); - - assert.equal(res.status, 404); - - const html = await res.text(); - const $ = cheerio.load(html); - - const body = $('body'); - assert.equal(body.text().includes('Page does not exist'), true); - }); - }); -}); - -describe('behavior from middleware, middleware with express', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - before(async () => { - process.env.PRERENDER = false; - fixture = await loadFixture({ - root: './fixtures/node-middleware/', - output: 'server', - adapter: nodejs({ mode: 'middleware' }), - }); - await fixture.build(); - const { handler } = await fixture.loadAdapterEntryModule(); - const app = express(); - app.use(handler); - server = app.listen(8889); - }); - - after(async () => { - server.close(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('should render the endpoint', async () => { - const res = await fetch('http://localhost:8889/ssr'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const $ = cheerio.load(html); - - const body = $('body'); - assert.equal(body.text().includes("Here's a random number"), true); - }); - - it('should render the index.html page [static]', async () => { - const res = await fetch('http://localhost:8889/'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const $ = cheerio.load(html); - - const body = $('body'); - assert.equal(body.text().includes('1'), true); - }); - - it('should render the index.html page [static] when the URL has the hash', async () => { - const res = await fetch('http://localhost:8889/#'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const $ = cheerio.load(html); - - const body = $('body'); - assert.equal(body.text().includes('1'), true); - }); - - it('should render the dynamic pages', async () => { - let res = await fetch('http://localhost:8889/dyn/foo'); - - assert.equal(res.status, 200); - - let html = await res.text(); - let $ = cheerio.load(html); - - let body = $('body'); - assert.equal(body.text().includes('foo'), true); - - res = await fetch('http://localhost:8889/dyn/bar'); - - assert.equal(res.status, 200); - - html = await res.text(); - $ = cheerio.load(html); - - body = $('body'); - assert.equal(body.text().includes('bar'), true); - }); -}); - -describe('behavior from middleware, middleware with fastify', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - before(async () => { - process.env.PRERENDER = false; - fixture = await loadFixture({ - root: './fixtures/node-middleware/', - output: 'server', - adapter: nodejs({ mode: 'middleware' }), - }); - await fixture.build(); - const { handler } = await fixture.loadAdapterEntryModule(); - const app = Fastify({ logger: false }); - await app - .register(fastifyStatic, { - root: fileURLToPath(new URL('./dist/client', import.meta.url)), - }) - .register(fastifyMiddie); - app.use(handler); - - await app.listen({ port: 8889 }); - - server = app; - }); - - after(async () => { - server.close(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('should render the endpoint', async () => { - const res = await fetch('http://localhost:8889/ssr'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const $ = cheerio.load(html); - - const body = $('body'); - assert.equal(body.text().includes("Here's a random number"), true); - }); - - it('should render the index.html page [static]', async () => { - const res = await fetch('http://localhost:8889'); - - assert.equal(res.status, 200); - - const html = await res.text(); - const $ = cheerio.load(html); - - const body = $('body'); - assert.equal(body.text().includes('1'), true); - }); - - it('should render the dynamic pages', async () => { - let res = await fetch('http://localhost:8889/dyn/foo'); - - assert.equal(res.status, 200); - - let html = await res.text(); - let $ = cheerio.load(html); - - let body = $('body'); - assert.equal(body.text().includes('foo'), true); - - res = await fetch('http://localhost:8889/dyn/bar'); - - assert.equal(res.status, 200); - - html = await res.text(); - $ = cheerio.load(html); - - body = $('body'); - assert.equal(body.text().includes('bar'), true); - }); -}); - -// Regression test for https://github.com/withastro/astro/issues/16039 -// SSR-emitted assets (CSS, fonts, images) must appear in manifest.assets so that -// the Node adapter in middleware mode can identify them as static files and NOT -// match them against catch-all routes. -describe('middleware with fastify and catch-all route: SSR assets in manifest', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/ssr-assets-middleware/', - output: 'server', - adapter: nodejs({ mode: 'middleware' }), - vite: { - build: { - // Prevent CSS/SVG from being inlined so they appear as separate - // files in dist/client/_astro/ and are tracked in ssrAssetsPerEnvironment. - assetsInlineLimit: 0, - }, - }, - }); - await fixture.build(); - const { handler } = await fixture.loadAdapterEntryModule(); - const app = Fastify({ logger: false }); - await app - .register(fastifyStatic, { - root: fileURLToPath( - new URL('./fixtures/ssr-assets-middleware/dist/client', import.meta.url), - ), - }) - .register(fastifyMiddie); - app.use(handler); - - await app.listen({ port: 8890 }); - - server = app; - }); - - after(async () => { - server.close(); - await fixture.clean(); - }); - - it('should serve SSR-emitted CSS assets directly, not the catch-all page', async () => { - // First get the index page to find the CSS asset URL - const indexRes = await fetch('http://localhost:8890/'); - assert.equal(indexRes.status, 200); - const html = await indexRes.text(); - const $ = cheerio.load(html); - - // Find the CSS link tag injected by Astro - const cssHref = $('link[rel="stylesheet"]').attr('href'); - assert.ok(cssHref, 'Expected a CSS tag in the page'); - assert.match(cssHref, /\/_astro\/.*\.css$/); - - // Request the CSS asset — it must be served as CSS, not as the catch-all HTML page - const cssRes = await fetch(`http://localhost:8890${cssHref}`); - assert.equal(cssRes.status, 200); - const contentType = cssRes.headers.get('content-type'); - assert.ok(contentType?.includes('text/css'), `Expected text/css, got: ${contentType}`); - }); -}); diff --git a/packages/integrations/node/test/node-middleware.test.ts b/packages/integrations/node/test/node-middleware.test.ts new file mode 100644 index 000000000000..cef931d083d9 --- /dev/null +++ b/packages/integrations/node/test/node-middleware.test.ts @@ -0,0 +1,269 @@ +import type { Server } from 'node:http'; +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import fastifyMiddie from '@fastify/middie'; +import fastifyStatic from '@fastify/static'; +import * as cheerio from 'cheerio'; +import express from 'express'; +import Fastify, { type FastifyInstance } from 'fastify'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture, waitServerListen, type AdapterServer } from './test-utils.ts'; + +describe('behavior from middleware, standalone', () => { + let fixture: Fixture; + let server: AdapterServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/node-middleware/', + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + describe('404', async () => { + it('when mode is standalone', async () => { + const res = await fetch(`http://${server.host}:${server.port}/error-page`); + + assert.equal(res.status, 404); + + const html = await res.text(); + const $ = cheerio.load(html); + + const body = $('body'); + assert.equal(body.text().includes('Page does not exist'), true); + }); + }); +}); + +describe('behavior from middleware, middleware with express', () => { + let fixture: Fixture; + let server: Server; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/node-middleware/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + }); + await fixture.build(); + const { handler } = await fixture.loadAdapterEntryModule(); + const app = express(); + app.use(handler); + server = app.listen(8889); + }); + + after(async () => { + server.close(); + await fixture.clean(); + }); + + it('should render the endpoint', async () => { + const res = await fetch('http://localhost:8889/ssr'); + + assert.equal(res.status, 200); + + const html = await res.text(); + const $ = cheerio.load(html); + + const body = $('body'); + assert.equal(body.text().includes("Here's a random number"), true); + }); + + it('should render the index.html page [static]', async () => { + const res = await fetch('http://localhost:8889/'); + + assert.equal(res.status, 200); + + const html = await res.text(); + const $ = cheerio.load(html); + + const body = $('body'); + assert.equal(body.text().includes('1'), true); + }); + + it('should render the index.html page [static] when the URL has the hash', async () => { + const res = await fetch('http://localhost:8889/#'); + + assert.equal(res.status, 200); + + const html = await res.text(); + const $ = cheerio.load(html); + + const body = $('body'); + assert.equal(body.text().includes('1'), true); + }); + + it('should render the dynamic pages', async () => { + let res = await fetch('http://localhost:8889/dyn/foo'); + + assert.equal(res.status, 200); + + let html = await res.text(); + let $ = cheerio.load(html); + + let body = $('body'); + assert.equal(body.text().includes('foo'), true); + + res = await fetch('http://localhost:8889/dyn/bar'); + + assert.equal(res.status, 200); + + html = await res.text(); + $ = cheerio.load(html); + + body = $('body'); + assert.equal(body.text().includes('bar'), true); + }); +}); + +describe('behavior from middleware, middleware with fastify', () => { + let fixture: Fixture; + let server: FastifyInstance; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/node-middleware/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + }); + await fixture.build(); + const { handler } = await fixture.loadAdapterEntryModule(); + const app = Fastify({ logger: false }); + await app + .register(fastifyStatic, { + root: fileURLToPath(new URL('./dist/client', import.meta.url)), + }) + .register(fastifyMiddie); + app.use(handler); + + await app.listen({ port: 8889 }); + + server = app; + }); + + after(async () => { + server.close(); + await fixture.clean(); + }); + + it('should render the endpoint', async () => { + const res = await fetch('http://localhost:8889/ssr'); + + assert.equal(res.status, 200); + + const html = await res.text(); + const $ = cheerio.load(html); + + const body = $('body'); + assert.equal(body.text().includes("Here's a random number"), true); + }); + + it('should render the index.html page [static]', async () => { + const res = await fetch('http://localhost:8889'); + + assert.equal(res.status, 200); + + const html = await res.text(); + const $ = cheerio.load(html); + + const body = $('body'); + assert.equal(body.text().includes('1'), true); + }); + + it('should render the dynamic pages', async () => { + let res = await fetch('http://localhost:8889/dyn/foo'); + + assert.equal(res.status, 200); + + let html = await res.text(); + let $ = cheerio.load(html); + + let body = $('body'); + assert.equal(body.text().includes('foo'), true); + + res = await fetch('http://localhost:8889/dyn/bar'); + + assert.equal(res.status, 200); + + html = await res.text(); + $ = cheerio.load(html); + + body = $('body'); + assert.equal(body.text().includes('bar'), true); + }); +}); + +// Regression test for https://github.com/withastro/astro/issues/16039 +// SSR-emitted assets (CSS, fonts, images) must appear in manifest.assets so that +// the Node adapter in middleware mode can identify them as static files and NOT +// match them against catch-all routes. +describe('middleware with fastify and catch-all route: SSR assets in manifest', () => { + let fixture: Fixture; + let server: FastifyInstance; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr-assets-middleware/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + vite: { + build: { + // Prevent CSS/SVG from being inlined so they appear as separate + // files in dist/client/_astro/ and are tracked in ssrAssetsPerEnvironment. + assetsInlineLimit: 0, + }, + }, + }); + await fixture.build(); + const { handler } = await fixture.loadAdapterEntryModule(); + const app = Fastify({ logger: false }); + await app + .register(fastifyStatic, { + root: fileURLToPath( + new URL('./fixtures/ssr-assets-middleware/dist/client', import.meta.url), + ), + }) + .register(fastifyMiddie); + app.use(handler); + + await app.listen({ port: 8890 }); + + server = app; + }); + + after(async () => { + server.close(); + await fixture.clean(); + }); + + it('should serve SSR-emitted CSS assets directly, not the catch-all page', async () => { + // First get the index page to find the CSS asset URL + const indexRes = await fetch('http://localhost:8890/'); + assert.equal(indexRes.status, 200); + const html = await indexRes.text(); + const $ = cheerio.load(html); + + // Find the CSS link tag injected by Astro + const cssHref = $('link[rel="stylesheet"]').attr('href'); + assert.ok(cssHref, 'Expected a CSS tag in the page'); + assert.match(cssHref, /\/_astro\/.*\.css$/); + + // Request the CSS asset — it must be served as CSS, not as the catch-all HTML page + const cssRes = await fetch(`http://localhost:8890${cssHref}`); + assert.equal(cssRes.status, 200); + const contentType = cssRes.headers.get('content-type'); + assert.ok(contentType?.includes('text/css'), `Expected text/css, got: ${contentType}`); + }); +}); diff --git a/packages/integrations/node/test/prerender-404-500.test.js b/packages/integrations/node/test/prerender-404-500.test.js deleted file mode 100644 index a7e968f0c01a..000000000000 --- a/packages/integrations/node/test/prerender-404-500.test.js +++ /dev/null @@ -1,284 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import nodejs from '../dist/index.js'; -import { loadFixture, waitServerListen } from './test-utils.js'; - -/** - * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture - */ - -describe('Prerender 404', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - describe('With base', async () => { - before(async () => { - process.env.PRERENDER = true; - - fixture = await loadFixture({ - // inconsequential config that differs between tests - // to bust cache and prevent modules and their state - // from being reused - site: 'https://test.dev/', - base: '/some-base', - root: './fixtures/prerender-404-500/', - output: 'server', - outDir: './dist/server-with-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - process.env.PRERENDER = undefined; - }); - - it('Can render SSR route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/static`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Hello world!'); - }); - - it('Can handle prerendered 404', async () => { - const url = `http://${server.host}:${server.port}/some-base/missing`; - const res1 = await fetch(url); - const res2 = await fetch(url); - const res3 = await fetch(url); - - assert.equal(res1.status, 404); - assert.equal(res2.status, 404); - assert.equal(res3.status, 404); - - const html1 = await res1.text(); - const html2 = await res2.text(); - const html3 = await res3.text(); - - assert.equal(html1, html2); - assert.equal(html2, html3); - - const $ = cheerio.load(html1); - - assert.equal($('body').text(), 'Page does not exist'); - }); - - it(' Can handle prerendered 500 called indirectly', async () => { - const url = `http://${server.host}:${server.port}/some-base/fivehundred`; - const response1 = await fetch(url); - const response2 = await fetch(url); - const response3 = await fetch(url); - - assert.equal(response1.status, 500); - - const html1 = await response1.text(); - const html2 = await response2.text(); - const html3 = await response3.text(); - - assert.equal(html1.includes('Something went wrong'), true); - - assert.equal(html1, html2); - assert.equal(html2, html3); - }); - - it('prerendered 500 page includes expected styles', async () => { - const response = await fetch(`http://${server.host}:${server.port}/some-base/fivehundred`); - const html = await response.text(); - const $ = cheerio.load(html); - - // length will be 0 if the stylesheet does not get included - assert.equal($('style').length, 1); - }); - }); - - describe('Without base', async () => { - before(async () => { - process.env.PRERENDER = true; - - fixture = await loadFixture({ - // inconsequential config that differs between tests - // to bust cache and prevent modules and their state - // from being reused - site: 'https://test.info/', - root: './fixtures/prerender-404-500/', - output: 'server', - outDir: './dist/server-without-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - process.env.PRERENDER = undefined; - }); - - it('Can render SSR route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/static`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Hello world!'); - }); - - it('Can handle prerendered 404', async () => { - const url = `http://${server.host}:${server.port}/some-base/missing`; - const res1 = await fetch(url); - const res2 = await fetch(url); - const res3 = await fetch(url); - - assert.equal(res1.status, 404); - assert.equal(res2.status, 404); - assert.equal(res3.status, 404); - - const html1 = await res1.text(); - const html2 = await res2.text(); - const html3 = await res3.text(); - - assert.equal(html1, html2); - assert.equal(html2, html3); - - const $ = cheerio.load(html1); - - assert.equal($('body').text(), 'Page does not exist'); - }); - }); -}); - -describe('Hybrid 404', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - describe('With base', async () => { - before(async () => { - process.env.PRERENDER = false; - fixture = await loadFixture({ - // inconsequential config that differs between tests - // to bust cache and prevent modules and their state - // from being reused - site: 'https://test.com/', - base: '/some-base', - root: './fixtures/prerender-404-500/', - output: 'static', - outDir: './dist/hybrid-with-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - process.env.PRERENDER = undefined; - }); - - it('Can render SSR route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/static`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Hello world!'); - }); - - it('Can handle prerendered 404', async () => { - const url = `http://${server.host}:${server.port}/some-base/missing`; - const res1 = await fetch(url); - const res2 = await fetch(url); - const res3 = await fetch(url); - - assert.equal(res1.status, 404); - assert.equal(res2.status, 404); - assert.equal(res3.status, 404); - - const html1 = await res1.text(); - const html2 = await res2.text(); - const html3 = await res3.text(); - - assert.equal(html1, html2); - assert.equal(html2, html3); - - const $ = cheerio.load(html1); - - assert.equal($('body').text(), 'Page does not exist'); - }); - }); - - describe('Without base', async () => { - before(async () => { - process.env.PRERENDER = false; - fixture = await loadFixture({ - // inconsequential config that differs between tests - // to bust cache and prevent modules and their state - // from being reused - site: 'https://test.net/', - root: './fixtures/prerender-404-500/', - output: 'static', - outDir: './dist/hybrid-without-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - process.env.PRERENDER = undefined; - }); - - it('Can render SSR route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/static`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Hello world!'); - }); - - it('Can handle prerendered 404', async () => { - const url = `http://${server.host}:${server.port}/missing`; - const res1 = await fetch(url); - const res2 = await fetch(url); - const res3 = await fetch(url); - - assert.equal(res1.status, 404); - assert.equal(res2.status, 404); - assert.equal(res3.status, 404); - - const html1 = await res1.text(); - const html2 = await res2.text(); - const html3 = await res3.text(); - - assert.equal(html1, html2); - assert.equal(html2, html3); - - const $ = cheerio.load(html1); - - assert.equal($('body').text(), 'Page does not exist'); - }); - }); -}); diff --git a/packages/integrations/node/test/prerender-404-500.test.ts b/packages/integrations/node/test/prerender-404-500.test.ts new file mode 100644 index 000000000000..c2b41fec2d50 --- /dev/null +++ b/packages/integrations/node/test/prerender-404-500.test.ts @@ -0,0 +1,268 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture, waitServerListen, type AdapterServer } from './test-utils.ts'; + +describe('Prerender 404', () => { + let fixture: Fixture; + let server: AdapterServer; + + describe('With base', async () => { + before(async () => { + fixture = await loadFixture({ + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.dev/', + base: '/some-base', + root: './fixtures/prerender-404-500/', + output: 'server', + outDir: './dist/server-with-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/static`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Hello world!'); + }); + + it('Can handle prerendered 404', async () => { + const url = `http://${server.host}:${server.port}/some-base/missing`; + const res1 = await fetch(url); + const res2 = await fetch(url); + const res3 = await fetch(url); + + assert.equal(res1.status, 404); + assert.equal(res2.status, 404); + assert.equal(res3.status, 404); + + const html1 = await res1.text(); + const html2 = await res2.text(); + const html3 = await res3.text(); + + assert.equal(html1, html2); + assert.equal(html2, html3); + + const $ = cheerio.load(html1); + + assert.equal($('body').text(), 'Page does not exist'); + }); + + it(' Can handle prerendered 500 called indirectly', async () => { + const url = `http://${server.host}:${server.port}/some-base/fivehundred`; + const response1 = await fetch(url); + const response2 = await fetch(url); + const response3 = await fetch(url); + + assert.equal(response1.status, 500); + + const html1 = await response1.text(); + const html2 = await response2.text(); + const html3 = await response3.text(); + + assert.equal(html1.includes('Something went wrong'), true); + + assert.equal(html1, html2); + assert.equal(html2, html3); + }); + + it('prerendered 500 page includes expected styles', async () => { + const response = await fetch(`http://${server.host}:${server.port}/some-base/fivehundred`); + const html = await response.text(); + const $ = cheerio.load(html); + + // length will be 0 if the stylesheet does not get included + assert.equal($('style').length, 1); + }); + }); + + describe('Without base', async () => { + before(async () => { + fixture = await loadFixture({ + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.info/', + root: './fixtures/prerender-404-500/', + output: 'server', + outDir: './dist/server-without-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/static`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Hello world!'); + }); + + it('Can handle prerendered 404', async () => { + const url = `http://${server.host}:${server.port}/some-base/missing`; + const res1 = await fetch(url); + const res2 = await fetch(url); + const res3 = await fetch(url); + + assert.equal(res1.status, 404); + assert.equal(res2.status, 404); + assert.equal(res3.status, 404); + + const html1 = await res1.text(); + const html2 = await res2.text(); + const html3 = await res3.text(); + + assert.equal(html1, html2); + assert.equal(html2, html3); + + const $ = cheerio.load(html1); + + assert.equal($('body').text(), 'Page does not exist'); + }); + }); +}); + +describe('Hybrid 404', () => { + let fixture: Fixture; + let server: AdapterServer; + + describe('With base', async () => { + before(async () => { + fixture = await loadFixture({ + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.com/', + base: '/some-base', + root: './fixtures/prerender-404-500/', + output: 'static', + outDir: './dist/hybrid-with-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/static`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Hello world!'); + }); + + it('Can handle prerendered 404', async () => { + const url = `http://${server.host}:${server.port}/some-base/missing`; + const res1 = await fetch(url); + const res2 = await fetch(url); + const res3 = await fetch(url); + + assert.equal(res1.status, 404); + assert.equal(res2.status, 404); + assert.equal(res3.status, 404); + + const html1 = await res1.text(); + const html2 = await res2.text(); + const html3 = await res3.text(); + + assert.equal(html1, html2); + assert.equal(html2, html3); + + const $ = cheerio.load(html1); + + assert.equal($('body').text(), 'Page does not exist'); + }); + }); + + describe('Without base', async () => { + before(async () => { + fixture = await loadFixture({ + // inconsequential config that differs between tests + // to bust cache and prevent modules and their state + // from being reused + site: 'https://test.net/', + root: './fixtures/prerender-404-500/', + output: 'static', + outDir: './dist/hybrid-without-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/static`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Hello world!'); + }); + + it('Can handle prerendered 404', async () => { + const url = `http://${server.host}:${server.port}/missing`; + const res1 = await fetch(url); + const res2 = await fetch(url); + const res3 = await fetch(url); + + assert.equal(res1.status, 404); + assert.equal(res2.status, 404); + assert.equal(res3.status, 404); + + const html1 = await res1.text(); + const html2 = await res2.text(); + const html3 = await res3.text(); + + assert.equal(html1, html2); + assert.equal(html2, html3); + + const $ = cheerio.load(html1); + + assert.equal($('body').text(), 'Page does not exist'); + }); + }); +}); diff --git a/packages/integrations/node/test/prerender.test.js b/packages/integrations/node/test/prerender.test.js deleted file mode 100644 index 0b30deb43903..000000000000 --- a/packages/integrations/node/test/prerender.test.js +++ /dev/null @@ -1,477 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import nodejs from '../dist/index.js'; -import { loadFixture, waitServerListen } from './test-utils.js'; - -/** - * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture - */ - -describe('Prerendering', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - describe('With base', async () => { - before(async () => { - process.env.PRERENDER = true; - - fixture = await loadFixture({ - base: '/some-base', - root: './fixtures/prerender/', - output: 'server', - outDir: './dist/with-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render SSR route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/two`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Two'); - assert.ok(fixture.pathExists('/client/two/index.html')); - }); - - it('Can render prerendered route with redirect and query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/two?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Two'); - }); - - it('Can render prerendered route with query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/two/?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Two'); - }); - - it('Can render prerendered route without trailing slash', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/two`, { - redirect: 'manual', - }); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Two'); - }); - - it('Can render prerendered dynamic route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/dogs/rover`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Good dog, rover!'); - }); - - it('Can render 404 matching a prerendered dynamic route pattern', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/dogs/unknown`); - - assert.equal(res.status, 404); - }); - }); - - describe('Without base', async () => { - before(async () => { - process.env.PRERENDER = true; - - fixture = await loadFixture({ - root: './fixtures/prerender/', - output: 'server', - outDir: './dist/without-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render SSR route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/two`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Two'); - assert.ok(fixture.pathExists('/client/two/index.html')); - }); - - it('Can render prerendered route with redirect and query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/two?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Two'); - }); - - it('Can render prerendered route with query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/two/?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Two'); - }); - - it('Can render prerendered dynamic route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/dogs/rover`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Good dog, rover!'); - }); - - it('Can render 404 matching a prerendered dynamic route pattern', async () => { - const res = await fetch(`http://${server.host}:${server.port}/dogs/unknown`); - - assert.equal(res.status, 404); - }); - }); - - describe('Via integration', () => { - before(async () => { - process.env.PRERENDER = false; - fixture = await loadFixture({ - root: './fixtures/prerender/', - output: 'server', - outDir: './dist/via-integration', - adapter: nodejs({ mode: 'standalone' }), - integrations: [ - { - name: 'test', - hooks: { - 'astro:route:setup': ({ route }) => { - if (route.component.endsWith('two.astro')) { - route.prerender = true; - } - }, - }, - }, - ], - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render SSR route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/two`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Two'); - assert.ok(fixture.pathExists('/client/two/index.html')); - }); - }); - - describe('Dev', () => { - let devServer; - - before(async () => { - process.env.PRERENDER = true; - - fixture = await loadFixture({ - root: './fixtures/prerender/', - output: 'server', - outDir: './dist/dev', - adapter: nodejs({ mode: 'standalone' }), - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - - delete process.env.PRERENDER; - }); - - it('Can render SSR route', async () => { - const res = await fixture.fetch(`/one`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route', async () => { - const res = await fixture.fetch(`/two`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Two'); - }); - }); -}); - -describe('Hybrid rendering', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - describe('With base', () => { - before(async () => { - process.env.PRERENDER = false; - fixture = await loadFixture({ - base: '/some-base', - root: './fixtures/prerender/', - output: 'static', - outDir: './dist/hybrid-with-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render SSR route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/two`); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Two'); - }); - - it('Can render prerendered route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - assert.ok(fixture.pathExists('/client/one/index.html')); - }); - - it('Can render prerendered route with redirect and query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route with query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route without trailing slash', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, { - redirect: 'manual', - }); - const html = await res.text(); - const $ = cheerio.load(html); - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered dynamic route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/dogs/rover`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Good dog, rover!'); - }); - - it('Can render 404 matching a prerendered dynamic route pattern', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/dogs/unknown`); - - assert.equal(res.status, 404); - }); - }); - - describe('Without base', () => { - before(async () => { - process.env.PRERENDER = false; - fixture = await loadFixture({ - root: './fixtures/prerender/', - output: 'static', - outDir: './dist/hybrid-without-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render SSR route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/two`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Two'); - }); - - it('Can render prerendered route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - assert.ok(fixture.pathExists('/client/one/index.html')); - }); - - it('Can render prerendered route with redirect and query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route with query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered dynamic route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/dogs/rover`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Good dog, rover!'); - }); - - it('Can render 404 matching a prerendered dynamic route pattern', async () => { - const res = await fetch(`http://${server.host}:${server.port}/dogs/unknown`); - - assert.equal(res.status, 404); - }); - }); - - describe('Shared modules', () => { - before(async () => { - process.env.PRERENDER = false; - - fixture = await loadFixture({ - root: './fixtures/prerender/', - output: 'static', - outDir: './dist/hybrid-shared-modules', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render SSR route', async () => { - const res = await fetch(`http://${server.host}:${server.port}/third`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'shared'); - }); - }); -}); diff --git a/packages/integrations/node/test/prerender.test.ts b/packages/integrations/node/test/prerender.test.ts new file mode 100644 index 000000000000..9a873ab0a3b6 --- /dev/null +++ b/packages/integrations/node/test/prerender.test.ts @@ -0,0 +1,452 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import nodejs from '../dist/index.js'; +import { + type Fixture, + loadFixture, + waitServerListen, + type AdapterServer, + type DevServer, +} from './test-utils.ts'; + +describe('Prerendering', () => { + let fixture: Fixture; + let server: AdapterServer; + + describe('With base', async () => { + before(async () => { + fixture = await loadFixture({ + base: '/some-base', + root: './fixtures/prerender/', + output: 'server', + outDir: './dist/with-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/two`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + assert.ok(fixture.pathExists('/client/two/index.html')); + }); + + it('Can render prerendered route with redirect and query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/two?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + }); + + it('Can render prerendered route with query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/two/?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + }); + + it('Can render prerendered route without trailing slash', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/two`, { + redirect: 'manual', + }); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + }); + + it('Can render prerendered dynamic route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/dogs/rover`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Good dog, rover!'); + }); + + it('Can render 404 matching a prerendered dynamic route pattern', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/dogs/unknown`); + + assert.equal(res.status, 404); + }); + }); + + describe('Without base', async () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/prerender/', + output: 'server', + outDir: './dist/without-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/two`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + assert.ok(fixture.pathExists('/client/two/index.html')); + }); + + it('Can render prerendered route with redirect and query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/two?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + }); + + it('Can render prerendered route with query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/two/?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + }); + + it('Can render prerendered dynamic route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/dogs/rover`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Good dog, rover!'); + }); + + it('Can render 404 matching a prerendered dynamic route pattern', async () => { + const res = await fetch(`http://${server.host}:${server.port}/dogs/unknown`); + + assert.equal(res.status, 404); + }); + }); + + describe('Via integration', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/prerender/', + output: 'server', + outDir: './dist/via-integration', + adapter: nodejs({ mode: 'standalone' }), + integrations: [ + { + name: 'test', + hooks: { + 'astro:route:setup': ({ route }) => { + if (route.component.endsWith('two.astro')) { + route.prerender = true; + } + }, + }, + }, + ], + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/two`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + assert.ok(fixture.pathExists('/client/two/index.html')); + }); + }); + + describe('Dev', () => { + let devServer: DevServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/prerender/', + output: 'server', + outDir: './dist/dev', + adapter: nodejs({ mode: 'standalone' }), + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Can render SSR route', async () => { + const res = await fixture.fetch(`/one`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route', async () => { + const res = await fixture.fetch(`/two`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + }); + }); +}); + +describe('Hybrid rendering', () => { + let fixture: Fixture; + let server: AdapterServer; + + describe('With base', () => { + before(async () => { + fixture = await loadFixture({ + base: '/some-base', + root: './fixtures/prerender/', + output: 'static', + outDir: './dist/hybrid-with-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/two`); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + }); + + it('Can render prerendered route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + assert.ok(fixture.pathExists('/client/one/index.html')); + }); + + it('Can render prerendered route with redirect and query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route with query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route without trailing slash', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, { + redirect: 'manual', + }); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered dynamic route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/dogs/rover`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Good dog, rover!'); + }); + + it('Can render 404 matching a prerendered dynamic route pattern', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/dogs/unknown`); + + assert.equal(res.status, 404); + }); + }); + + describe('Without base', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/prerender/', + output: 'static', + outDir: './dist/hybrid-without-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/two`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Two'); + }); + + it('Can render prerendered route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + assert.ok(fixture.pathExists('/client/one/index.html')); + }); + + it('Can render prerendered route with redirect and query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route with query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered dynamic route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/dogs/rover`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Good dog, rover!'); + }); + + it('Can render 404 matching a prerendered dynamic route pattern', async () => { + const res = await fetch(`http://${server.host}:${server.port}/dogs/unknown`); + + assert.equal(res.status, 404); + }); + }); + + describe('Shared modules', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/prerender/', + output: 'static', + outDir: './dist/hybrid-shared-modules', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/third`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'shared'); + }); + }); +}); diff --git a/packages/integrations/node/test/prerendered-error-page-fetch.test.js b/packages/integrations/node/test/prerendered-error-page-fetch.test.js deleted file mode 100644 index b66482b85595..000000000000 --- a/packages/integrations/node/test/prerendered-error-page-fetch.test.js +++ /dev/null @@ -1,54 +0,0 @@ -// @ts-check -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import node from '../dist/index.js'; -import { loadFixture } from './test-utils.js'; - -describe('prerenderedErrorPageFetch', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('astro').PreviewServer} */ - let devPreview; - /** @type {typeof globalThis.fetch} */ - let originalFetch; - /** @type {Array} */ - let urls; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/prerendered-error-page-fetch/', - adapter: node({ mode: 'standalone' }), - }); - await fixture.clean(); - await fixture.build({}); - devPreview = await fixture.preview({}); - originalFetch = globalThis.fetch; - globalThis.fetch = (...args) => { - urls ??= []; - if (typeof args[0] === 'string') { - urls.push(args[0]); - } - return originalFetch(...args); - }; - }); - - after(async () => { - await devPreview.stop(); - globalThis.fetch = originalFetch; - }); - - it('requests prerendered 404 page', async () => { - urls = []; - const response = await fixture.fetch('/nonexistent'); - const text = await response.text(); - assert.ok(text.includes('Custom 404 Page'), 'Should serve error page content from disk'); - assert.ok(!urls.some((url) => url.endsWith('404.html'))); - }); - it('requests prerendered 500 page', async () => { - urls = []; - const response = await fixture.fetch('/error?error=true'); - const text = await response.text(); - assert.ok(text.includes('Custom 500 Page'), 'Should serve error page content from disk'); - assert.ok(!urls.some((url) => url.endsWith('500.html'))); - }); -}); diff --git a/packages/integrations/node/test/prerendered-error-page-fetch.test.ts b/packages/integrations/node/test/prerendered-error-page-fetch.test.ts new file mode 100644 index 000000000000..9fb6c6110f89 --- /dev/null +++ b/packages/integrations/node/test/prerendered-error-page-fetch.test.ts @@ -0,0 +1,49 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import type { PreviewServer } from '../../../astro/src/types/public/preview.js'; +import node from '../dist/index.js'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('prerenderedErrorPageFetch', () => { + let fixture: Fixture; + let devPreview: PreviewServer; + let originalFetch: typeof globalThis.fetch; + let urls: Array = []; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/prerendered-error-page-fetch/', + adapter: node({ mode: 'standalone' }), + }); + await fixture.clean(); + await fixture.build(); + devPreview = await fixture.preview(); + originalFetch = globalThis.fetch; + globalThis.fetch = (...args) => { + if (typeof args[0] === 'string') { + urls.push(args[0]); + } + return originalFetch(...args); + }; + }); + + after(async () => { + await devPreview.stop(); + globalThis.fetch = originalFetch; + }); + + it('requests prerendered 404 page', async () => { + urls = []; + const response = await fixture.fetch('/nonexistent'); + const text = await response.text(); + assert.ok(text.includes('Custom 404 Page'), 'Should serve error page content from disk'); + assert.ok(!urls.some((url) => url.endsWith('404.html'))); + }); + it('requests prerendered 500 page', async () => { + urls = []; + const response = await fixture.fetch('/error?error=true'); + const text = await response.text(); + assert.ok(text.includes('Custom 500 Page'), 'Should serve error page content from disk'); + assert.ok(!urls.some((url) => url.endsWith('500.html'))); + }); +}); diff --git a/packages/integrations/node/test/preview-headers.test.js b/packages/integrations/node/test/preview-headers.test.js deleted file mode 100644 index 3fd9d0508d6e..000000000000 --- a/packages/integrations/node/test/preview-headers.test.js +++ /dev/null @@ -1,38 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import nodejs from '../dist/index.js'; -import { loadFixture } from './test-utils.js'; - -describe('Astro preview headers', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let devPreview; - const headers = { - astro: 'test', - }; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/preview-headers/', - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - server: { - headers, - }, - }); - await fixture.build(); - devPreview = await fixture.preview(); - }); - - after(async () => { - await devPreview.stop(); - }); - - describe('Preview Headers', () => { - it('returns custom headers for valid URLs', async () => { - const result = await fixture.fetch('/'); - assert.equal(result.status, 200); - assert.equal(Object.fromEntries(result.headers).astro, headers.astro); - }); - }); -}); diff --git a/packages/integrations/node/test/preview-headers.test.ts b/packages/integrations/node/test/preview-headers.test.ts new file mode 100644 index 000000000000..449d9657b4c3 --- /dev/null +++ b/packages/integrations/node/test/preview-headers.test.ts @@ -0,0 +1,38 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import type { PreviewServer } from '../../../astro/src/types/public/preview.js'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('Astro preview headers', () => { + let fixture: Fixture; + let devPreview: PreviewServer; + const headers = { + astro: 'test', + }; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/preview-headers/', + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + server: { + headers, + }, + }); + await fixture.build(); + devPreview = await fixture.preview(); + }); + + after(async () => { + await devPreview.stop(); + }); + + describe('Preview Headers', () => { + it('returns custom headers for valid URLs', async () => { + const result = await fixture.fetch('/'); + assert.equal(result.status, 200); + assert.equal(Object.fromEntries(result.headers).astro, headers.astro); + }); + }); +}); diff --git a/packages/integrations/node/test/preview-host.test.js b/packages/integrations/node/test/preview-host.test.js deleted file mode 100644 index fec925e677f3..000000000000 --- a/packages/integrations/node/test/preview-host.test.js +++ /dev/null @@ -1,63 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import nodejs from '../dist/index.js'; -import { loadFixture } from './test-utils.js'; - -describe('Astro preview host', () => { - it('defaults to localhost', async () => { - const fixture = await loadFixture({ - root: './fixtures/preview-headers/', - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const devPreview = await fixture.preview(); - assert.equal(devPreview.host, 'localhost'); - await devPreview.stop(); - }); - - it('uses default when set to false', async () => { - const fixture = await loadFixture({ - root: './fixtures/preview-headers/', - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - server: { - host: false, - }, - }); - await fixture.build(); - const devPreview = await fixture.preview(); - assert.equal(devPreview.host, 'localhost'); - await devPreview.stop(); - }); - - it('sets wildcard host if set to true', async () => { - const fixture = await loadFixture({ - root: './fixtures/preview-headers/', - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - server: { - host: true, - }, - }); - await fixture.build(); - const devPreview = await fixture.preview(); - assert.equal(devPreview.host, '0.0.0.0'); - await devPreview.stop(); - }); - - it('allows setting specific host', async () => { - const fixture = await loadFixture({ - root: './fixtures/preview-headers/', - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - server: { - host: '127.0.0.1', - }, - }); - await fixture.build(); - const devPreview = await fixture.preview(); - assert.equal(devPreview.host, '127.0.0.1'); - await devPreview.stop(); - }); -}); diff --git a/packages/integrations/node/test/preview-host.test.ts b/packages/integrations/node/test/preview-host.test.ts new file mode 100644 index 000000000000..f601fc235772 --- /dev/null +++ b/packages/integrations/node/test/preview-host.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import nodejs from '../dist/index.js'; +import { loadFixture } from './test-utils.ts'; + +describe('Astro preview host', () => { + it('defaults to localhost', async () => { + const fixture = await loadFixture({ + root: './fixtures/preview-headers/', + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const devPreview = await fixture.preview(); + assert.equal(devPreview.host, 'localhost'); + await devPreview.stop(); + }); + + it('uses default when set to false', async () => { + const fixture = await loadFixture({ + root: './fixtures/preview-headers/', + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + server: { + host: false, + }, + }); + await fixture.build(); + const devPreview = await fixture.preview(); + assert.equal(devPreview.host, 'localhost'); + await devPreview.stop(); + }); + + it('sets wildcard host if set to true', async () => { + const fixture = await loadFixture({ + root: './fixtures/preview-headers/', + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + server: { + host: true, + }, + }); + await fixture.build(); + const devPreview = await fixture.preview(); + assert.equal(devPreview.host, '0.0.0.0'); + await devPreview.stop(); + }); + + it('allows setting specific host', async () => { + const fixture = await loadFixture({ + root: './fixtures/preview-headers/', + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + server: { + host: '127.0.0.1', + }, + }); + await fixture.build(); + const devPreview = await fixture.preview(); + assert.equal(devPreview.host, '127.0.0.1'); + await devPreview.stop(); + }); +}); diff --git a/packages/integrations/node/test/redirects.test.js b/packages/integrations/node/test/redirects.test.js deleted file mode 100644 index 0688414faa3d..000000000000 --- a/packages/integrations/node/test/redirects.test.js +++ /dev/null @@ -1,67 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import nodejs from '../dist/index.js'; -import { loadFixture, waitServerListen } from './test-utils.js'; - -describe('Redirects', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/redirects/', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - }); - - function fetchEndpoint(url, options = {}) { - return fetch(`http://${server.host}:${server.port}/${url}`, { ...options, redirect: 'manual' }); - } - - it('should redirect with default 301 status for simple redirects', async () => { - const response = await fetchEndpoint('old-page'); - assert.equal(response.status, 301); - assert.equal(response.headers.get('location'), '/new-page'); - }); - - it('should redirect with custom 301 status', async () => { - const response = await fetchEndpoint('old-page-301'); - assert.equal(response.status, 301); - assert.equal(response.headers.get('location'), '/new-page-301'); - }); - - it('should redirect with custom 302 status', async () => { - const response = await fetchEndpoint('old-page-302'); - assert.equal(response.status, 302); - assert.equal(response.headers.get('location'), '/new-page-302'); - }); - - it('should handle dynamic redirects with parameters', async () => { - const response = await fetchEndpoint('dynamic/test-slug'); - assert.equal(response.status, 301); - assert.equal(response.headers.get('location'), '/pages/test-slug'); - }); - - it('should handle spread redirects with parameters', async () => { - const response = await fetchEndpoint('spread/some/nested/path'); - assert.equal(response.status, 301); - assert.equal(response.headers.get('location'), '/content/some/nested/path'); - }); - - it('should redirect to external URL', async () => { - const response = await fetchEndpoint('external'); - assert.equal(response.status, 301); - assert.equal(response.headers.get('location'), 'https://example.com/'); - }); -}); diff --git a/packages/integrations/node/test/redirects.test.ts b/packages/integrations/node/test/redirects.test.ts new file mode 100644 index 000000000000..b46a4f2e7da0 --- /dev/null +++ b/packages/integrations/node/test/redirects.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture, waitServerListen, type AdapterServer } from './test-utils.ts'; + +describe('Redirects', () => { + let fixture: Fixture; + let server: AdapterServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/redirects/', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + function fetchEndpoint(url: string, options: RequestInit = {}) { + return fetch(`http://${server.host}:${server.port}/${url}`, { ...options, redirect: 'manual' }); + } + + it('should redirect with default 301 status for simple redirects', async () => { + const response = await fetchEndpoint('old-page'); + assert.equal(response.status, 301); + assert.equal(response.headers.get('location'), '/new-page'); + }); + + it('should redirect with custom 301 status', async () => { + const response = await fetchEndpoint('old-page-301'); + assert.equal(response.status, 301); + assert.equal(response.headers.get('location'), '/new-page-301'); + }); + + it('should redirect with custom 302 status', async () => { + const response = await fetchEndpoint('old-page-302'); + assert.equal(response.status, 302); + assert.equal(response.headers.get('location'), '/new-page-302'); + }); + + it('should handle dynamic redirects with parameters', async () => { + const response = await fetchEndpoint('dynamic/test-slug'); + assert.equal(response.status, 301); + assert.equal(response.headers.get('location'), '/pages/test-slug'); + }); + + it('should handle spread redirects with parameters', async () => { + const response = await fetchEndpoint('spread/some/nested/path'); + assert.equal(response.status, 301); + assert.equal(response.headers.get('location'), '/content/some/nested/path'); + }); + + it('should redirect to external URL', async () => { + const response = await fetchEndpoint('external'); + assert.equal(response.status, 301); + assert.equal(response.headers.get('location'), 'https://example.com/'); + }); +}); diff --git a/packages/integrations/node/test/server-host.test.js b/packages/integrations/node/test/server-host.test.ts similarity index 100% rename from packages/integrations/node/test/server-host.test.js rename to packages/integrations/node/test/server-host.test.ts diff --git a/packages/integrations/node/test/sessions.test.js b/packages/integrations/node/test/sessions.test.js deleted file mode 100644 index 5abd5a566c5d..000000000000 --- a/packages/integrations/node/test/sessions.test.js +++ /dev/null @@ -1,181 +0,0 @@ -// @ts-check -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as devalue from 'devalue'; -import nodejs from '../dist/index.js'; -import { loadFixture } from './test-utils.js'; - -describe('Astro.session', () => { - describe('Production', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('../../../astro/src/types/public/preview.js').PreviewServer} */ - let app; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/sessions/', - output: 'server', - adapter: nodejs({ mode: 'middleware' }), - }); - await fixture.build({}); - app = await fixture.preview({}); - }); - - after(async () => { - await app.stop(); - }); - - it('can regenerate session cookies upon request', async () => { - const firstResponse = await fixture.fetch('/regenerate'); - // @ts-ignore - const firstHeaders = firstResponse.headers.get('set-cookie').split(','); - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - - const secondResponse = await fixture.fetch('/regenerate', { - method: 'GET', - headers: { - cookie: `astro-session=${firstSessionId}`, - }, - }); - // @ts-ignore - const secondHeaders = secondResponse.headers.get('set-cookie').split(','); - const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; - assert.notEqual(firstSessionId, secondSessionId); - }); - - it('can save session data by value', async () => { - const firstResponse = await fixture.fetch('/update'); - const firstValue = await firstResponse.json(); - assert.equal(firstValue.previousValue, 'none'); - - // @ts-ignore - const firstHeaders = firstResponse.headers.get('set-cookie').split(','); - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - const secondResponse = await fixture.fetch('/update', { - method: 'GET', - headers: { - cookie: `astro-session=${firstSessionId}`, - }, - }); - const secondValue = await secondResponse.json(); - assert.equal(secondValue.previousValue, 'expected'); - }); - - it('can save and restore URLs in session data', async () => { - const firstResponse = await fixture.fetch('/_actions/addUrl', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), - }); - - assert.equal(firstResponse.ok, true); - // @ts-ignore - const firstHeaders = firstResponse.headers.get('set-cookie').split(','); - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - - const data = devalue.parse(await firstResponse.text()); - assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); - const secondResponse = await fixture.fetch('/_actions/addUrl', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - cookie: `astro-session=${firstSessionId}`, - }, - body: JSON.stringify({ favoriteUrl: 'https://example.com' }), - }); - const secondData = devalue.parse(await secondResponse.text()); - assert.equal( - secondData.message, - 'Favorite URL set to https://example.com/ from https://domain.invalid/', - ); - }); - }); - - describe('Development', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let devServer; - before(async () => { - fixture = await loadFixture({ - root: './fixtures/sessions/', - output: 'server', - adapter: nodejs({ mode: 'middleware' }), - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('can regenerate session cookies upon request', async () => { - const firstResponse = await fixture.fetch('/regenerate'); - // @ts-ignore - const firstHeaders = firstResponse.headers.get('set-cookie').split(','); - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - - const secondResponse = await fixture.fetch('/regenerate', { - method: 'GET', - headers: { - cookie: `astro-session=${firstSessionId}`, - }, - }); - // @ts-ignore - const secondHeaders = secondResponse.headers.get('set-cookie').split(','); - const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; - assert.notEqual(firstSessionId, secondSessionId); - }); - - it('can save session data by value', async () => { - const firstResponse = await fixture.fetch('/update'); - const firstValue = await firstResponse.json(); - assert.equal(firstValue.previousValue, 'none'); - - // @ts-ignore - const firstHeaders = firstResponse.headers.get('set-cookie').split(','); - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - const secondResponse = await fixture.fetch('/update', { - method: 'GET', - headers: { - cookie: `astro-session=${firstSessionId}`, - }, - }); - const secondValue = await secondResponse.json(); - assert.equal(secondValue.previousValue, 'expected'); - }); - - it('can save and restore URLs in session data', async () => { - const firstResponse = await fixture.fetch('/_actions/addUrl', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), - }); - - assert.equal(firstResponse.ok, true); - // @ts-ignore - const firstHeaders = firstResponse.headers.get('set-cookie').split(','); - const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; - - const data = devalue.parse(await firstResponse.text()); - assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); - const secondResponse = await fixture.fetch('/_actions/addUrl', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - cookie: `astro-session=${firstSessionId}`, - }, - body: JSON.stringify({ favoriteUrl: 'https://example.com' }), - }); - const secondData = devalue.parse(await secondResponse.text()); - assert.equal( - secondData.message, - 'Favorite URL set to https://example.com/ from https://domain.invalid/', - ); - }); - }); -}); diff --git a/packages/integrations/node/test/sessions.test.ts b/packages/integrations/node/test/sessions.test.ts new file mode 100644 index 000000000000..9cee8bae9403 --- /dev/null +++ b/packages/integrations/node/test/sessions.test.ts @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import type { PreviewServer } from '../../../astro/src/types/public/preview.js'; +import * as devalue from 'devalue'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture, type DevServer } from './test-utils.ts'; + +describe('Astro.session', () => { + describe('Production', () => { + let fixture: Fixture; + let app: PreviewServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/sessions/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + }); + await fixture.build(); + app = await fixture.preview(); + }); + + after(async () => { + await app.stop(); + }); + + it('can regenerate session cookies upon request', async () => { + const firstResponse = await fixture.fetch('/regenerate'); + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const secondResponse = await fixture.fetch('/regenerate', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + // @ts-ignore + const secondHeaders = secondResponse.headers.get('set-cookie').split(','); + const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; + assert.notEqual(firstSessionId, secondSessionId); + }); + + it('can save session data by value', async () => { + const firstResponse = await fixture.fetch('/update'); + const firstValue = await firstResponse.json(); + assert.equal(firstValue.previousValue, 'none'); + + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + const secondResponse = await fixture.fetch('/update', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondValue = await secondResponse.json(); + assert.equal(secondValue.previousValue, 'expected'); + }); + + it('can save and restore URLs in session data', async () => { + const firstResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), + }); + + assert.equal(firstResponse.ok, true); + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const data = devalue.parse(await firstResponse.text()); + assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); + const secondResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: `astro-session=${firstSessionId}`, + }, + body: JSON.stringify({ favoriteUrl: 'https://example.com' }), + }); + const secondData = devalue.parse(await secondResponse.text()); + assert.equal( + secondData.message, + 'Favorite URL set to https://example.com/ from https://domain.invalid/', + ); + }); + }); + + describe('Development', () => { + let fixture: Fixture; + let devServer: DevServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/sessions/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('can regenerate session cookies upon request', async () => { + const firstResponse = await fixture.fetch('/regenerate'); + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const secondResponse = await fixture.fetch('/regenerate', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + // @ts-ignore + const secondHeaders = secondResponse.headers.get('set-cookie').split(','); + const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; + assert.notEqual(firstSessionId, secondSessionId); + }); + + it('can save session data by value', async () => { + const firstResponse = await fixture.fetch('/update'); + const firstValue = await firstResponse.json(); + assert.equal(firstValue.previousValue, 'none'); + + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + const secondResponse = await fixture.fetch('/update', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondValue = await secondResponse.json(); + assert.equal(secondValue.previousValue, 'expected'); + }); + + it('can save and restore URLs in session data', async () => { + const firstResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ favoriteUrl: 'https://domain.invalid' }), + }); + + assert.equal(firstResponse.ok, true); + // @ts-ignore + const firstHeaders = firstResponse.headers.get('set-cookie').split(','); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const data = devalue.parse(await firstResponse.text()); + assert.equal(data.message, 'Favorite URL set to https://domain.invalid/ from nothing'); + const secondResponse = await fixture.fetch('/_actions/addUrl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: `astro-session=${firstSessionId}`, + }, + body: JSON.stringify({ favoriteUrl: 'https://example.com' }), + }); + const secondData = devalue.parse(await secondResponse.text()); + assert.equal( + secondData.message, + 'Favorite URL set to https://example.com/ from https://domain.invalid/', + ); + }); + }); +}); diff --git a/packages/integrations/node/test/static-headers.test.js b/packages/integrations/node/test/static-headers.test.js deleted file mode 100644 index 3dc3d98f4c37..000000000000 --- a/packages/integrations/node/test/static-headers.test.js +++ /dev/null @@ -1,119 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import nodejs from '../dist/index.js'; -import { loadFixture, waitServerListen } from './test-utils.js'; - -describe('Static headers', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ root: './fixtures/static-headers' }); - await fixture.build(); - }); - - it('CSP headers are added when CSP is enabled', async () => { - const headers = JSON.parse(await fixture.readFile('../dist/_headers.json')); - - const csp = headers - .find((x) => x.pathname === '/') - .headers.find((x) => x.key === 'Content-Security-Policy'); - - assert.notEqual(csp, undefined, 'the index must have CSP headers'); - assert.ok( - csp.value.includes('script-src'), - 'must contain the script-src directive because of the server island', - ); - }); - - it('CSP headers are added to the request', async () => {}); -}); - -describe('Static headers', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static-headers/', - outDir: './dist/root-base', - output: 'server', - adapter: nodejs({ mode: 'standalone', staticHeaders: true }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - process.env.PORT = '4322'; - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - // await fixture.clean(); - }); - - it('CSP headers are added to the request', async () => { - const res = await fetch(`http://${server.host}:${server.port}/`); - const cps = res.headers.get('Content-Security-Policy'); - assert.ok( - cps.includes('script-src'), - 'should contain script-src directive due to server island', - ); - }); - - it('CSP headers are added to dynamic orute', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one`); - const cps = res.headers.get('Content-Security-Policy'); - assert.ok( - cps.includes('script-src'), - 'should contain script-src directive due to server island', - ); - }); -}); - -describe('Static headers with non-root base', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static-headers/', - outDir: './dist/non-root-base', - base: '/docs', - output: 'server', - adapter: nodejs({ mode: 'standalone', staticHeaders: true }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - process.env.PORT = '4323'; - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - }); - - it('CSP headers are added to the index route under the base path', async () => { - const res = await fetch(`http://${server.host}:${server.port}/docs/`); - const csp = res.headers.get('Content-Security-Policy'); - assert.ok(csp, 'Content-Security-Policy header must be present for the index route'); - assert.ok( - csp.includes('script-src'), - 'should contain script-src directive due to server island', - ); - }); - - it('CSP headers are added to a dynamic route under the base path', async () => { - const res = await fetch(`http://${server.host}:${server.port}/docs/one`); - const csp = res.headers.get('Content-Security-Policy'); - assert.ok(csp, 'Content-Security-Policy header must be present for dynamic routes'); - assert.ok( - csp.includes('script-src'), - 'should contain script-src directive due to server island', - ); - }); -}); diff --git a/packages/integrations/node/test/static-headers.test.ts b/packages/integrations/node/test/static-headers.test.ts new file mode 100644 index 000000000000..68bfb82b089b --- /dev/null +++ b/packages/integrations/node/test/static-headers.test.ts @@ -0,0 +1,121 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture, waitServerListen, type AdapterServer } from './test-utils.ts'; + +type StaticHeaderEntry = { pathname: string; headers: Array<{ key: string; value: string }> }; + +describe('Static headers', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ root: './fixtures/static-headers' }); + await fixture.build(); + }); + + it('CSP headers are added when CSP is enabled', async () => { + const headers: StaticHeaderEntry[] = JSON.parse( + await fixture.readFile('../dist/_headers.json'), + ); + + const csp = headers + .find((x) => x.pathname === '/')! + .headers.find((x) => x.key === 'Content-Security-Policy')!; + + assert.notEqual(csp, undefined, 'the index must have CSP headers'); + assert.ok( + csp.value.includes('script-src'), + 'must contain the script-src directive because of the server island', + ); + }); + + it('CSP headers are added to the request', async () => {}); +}); + +describe('Static headers', () => { + let fixture: Fixture; + let server: AdapterServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static-headers/', + outDir: './dist/root-base', + output: 'server', + adapter: nodejs({ mode: 'standalone', staticHeaders: true }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + process.env.PORT = '4322'; + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + // await fixture.clean(); + }); + + it('CSP headers are added to the request', async () => { + const res = await fetch(`http://${server.host}:${server.port}/`); + const cps = res.headers.get('Content-Security-Policy')!; + assert.ok( + cps.includes('script-src'), + 'should contain script-src directive due to server island', + ); + }); + + it('CSP headers are added to dynamic orute', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one`); + const cps = res.headers.get('Content-Security-Policy')!; + assert.ok( + cps.includes('script-src'), + 'should contain script-src directive due to server island', + ); + }); +}); + +describe('Static headers with non-root base', () => { + let fixture: Fixture; + let server: AdapterServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static-headers/', + outDir: './dist/non-root-base', + base: '/docs', + output: 'server', + adapter: nodejs({ mode: 'standalone', staticHeaders: true }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + process.env.PORT = '4323'; + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + }); + + it('CSP headers are added to the index route under the base path', async () => { + const res = await fetch(`http://${server.host}:${server.port}/docs/`); + const csp = res.headers.get('Content-Security-Policy'); + assert.ok(csp, 'Content-Security-Policy header must be present for the index route'); + assert.ok( + csp.includes('script-src'), + 'should contain script-src directive due to server island', + ); + }); + + it('CSP headers are added to a dynamic route under the base path', async () => { + const res = await fetch(`http://${server.host}:${server.port}/docs/one`); + const csp = res.headers.get('Content-Security-Policy'); + assert.ok(csp, 'Content-Security-Policy header must be present for dynamic routes'); + assert.ok( + csp.includes('script-src'), + 'should contain script-src directive due to server island', + ); + }); +}); diff --git a/packages/integrations/node/test/test-utils.js b/packages/integrations/node/test/test-utils.js deleted file mode 100644 index 8553a159e09c..000000000000 --- a/packages/integrations/node/test/test-utils.js +++ /dev/null @@ -1,82 +0,0 @@ -import { EventEmitter } from 'node:events'; -import httpMocks from 'node-mocks-http'; -import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; - -process.env.ASTRO_NODE_AUTOSTART = 'disabled'; -process.env.ASTRO_NODE_LOGGING = 'disabled'; -/** - * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture - */ - -export function loadFixture(inlineConfig) { - if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }"); - - // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath - // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test` - return baseLoadFixture({ - ...inlineConfig, - root: new URL(inlineConfig.root, import.meta.url).toString(), - }); -} - -export function createRequestAndResponse(reqOptions) { - const req = httpMocks.createRequest(reqOptions); - - const res = httpMocks.createResponse({ - eventEmitter: EventEmitter, - req, - }); - - const done = toPromise(res); - - // Get the response as text - const text = async () => { - const chunks = await done; - return buffersToString(chunks); - }; - - return { req, res, done, text }; -} - -/** @returns {Promise>} */ -function toPromise(res) { - return new Promise((resolve) => { - // node-mocks-http doesn't correctly handle non-Buffer typed arrays, - // so override the write method to fix it. - const write = res.write; - res.write = function (data, encoding) { - if (ArrayBuffer.isView(data) && !Buffer.isBuffer(data)) { - data = Buffer.from(data.buffer); - } - return write.call(this, data, encoding); - }; - res.on('end', () => { - const chunks = res._getChunks(); - resolve(chunks); - }); - }); -} - -function buffersToString(buffers) { - const decoder = new TextDecoder(); - let str = ''; - for (const buffer of buffers) { - str += decoder.decode(buffer); - } - return str; -} - -export function waitServerListen(server) { - return new Promise((resolve, reject) => { - function onListen() { - server.off('error', onError); - resolve(); - } - function onError(error) { - server.off('listening', onListen); - reject(error); - } - server.once('listening', onListen); - server.once('error', onError); - }); -} diff --git a/packages/integrations/node/test/test-utils.ts b/packages/integrations/node/test/test-utils.ts new file mode 100644 index 000000000000..3fea903844cd --- /dev/null +++ b/packages/integrations/node/test/test-utils.ts @@ -0,0 +1,94 @@ +import { EventEmitter } from 'node:events'; +import type { Server, ServerResponse } from 'node:http'; +import * as httpMocks from 'node-mocks-http'; +import { + type AstroInlineConfig, + type Fixture, + type AdapterServer, + type DevServer, + loadFixture as baseLoadFixture, +} from '../../../astro/test/test-utils.js'; +import type * as express from 'express'; + +process.env.ASTRO_NODE_AUTOSTART = 'disabled'; +process.env.ASTRO_NODE_LOGGING = 'disabled'; + +export type { AstroInlineConfig, Fixture, AdapterServer, DevServer }; + +export function loadFixture(inlineConfig: AstroInlineConfig) { + if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }"); + + // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath + // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test` + return baseLoadFixture({ + ...inlineConfig, + root: new URL(inlineConfig.root as string, import.meta.url).toString(), + }); +} + +export function createRequestAndResponse(reqOptions?: httpMocks.RequestOptions): { + req: httpMocks.MockRequest; + res: httpMocks.MockResponse; + done: Promise[]>; + text: () => Promise; +} { + const req: httpMocks.MockRequest = + httpMocks.createRequest(reqOptions); + + const res: httpMocks.MockResponse = httpMocks.createResponse({ + eventEmitter: EventEmitter, + req, + }); + + const done: Promise[]> = toPromise(res); + + // Get the response as text + const text: () => Promise = async () => { + const chunks = await done; + return buffersToString(chunks); + }; + + return { req, res, done, text }; +} + +function toPromise(res: httpMocks.MockResponse): Promise> { + return new Promise((resolve) => { + // node-mocks-http doesn't correctly handle non-Buffer typed arrays, + // so override the write method to fix it. + const write = res.write; + res.write = function (data: any, encoding?: any) { + if (ArrayBuffer.isView(data) && !Buffer.isBuffer(data)) { + data = Buffer.from(data.buffer); + } + return write.call(this, data, encoding); + }; + res.on('end', () => { + const chunks = (res as any)._getChunks(); + resolve(chunks); + }); + }); +} + +function buffersToString(buffers: Array) { + const decoder = new TextDecoder(); + let str = ''; + for (const buffer of buffers) { + str += decoder.decode(buffer); + } + return str; +} + +export function waitServerListen(server: Server): Promise { + return new Promise((resolve, reject) => { + function onListen() { + server.off('error', onError); + resolve(); + } + function onError(error: Error) { + server.off('listening', onListen); + reject(error); + } + server.once('listening', onListen); + server.once('error', onError); + }); +} diff --git a/packages/integrations/node/test/trailing-slash.test.js b/packages/integrations/node/test/trailing-slash.test.js deleted file mode 100644 index 2f37f4d64df8..000000000000 --- a/packages/integrations/node/test/trailing-slash.test.js +++ /dev/null @@ -1,493 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import nodejs from '../dist/index.js'; -import { loadFixture, waitServerListen } from './test-utils.js'; - -/** - * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture - */ - -describe('Trailing slash', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let server; - describe('Always', async () => { - describe('With base', async () => { - before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; - process.env.PRERENDER = true; - - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - base: '/some-base', - output: 'static', - trailingSlash: 'always', - outDir: './dist/always-with-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render prerendered base route', async () => { - const res = await fetch(`http://${server.host}:${server.port}`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Index'); - }); - - it('Can render prerendered route with redirect', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, { - redirect: 'manual', - }); - assert.equal(res.status, 301); - assert.equal(res.headers.get('location'), '/some-base/one/'); - }); - - it('Can render prerendered route with redirect and query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`, { - redirect: 'manual', - }); - assert.equal(res.status, 301); - assert.equal(res.headers.get('location'), '/some-base/one/?foo=bar'); - }); - - it('Can render prerendered route with query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Does not add trailing slash to subresource urls', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one.css`); - const css = await res.text(); - - assert.equal(res.status, 200); - assert.equal(css, 'h1 { color: red; }\n'); - }); - - it('Does not redirect requests for static assets with unusual filenames', async () => { - const res = await fetch( - `http://${server.host}:${server.port}/some-base/_astro/bitgeneva12.NY2V_gnX.woff2`, - { - redirect: 'manual', - }, - ); - - assert.equal(res.status, 200); - }); - }); - describe('Without base', async () => { - before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; - process.env.PRERENDER = true; - - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - output: 'static', - trailingSlash: 'always', - outDir: './dist/always-without-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render prerendered base route', async () => { - const res = await fetch(`http://${server.host}:${server.port}`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Index'); - }); - - it('Can render prerendered route with redirect', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one`, { - redirect: 'manual', - }); - assert.equal(res.status, 301); - assert.equal(res.headers.get('location'), '/one/'); - }); - - it('Can render prerendered route with redirect and query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`, { - redirect: 'manual', - }); - assert.equal(res.status, 301); - assert.equal(res.headers.get('location'), '/one/?foo=bar'); - }); - - it('Can render prerendered route with query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Does not add trailing slash to subresource urls', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one.css`, { - redirect: 'manual', - }); - const css = await res.text(); - - assert.equal(res.status, 200); - assert.equal(css, 'h1 { color: red; }\n'); - }); - - it('Does not redirect requests for static assets with unusual filenames', async () => { - const res = await fetch( - `http://${server.host}:${server.port}/_astro/bitgeneva12.NY2V_gnX.woff2`, - { - redirect: 'manual', - }, - ); - - assert.equal(res.status, 200); - }); - }); - - describe('Without automatic output', () => { - before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; - - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - trailingSlash: 'always', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - }); - - it('Should return 404 when trying to serve a page with an internal path added to the URL', async () => { - let res = await fetch(`http://${server.host}:${server.port}//astro.build/press`); - assert.equal(res.status, 404); - res = await fetch(`http://${server.host}:${server.port}/foo//astro.build/press`); - assert.equal(res.status, 404); - res = await fetch(`http://${server.host}:${server.port}//example.com/es//astro.build`); - assert.equal(res.status, 404); - res = await fetch( - `http://${server.host}:${server.port}//example.com/es//astro.build/press`, - ); - assert.equal(res.status, 404); - }); - }); - }); - describe('Never', async () => { - describe('With base', async () => { - before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; - process.env.PRERENDER = true; - - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - base: '/some-base', - output: 'static', - trailingSlash: 'never', - outDir: './dist/never-with-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render prerendered base route', async () => { - const res = await fetch(`http://${server.host}:${server.port}`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Index'); - }); - - it('Can render prerendered route with redirect', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one/`, { - redirect: 'manual', - }); - assert.equal(res.status, 301); - assert.equal(res.headers.get('location'), '/some-base/one'); - }); - - it('Can render prerendered route with redirect and query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`, { - redirect: 'manual', - }); - - assert.equal(res.status, 301); - assert.equal(res.headers.get('location'), '/some-base/one?foo=bar'); - }); - - it('Can render prerendered route with query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - }); - describe('Without base', async () => { - before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; - process.env.PRERENDER = true; - - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - output: 'static', - trailingSlash: 'never', - outDir: './dist/never-without-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render prerendered base route', async () => { - const res = await fetch(`http://${server.host}:${server.port}`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Index'); - }); - - it('Can render prerendered route with redirect', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one/`, { - redirect: 'manual', - }); - assert.equal(res.status, 301); - assert.equal(res.headers.get('location'), '/one'); - }); - - it('Can render prerendered route with redirect and query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`, { - redirect: 'manual', - }); - - assert.equal(res.status, 301); - assert.equal(res.headers.get('location'), '/one?foo=bar'); - }); - - it('Can render prerendered route and query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - }); - }); - describe('Ignore', async () => { - describe('With base', async () => { - before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; - process.env.PRERENDER = true; - - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - base: '/some-base', - output: 'static', - trailingSlash: 'ignore', - outDir: './dist/ignore-with-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render prerendered base route', async () => { - const res = await fetch(`http://${server.host}:${server.port}`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Index'); - }); - - it('Can render prerendered route with slash', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one/`, { - redirect: 'manual', - }); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route without slash', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, { - redirect: 'manual', - }); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route with slash and query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`, { - redirect: 'manual', - }); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route without slash and with query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`, { - redirect: 'manual', - }); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - }); - describe('Without base', async () => { - before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; - process.env.PRERENDER = true; - - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - output: 'static', - trailingSlash: 'ignore', - outDir: './dist/ignore-without-base', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - const { startServer } = await fixture.loadAdapterEntryModule(); - const res = startServer(); - server = res.server; - await waitServerListen(server.server); - }); - - after(async () => { - await server.stop(); - await fixture.clean(); - - delete process.env.PRERENDER; - }); - - it('Can render prerendered base route', async () => { - const res = await fetch(`http://${server.host}:${server.port}`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'Index'); - }); - - it('Can render prerendered route with slash', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one/`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route without slash', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route with slash and query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`, { - redirect: 'manual', - }); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - - it('Can render prerendered route without slash and with query params', async () => { - const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`); - const html = await res.text(); - const $ = cheerio.load(html); - - assert.equal(res.status, 200); - assert.equal($('h1').text(), 'One'); - }); - }); - }); -}); diff --git a/packages/integrations/node/test/trailing-slash.test.ts b/packages/integrations/node/test/trailing-slash.test.ts new file mode 100644 index 000000000000..ef375823761d --- /dev/null +++ b/packages/integrations/node/test/trailing-slash.test.ts @@ -0,0 +1,470 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import nodejs from '../dist/index.js'; +import { type Fixture, loadFixture, waitServerListen, type AdapterServer } from './test-utils.ts'; + +describe('Trailing slash', () => { + let fixture: Fixture; + let server: AdapterServer; + describe('Always', async () => { + describe('With base', async () => { + before(async () => { + process.env.ASTRO_NODE_AUTOSTART = 'disabled'; + + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + base: '/some-base', + output: 'static', + trailingSlash: 'always', + outDir: './dist/always-with-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render prerendered base route', async () => { + const res = await fetch(`http://${server.host}:${server.port}`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Index'); + }); + + it('Can render prerendered route with redirect', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, { + redirect: 'manual', + }); + assert.equal(res.status, 301); + assert.equal(res.headers.get('location'), '/some-base/one/'); + }); + + it('Can render prerendered route with redirect and query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`, { + redirect: 'manual', + }); + assert.equal(res.status, 301); + assert.equal(res.headers.get('location'), '/some-base/one/?foo=bar'); + }); + + it('Can render prerendered route with query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Does not add trailing slash to subresource urls', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one.css`); + const css = await res.text(); + + assert.equal(res.status, 200); + assert.equal(css, 'h1 { color: red; }\n'); + }); + + it('Does not redirect requests for static assets with unusual filenames', async () => { + const res = await fetch( + `http://${server.host}:${server.port}/some-base/_astro/bitgeneva12.NY2V_gnX.woff2`, + { + redirect: 'manual', + }, + ); + + assert.equal(res.status, 200); + }); + }); + describe('Without base', async () => { + before(async () => { + process.env.ASTRO_NODE_AUTOSTART = 'disabled'; + + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + output: 'static', + trailingSlash: 'always', + outDir: './dist/always-without-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render prerendered base route', async () => { + const res = await fetch(`http://${server.host}:${server.port}`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Index'); + }); + + it('Can render prerendered route with redirect', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one`, { + redirect: 'manual', + }); + assert.equal(res.status, 301); + assert.equal(res.headers.get('location'), '/one/'); + }); + + it('Can render prerendered route with redirect and query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`, { + redirect: 'manual', + }); + assert.equal(res.status, 301); + assert.equal(res.headers.get('location'), '/one/?foo=bar'); + }); + + it('Can render prerendered route with query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Does not add trailing slash to subresource urls', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one.css`, { + redirect: 'manual', + }); + const css = await res.text(); + + assert.equal(res.status, 200); + assert.equal(css, 'h1 { color: red; }\n'); + }); + + it('Does not redirect requests for static assets with unusual filenames', async () => { + const res = await fetch( + `http://${server.host}:${server.port}/_astro/bitgeneva12.NY2V_gnX.woff2`, + { + redirect: 'manual', + }, + ); + + assert.equal(res.status, 200); + }); + }); + + describe('Without automatic output', () => { + before(async () => { + process.env.ASTRO_NODE_AUTOSTART = 'disabled'; + + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + trailingSlash: 'always', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Should return 404 when trying to serve a page with an internal path added to the URL', async () => { + let res = await fetch(`http://${server.host}:${server.port}//astro.build/press`); + assert.equal(res.status, 404); + res = await fetch(`http://${server.host}:${server.port}/foo//astro.build/press`); + assert.equal(res.status, 404); + res = await fetch(`http://${server.host}:${server.port}//example.com/es//astro.build`); + assert.equal(res.status, 404); + res = await fetch( + `http://${server.host}:${server.port}//example.com/es//astro.build/press`, + ); + assert.equal(res.status, 404); + }); + }); + }); + describe('Never', async () => { + describe('With base', async () => { + before(async () => { + process.env.ASTRO_NODE_AUTOSTART = 'disabled'; + + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + base: '/some-base', + output: 'static', + trailingSlash: 'never', + outDir: './dist/never-with-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render prerendered base route', async () => { + const res = await fetch(`http://${server.host}:${server.port}`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Index'); + }); + + it('Can render prerendered route with redirect', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one/`, { + redirect: 'manual', + }); + assert.equal(res.status, 301); + assert.equal(res.headers.get('location'), '/some-base/one'); + }); + + it('Can render prerendered route with redirect and query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`, { + redirect: 'manual', + }); + + assert.equal(res.status, 301); + assert.equal(res.headers.get('location'), '/some-base/one?foo=bar'); + }); + + it('Can render prerendered route with query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + }); + describe('Without base', async () => { + before(async () => { + process.env.ASTRO_NODE_AUTOSTART = 'disabled'; + + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + output: 'static', + trailingSlash: 'never', + outDir: './dist/never-without-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render prerendered base route', async () => { + const res = await fetch(`http://${server.host}:${server.port}`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Index'); + }); + + it('Can render prerendered route with redirect', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one/`, { + redirect: 'manual', + }); + assert.equal(res.status, 301); + assert.equal(res.headers.get('location'), '/one'); + }); + + it('Can render prerendered route with redirect and query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`, { + redirect: 'manual', + }); + + assert.equal(res.status, 301); + assert.equal(res.headers.get('location'), '/one?foo=bar'); + }); + + it('Can render prerendered route and query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + }); + }); + describe('Ignore', async () => { + describe('With base', async () => { + before(async () => { + process.env.ASTRO_NODE_AUTOSTART = 'disabled'; + + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + base: '/some-base', + output: 'static', + trailingSlash: 'ignore', + outDir: './dist/ignore-with-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render prerendered base route', async () => { + const res = await fetch(`http://${server.host}:${server.port}`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Index'); + }); + + it('Can render prerendered route with slash', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one/`, { + redirect: 'manual', + }); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route without slash', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, { + redirect: 'manual', + }); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route with slash and query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`, { + redirect: 'manual', + }); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route without slash and with query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`, { + redirect: 'manual', + }); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + }); + describe('Without base', async () => { + before(async () => { + process.env.ASTRO_NODE_AUTOSTART = 'disabled'; + + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + output: 'static', + trailingSlash: 'ignore', + outDir: './dist/ignore-without-base', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await fixture.loadAdapterEntryModule(); + const res = startServer(); + server = res.server; + await waitServerListen(server.server); + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + }); + + it('Can render prerendered base route', async () => { + const res = await fetch(`http://${server.host}:${server.port}`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'Index'); + }); + + it('Can render prerendered route with slash', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one/`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route without slash', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route with slash and query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`, { + redirect: 'manual', + }); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + + it('Can render prerendered route without slash and with query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + assert.equal(res.status, 200); + assert.equal($('h1').text(), 'One'); + }); + }); + }); +}); diff --git a/packages/integrations/node/test/units/resolve-client-dir.test.js b/packages/integrations/node/test/units/resolve-client-dir.test.ts similarity index 100% rename from packages/integrations/node/test/units/resolve-client-dir.test.js rename to packages/integrations/node/test/units/resolve-client-dir.test.ts diff --git a/packages/integrations/node/test/units/serve-static-path-traversal.test.js b/packages/integrations/node/test/units/serve-static-path-traversal.test.js deleted file mode 100644 index 6265450330a4..000000000000 --- a/packages/integrations/node/test/units/serve-static-path-traversal.test.js +++ /dev/null @@ -1,59 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { describe, it, before, after } from 'node:test'; -import { resolveStaticPath } from '../../dist/serve-static.js'; - -describe('resolveStaticPath', () => { - let tmpRoot; - let clientDir; - - before(() => { - tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'astro-test-')); - clientDir = path.join(tmpRoot, 'client'); - fs.mkdirSync(clientDir); - fs.mkdirSync(path.join(clientDir, 'assets')); - fs.writeFileSync(path.join(clientDir, 'index.html'), '

    hello

    '); - fs.mkdirSync(path.join(tmpRoot, 'secret')); - }); - - after(() => { - fs.rmSync(tmpRoot, { recursive: true, force: true }); - }); - - it('detects a subdirectory within client root', () => { - const result = resolveStaticPath(clientDir, '/assets'); - assert.equal(result.isDirectory, true); - }); - - it('returns false for a non-existent path', () => { - const result = resolveStaticPath(clientDir, '/nope'); - assert.equal(result.isDirectory, false); - }); - - it('returns false for a sibling directory via ../', () => { - const result = resolveStaticPath(clientDir, '/../secret'); - assert.equal(result.isDirectory, false); - }); - - it('returns false for the parent directory', () => { - const result = resolveStaticPath(clientDir, '/..'); - assert.equal(result.isDirectory, false); - }); - - it('returns false for deep .. traversal', () => { - const result = resolveStaticPath(clientDir, '/../../../../../../../usr'); - assert.equal(result.isDirectory, false); - }); - - it('returns false for .. traversal with trailing slash', () => { - const result = resolveStaticPath(clientDir, '/../secret/'); - assert.equal(result.isDirectory, false); - }); - - it('detects the client root itself', () => { - const result = resolveStaticPath(clientDir, '/'); - assert.equal(result.isDirectory, true); - }); -}); diff --git a/packages/integrations/node/test/units/serve-static-path-traversal.test.ts b/packages/integrations/node/test/units/serve-static-path-traversal.test.ts new file mode 100644 index 000000000000..131aa5a2e73c --- /dev/null +++ b/packages/integrations/node/test/units/serve-static-path-traversal.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, describe, it } from 'node:test'; +import { resolveStaticPath } from '../../dist/serve-static.js'; + +describe('resolveStaticPath', () => { + let tmpRoot: string; + let clientDir: string; + + before(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'astro-test-')); + clientDir = path.join(tmpRoot, 'client'); + fs.mkdirSync(clientDir); + fs.mkdirSync(path.join(clientDir, 'assets')); + fs.writeFileSync(path.join(clientDir, 'index.html'), '

    hello

    '); + fs.mkdirSync(path.join(tmpRoot, 'secret')); + }); + + after(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('detects a subdirectory within client root', () => { + const result = resolveStaticPath(clientDir, '/assets'); + assert.equal(result.isDirectory, true); + }); + + it('returns false for a non-existent path', () => { + const result = resolveStaticPath(clientDir, '/nope'); + assert.equal(result.isDirectory, false); + }); + + it('returns false for a sibling directory via ../', () => { + const result = resolveStaticPath(clientDir, '/../secret'); + assert.equal(result.isDirectory, false); + }); + + it('returns false for the parent directory', () => { + const result = resolveStaticPath(clientDir, '/..'); + assert.equal(result.isDirectory, false); + }); + + it('returns false for deep .. traversal', () => { + const result = resolveStaticPath(clientDir, '/../../../../../../../usr'); + assert.equal(result.isDirectory, false); + }); + + it('returns false for .. traversal with trailing slash', () => { + const result = resolveStaticPath(clientDir, '/../secret/'); + assert.equal(result.isDirectory, false); + }); + + it('detects the client root itself', () => { + const result = resolveStaticPath(clientDir, '/'); + assert.equal(result.isDirectory, true); + }); +}); diff --git a/packages/integrations/node/test/url.test.js b/packages/integrations/node/test/url.test.js deleted file mode 100644 index c3e90ead56f9..000000000000 --- a/packages/integrations/node/test/url.test.js +++ /dev/null @@ -1,200 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { TLSSocket } from 'node:tls'; -import * as cheerio from 'cheerio'; -import nodejs from '../dist/index.js'; -import { createRequestAndResponse, loadFixture } from './test-utils.js'; - -describe('URL', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/url/', - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - }); - - it('return http when non-secure', async () => { - const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - url: '/', - }); - - handler(req, res); - req.send(); - - const html = await text(); - assert.equal(html.includes('http:'), true); - }); - - it('return https when secure', async () => { - const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - socket: new TLSSocket(), - url: '/', - }); - - handler(req, res); - req.send(); - - const html = await text(); - assert.equal(html.includes('https:'), true); - }); - - it('return http when the X-Forwarded-Proto header is set to http', async () => { - const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - headers: { 'X-Forwarded-Proto': 'http' }, - url: '/', - }); - - handler(req, res); - req.send(); - - const html = await text(); - assert.equal(html.includes('http:'), true); - }); - - it('return https when the X-Forwarded-Proto header is set to https', async () => { - const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - headers: { 'X-Forwarded-Proto': 'https' }, - url: '/', - }); - - handler(req, res); - req.send(); - - const html = await text(); - assert.equal(html.includes('https:'), true); - }); - - it('includes forwarded host and port in the url', async () => { - const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - headers: { - 'X-Forwarded-Proto': 'https', - 'X-Forwarded-Host': 'abc.xyz', - 'X-Forwarded-Port': '444', - Host: 'localhost:3000', - }, - url: '/', - }); - - handler(req, res); - req.send(); - - const html = await text(); - const $ = cheerio.load(html); - - assert.equal($('body').text(), 'https://abc.xyz:444/'); - }); - - it('accepts port in forwarded host and forwarded port', async () => { - const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - headers: { - 'X-Forwarded-Proto': 'https', - 'X-Forwarded-Host': 'abc.xyz:444', - 'X-Forwarded-Port': '444', - }, - url: '/', - }); - - handler(req, res); - req.send(); - - const html = await text(); - const $ = cheerio.load(html); - - assert.equal($('body').text(), 'https://abc.xyz:444/'); - }); - - it('ignores X-Forwarded-Host when no allowedDomains configured', async () => { - const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - headers: { - 'X-Forwarded-Proto': 'https', - 'X-Forwarded-Host': 'malicious.example.com', - Host: 'legitimate.example.com', - }, - url: '/', - }); - - handler(req, res); - req.send(); - - const html = await text(); - const $ = cheerio.load(html); - - // Should use the Host header, not X-Forwarded-Host when allowedDomains is not configured - assert.equal($('body').text(), 'https://legitimate.example.com/'); - }); - - it('rejects port in forwarded host when port not in allowedDomains', async () => { - const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - headers: { - 'X-Forwarded-Proto': 'https', - 'X-Forwarded-Host': 'abc.xyz:8080', - Host: 'localhost:3000', - }, - url: '/', - }); - - handler(req, res); - req.send(); - - const html = await text(); - const $ = cheerio.load(html); - - // Port 8080 not in allowedDomains (only 444), so should fall back to Host header - assert.equal($('body').text(), 'https://localhost:3000/'); - }); - - it('rejects empty X-Forwarded-Host with allowedDomains configured', async () => { - const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - headers: { - 'X-Forwarded-Proto': 'https', - 'X-Forwarded-Host': '', - Host: 'legitimate.example.com', - }, - url: '/', - }); - - handler(req, res); - req.send(); - - const html = await text(); - const $ = cheerio.load(html); - - // Empty X-Forwarded-Host should be rejected and fall back to Host header - assert.equal($('body').text(), 'https://legitimate.example.com/'); - }); - - it('rejects X-Forwarded-Host with path injection attempt', async () => { - const { handler } = await import('./fixtures/url/dist/server/entry.mjs'); - const { req, res, text } = createRequestAndResponse({ - headers: { - 'X-Forwarded-Proto': 'https', - 'X-Forwarded-Host': 'example.com/admin', - Host: 'localhost:3000', - }, - url: '/', - }); - - handler(req, res); - req.send(); - - const html = await text(); - const $ = cheerio.load(html); - - // Path injection attempt should be rejected and fall back to Host header - assert.equal($('body').text(), 'https://localhost:3000/'); - }); -}); diff --git a/packages/integrations/node/test/url.test.ts b/packages/integrations/node/test/url.test.ts new file mode 100644 index 000000000000..2c7b4fbac32f --- /dev/null +++ b/packages/integrations/node/test/url.test.ts @@ -0,0 +1,202 @@ +import * as assert from 'node:assert/strict'; +import { Socket } from 'node:net'; +import { before, describe, it } from 'node:test'; +import { TLSSocket } from 'node:tls'; +import * as cheerio from 'cheerio'; +import nodejs from '../dist/index.js'; +import { createRequestAndResponse, type Fixture, loadFixture } from './test-utils.ts'; + +describe('URL', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/url/', + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + }); + + it('return http when non-secure', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + url: '/', + }); + + handler(req, res); + req.send(); + + const html: string = await text(); + assert.equal(html.includes('http:'), true); + assert.equal(html.includes('https:'), false); + }); + + it('return https when secure', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + socket: new TLSSocket(new Socket()), + url: '/', + }); + + handler(req, res); + req.send(); + + const html: string = await text(); + assert.equal(html.includes('http:'), false); + assert.equal(html.includes('https:'), true); + }); + + it('return http when the X-Forwarded-Proto header is set to http', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + headers: { 'X-Forwarded-Proto': 'http' }, + url: '/', + }); + + handler(req, res); + req.send(); + + const html = await text(); + assert.equal(html.includes('http:'), true); + }); + + it('return https when the X-Forwarded-Proto header is set to https', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + headers: { 'X-Forwarded-Proto': 'https' }, + url: '/', + }); + + handler(req, res); + req.send(); + + const html = await text(); + assert.equal(html.includes('https:'), true); + }); + + it('includes forwarded host and port in the url', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + headers: { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'abc.xyz', + 'X-Forwarded-Port': '444', + Host: 'localhost:3000', + }, + url: '/', + }); + + handler(req, res); + req.send(); + + const html = await text(); + const $ = cheerio.load(html); + + assert.equal($('body').text(), 'https://abc.xyz:444/'); + }); + + it('accepts port in forwarded host and forwarded port', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + headers: { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'abc.xyz:444', + 'X-Forwarded-Port': '444', + }, + url: '/', + }); + + handler(req, res); + req.send(); + + const html = await text(); + const $ = cheerio.load(html); + + assert.equal($('body').text(), 'https://abc.xyz:444/'); + }); + + it('ignores X-Forwarded-Host when no allowedDomains configured', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + headers: { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'malicious.example.com', + Host: 'legitimate.example.com', + }, + url: '/', + }); + + handler(req, res); + req.send(); + + const html = await text(); + const $ = cheerio.load(html); + + // Should use the Host header, not X-Forwarded-Host when allowedDomains is not configured + assert.equal($('body').text(), 'https://legitimate.example.com/'); + }); + + it('rejects port in forwarded host when port not in allowedDomains', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + headers: { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'abc.xyz:8080', + Host: 'localhost:3000', + }, + url: '/', + }); + + handler(req, res); + req.send(); + + const html = await text(); + const $ = cheerio.load(html); + + // Port 8080 not in allowedDomains (only 444), so should fall back to Host header + assert.equal($('body').text(), 'https://localhost:3000/'); + }); + + it('rejects empty X-Forwarded-Host with allowedDomains configured', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + headers: { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': '', + Host: 'legitimate.example.com', + }, + url: '/', + }); + + handler(req, res); + req.send(); + + const html = await text(); + const $ = cheerio.load(html); + + // Empty X-Forwarded-Host should be rejected and fall back to Host header + assert.equal($('body').text(), 'https://legitimate.example.com/'); + }); + + it('rejects X-Forwarded-Host with path injection attempt', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, text } = createRequestAndResponse({ + headers: { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'example.com/admin', + Host: 'localhost:3000', + }, + url: '/', + }); + + handler(req, res); + req.send(); + + const html = await text(); + const $ = cheerio.load(html); + + // Path injection attempt should be rejected and fall back to Host header + assert.equal($('body').text(), 'https://localhost:3000/'); + }); +}); diff --git a/packages/integrations/node/test/well-known-locations.test.js b/packages/integrations/node/test/well-known-locations.test.js deleted file mode 100644 index 57082f28ad6e..000000000000 --- a/packages/integrations/node/test/well-known-locations.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import nodejs from '../dist/index.js'; -import { createRequestAndResponse, loadFixture } from './test-utils.js'; - -describe('test URIs beginning with a dot', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/well-known-locations/', - output: 'server', - adapter: nodejs({ mode: 'standalone' }), - }); - await fixture.build(); - }); - - describe('can load well-known URIs', async () => { - let devPreview; - - before(async () => { - devPreview = await fixture.preview(); - }); - - after(async () => { - await devPreview.stop(); - }); - - it('can load a valid well-known URI', async () => { - const res = await fixture.fetch('/.well-known/apple-app-site-association'); - - assert.equal(res.status, 200); - - const json = await res.json(); - - assert.notEqual(json.applinks, {}); - }); - - it('cannot load a dot folder that is not a well-known URI', async () => { - const res = await fixture.fetch('/.hidden/file.json'); - - assert.equal(res.status, 404); - }); - }); - - describe('dotfile access via unnormalized paths', async () => { - it('denies dotfile access when path contains .well-known/../ traversal', async () => { - const { handler } = await import('./fixtures/well-known-locations/dist/server/entry.mjs'); - const { req, res, done } = createRequestAndResponse({ - method: 'GET', - url: '/.well-known/../.hidden-file', - }); - - handler(req, res); - req.send(); - - await done; - assert.notEqual( - res.statusCode, - 200, - 'dotfile should not be served via .well-known path traversal', - ); - }); - - it('denies dotfolder file access when path contains .well-known/../ traversal', async () => { - const { handler } = await import('./fixtures/well-known-locations/dist/server/entry.mjs'); - const { req, res, done } = createRequestAndResponse({ - method: 'GET', - url: '/.well-known/../.hidden/file.json', - }); - - handler(req, res); - req.send(); - - await done; - assert.notEqual( - res.statusCode, - 200, - 'dotfolder file should not be served via .well-known path traversal', - ); - }); - }); -}); diff --git a/packages/integrations/node/test/well-known-locations.test.ts b/packages/integrations/node/test/well-known-locations.test.ts new file mode 100644 index 000000000000..a8afdd1519b4 --- /dev/null +++ b/packages/integrations/node/test/well-known-locations.test.ts @@ -0,0 +1,84 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import type { PreviewServer } from '../../../astro/src/types/public/preview.js'; +import nodejs from '../dist/index.js'; +import { createRequestAndResponse, type Fixture, loadFixture } from './test-utils.ts'; + +describe('test URIs beginning with a dot', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/well-known-locations/', + output: 'server', + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + }); + + describe('can load well-known URIs', async () => { + let devPreview: PreviewServer; + + before(async () => { + devPreview = await fixture.preview(); + }); + + after(async () => { + await devPreview.stop(); + }); + + it('can load a valid well-known URI', async () => { + const res = await fixture.fetch('/.well-known/apple-app-site-association'); + + assert.equal(res.status, 200); + + const json = await res.json(); + + assert.notEqual(json.applinks, {}); + }); + + it('cannot load a dot folder that is not a well-known URI', async () => { + const res = await fixture.fetch('/.hidden/file.json'); + + assert.equal(res.status, 404); + }); + }); + + describe('dotfile access via unnormalized paths', async () => { + it('denies dotfile access when path contains .well-known/../ traversal', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, done } = createRequestAndResponse({ + method: 'GET', + url: '/.well-known/../.hidden-file', + }); + + handler(req, res); + req.send(); + + await done; + assert.notEqual( + res.statusCode, + 200, + 'dotfile should not be served via .well-known path traversal', + ); + }); + + it('denies dotfolder file access when path contains .well-known/../ traversal', async () => { + const handler = await fixture.loadNodeAdapterHandler(); + const { req, res, done } = createRequestAndResponse({ + method: 'GET', + url: '/.well-known/../.hidden/file.json', + }); + + handler(req, res); + req.send(); + + await done; + assert.notEqual( + res.statusCode, + 200, + 'dotfolder file should not be served via .well-known path traversal', + ); + }); + }); +}); diff --git a/packages/integrations/node/tsconfig.test.json b/packages/integrations/node/tsconfig.test.json new file mode 100644 index 000000000000..27c89c5fe7a7 --- /dev/null +++ b/packages/integrations/node/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../../astro/tsconfig.test.json" + } + ] +} diff --git a/packages/integrations/partytown/CHANGELOG.md b/packages/integrations/partytown/CHANGELOG.md index ac6e563a923a..f02cf6c2c9cb 100644 --- a/packages/integrations/partytown/CHANGELOG.md +++ b/packages/integrations/partytown/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/partytown +## 2.1.7 + +### Patch Changes + +- [#16265](https://github.com/withastro/astro/pull/16265) [`7fe40bc`](https://github.com/withastro/astro/commit/7fe40bc7381d981dedad16625d89c00e31cd8fd0) Thanks [@ChrisLaRocque](https://github.com/ChrisLaRocque)! - Updates `@qwik.dev/partytown` to 0.13.2 + ## 2.1.6 ### Patch Changes diff --git a/packages/integrations/partytown/package.json b/packages/integrations/partytown/package.json index f49d9c95ade6..69c4baba0447 100644 --- a/packages/integrations/partytown/package.json +++ b/packages/integrations/partytown/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/partytown", "description": "Use Partytown to move scripts into a web worker in your Astro project", - "version": "2.1.6", + "version": "2.1.7", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", @@ -32,7 +32,7 @@ "dev": "astro-scripts dev \"src/**/*.ts\"" }, "dependencies": { - "@qwik.dev/partytown": "^0.11.2", + "@qwik.dev/partytown": "^0.13.2", "mrmime": "^2.0.1" }, "devDependencies": { diff --git a/packages/integrations/preact/CHANGELOG.md b/packages/integrations/preact/CHANGELOG.md index 05d807ee4ff4..e048aeefc3a1 100644 --- a/packages/integrations/preact/CHANGELOG.md +++ b/packages/integrations/preact/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/preact +## 5.1.1 + +### Patch Changes + +- [#16180](https://github.com/withastro/astro/pull/16180) [`1d1448c`](https://github.com/withastro/astro/commit/1d1448c2c0e1a149709ada5d00a74f1cd7c1142b) Thanks [@matthewp](https://github.com/matthewp)! - Pre-optimizes `@preact/signals` and `preact/hooks` in the Vite dep optimizer to prevent late discovery triggering full page reloads during dev + ## 5.1.0 ### Minor Changes diff --git a/packages/integrations/preact/package.json b/packages/integrations/preact/package.json index 79b8ef348017..e2d8464db5d3 100644 --- a/packages/integrations/preact/package.json +++ b/packages/integrations/preact/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/preact", "description": "Use Preact components within Astro", - "version": "5.1.0", + "version": "5.1.1", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", diff --git a/packages/integrations/preact/src/index.ts b/packages/integrations/preact/src/index.ts index 2a3c1d20e9b2..205c696c6e04 100644 --- a/packages/integrations/preact/src/index.ts +++ b/packages/integrations/preact/src/index.ts @@ -126,6 +126,8 @@ function configEnvironmentPlugin(compat: boolean | undefined): Plugin { '@astrojs/preact/client.js', 'preact', 'preact/jsx-runtime', + 'preact/hooks', + '@astrojs/preact > @preact/signals', ]; } diff --git a/packages/integrations/react/CHANGELOG.md b/packages/integrations/react/CHANGELOG.md index 288acdc989fd..5e4ed2d2f34d 100644 --- a/packages/integrations/react/CHANGELOG.md +++ b/packages/integrations/react/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/react +## 5.0.3 + +### Patch Changes + +- [#16224](https://github.com/withastro/astro/pull/16224) [`a2b9eeb`](https://github.com/withastro/astro/commit/a2b9eeb14e300c9b6ce1d6ea423d20f4ef9d92f5) Thanks [@fkatsuhiro](https://github.com/fkatsuhiro)! - Fix React 19 "Float" mechanism injecting into Astro islands instead of the . This PR adds a filter to @astrojs/react to strip these auto-generated resource from the island's HTML output, ensuring valid HTML structure. + ## 5.0.2 ### Patch Changes diff --git a/packages/integrations/react/package.json b/packages/integrations/react/package.json index cc60c6d14cd3..bdd9f52439a0 100644 --- a/packages/integrations/react/package.json +++ b/packages/integrations/react/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/react", "description": "Use React components within Astro", - "version": "5.0.2", + "version": "5.0.3", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", @@ -36,7 +36,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "dependencies": { "@astrojs/internal-helpers": "workspace:*", diff --git a/packages/integrations/react/src/server.ts b/packages/integrations/react/src/server.ts index 4a610d429586..7da3c4535c93 100644 --- a/packages/integrations/react/src/server.ts +++ b/packages/integrations/react/src/server.ts @@ -129,6 +129,13 @@ async function renderToStaticMarkup( } else { html = await renderToPipeableStreamAsync(vnode, renderOptions); } + // Strip React 19 auto-injected resource hints (preloads, etc.) from island output. + // These should be in , not inside the island. + // See: https://github.com/facebook/react/issues/27910 + html = html.replace( + /]*rel="(?:preload|modulepreload|stylesheet|preconnect|dns-prefetch)"[^>]*>/g, + '', + ); return { html, attrs }; } diff --git a/packages/integrations/react/test/fixtures/react-19-preloads/astro.config.mjs b/packages/integrations/react/test/fixtures/react-19-preloads/astro.config.mjs new file mode 100644 index 000000000000..657f300a70d6 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-19-preloads/astro.config.mjs @@ -0,0 +1,6 @@ +import { defineConfig } from 'astro/config'; +import react from '@astrojs/react'; + +export default defineConfig({ + integrations: [react()], +}); diff --git a/packages/integrations/react/test/fixtures/react-19-preloads/package.json b/packages/integrations/react/test/fixtures/react-19-preloads/package.json new file mode 100644 index 000000000000..b7c092cfdf12 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-19-preloads/package.json @@ -0,0 +1,10 @@ +{ + "name": "@fixture/react-19-preloads", + "type": "module", + "dependencies": { + "astro": "latest", + "@astrojs/react": "latest", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/packages/integrations/react/test/fixtures/react-19-preloads/src/components/ImageComponent.jsx b/packages/integrations/react/test/fixtures/react-19-preloads/src/components/ImageComponent.jsx new file mode 100644 index 000000000000..881bcc6ba432 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-19-preloads/src/components/ImageComponent.jsx @@ -0,0 +1,8 @@ +export default function ImageComponent() { + return ( +
    +

    React 19 Island

    + Test +
    + ); +} diff --git a/packages/integrations/react/test/fixtures/react-19-preloads/src/pages/index.astro b/packages/integrations/react/test/fixtures/react-19-preloads/src/pages/index.astro new file mode 100644 index 000000000000..6df24b0504b1 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-19-preloads/src/pages/index.astro @@ -0,0 +1,11 @@ +--- +import ImageComponent from '../components/ImageComponent'; +--- + + + React 19 Test + + + + + diff --git a/packages/integrations/react/test/parsed-react-children.test.js b/packages/integrations/react/test/parsed-react-children.test.ts similarity index 100% rename from packages/integrations/react/test/parsed-react-children.test.js rename to packages/integrations/react/test/parsed-react-children.test.ts diff --git a/packages/integrations/react/test/react-19-preloads.test.ts b/packages/integrations/react/test/react-19-preloads.test.ts new file mode 100644 index 000000000000..ccca49827066 --- /dev/null +++ b/packages/integrations/react/test/react-19-preloads.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert'; +import { test } from 'node:test'; +import { loadFixture } from '../../../astro/test/test-utils.js'; + +test.describe('React 19 SSR integration', () => { + test('should strip preloads to prevent invalid HTML inside astro-islands', async () => { + const fixture = await loadFixture({ + root: new URL('./fixtures/react-19-preloads/', import.meta.url), + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + const islandPattern = /]*>([\s\S]*?)<\/astro-island>/; + const match = islandPattern.exec(html); + const island = match ? match[1] : ''; + + assert.ok(!island.includes('rel="preload"'), 'React 19: preloads should be stripped'); + assert.ok(island.includes(' { - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/react-component/', import.meta.url), - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('Can load React', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - // test 1: basic component renders - assert.equal($('#react-static').text(), 'Hello static!'); - - // test 2: no reactroot - assert.equal($('#react-static').attr('data-reactroot'), undefined); - - // test 3: Can use function components - assert.equal($('#arrow-fn-component').length, 1); - - // test 4: Can use spread for components - assert.equal($('#component-spread-props').length, 1); - - // test 5: spread props renders - assert.equal($('#component-spread-props').text(), 'Hello world!'); - - // test 6: Can use TS components - assert.equal($('.ts-component').length, 1); - - // test 7: Can use Pure components - assert.equal($('#pure').length, 1); - - // test 8: Check number of islands - assert.equal($('astro-island[uid]').length, 9); - - // test 9: Check island deduplication - const uniqueRootUIDs = new Set($('astro-island').map((_i, el) => $(el).attr('uid'))); - assert.equal(uniqueRootUIDs.size, 8); - - // test 10: Should properly render children passed as props - const islandsWithChildren = $('.with-children'); - assert.equal(islandsWithChildren.length, 2); - assert.equal( - $(islandsWithChildren[0]).html(), - $(islandsWithChildren[1]).find('astro-slot').html(), - ); - - // test 11: Should generate unique React.useId per island - const islandsWithId = $('.react-use-id'); - assert.equal(islandsWithId.length, 2); - assert.notEqual($(islandsWithId[0]).attr('id'), $(islandsWithId[1]).attr('id')); - - // test 12: Passes boolean attributes to components as expected - assert.equal($('#true').attr('attr'), 'attr-true'); - assert.equal($('#true').attr('type'), 'boolean'); - assert.equal($('#false').attr('attr'), 'attr-false'); - assert.equal($('#false').attr('type'), 'boolean'); - }); - - it('Can load Vue', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - assert.equal($('#vue-h2').text(), 'Hasta la vista, baby'); - }); - - it('Can use a pragma comment', async () => { - const html = await fixture.readFile('/pragma-comment/index.html'); - const $ = cheerioLoad(html); - - // test 1: rendered the PragmaComment component - assert.equal($('.pragma-comment').length, 2); - }); - - // TODO: is this still a relevant test? - it.skip('Includes reactroot on hydrating components', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - const div = $('#research'); - - // test 1: has the hydration attr - assert.ok(div.attr('data-reactroot')); - - // test 2: renders correctly - assert.equal(div.html(), 'foo bar 1'); - }); - - it('Can load Suspense-using components', async () => { - const html = await fixture.readFile('/suspense/index.html'); - const $ = cheerioLoad(html); - assert.equal($('#client #lazy').length, 1); - assert.equal($('#server #lazy').length, 1); - }); - - it('Can pass through props with cloneElement', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - assert.equal($('#cloned').text(), 'Cloned With Props'); - }); - - it('Children are parsed as React components, can be manipulated', async () => { - const html = await fixture.readFile('/children/index.html'); - const $ = cheerioLoad(html); - assert.equal($('#one .with-children-count').text(), '2'); - }); - - it('Client children passes option to the client', async () => { - const html = await fixture.readFile('/children/index.html'); - const $ = cheerioLoad(html); - assert.equal($('[data-react-children]').length, 2); - }); - - it('Children with class attributes are properly rendered', async () => { - const html = await fixture.readFile('/children/index.html'); - const $ = cheerioLoad(html); - assert.equal($('#three .title').length, 1); - assert.equal($('#three .subtitle').length, 1); - assert.equal($('#three .title').text(), 'Hello'); - assert.equal($('#three .subtitle').text(), 'World'); - }); - - it('Does not render astro-slot for empty slots', async () => { - const html = await fixture.readFile('/slots/index.html'); - const $ = cheerioLoad(html); - - assert.equal($('#empty astro-slot[name="conditional"]').length, 0); - assert.equal($('#filled astro-slot[name="conditional"]').length, 1); - assert.equal($('#filled astro-slot[name="conditional"]').text().trim(), 'Visible'); - }); - }); - - if (isWindows) return; - - describe('dev', () => { - /** @type {import('../../../astro/test/test-utils.js').Fixture} */ - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('scripts proxy correctly', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - for (const script of $('script').toArray()) { - const { src } = script.attribs; - if (!src) continue; - assert.equal((await fixture.fetch(src)).status, 200, `404: ${src}`); - } - }); - - // TODO: move this to separate dev test? - it.skip('Throws helpful error message on window SSR', async () => { - const html = await fixture.fetch('/window/index.html'); - assert.ok( - (await html.text()).includes( - `[/window] - The window object is not available during server-side rendering (SSR). - Try using \`import.meta.env.SSR\` to write SSR-friendly code. - https://docs.astro.build/reference/api-reference/#importmeta`, - ), - ); - }); - - // In moving over to Vite, the jsx-runtime import is now obscured. TODO: update the method of finding this. - it.skip('uses the new JSX transform', async () => { - const html = await fixture.fetch('/index.html'); - - // Grab the imports - const exp = /import\("(.+?)"\)/g; - let match, componentUrl; - while ((match = exp.exec(html))) { - if (match[1].includes('Research.js')) { - componentUrl = match[1]; - break; - } - } - const component = await fixture.readFile(componentUrl); - const jsxRuntime = component.imports.filter((i) => i.specifier.includes('jsx-runtime')); - - // test 1: react/jsx-runtime is used for the component - assert.ok(jsxRuntime); - }); - - it('When a nested component throws it does not crash the server', async () => { - const res = await fixture.fetch('/error-rendering'); - await res.arrayBuffer(); - }); - }); -}); diff --git a/packages/integrations/react/test/react-component.test.ts b/packages/integrations/react/test/react-component.test.ts new file mode 100644 index 000000000000..ceaadaac8d34 --- /dev/null +++ b/packages/integrations/react/test/react-component.test.ts @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { + isWindows, + loadFixture, + type Fixture, + type DevServer, +} from '../../../astro/test/test-utils.js'; + +let fixture: Fixture; + +describe('React Components', () => { + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/react-component/', import.meta.url), + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('Can load React', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + // test 1: basic component renders + assert.equal($('#react-static').text(), 'Hello static!'); + + // test 2: no reactroot + assert.equal($('#react-static').attr('data-reactroot'), undefined); + + // test 3: Can use function components + assert.equal($('#arrow-fn-component').length, 1); + + // test 4: Can use spread for components + assert.equal($('#component-spread-props').length, 1); + + // test 5: spread props renders + assert.equal($('#component-spread-props').text(), 'Hello world!'); + + // test 6: Can use TS components + assert.equal($('.ts-component').length, 1); + + // test 7: Can use Pure components + assert.equal($('#pure').length, 1); + + // test 8: Check number of islands + assert.equal($('astro-island[uid]').length, 9); + + // test 9: Check island deduplication + const uniqueRootUIDs = new Set($('astro-island').map((_i, el) => $(el).attr('uid'))); + assert.equal(uniqueRootUIDs.size, 8); + + // test 10: Should properly render children passed as props + const islandsWithChildren = $('.with-children'); + assert.equal(islandsWithChildren.length, 2); + assert.equal( + $(islandsWithChildren[0]).html(), + $(islandsWithChildren[1]).find('astro-slot').html(), + ); + + // test 11: Should generate unique React.useId per island + const islandsWithId = $('.react-use-id'); + assert.equal(islandsWithId.length, 2); + assert.notEqual($(islandsWithId[0]).attr('id'), $(islandsWithId[1]).attr('id')); + + // test 12: Passes boolean attributes to components as expected + assert.equal($('#true').attr('attr'), 'attr-true'); + assert.equal($('#true').attr('type'), 'boolean'); + assert.equal($('#false').attr('attr'), 'attr-false'); + assert.equal($('#false').attr('type'), 'boolean'); + }); + + it('Can load Vue', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + assert.equal($('#vue-h2').text(), 'Hasta la vista, baby'); + }); + + it('Can use a pragma comment', async () => { + const html = await fixture.readFile('/pragma-comment/index.html'); + const $ = cheerioLoad(html); + + // test 1: rendered the PragmaComment component + assert.equal($('.pragma-comment').length, 2); + }); + + // TODO: is this still a relevant test? + it.skip('Includes reactroot on hydrating components', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + const div = $('#research'); + + // test 1: has the hydration attr + assert.ok(div.attr('data-reactroot')); + + // test 2: renders correctly + assert.equal(div.html(), 'foo bar 1'); + }); + + it('Can load Suspense-using components', async () => { + const html = await fixture.readFile('/suspense/index.html'); + const $ = cheerioLoad(html); + assert.equal($('#client #lazy').length, 1); + assert.equal($('#server #lazy').length, 1); + }); + + it('Can pass through props with cloneElement', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + assert.equal($('#cloned').text(), 'Cloned With Props'); + }); + + it('Children are parsed as React components, can be manipulated', async () => { + const html = await fixture.readFile('/children/index.html'); + const $ = cheerioLoad(html); + assert.equal($('#one .with-children-count').text(), '2'); + }); + + it('Client children passes option to the client', async () => { + const html = await fixture.readFile('/children/index.html'); + const $ = cheerioLoad(html); + assert.equal($('[data-react-children]').length, 2); + }); + + it('Children with class attributes are properly rendered', async () => { + const html = await fixture.readFile('/children/index.html'); + const $ = cheerioLoad(html); + assert.equal($('#three .title').length, 1); + assert.equal($('#three .subtitle').length, 1); + assert.equal($('#three .title').text(), 'Hello'); + assert.equal($('#three .subtitle').text(), 'World'); + }); + + it('Does not render astro-slot for empty slots', async () => { + const html = await fixture.readFile('/slots/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('#empty astro-slot[name="conditional"]').length, 0); + assert.equal($('#filled astro-slot[name="conditional"]').length, 1); + assert.equal($('#filled astro-slot[name="conditional"]').text().trim(), 'Visible'); + }); + }); + + if (isWindows) return; + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('scripts proxy correctly', async () => { + const response = await fixture.fetch('/'); + const html: string = await response.text(); + const $ = cheerioLoad(html); + + for (const script of $('script').toArray()) { + const { src } = script.attribs; + if (!src) continue; + assert.equal((await fixture.fetch(src)).status, 200, `404: ${src}`); + } + }); + + // TODO: move this to separate dev test? + it.skip('Throws helpful error message on window SSR', async () => { + const response = await fixture.fetch('/window/index.html'); + const html: string = await response.text(); + assert.ok( + html.includes( + `[/window] + The window object is not available during server-side rendering (SSR). + Try using \`import.meta.env.SSR\` to write SSR-friendly code. + https://docs.astro.build/reference/api-reference/#importmeta`, + ), + ); + }); + + // In moving over to Vite, the jsx-runtime import is now obscured. TODO: update the method of finding this. + it.skip('uses the new JSX transform', async () => { + const response = await fixture.fetch('/index.html'); + const html: string = await response.text(); + + // Grab the imports + const exp = /import\("(.+?)"\)/g; + let match, componentUrl: string | undefined; + while ((match = exp.exec(html))) { + if (match[1].includes('Research.js')) { + componentUrl = match[1]; + break; + } + } + if (!componentUrl) { + throw new Error('Could not find component URL in HTML'); + } + + const component = await fixture.readFile(componentUrl); + // @ts-expect-error: error TS2339: Property 'imports' does not exist on type 'string'. + const imports = component.imports; + const jsxRuntime = imports.filter((i: any) => i.specifier.includes('jsx-runtime')); + + // test 1: react/jsx-runtime is used for the component + assert.ok(jsxRuntime); + }); + + it('When a nested component throws it does not crash the server', async () => { + const res = await fixture.fetch('/error-rendering'); + await res.arrayBuffer(); + }); + }); +}); diff --git a/packages/integrations/react/tsconfig.test.json b/packages/integrations/react/tsconfig.test.json new file mode 100644 index 000000000000..27c89c5fe7a7 --- /dev/null +++ b/packages/integrations/react/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../../astro/tsconfig.test.json" + } + ] +} diff --git a/packages/integrations/sitemap/package.json b/packages/integrations/sitemap/package.json index a0edd9f3ca46..abcc7c8afdb5 100644 --- a/packages/integrations/sitemap/package.json +++ b/packages/integrations/sitemap/package.json @@ -30,7 +30,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "dependencies": { "sitemap": "^9.0.0", diff --git a/packages/integrations/sitemap/test/base-path.test.js b/packages/integrations/sitemap/test/base-path.test.js deleted file mode 100644 index fee031ff4e02..000000000000 --- a/packages/integrations/sitemap/test/base-path.test.js +++ /dev/null @@ -1,52 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture, readXML } from './test-utils.js'; - -describe('URLs with base path', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - describe('using node adapter', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/ssr/', - base: '/base', - }); - await fixture.build(); - }); - - it('Base path is concatenated correctly', async () => { - const [sitemapZero, sitemapIndex] = await Promise.all([ - readXML(fixture.readFile('/client/sitemap-0.xml')), - readXML(fixture.readFile('/client/sitemap-index.xml')), - ]); - assert.equal(sitemapZero.urlset.url[0].loc[0], 'http://example.com/base/one/'); - assert.equal( - sitemapIndex.sitemapindex.sitemap[0].loc[0], - 'http://example.com/base/sitemap-0.xml', - ); - }); - }); - - describe('static', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - base: '/base', - }); - await fixture.build(); - }); - - it('Base path is concatenated correctly', async () => { - const [sitemapZero, sitemapIndex] = await Promise.all([ - readXML(fixture.readFile('/sitemap-0.xml')), - readXML(fixture.readFile('/sitemap-index.xml')), - ]); - assert.equal(sitemapZero.urlset.url[0].loc[0], 'http://example.com/base/123/'); - assert.equal( - sitemapIndex.sitemapindex.sitemap[0].loc[0], - 'http://example.com/base/sitemap-0.xml', - ); - }); - }); -}); diff --git a/packages/integrations/sitemap/test/base-path.test.ts b/packages/integrations/sitemap/test/base-path.test.ts new file mode 100644 index 000000000000..4444801a3670 --- /dev/null +++ b/packages/integrations/sitemap/test/base-path.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture, readXML } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('URLs with base path', () => { + let fixture: Fixture; + + describe('using node adapter', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr/', + base: '/base', + }); + await fixture.build(); + }); + + it('Base path is concatenated correctly', async () => { + const [sitemapZero, sitemapIndex] = await Promise.all([ + readXML(fixture.readFile('/client/sitemap-0.xml')), + readXML(fixture.readFile('/client/sitemap-index.xml')), + ]); + assert.equal(sitemapZero.urlset.url[0].loc[0], 'http://example.com/base/one/'); + assert.equal( + sitemapIndex.sitemapindex.sitemap[0].loc[0], + 'http://example.com/base/sitemap-0.xml', + ); + }); + }); + + describe('static', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + base: '/base', + }); + await fixture.build(); + }); + + it('Base path is concatenated correctly', async () => { + const [sitemapZero, sitemapIndex] = await Promise.all([ + readXML(fixture.readFile('/sitemap-0.xml')), + readXML(fixture.readFile('/sitemap-index.xml')), + ]); + assert.equal(sitemapZero.urlset.url[0].loc[0], 'http://example.com/base/123/'); + assert.equal( + sitemapIndex.sitemapindex.sitemap[0].loc[0], + 'http://example.com/base/sitemap-0.xml', + ); + }); + }); +}); diff --git a/packages/integrations/sitemap/test/chunks-files.test.js b/packages/integrations/sitemap/test/chunks-files.test.js deleted file mode 100644 index 0fe34078fb25..000000000000 --- a/packages/integrations/sitemap/test/chunks-files.test.js +++ /dev/null @@ -1,61 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { sitemap } from './fixtures/static/deps.mjs'; -import { loadFixture, readXML } from './test-utils.js'; - -describe('Sitemap with chunked files', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - /** @type {string[]} */ - let blogUrls; - let glossaryUrls; - let pagesUrls; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/chunks/', - integrations: [ - sitemap({ - serialize(item) { - return item; - }, - chunks: { - blog: (item) => { - if (item.url.includes('blog')) { - item.changefreq = 'weekly'; - item.lastmod = new Date(); - item.priority = 0.9; - return item; - } - }, - glossary: (item) => { - if (item.url.includes('glossary')) { - item.changefreq = 'weekly'; - item.lastmod = new Date(); - item.priority = 0.9; - return item; - } - }, - }, - }), - ], - }); - await fixture.build(); - const flatMapUrls = async (file) => { - const data = await readXML(fixture.readFile(file)); - return data.urlset.url.map((url) => url.loc[0]); - }; - blogUrls = await flatMapUrls('sitemap-blog-0.xml'); - glossaryUrls = await flatMapUrls('sitemap-glossary-0.xml'); - pagesUrls = await flatMapUrls('sitemap-pages-0.xml'); - }); - - it('includes defined custom pages', async () => { - assert.equal(blogUrls.includes('http://example.com/blog/one/'), true); - assert.equal(blogUrls.includes('http://example.com/blog/two/'), true); - assert.equal(glossaryUrls.includes('http://example.com/glossary/one/'), true); - assert.equal(glossaryUrls.includes('http://example.com/glossary/two/'), true); - assert.equal(pagesUrls.includes('http://example.com/one/'), true); - assert.equal(pagesUrls.includes('http://example.com/two/'), true); - }); -}); diff --git a/packages/integrations/sitemap/test/chunks-files.test.ts b/packages/integrations/sitemap/test/chunks-files.test.ts new file mode 100644 index 000000000000..025b44c98734 --- /dev/null +++ b/packages/integrations/sitemap/test/chunks-files.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { EnumChangefreq } from 'sitemap'; +import { sitemap } from './fixtures/static/deps.mjs'; +import { loadFixture, readXML } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('Sitemap with chunked files', () => { + let fixture: Fixture; + let blogUrls: string[]; + let glossaryUrls: string[]; + let pagesUrls: string[]; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/chunks/', + integrations: [ + sitemap({ + serialize(item) { + return item; + }, + chunks: { + blog: (item) => { + if (item.url.includes('blog')) { + item.changefreq = EnumChangefreq.WEEKLY; + // @ts-expect-error - a string is expected but the original JS code assigns a Date object here + item.lastmod = new Date(); + item.priority = 0.9; + return item; + } + }, + glossary: (item) => { + if (item.url.includes('glossary')) { + item.changefreq = EnumChangefreq.WEEKLY; + // @ts-expect-error - a string is expected but the original JS code assigns a Date object here + item.lastmod = new Date(); + item.priority = 0.9; + return item; + } + }, + }, + }), + ], + }); + await fixture.build(); + const flatMapUrls = async (file: string) => { + const data = await readXML(fixture.readFile(file)); + return data.urlset.url.map((url: { loc: string[] }) => url.loc[0]); + }; + blogUrls = await flatMapUrls('sitemap-blog-0.xml'); + glossaryUrls = await flatMapUrls('sitemap-glossary-0.xml'); + pagesUrls = await flatMapUrls('sitemap-pages-0.xml'); + }); + + it('includes defined custom pages', async () => { + assert.equal(blogUrls.includes('http://example.com/blog/one/'), true); + assert.equal(blogUrls.includes('http://example.com/blog/two/'), true); + assert.equal(glossaryUrls.includes('http://example.com/glossary/one/'), true); + assert.equal(glossaryUrls.includes('http://example.com/glossary/two/'), true); + assert.equal(pagesUrls.includes('http://example.com/one/'), true); + assert.equal(pagesUrls.includes('http://example.com/two/'), true); + }); +}); diff --git a/packages/integrations/sitemap/test/config.test.js b/packages/integrations/sitemap/test/config.test.js deleted file mode 100644 index f95333876d25..000000000000 --- a/packages/integrations/sitemap/test/config.test.js +++ /dev/null @@ -1,125 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { sitemap } from './fixtures/static/deps.mjs'; -import { loadFixture, readXML } from './test-utils.js'; - -describe('Config', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - describe('Static', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - integrations: [ - sitemap({ - filter: (page) => page === 'http://example.com/one/', - xslURL: '/sitemap.xsl', - }), - ], - }); - await fixture.build(); - }); - - it('filter: Just one page is added', async () => { - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - const urls = data.urlset.url; - assert.equal(urls.length, 1); - }); - - it('xslURL: Includes xml-stylesheet', async () => { - const indexXml = await fixture.readFile('/sitemap-index.xml'); - assert.ok( - indexXml.includes( - '', - ), - indexXml, - ); - - const xml = await fixture.readFile('/sitemap-0.xml'); - assert.ok( - xml.includes(''), - xml, - ); - }); - }); - - describe('SSR', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/ssr/', - integrations: [ - sitemap({ - filter: (page) => page === 'http://example.com/one/', - xslURL: '/sitemap.xsl', - }), - ], - }); - await fixture.build(); - }); - - it('filter: Just one page is added', async () => { - const data = await readXML(fixture.readFile('/client/sitemap-0.xml')); - const urls = data.urlset.url; - assert.equal(urls.length, 1); - }); - - it('xslURL: Includes xml-stylesheet', async () => { - const indexXml = await fixture.readFile('/client/sitemap-index.xml'); - assert.ok( - indexXml.includes( - '', - ), - indexXml, - ); - - const xml = await fixture.readFile('/client/sitemap-0.xml'); - assert.ok( - xml.includes(''), - xml, - ); - }); - }); - - describe('Configuring the filename', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - integrations: [ - sitemap({ - filter: (page) => page === 'http://example.com/one/', - filenameBase: 'my-sitemap', - }), - ], - }); - await fixture.build(); - }); - - it('filenameBase: Sets the generated sitemap filename', async () => { - const data = await readXML(fixture.readFile('/my-sitemap-0.xml')); - const urls = data.urlset.url; - assert.equal(urls.length, 1); - - const indexData = await readXML(fixture.readFile('/my-sitemap-index.xml')); - const sitemapUrls = indexData.sitemapindex.sitemap; - assert.equal(sitemapUrls.length, 1); - assert.equal(sitemapUrls[0].loc[0], 'http://example.com/my-sitemap-0.xml'); - }); - }); - - describe('Filtering pages - error handling', () => { - it('filter: uncaught errors are thrown', async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - integrations: [ - sitemap({ - filter: () => { - throw new Error('filter error'); - }, - }), - ], - }); - await assert.rejects(fixture.build(), /^Error: filter error$/); - }); - }); -}); diff --git a/packages/integrations/sitemap/test/config.test.ts b/packages/integrations/sitemap/test/config.test.ts new file mode 100644 index 000000000000..aa55bac77e22 --- /dev/null +++ b/packages/integrations/sitemap/test/config.test.ts @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { sitemap } from './fixtures/static/deps.mjs'; +import { loadFixture, readXML } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('Config', () => { + let fixture: Fixture; + + describe('Static', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + integrations: [ + sitemap({ + filter: (page) => page === 'http://example.com/one/', + xslURL: '/sitemap.xsl', + }), + ], + }); + await fixture.build(); + }); + + it('filter: Just one page is added', async () => { + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + const urls = data.urlset.url; + assert.equal(urls.length, 1); + }); + + it('xslURL: Includes xml-stylesheet', async () => { + const indexXml = await fixture.readFile('/sitemap-index.xml'); + assert.ok( + indexXml.includes( + '', + ), + indexXml, + ); + + const xml = await fixture.readFile('/sitemap-0.xml'); + assert.ok( + xml.includes(''), + xml, + ); + }); + }); + + describe('SSR', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr/', + integrations: [ + sitemap({ + filter: (page) => page === 'http://example.com/one/', + xslURL: '/sitemap.xsl', + }), + ], + }); + await fixture.build(); + }); + + it('filter: Just one page is added', async () => { + const data = await readXML(fixture.readFile('/client/sitemap-0.xml')); + const urls = data.urlset.url; + assert.equal(urls.length, 1); + }); + + it('xslURL: Includes xml-stylesheet', async () => { + const indexXml = await fixture.readFile('/client/sitemap-index.xml'); + assert.ok( + indexXml.includes( + '', + ), + indexXml, + ); + + const xml = await fixture.readFile('/client/sitemap-0.xml'); + assert.ok( + xml.includes(''), + xml, + ); + }); + }); + + describe('Configuring the filename', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + integrations: [ + sitemap({ + filter: (page) => page === 'http://example.com/one/', + filenameBase: 'my-sitemap', + }), + ], + }); + await fixture.build(); + }); + + it('filenameBase: Sets the generated sitemap filename', async () => { + const data = await readXML(fixture.readFile('/my-sitemap-0.xml')); + const urls = data.urlset.url; + assert.equal(urls.length, 1); + + const indexData = await readXML(fixture.readFile('/my-sitemap-index.xml')); + const sitemapUrls = indexData.sitemapindex.sitemap; + assert.equal(sitemapUrls.length, 1); + assert.equal(sitemapUrls[0].loc[0], 'http://example.com/my-sitemap-0.xml'); + }); + }); + + describe('Filtering pages - error handling', () => { + it('filter: uncaught errors are thrown', async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + integrations: [ + sitemap({ + filter: () => { + throw new Error('filter error'); + }, + }), + ], + }); + await assert.rejects(fixture.build(), /^Error: filter error$/); + }); + }); +}); diff --git a/packages/integrations/sitemap/test/custom-pages.test.js b/packages/integrations/sitemap/test/custom-pages.test.js deleted file mode 100644 index 45ea60b839ab..000000000000 --- a/packages/integrations/sitemap/test/custom-pages.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { sitemap } from './fixtures/static/deps.mjs'; -import { loadFixture, readXML } from './test-utils.js'; - -describe('Sitemap with custom pages', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - /** @type {string[]} */ - let urls; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - integrations: [ - sitemap({ - customPages: ['http://example.com/custom-page'], - }), - ], - }); - await fixture.build(); - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - urls = data.urlset.url.map((url) => url.loc[0]); - }); - - it('includes defined custom pages', async () => { - assert.equal(urls.includes('http://example.com/custom-page'), true); - }); -}); diff --git a/packages/integrations/sitemap/test/custom-pages.test.ts b/packages/integrations/sitemap/test/custom-pages.test.ts new file mode 100644 index 000000000000..370ef67ba3ad --- /dev/null +++ b/packages/integrations/sitemap/test/custom-pages.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { sitemap } from './fixtures/static/deps.mjs'; +import { loadFixture, readXML } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('Sitemap with custom pages', () => { + let fixture: Fixture; + let urls: string[]; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + integrations: [ + sitemap({ + customPages: ['http://example.com/custom-page'], + }), + ], + }); + await fixture.build(); + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + urls = data.urlset.url.map((url: { loc: string[] }) => url.loc[0]); + }); + + it('includes defined custom pages', async () => { + assert.equal(urls.includes('http://example.com/custom-page'), true); + }); +}); diff --git a/packages/integrations/sitemap/test/custom-sitemaps.test.js b/packages/integrations/sitemap/test/custom-sitemaps.test.js deleted file mode 100644 index 7794e626d157..000000000000 --- a/packages/integrations/sitemap/test/custom-sitemaps.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { sitemap } from './fixtures/static/deps.mjs'; -import { loadFixture, readXML } from './test-utils.js'; - -describe('Custom sitemaps', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - /** @type {{ [key: string]: string }} */ - let sitemaps; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - integrations: [ - sitemap({ - lastmod: new Date(), - customSitemaps: ['http://example.com/custom-sitemap.xml'], - }), - ], - }); - await fixture.build(); - const data = await readXML(fixture.readFile('/sitemap-index.xml')); - sitemaps = data.sitemapindex.sitemap.map((s) => ({ loc: s.loc[0], lastmod: s.lastmod[0] })); - }); - - it('includes defined custom sitemaps', async () => { - assert.equal( - sitemaps.some((s) => s.loc === 'http://example.com/custom-sitemap.xml'), - true, - ); - }); - - it('includes lastmod for sitemaps', async () => { - assert.equal( - sitemaps.every((s) => typeof s.lastmod === 'string'), - true, - ); - }); -}); diff --git a/packages/integrations/sitemap/test/custom-sitemaps.test.ts b/packages/integrations/sitemap/test/custom-sitemaps.test.ts new file mode 100644 index 000000000000..3c6bcb6f8f4b --- /dev/null +++ b/packages/integrations/sitemap/test/custom-sitemaps.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { sitemap } from './fixtures/static/deps.mjs'; +import { loadFixture, readXML } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('Custom sitemaps', () => { + let fixture: Fixture; + let sitemaps: { loc: string; lastmod: string }[]; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + integrations: [ + sitemap({ + lastmod: new Date(), + customSitemaps: ['http://example.com/custom-sitemap.xml'], + }), + ], + }); + await fixture.build(); + const data = await readXML(fixture.readFile('/sitemap-index.xml')); + sitemaps = data.sitemapindex.sitemap.map((s: { loc: string[]; lastmod: string[] }) => ({ + loc: s.loc[0], + lastmod: s.lastmod[0], + })); + }); + + it('includes defined custom sitemaps', async () => { + assert.equal( + sitemaps.some((s) => s.loc === 'http://example.com/custom-sitemap.xml'), + true, + ); + }); + + it('includes lastmod for sitemaps', async () => { + assert.equal( + sitemaps.every((s) => typeof s.lastmod === 'string'), + true, + ); + }); +}); diff --git a/packages/integrations/sitemap/test/dynamic-path.test.js b/packages/integrations/sitemap/test/dynamic-path.test.js deleted file mode 100644 index eab3b912c1bc..000000000000 --- a/packages/integrations/sitemap/test/dynamic-path.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture, readXML } from './test-utils.js'; - -describe('Dynamic with rest parameter', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/dynamic', - }); - await fixture.build(); - }); - - it('Should generate correct urls', async () => { - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - const urls = data.urlset.url.map((url) => url.loc[0]); - - assert.ok(urls.includes('http://example.com/')); - assert.ok(urls.includes('http://example.com/blog/')); - assert.ok(urls.includes('http://example.com/test/')); - }); -}); diff --git a/packages/integrations/sitemap/test/dynamic-path.test.ts b/packages/integrations/sitemap/test/dynamic-path.test.ts new file mode 100644 index 000000000000..7290bfc6b6d6 --- /dev/null +++ b/packages/integrations/sitemap/test/dynamic-path.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture, readXML } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('Dynamic with rest parameter', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/dynamic', + }); + await fixture.build(); + }); + + it('Should generate correct urls', async () => { + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + const urls = data.urlset.url.map((url: { loc: string[] }) => url.loc[0]); + + assert.ok(urls.includes('http://example.com/')); + assert.ok(urls.includes('http://example.com/blog/')); + assert.ok(urls.includes('http://example.com/test/')); + }); +}); diff --git a/packages/integrations/sitemap/test/i18n-fallback.test.js b/packages/integrations/sitemap/test/i18n-fallback.test.js deleted file mode 100644 index 6d621ca81271..000000000000 --- a/packages/integrations/sitemap/test/i18n-fallback.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture, readXML } from './test-utils.js'; - -describe('i18n fallback', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - /** @type {string[]} */ - let urls; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/i18n-fallback/', - }); - await fixture.build(); - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - urls = data.urlset.url.map((url) => url.loc[0]); - }); - - it('includes default locale pages', async () => { - assert.equal(urls.includes('http://example.com/'), true); - assert.equal(urls.includes('http://example.com/about/'), true); - }); - - it('includes fallback locale pages', async () => { - assert.equal(urls.includes('http://example.com/fr/'), true); - assert.equal(urls.includes('http://example.com/fr/about/'), true); - }); -}); diff --git a/packages/integrations/sitemap/test/i18n-fallback.test.ts b/packages/integrations/sitemap/test/i18n-fallback.test.ts new file mode 100644 index 000000000000..c3e2d0fef35f --- /dev/null +++ b/packages/integrations/sitemap/test/i18n-fallback.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture, readXML } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('i18n fallback', () => { + let fixture: Fixture; + let urls: string[]; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-fallback/', + }); + await fixture.build(); + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + urls = data.urlset.url.map((url: { loc: string[] }) => url.loc[0]); + }); + + it('includes default locale pages', async () => { + assert.equal(urls.includes('http://example.com/'), true); + assert.equal(urls.includes('http://example.com/about/'), true); + }); + + it('includes fallback locale pages', async () => { + assert.equal(urls.includes('http://example.com/fr/'), true); + assert.equal(urls.includes('http://example.com/fr/about/'), true); + }); +}); diff --git a/packages/integrations/sitemap/test/namespaces.test.js b/packages/integrations/sitemap/test/namespaces.test.js deleted file mode 100644 index 79c0c44d4022..000000000000 --- a/packages/integrations/sitemap/test/namespaces.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { sitemap } from './fixtures/static/deps.mjs'; -import { loadFixture } from './test-utils.js'; - -describe('Namespaces Configuration', () => { - let fixture; - - describe('Default namespaces', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - integrations: [sitemap()], - }); - await fixture.build(); - }); - - it('includes all default namespaces', async () => { - const xml = await fixture.readFile('/sitemap-0.xml'); - assert.ok(xml.includes('xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"')); - assert.ok(xml.includes('xmlns:xhtml="http://www.w3.org/1999/xhtml"')); - assert.ok(xml.includes('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"')); - assert.ok(xml.includes('xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"')); - }); - }); - - describe('Excluding news namespace', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - integrations: [ - sitemap({ - namespaces: { - news: false, - }, - }), - ], - }); - await fixture.build(); - }); - - it('excludes news namespace but includes others', async () => { - const xml = await fixture.readFile('/sitemap-0.xml'); - assert.ok(!xml.includes('xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"')); - assert.ok(xml.includes('xmlns:xhtml="http://www.w3.org/1999/xhtml"')); - assert.ok(xml.includes('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"')); - assert.ok(xml.includes('xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"')); - }); - }); - - describe('Minimal namespaces', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - integrations: [ - sitemap({ - namespaces: { - news: false, - xhtml: false, - image: false, - video: false, - }, - }), - ], - }); - await fixture.build(); - }); - - it('excludes all optional namespaces', async () => { - const xml = await fixture.readFile('/sitemap-0.xml'); - assert.ok(!xml.includes('xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"')); - assert.ok(!xml.includes('xmlns:xhtml="http://www.w3.org/1999/xhtml"')); - assert.ok(!xml.includes('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"')); - assert.ok(!xml.includes('xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"')); - // Still includes the main sitemap namespace - assert.ok(xml.includes('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"')); - }); - }); -}); diff --git a/packages/integrations/sitemap/test/namespaces.test.ts b/packages/integrations/sitemap/test/namespaces.test.ts new file mode 100644 index 000000000000..3952b6b1b09e --- /dev/null +++ b/packages/integrations/sitemap/test/namespaces.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { sitemap } from './fixtures/static/deps.mjs'; +import { loadFixture } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('Namespaces Configuration', () => { + let fixture: Fixture; + + describe('Default namespaces', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + integrations: [sitemap()], + }); + await fixture.build(); + }); + + it('includes all default namespaces', async () => { + const xml = await fixture.readFile('/sitemap-0.xml'); + assert.ok(xml.includes('xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"')); + assert.ok(xml.includes('xmlns:xhtml="http://www.w3.org/1999/xhtml"')); + assert.ok(xml.includes('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"')); + assert.ok(xml.includes('xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"')); + }); + }); + + describe('Excluding news namespace', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + integrations: [ + sitemap({ + namespaces: { + news: false, + }, + }), + ], + }); + await fixture.build(); + }); + + it('excludes news namespace but includes others', async () => { + const xml = await fixture.readFile('/sitemap-0.xml'); + assert.ok(!xml.includes('xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"')); + assert.ok(xml.includes('xmlns:xhtml="http://www.w3.org/1999/xhtml"')); + assert.ok(xml.includes('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"')); + assert.ok(xml.includes('xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"')); + }); + }); + + describe('Minimal namespaces', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + integrations: [ + sitemap({ + namespaces: { + news: false, + xhtml: false, + image: false, + video: false, + }, + }), + ], + }); + await fixture.build(); + }); + + it('excludes all optional namespaces', async () => { + const xml = await fixture.readFile('/sitemap-0.xml'); + assert.ok(!xml.includes('xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"')); + assert.ok(!xml.includes('xmlns:xhtml="http://www.w3.org/1999/xhtml"')); + assert.ok(!xml.includes('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"')); + assert.ok(!xml.includes('xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"')); + // Still includes the main sitemap namespace + assert.ok(xml.includes('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"')); + }); + }); +}); diff --git a/packages/integrations/sitemap/test/routes.test.js b/packages/integrations/sitemap/test/routes.test.js deleted file mode 100644 index 00d6ccde305b..000000000000 --- a/packages/integrations/sitemap/test/routes.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture, readXML } from './test-utils.js'; - -describe('routes', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - /** @type {string[]} */ - let urls; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - }); - await fixture.build(); - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - urls = data.urlset.url.map((url) => url.loc[0]); - }); - - it('does not include endpoints', async () => { - assert.equal(urls.includes('http://example.com/endpoint.json'), false); - }); - - it('does not include redirects', async () => { - assert.equal(urls.includes('http://example.com/redirect'), false); - }); -}); diff --git a/packages/integrations/sitemap/test/routes.test.ts b/packages/integrations/sitemap/test/routes.test.ts new file mode 100644 index 000000000000..eebeb2589b5d --- /dev/null +++ b/packages/integrations/sitemap/test/routes.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture, readXML } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('routes', () => { + let fixture: Fixture; + let urls: string[]; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + }); + await fixture.build(); + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + urls = data.urlset.url.map((url: { loc: string[] }) => url.loc[0]); + }); + + it('does not include endpoints', async () => { + assert.equal(urls.includes('http://example.com/endpoint.json'), false); + }); + + it('does not include redirects', async () => { + assert.equal(urls.includes('http://example.com/redirect'), false); + }); +}); diff --git a/packages/integrations/sitemap/test/smoke.test.js b/packages/integrations/sitemap/test/smoke.test.ts similarity index 100% rename from packages/integrations/sitemap/test/smoke.test.js rename to packages/integrations/sitemap/test/smoke.test.ts diff --git a/packages/integrations/sitemap/test/ssr.test.js b/packages/integrations/sitemap/test/ssr.test.js deleted file mode 100644 index b5c92698b3ba..000000000000 --- a/packages/integrations/sitemap/test/ssr.test.js +++ /dev/null @@ -1,23 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture, readXML } from './test-utils.js'; - -describe('SSR support', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/ssr/', - }); - await fixture.build(); - }); - - it('SSR pages require zero config', async () => { - const data = await readXML(fixture.readFile('/client/sitemap-0.xml')); - const urls = data.urlset.url; - - assert.equal(urls[0].loc[0], 'http://example.com/one/'); - assert.equal(urls[1].loc[0], 'http://example.com/two/'); - }); -}); diff --git a/packages/integrations/sitemap/test/ssr.test.ts b/packages/integrations/sitemap/test/ssr.test.ts new file mode 100644 index 000000000000..022f77a7de05 --- /dev/null +++ b/packages/integrations/sitemap/test/ssr.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture, readXML } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('SSR support', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr/', + }); + await fixture.build(); + }); + + it('SSR pages require zero config', async () => { + const data = await readXML(fixture.readFile('/client/sitemap-0.xml')); + const urls = data.urlset.url; + + assert.equal(urls[0].loc[0], 'http://example.com/one/'); + assert.equal(urls[1].loc[0], 'http://example.com/two/'); + }); +}); diff --git a/packages/integrations/sitemap/test/staticPaths.test.js b/packages/integrations/sitemap/test/staticPaths.test.js deleted file mode 100644 index 7df9d5cb6ac7..000000000000 --- a/packages/integrations/sitemap/test/staticPaths.test.js +++ /dev/null @@ -1,48 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture, readXML } from './test-utils.js'; - -describe('getStaticPaths support', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - /** @type {string[]} */ - let urls; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - trailingSlash: 'always', - }); - await fixture.build(); - - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - urls = data.urlset.url.map((url) => url.loc[0]); - }); - - it('requires zero config for getStaticPaths', async () => { - assert.equal(urls.includes('http://example.com/one/'), true); - assert.equal(urls.includes('http://example.com/two/'), true); - }); - - it('does not include 404 pages', () => { - assert.equal(urls.includes('http://example.com/404/'), false); - }); - - it('does not include nested 404 pages', () => { - assert.equal(urls.includes('http://example.com/de/404/'), false); - }); - - it('includes numerical pages', () => { - assert.equal(urls.includes('http://example.com/123/'), true); - }); - - it('includes numerical 404 pages if not for i18n', () => { - assert.equal(urls.includes('http://example.com/products-by-id/405/'), true); - assert.equal(urls.includes('http://example.com/products-by-id/404/'), true); - }); - - it('should render the endpoint', async () => { - const page = await fixture.readFile('./it/manifest'); - assert.match(page, /I'm a route in the "it" language./); - }); -}); diff --git a/packages/integrations/sitemap/test/staticPaths.test.ts b/packages/integrations/sitemap/test/staticPaths.test.ts new file mode 100644 index 000000000000..56a892755662 --- /dev/null +++ b/packages/integrations/sitemap/test/staticPaths.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture, readXML } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('getStaticPaths support', () => { + let fixture: Fixture; + let urls: string[]; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + trailingSlash: 'always', + }); + await fixture.build(); + + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + urls = data.urlset.url.map((url: { loc: string[] }) => url.loc[0]); + }); + + it('requires zero config for getStaticPaths', async () => { + assert.equal(urls.includes('http://example.com/one/'), true); + assert.equal(urls.includes('http://example.com/two/'), true); + }); + + it('does not include 404 pages', () => { + assert.equal(urls.includes('http://example.com/404/'), false); + }); + + it('does not include nested 404 pages', () => { + assert.equal(urls.includes('http://example.com/de/404/'), false); + }); + + it('includes numerical pages', () => { + assert.equal(urls.includes('http://example.com/123/'), true); + }); + + it('includes numerical 404 pages if not for i18n', () => { + assert.equal(urls.includes('http://example.com/products-by-id/405/'), true); + assert.equal(urls.includes('http://example.com/products-by-id/404/'), true); + }); + + it('should render the endpoint', async () => { + const page = await fixture.readFile('./it/manifest'); + assert.match(page, /I'm a route in the "it" language./); + }); +}); diff --git a/packages/integrations/sitemap/test/trailing-slash.test.js b/packages/integrations/sitemap/test/trailing-slash.test.js deleted file mode 100644 index 181f0def53d2..000000000000 --- a/packages/integrations/sitemap/test/trailing-slash.test.js +++ /dev/null @@ -1,127 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture, readXML } from './test-utils.js'; - -describe('Trailing slash', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - describe('trailingSlash: ignore', () => { - describe('build.format: directory', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - trailingSlash: 'ignore', - build: { - format: 'directory', - }, - }); - await fixture.build(); - }); - - it('URLs end with trailing slash', async () => { - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - const urls = data.urlset.url; - - assert.equal(urls[0].loc[0], 'http://example.com/'); - assert.equal(urls[1].loc[0], 'http://example.com/one/'); - assert.equal(urls[2].loc[0], 'http://example.com/two/'); - }); - }); - - describe('build.format: file', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - trailingSlash: 'ignore', - build: { - format: 'file', - }, - }); - await fixture.build(); - }); - - it('URLs do not end with trailing slash', async () => { - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - const urls = data.urlset.url; - - assert.equal(urls[0].loc[0], 'http://example.com'); - assert.equal(urls[1].loc[0], 'http://example.com/one'); - assert.equal(urls[2].loc[0], 'http://example.com/two'); - }); - }); - }); - - describe('trailingSlash: never', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - trailingSlash: 'never', - }); - await fixture.build(); - }); - - it('URLs do not end with trailing slash', async () => { - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - const urls = data.urlset.url; - - assert.equal(urls[0].loc[0], 'http://example.com'); - assert.equal(urls[1].loc[0], 'http://example.com/one'); - assert.equal(urls[2].loc[0], 'http://example.com/two'); - }); - describe('with base path', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - trailingSlash: 'never', - base: '/base', - }); - await fixture.build(); - }); - - it('URLs do not end with trailing slash', async () => { - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - const urls = data.urlset.url; - assert.equal(urls[0].loc[0], 'http://example.com/base'); - assert.equal(urls[1].loc[0], 'http://example.com/base/one'); - assert.equal(urls[2].loc[0], 'http://example.com/base/two'); - }); - }); - }); - - describe('trailingSlash: always', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - trailingSlash: 'always', - }); - await fixture.build(); - }); - - it('URLs end with trailing slash', async () => { - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - const urls = data.urlset.url; - assert.equal(urls[0].loc[0], 'http://example.com/'); - assert.equal(urls[1].loc[0], 'http://example.com/one/'); - assert.equal(urls[2].loc[0], 'http://example.com/two/'); - }); - describe('with base path', () => { - before(async () => { - fixture = await loadFixture({ - root: './fixtures/trailing-slash/', - trailingSlash: 'always', - base: '/base', - }); - await fixture.build(); - }); - - it('URLs end with trailing slash', async () => { - const data = await readXML(fixture.readFile('/sitemap-0.xml')); - const urls = data.urlset.url; - assert.equal(urls[0].loc[0], 'http://example.com/base/'); - assert.equal(urls[1].loc[0], 'http://example.com/base/one/'); - assert.equal(urls[2].loc[0], 'http://example.com/base/two/'); - }); - }); - }); -}); diff --git a/packages/integrations/sitemap/test/trailing-slash.test.ts b/packages/integrations/sitemap/test/trailing-slash.test.ts new file mode 100644 index 000000000000..229cf66516ad --- /dev/null +++ b/packages/integrations/sitemap/test/trailing-slash.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture, readXML } from './test-utils.js'; +import type { Fixture } from '../../../astro/test/test-utils.js'; + +describe('Trailing slash', () => { + let fixture: Fixture; + + describe('trailingSlash: ignore', () => { + describe('build.format: directory', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + trailingSlash: 'ignore', + build: { + format: 'directory', + }, + }); + await fixture.build(); + }); + + it('URLs end with trailing slash', async () => { + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + const urls = data.urlset.url; + + assert.equal(urls[0].loc[0], 'http://example.com/'); + assert.equal(urls[1].loc[0], 'http://example.com/one/'); + assert.equal(urls[2].loc[0], 'http://example.com/two/'); + }); + }); + + describe('build.format: file', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + trailingSlash: 'ignore', + build: { + format: 'file', + }, + }); + await fixture.build(); + }); + + it('URLs do not end with trailing slash', async () => { + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + const urls = data.urlset.url; + + assert.equal(urls[0].loc[0], 'http://example.com'); + assert.equal(urls[1].loc[0], 'http://example.com/one'); + assert.equal(urls[2].loc[0], 'http://example.com/two'); + }); + }); + }); + + describe('trailingSlash: never', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + trailingSlash: 'never', + }); + await fixture.build(); + }); + + it('URLs do not end with trailing slash', async () => { + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + const urls = data.urlset.url; + + assert.equal(urls[0].loc[0], 'http://example.com'); + assert.equal(urls[1].loc[0], 'http://example.com/one'); + assert.equal(urls[2].loc[0], 'http://example.com/two'); + }); + describe('with base path', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + trailingSlash: 'never', + base: '/base', + }); + await fixture.build(); + }); + + it('URLs do not end with trailing slash', async () => { + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + const urls = data.urlset.url; + assert.equal(urls[0].loc[0], 'http://example.com/base'); + assert.equal(urls[1].loc[0], 'http://example.com/base/one'); + assert.equal(urls[2].loc[0], 'http://example.com/base/two'); + }); + }); + }); + + describe('trailingSlash: always', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + trailingSlash: 'always', + }); + await fixture.build(); + }); + + it('URLs end with trailing slash', async () => { + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + const urls = data.urlset.url; + assert.equal(urls[0].loc[0], 'http://example.com/'); + assert.equal(urls[1].loc[0], 'http://example.com/one/'); + assert.equal(urls[2].loc[0], 'http://example.com/two/'); + }); + describe('with base path', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/trailing-slash/', + trailingSlash: 'always', + base: '/base', + }); + await fixture.build(); + }); + + it('URLs end with trailing slash', async () => { + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + const urls = data.urlset.url; + assert.equal(urls[0].loc[0], 'http://example.com/base/'); + assert.equal(urls[1].loc[0], 'http://example.com/base/one/'); + assert.equal(urls[2].loc[0], 'http://example.com/base/two/'); + }); + }); + }); +}); diff --git a/packages/integrations/sitemap/test/units/generate-sitemap.test.js b/packages/integrations/sitemap/test/units/generate-sitemap.test.ts similarity index 100% rename from packages/integrations/sitemap/test/units/generate-sitemap.test.js rename to packages/integrations/sitemap/test/units/generate-sitemap.test.ts diff --git a/packages/integrations/sitemap/tsconfig.test.json b/packages/integrations/sitemap/tsconfig.test.json new file mode 100644 index 000000000000..27c89c5fe7a7 --- /dev/null +++ b/packages/integrations/sitemap/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../../astro/tsconfig.test.json" + } + ] +} diff --git a/packages/integrations/svelte/CHANGELOG.md b/packages/integrations/svelte/CHANGELOG.md index c1d7b51b14bf..9f51ad4aa10c 100644 --- a/packages/integrations/svelte/CHANGELOG.md +++ b/packages/integrations/svelte/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/svelte +## 8.0.5 + +### Patch Changes + +- [#16210](https://github.com/withastro/astro/pull/16210) [`e030bd0`](https://github.com/withastro/astro/commit/e030bd058457505b605ef573cfc71239baa963f0) Thanks [@matthewp](https://github.com/matthewp)! - Fixes `.svelte` files in `node_modules` failing with `Unknown file extension ".svelte"` when using the Cloudflare adapter with `prerenderEnvironment: 'node'` + ## 8.0.4 ### Patch Changes diff --git a/packages/integrations/svelte/package.json b/packages/integrations/svelte/package.json index b4ad39589949..509858c5e17f 100644 --- a/packages/integrations/svelte/package.json +++ b/packages/integrations/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@astrojs/svelte", - "version": "8.0.4", + "version": "8.0.5", "description": "Use Svelte components within Astro", "type": "module", "types": "./dist/index.d.ts", @@ -35,12 +35,14 @@ "build": "astro-scripts build \"src/**/*.ts\" && astro-scripts build \"src/editor.cts\" --force-cjs --no-clean-dist && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\" && astro-scripts build \"src/editor.cts\" --force-cjs --no-clean-dist", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "dependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.4", "svelte2tsx": "^0.7.52", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitefu": "^1.1.2" }, "devDependencies": { "astro": "workspace:*", diff --git a/packages/integrations/svelte/src/index.ts b/packages/integrations/svelte/src/index.ts index b391cd1c90cb..9a92bd6a354b 100644 --- a/packages/integrations/svelte/src/index.ts +++ b/packages/integrations/svelte/src/index.ts @@ -1,7 +1,9 @@ import type { Options } from '@sveltejs/vite-plugin-svelte'; import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import type { AstroIntegration, AstroRenderer } from 'astro'; +import { fileURLToPath } from 'node:url'; import type { Plugin } from 'vite'; +import { crawlFrameworkPkgs } from 'vitefu'; import { createSvelteOptimizeEsbuildPlugins } from './optimize-esbuild-plugins.js'; function getRenderer(): AstroRenderer { @@ -18,11 +20,20 @@ export default function svelteIntegration(options?: Options): AstroIntegration { return { name: '@astrojs/svelte', hooks: { - 'astro:config:setup': async ({ updateConfig, addRenderer }) => { + 'astro:config:setup': async ({ config, updateConfig, addRenderer }) => { addRenderer(getRenderer()); + + const sveltePackages = await crawlFrameworkPkgs({ + root: fileURLToPath(config.root), + isBuild: false, + isFrameworkPkgByJson(pkgJson) { + return !!pkgJson.peerDependencies?.svelte; + }, + }); + updateConfig({ vite: { - plugins: [svelte(options), configEnvironmentPlugin()], + plugins: [svelte(options), configEnvironmentPlugin(sveltePackages.ssr.noExternal)], }, }); }, @@ -30,16 +41,42 @@ export default function svelteIntegration(options?: Options): AstroIntegration { }; } -function configEnvironmentPlugin(): Plugin { +function configEnvironmentPlugin(svelteNoExternal: string[]): Plugin { return { name: '@astrojs/svelte:config-environment', configEnvironment(environmentName, options) { + const isServer = environmentName !== 'client'; + + if (isServer && svelteNoExternal.length > 0) { + // Add svelte framework packages to noExternal so they go through + // Vite's transform pipeline (Node can't import .svelte files natively). + const result: any = { + resolve: { + noExternal: svelteNoExternal, + }, + }; + + if ( + (environmentName === 'ssr' || environmentName === 'prerender') && + options.optimizeDeps?.noDiscovery === false + ) { + result.optimizeDeps = { + include: ['svelte/server', 'svelte/internal/server'], + exclude: ['@astrojs/svelte/server.js'], + esbuildOptions: { + plugins: createSvelteOptimizeEsbuildPlugins('server'), + }, + }; + } + + return result; + } + if ( environmentName === 'client' || ((environmentName === 'ssr' || environmentName === 'prerender') && options.optimizeDeps?.noDiscovery === false) ) { - const isServer = environmentName !== 'client'; return { optimizeDeps: { include: isServer diff --git a/packages/integrations/svelte/test/async-rendering.test.js b/packages/integrations/svelte/test/async-rendering.test.js deleted file mode 100644 index bcbb8a3ba4a4..000000000000 --- a/packages/integrations/svelte/test/async-rendering.test.js +++ /dev/null @@ -1,49 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -let fixture; - -// Svelte made breaking changes to async rendering in a patch. -// TODO figure out if we need to change our code or not, might just be an upstream bug. -describe.skip('Async rendering', () => { - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/async-rendering/', import.meta.url), - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('Can render async components', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - assert.ok($('.weather').text().startsWith('The current temperature at KSC is')); - }); - }); - - describe('dev', () => { - /** @type {import('../../../astro/test/test-utils.js').Fixture} */ - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('Can render async components', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - assert.ok($('.weather').text().startsWith('The current temperature at KSC is')); - }); - }); -}); diff --git a/packages/integrations/svelte/test/async-rendering.test.ts b/packages/integrations/svelte/test/async-rendering.test.ts new file mode 100644 index 000000000000..31c0818de647 --- /dev/null +++ b/packages/integrations/svelte/test/async-rendering.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +let fixture: Fixture; + +// Svelte made breaking changes to async rendering in a patch. +// TODO figure out if we need to change our code or not, might just be an upstream bug. +describe.skip('Async rendering', () => { + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/async-rendering/', import.meta.url), + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('Can render async components', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + assert.ok($('.weather').text().startsWith('The current temperature at KSC is')); + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Can render async components', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.ok($('.weather').text().startsWith('The current temperature at KSC is')); + }); + }); +}); diff --git a/packages/integrations/svelte/test/check.test.js b/packages/integrations/svelte/test/check.test.ts similarity index 100% rename from packages/integrations/svelte/test/check.test.js rename to packages/integrations/svelte/test/check.test.ts diff --git a/packages/integrations/svelte/test/conditional-rendering.test.js b/packages/integrations/svelte/test/conditional-rendering.test.js deleted file mode 100644 index 42bc8e6aad1c..000000000000 --- a/packages/integrations/svelte/test/conditional-rendering.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -/** - * @see https://github.com/withastro/astro/issues/14252 - * - * Svelte components that are conditionally rendered (inside {#if} blocks) - * should have their styles included in the production build, even when - * the condition is initially false during SSR. - */ - -let fixture; - -describe('Conditional rendering styles', () => { - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/conditional-rendering/', import.meta.url), - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('includes styles for conditionally rendered Svelte components', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - // Get all CSS - either inline styles or linked stylesheets - let allCss = ''; - - // Check inline styles - $('style').each((_, el) => { - allCss += $(el).text(); - }); - - // Check linked stylesheets - const cssLinks = $('link[rel="stylesheet"]'); - for (const link of cssLinks.toArray()) { - const href = $(link).attr('href'); - if (href) { - const cssContent = await fixture.readFile(href); - allCss += cssContent; - } - } - - // Verify that styles from the Child component are included - // The Child has: background-color: red - // Even though the child is not rendered during SSR (showChild starts as false), - // its styles should still be included in the build output - const hasChildStyles = allCss.includes('red'); - assert.ok( - hasChildStyles, - `Child component styles (background-color: red) should be included in build output even when conditionally rendered. CSS found: ${allCss.substring(0, 500)}`, - ); - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('includes styles for conditionally rendered Svelte components', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - - // In dev mode, styles are typically injected via JS - // The component should be present and work correctly - const hasParentComponent = html.includes('parent'); - - assert.ok(hasParentComponent, 'Parent component should be present in dev mode'); - }); - }); -}); diff --git a/packages/integrations/svelte/test/conditional-rendering.test.ts b/packages/integrations/svelte/test/conditional-rendering.test.ts new file mode 100644 index 000000000000..9abe1e53f623 --- /dev/null +++ b/packages/integrations/svelte/test/conditional-rendering.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +/** + * @see https://github.com/withastro/astro/issues/14252 + * + * Svelte components that are conditionally rendered (inside {#if} blocks) + * should have their styles included in the production build, even when + * the condition is initially false during SSR. + */ + +let fixture: Fixture; + +describe('Conditional rendering styles', () => { + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/conditional-rendering/', import.meta.url), + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('includes styles for conditionally rendered Svelte components', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + // Get all CSS - either inline styles or linked stylesheets + let allCss = ''; + + // Check inline styles + $('style').each((_, el) => { + allCss += $(el).text(); + }); + + // Check linked stylesheets + const cssLinks = $('link[rel="stylesheet"]'); + for (const link of cssLinks.toArray()) { + const href = $(link).attr('href'); + if (href) { + const cssContent = await fixture.readFile(href); + allCss += cssContent; + } + } + + // Verify that styles from the Child component are included + // The Child has: background-color: red + // Even though the child is not rendered during SSR (showChild starts as false), + // its styles should still be included in the build output + const hasChildStyles = allCss.includes('red'); + assert.ok( + hasChildStyles, + `Child component styles (background-color: red) should be included in build output even when conditionally rendered. CSS found: ${allCss.substring(0, 500)}`, + ); + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('includes styles for conditionally rendered Svelte components', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + + // In dev mode, styles are typically injected via JS + // The component should be present and work correctly + const hasParentComponent = html.includes('parent'); + + assert.ok(hasParentComponent, 'Parent component should be present in dev mode'); + }); + }); +}); diff --git a/packages/integrations/svelte/test/empty-class-attribute.test.js b/packages/integrations/svelte/test/empty-class-attribute.test.js deleted file mode 100644 index 6ef1124ba65c..000000000000 --- a/packages/integrations/svelte/test/empty-class-attribute.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from '../../../astro/test/test-utils.js'; - -/** - * @see https://github.com/withastro/astro/issues/15576 - * - * Svelte components that extract the class property with a null default should not - * render an empty class attribute when no class is provided. This matches native - * Svelte behavior. - */ - -describe('Empty class attribute', () => { - describe('build', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/empty-class/', import.meta.url), - }); - await fixture.build(); - }); - - it('should not render empty class attribute when class prop is not provided', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - // Component without class prop should not have class attribute - const withoutClass = $('#without-class'); - assert.ok(withoutClass.length > 0, 'Element with id="without-class" should exist'); - assert.strictEqual( - withoutClass.attr('class'), - undefined, - 'Element should not have a class attribute when none is provided', - ); - }); - - it('should render class attribute when class prop is provided', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - // Component with class prop should have class attribute - const withClass = $('#with-class'); - assert.ok(withClass.length > 0, 'Element with id="with-class" should exist'); - assert.strictEqual( - withClass.attr('class'), - 'my-class', - 'Element should have class="my-class" when provided', - ); - }); - }); - - describe('dev', () => { - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: new URL('./fixtures/empty-class/', import.meta.url), - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('should not render empty class attribute in dev mode', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - const withoutClass = $('#without-class'); - assert.ok(withoutClass.length > 0, 'Element with id="without-class" should exist in dev'); - assert.strictEqual( - withoutClass.attr('class'), - undefined, - 'Element should not have a class attribute in dev mode', - ); - }); - }); -}); diff --git a/packages/integrations/svelte/test/empty-class-attribute.test.ts b/packages/integrations/svelte/test/empty-class-attribute.test.ts new file mode 100644 index 000000000000..5d2748e1ab9e --- /dev/null +++ b/packages/integrations/svelte/test/empty-class-attribute.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { loadFixture, type Fixture, type DevServer } from '../../../astro/test/test-utils.js'; + +/** + * @see https://github.com/withastro/astro/issues/15576 + * + * Svelte components that extract the class property with a null default should not + * render an empty class attribute when no class is provided. This matches native + * Svelte behavior. + */ + +describe('Empty class attribute', () => { + describe('build', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/empty-class/', import.meta.url), + }); + await fixture.build(); + }); + + it('should not render empty class attribute when class prop is not provided', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + // Component without class prop should not have class attribute + const withoutClass = $('#without-class'); + assert.ok(withoutClass.length > 0, 'Element with id="without-class" should exist'); + assert.strictEqual( + withoutClass.attr('class'), + undefined, + 'Element should not have a class attribute when none is provided', + ); + }); + + it('should render class attribute when class prop is provided', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + // Component with class prop should have class attribute + const withClass = $('#with-class'); + assert.ok(withClass.length > 0, 'Element with id="with-class" should exist'); + assert.strictEqual( + withClass.attr('class'), + 'my-class', + 'Element should have class="my-class" when provided', + ); + }); + }); + + describe('dev', () => { + let fixture: Fixture; + let devServer: DevServer; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/empty-class/', import.meta.url), + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should not render empty class attribute in dev mode', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + const withoutClass = $('#without-class'); + assert.ok(withoutClass.length > 0, 'Element with id="without-class" should exist in dev'); + assert.strictEqual( + withoutClass.attr('class'), + undefined, + 'Element should not have a class attribute in dev mode', + ); + }); + }); +}); diff --git a/packages/integrations/svelte/test/extract-generics.test.js b/packages/integrations/svelte/test/extract-generics.test.ts similarity index 100% rename from packages/integrations/svelte/test/extract-generics.test.js rename to packages/integrations/svelte/test/extract-generics.test.ts diff --git a/packages/integrations/svelte/tsconfig.test.json b/packages/integrations/svelte/tsconfig.test.json new file mode 100644 index 000000000000..27c89c5fe7a7 --- /dev/null +++ b/packages/integrations/svelte/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../../astro/tsconfig.test.json" + } + ] +} diff --git a/packages/integrations/vercel/CHANGELOG.md b/packages/integrations/vercel/CHANGELOG.md index e82a55da5f29..ae40a28d52f5 100644 --- a/packages/integrations/vercel/CHANGELOG.md +++ b/packages/integrations/vercel/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/vercel +## 10.0.4 + +### Patch Changes + +- [#16170](https://github.com/withastro/astro/pull/16170) [`d0fe1ec`](https://github.com/withastro/astro/commit/d0fe1ec216f8f322392e34ce40378d022e495cef) Thanks [@bittoby](https://github.com/bittoby)! - Fixes edge middleware `next()` dropping the HTTP method and body when forwarding requests to the serverless function, which caused non-GET API routes (POST, PUT, PATCH, DELETE) to return 404 + ## 10.0.3 ### Patch Changes diff --git a/packages/integrations/vercel/package.json b/packages/integrations/vercel/package.json index 2bcf5646cfbb..fe44fbae86cf 100644 --- a/packages/integrations/vercel/package.json +++ b/packages/integrations/vercel/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/vercel", "description": "Deploy your site to Vercel", - "version": "10.0.3", + "version": "10.0.4", "type": "module", "author": "withastro", "license": "MIT", @@ -42,8 +42,9 @@ "dev": "astro-scripts dev \"src/**/*.ts\"", "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", - "test": "astro-scripts test --timeout 60000 \"test/**/!(hosted).test.js\"", - "test:hosted": "astro-scripts test --timeout 30000 \"test/hosted/*.test.js\"" + "test": "astro-scripts test --timeout 60000 \"test/**/!(hosted).test.ts\"", + "test:hosted": "astro-scripts test --timeout 30000 \"test/hosted/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "dependencies": { "@astrojs/internal-helpers": "workspace:*", diff --git a/packages/integrations/vercel/src/index.ts b/packages/integrations/vercel/src/index.ts index c7672aacdf8c..bb7ae4eacc1d 100644 --- a/packages/integrations/vercel/src/index.ts +++ b/packages/integrations/vercel/src/index.ts @@ -651,16 +651,31 @@ type Runtime = `nodejs${string}.x`; class VercelBuilder { readonly NTF_CACHE = {}; + readonly config: AstroConfig; + readonly excludeFiles: URL[]; + readonly includeFiles: URL[]; + readonly logger: AstroIntegrationLogger; + readonly outDir: URL; + readonly maxDuration: number | undefined; + readonly runtime: string; constructor( - readonly config: AstroConfig, - readonly excludeFiles: URL[], - readonly includeFiles: URL[], - readonly logger: AstroIntegrationLogger, - readonly outDir: URL, - readonly maxDuration?: number, - readonly runtime = getRuntime(process, logger), - ) {} + config: AstroConfig, + excludeFiles: URL[], + includeFiles: URL[], + logger: AstroIntegrationLogger, + outDir: URL, + maxDuration?: number, + runtime = getRuntime(process, logger), + ) { + this.config = config; + this.excludeFiles = excludeFiles; + this.includeFiles = includeFiles; + this.logger = logger; + this.outDir = outDir; + this.maxDuration = maxDuration; + this.runtime = runtime; + } async buildServerlessFolder(entry: URL, functionName: string, root: URL) { const { includeFiles, excludeFiles, logger, NTF_CACHE, runtime, maxDuration } = this; diff --git a/packages/integrations/vercel/src/serverless/middleware.ts b/packages/integrations/vercel/src/serverless/middleware.ts index 0f215ee84aa4..058d78296ba3 100644 --- a/packages/integrations/vercel/src/serverless/middleware.ts +++ b/packages/integrations/vercel/src/serverless/middleware.ts @@ -127,12 +127,14 @@ export default async function middleware(request, context) { const next = async () => { const { vercel, ...locals } = ctx.locals; const response = await fetch(new URL('/${NODE_PATH}', request.url), { + method: request.method, headers: { ...Object.fromEntries(request.headers.entries()), '${ASTRO_MIDDLEWARE_SECRET_HEADER}': '${middlewareSecret}', '${ASTRO_PATH_HEADER}': request.url.replace(origin, ''), '${ASTRO_LOCALS_HEADER}': trySerializeLocals(locals) - } + }, + ...(request.body ? { body: request.body, duplex: 'half' } : {}), }); return new Response(response.body, { status: response.status, diff --git a/packages/integrations/vercel/test/edge-middleware.test.js b/packages/integrations/vercel/test/edge-middleware.test.js deleted file mode 100644 index d6313d483ab6..000000000000 --- a/packages/integrations/vercel/test/edge-middleware.test.js +++ /dev/null @@ -1,74 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('Vercel edge middleware', () => { - /** @type {import('./test-utils.js').Fixture} */ - let build; - before(async () => { - build = await loadFixture({ - root: './fixtures/middleware-with-edge-file/', - }); - await build.build(); - }); - - it('an edge function is created', async () => { - const contents = await build.readFile( - '../.vercel/output/functions/_middleware.func/.vc-config.json', - ); - const contentsJSON = JSON.parse(contents); - assert.equal(contentsJSON.runtime, 'edge'); - assert.equal(contentsJSON.entrypoint, 'middleware.mjs'); - }); - - it('deployment config points to the middleware edge function', async () => { - const contents = await build.readFile('../.vercel/output/config.json'); - const { routes } = JSON.parse(contents); - assert.equal( - routes.some((route) => route.dest === '_middleware'), - true, - ); - }); - - it('edge sets Set-Cookie headers', async () => { - const entry = new URL( - '../.vercel/output/functions/_middleware.func/middleware.mjs', - build.config.outDir, - ); - const module = await import(entry); - const request = new Request('http://example.com/foo'); - const response = await module.default(request, {}); - assert.equal(response.headers.get('set-cookie'), 'foo=bar'); - assert.ok((await response.text()).length, 'Body is included'); - }); - - // TODO: The path here seems to be inconsistent? - it.skip('with edge handle file, should successfully build the middleware', async () => { - const fixture = await loadFixture({ - root: './fixtures/middleware-with-edge-file/', - }); - await fixture.build(); - const _contents = await fixture.readFile( - // this is abysmal... - '../.vercel/output/functions/render.func/www/withastro/astro/packages/vercel/test/fixtures/middleware-with-edge-file/dist/middleware.mjs', - ); - // assert.equal(contents.includes('title:')).to.be.true; - // chaiJestSnapshot.setTestName('Middleware with handler file'); - // assert.equal(contents).to.matchSnapshot(true); - }); - - // TODO: The path here seems to be inconsistent? - it.skip('without edge handle file, should successfully build the middleware', async () => { - const fixture = await loadFixture({ - root: './fixtures/middleware-without-edge-file/', - }); - await fixture.build(); - const _contents = await fixture.readFile( - // this is abysmal... - '../.vercel/output/functions/render.func/www/withastro/astro/packages/vercel/test/fixtures/middleware-without-edge-file/dist/middleware.mjs', - ); - // assert.equal(contents.includes('title:')).to.be.false; - // chaiJestSnapshot.setTestName('Middleware without handler file'); - // assert.equal(contents).to.matchSnapshot(true); - }); -}); diff --git a/packages/integrations/vercel/test/edge-middleware.test.ts b/packages/integrations/vercel/test/edge-middleware.test.ts new file mode 100644 index 000000000000..3785e0cdd2de --- /dev/null +++ b/packages/integrations/vercel/test/edge-middleware.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture, getVercelConfig } from './test-utils.ts'; + +describe('Vercel edge middleware', () => { + let build: Fixture; + before(async () => { + build = await loadFixture({ + root: './fixtures/middleware-with-edge-file/', + }); + await build.build({}); + }); + + it('an edge function is created', async () => { + const contents = await build.readFile( + '../.vercel/output/functions/_middleware.func/.vc-config.json', + ); + const contentsJSON = JSON.parse(contents); + assert.equal(contentsJSON.runtime, 'edge'); + assert.equal(contentsJSON.entrypoint, 'middleware.mjs'); + }); + + it('deployment config points to the middleware edge function', async () => { + const { routes } = await getVercelConfig(build); + assert.equal( + routes.some((route) => route.dest === '_middleware'), + true, + ); + }); + + it('edge sets Set-Cookie headers', async () => { + const entry = new URL( + '../.vercel/output/functions/_middleware.func/middleware.mjs', + build.config.outDir, + ); + const module = await import(entry.href); + const request = new Request('http://example.com/foo'); + const response = await module.default(request, {}); + assert.equal(response.headers.get('set-cookie'), 'foo=bar'); + assert.ok((await response.text()).length, 'Body is included'); + }); + + it('edge middleware forwards HTTP method and body', async () => { + const entry = new URL( + '../.vercel/output/functions/_middleware.func/middleware.mjs', + build.config.outDir, + ); + const module = await import(entry.href); + + const originalFetch = globalThis.fetch; + let captured: RequestInit | undefined; + globalThis.fetch = async (_url, opts) => { + captured = opts; + return new Response('ok', { status: 200 }); + }; + try { + const request = new Request('http://example.com/api/test', { + method: 'POST', + body: '{"data":"test"}', + headers: { 'Content-Type': 'application/json' }, + }); + await module.default(request, {}); + assert.ok(captured, 'fetch was called'); + assert.equal(captured.method, 'POST', 'forwards the HTTP method'); + assert.ok(captured.body, 'forwards the request body'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + // TODO: The path here seems to be inconsistent? + it.skip('with edge handle file, should successfully build the middleware', async () => { + const fixture = await loadFixture({ + root: './fixtures/middleware-with-edge-file/', + }); + await fixture.build({}); + const _contents = await fixture.readFile( + // this is abysmal... + '../.vercel/output/functions/render.func/www/withastro/astro/packages/vercel/test/fixtures/middleware-with-edge-file/dist/middleware.mjs', + ); + // assert.equal(contents.includes('title:')).to.be.true; + // chaiJestSnapshot.setTestName('Middleware with handler file'); + // assert.equal(contents).to.matchSnapshot(true); + }); + + // TODO: The path here seems to be inconsistent? + it.skip('without edge handle file, should successfully build the middleware', async () => { + const fixture = await loadFixture({ + root: './fixtures/middleware-without-edge-file/', + }); + await fixture.build({}); + const _contents = await fixture.readFile( + // this is abysmal... + '../.vercel/output/functions/render.func/www/withastro/astro/packages/vercel/test/fixtures/middleware-without-edge-file/dist/middleware.mjs', + ); + // assert.equal(contents.includes('title:')).to.be.false; + // chaiJestSnapshot.setTestName('Middleware without handler file'); + // assert.equal(contents).to.matchSnapshot(true); + }); +}); diff --git a/packages/integrations/vercel/test/fixtures/image/astro.config.mjs b/packages/integrations/vercel/test/fixtures/image/astro.config.mjs index bd3385ad8a79..26998fb03ce4 100644 --- a/packages/integrations/vercel/test/fixtures/image/astro.config.mjs +++ b/packages/integrations/vercel/test/fixtures/image/astro.config.mjs @@ -1,6 +1,6 @@ import vercel from '@astrojs/vercel'; import { defineConfig } from 'astro/config'; -import { testImageService } from '../../test-image-service.js'; +import { testImageService } from '../../../../../astro/test/test-image-service.js'; export default defineConfig({ adapter: vercel({ diff --git a/packages/integrations/vercel/test/hosted/hosted.test.js b/packages/integrations/vercel/test/hosted/hosted.test.ts similarity index 100% rename from packages/integrations/vercel/test/hosted/hosted.test.js rename to packages/integrations/vercel/test/hosted/hosted.test.ts diff --git a/packages/integrations/vercel/test/image.test.js b/packages/integrations/vercel/test/image.test.js deleted file mode 100644 index c3ae7b60ff01..000000000000 --- a/packages/integrations/vercel/test/image.test.js +++ /dev/null @@ -1,146 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './test-utils.js'; - -describe('Image', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/image/', - }); - await fixture.build(); - }); - - it('build successful', async () => { - assert.ok(await fixture.readFile('../.vercel/output/static/index.html')); - }); - - it('has link to vercel in build with proper attributes', async () => { - const html = await fixture.readFile('../.vercel/output/static/index.html'); - const $ = cheerio.load(html); - const img = $('#basic-image img'); - - assert.equal(img.attr('src').startsWith('/_vercel/image?url=_astr'), true); - assert.equal(img.attr('loading'), 'lazy'); - assert.equal(img.attr('width'), '225'); - }); - - it('generates valid image sizes when requested width is larger than source image', async () => { - const html = await fixture.readFile('../.vercel/output/static/index.html'); - const $ = cheerio.load(html); - const img = $('#small-source img'); - const widths = img - .attr('srcset') - .split(', ') - .map((entry) => entry.split(' ')[1]); - assert.deepEqual(widths, ['640w'], 'uses valid widths in srcset'); - - const url = new URL(img.attr('src'), 'http://localhost'); - assert.equal(url.searchParams.get('w'), '640', 'uses valid width in src'); - - assert.equal(img.attr('width'), '225', 'uses requested width in img attribute'); - }); - - it('generates valid densities-based srcset using only configured sizes', async () => { - const html = await fixture.readFile('../.vercel/output/static/index.html'); - const $ = cheerio.load(html); - const img = $('#densities-test img'); - const srcset = img.attr('srcset'); - - // Extract widths from srcset (format: "url 1x", "url 1.5x", etc) - const descriptors = srcset.split(', ').map((entry) => entry.split(' ')[1]); - - // Extract the widths from the URLs (they should be valid configured sizes) - const urls = srcset.split(', ').map((entry) => entry.split(' ')[0]); - const widthsFromUrls = urls.map((url) => { - const urlObj = new URL(url, 'http://localhost'); - return Number.parseInt(urlObj.searchParams.get('w'), 10); - }); - - // The configured sizes are [640, 750, 828, 1080, 1200, 1920, 2048, 3840] - // width=600 with densities [1, 1.5, 2] would calculate [600, 900, 1200] - // 600 -> nearest is 640 - // 900 -> nearest is 828 - // 1200 -> exactly in configured sizes - // So we expect widths [640, 828, 1200] - assert.deepEqual( - widthsFromUrls.sort((a, b) => a - b), - [640, 828, 1200], - `widths are mapped to nearest configured sizes`, - ); - - // All widths should be from the configured sizes - assert.ok( - widthsFromUrls.every((w) => [640, 750, 828, 1080, 1200, 1920, 2048, 3840].includes(w)), - `all widths in srcset are from configured sizes: ${widthsFromUrls}`, - ); - - // Check that we have density descriptors - assert.ok( - descriptors.every((d) => /^\d+(\.\d+)?x$/.exec(d)), - `all descriptors are density-based (e.g., 1x, 1.5x): ${descriptors}`, - ); - }); - - it('has proper vercel config', async () => { - const vercelConfig = JSON.parse(await fixture.readFile('../.vercel/output/config.json')); - - assert.deepEqual(vercelConfig.images, { - sizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], - domains: ['astro.build'], - remotePatterns: [ - { - protocol: 'https', - hostname: '**.amazonaws.com', - }, - ], - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('has link to local image in dev with proper attributes', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerio.load(html); - const img = $('#basic-image img'); - - assert.equal(img.attr('src').startsWith('/_image?href='), true); - assert.equal(img.attr('loading'), 'lazy'); - assert.equal(img.attr('width'), '225'); - }); - - it('supports SVGs', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerio.load(html); - const img = $('#svg img'); - const src = img.attr('src'); - - const res = await fixture.fetch(src); - assert.equal(res.status, 200); - assert.equal(res.headers.get('content-type'), 'image/svg+xml'); - }); - - it('generates valid srcset for responsive images', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerio.load(html); - const img = $('#responsive img'); - const widths = img - .attr('srcset') - .split(', ') - .map((entry) => entry.split(' ')[1]); - assert.deepEqual(widths, ['640w', '750w', '828w', '1080w', '1200w', '1920w']); - }); - }); -}); diff --git a/packages/integrations/vercel/test/image.test.ts b/packages/integrations/vercel/test/image.test.ts new file mode 100644 index 000000000000..ac2125e8ed1d --- /dev/null +++ b/packages/integrations/vercel/test/image.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { type Fixture, type DevServer, loadFixture } from './test-utils.ts'; + +describe('Image', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/image/', + }); + await fixture.build({}); + }); + + it('build successful', async () => { + assert.ok(await fixture.readFile('../.vercel/output/static/index.html')); + }); + + it('has link to vercel in build with proper attributes', async () => { + const html = await fixture.readFile('../.vercel/output/static/index.html'); + const $ = cheerio.load(html); + const img = $('#basic-image img'); + + assert.equal(img.attr('src')!.startsWith('/_vercel/image?url=_astr'), true); + assert.equal(img.attr('loading'), 'lazy'); + assert.equal(img.attr('width'), '225'); + }); + + it('generates valid image sizes when requested width is larger than source image', async () => { + const html = await fixture.readFile('../.vercel/output/static/index.html'); + const $ = cheerio.load(html); + const img = $('#small-source img'); + const widths = img + .attr('srcset')! + .split(', ') + .map((entry) => entry.split(' ')[1]); + assert.deepEqual(widths, ['640w'], 'uses valid widths in srcset'); + + const url = new URL(img.attr('src')!, 'http://localhost'); + assert.equal(url.searchParams.get('w'), '640', 'uses valid width in src'); + + assert.equal(img.attr('width'), '225', 'uses requested width in img attribute'); + }); + + it('generates valid densities-based srcset using only configured sizes', async () => { + const html = await fixture.readFile('../.vercel/output/static/index.html'); + const $ = cheerio.load(html); + const img = $('#densities-test img'); + const srcset = img.attr('srcset')!; + + // Extract widths from srcset (format: "url 1x", "url 1.5x", etc) + const descriptors = srcset.split(', ').map((entry) => entry.split(' ')[1]); + + // Extract the widths from the URLs (they should be valid configured sizes) + const urls = srcset.split(', ').map((entry) => entry.split(' ')[0]); + const widthsFromUrls = urls.map((url) => { + const urlObj = new URL(url, 'http://localhost'); + return Number.parseInt(urlObj.searchParams.get('w')!, 10); + }); + + // The configured sizes are [640, 750, 828, 1080, 1200, 1920, 2048, 3840] + // width=600 with densities [1, 1.5, 2] would calculate [600, 900, 1200] + // 600 -> nearest is 640 + // 900 -> nearest is 828 + // 1200 -> exactly in configured sizes + // So we expect widths [640, 828, 1200] + assert.deepEqual( + widthsFromUrls.sort((a, b) => a - b), + [640, 828, 1200], + `widths are mapped to nearest configured sizes`, + ); + + // All widths should be from the configured sizes + assert.ok( + widthsFromUrls.every((w) => [640, 750, 828, 1080, 1200, 1920, 2048, 3840].includes(w)), + `all widths in srcset are from configured sizes: ${widthsFromUrls}`, + ); + + // Check that we have density descriptors + assert.ok( + descriptors.every((d) => /^\d+(\.\d+)?x$/.exec(d)), + `all descriptors are density-based (e.g., 1x, 1.5x): ${descriptors}`, + ); + }); + + it('has proper vercel config', async () => { + const vercelConfig = JSON.parse(await fixture.readFile('../.vercel/output/config.json')); + + assert.deepEqual(vercelConfig.images, { + sizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + domains: ['astro.build'], + remotePatterns: [ + { + protocol: 'https', + hostname: '**.amazonaws.com', + }, + ], + }); + }); + + describe('dev', () => { + let devServer: DevServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('has link to local image in dev with proper attributes', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerio.load(html); + const img = $('#basic-image img'); + + assert.equal(img.attr('src')!.startsWith('/_image?href='), true); + assert.equal(img.attr('loading'), 'lazy'); + assert.equal(img.attr('width'), '225'); + }); + + it('supports SVGs', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerio.load(html); + const img = $('#svg img'); + const src = img.attr('src')!; + + const res = await fixture.fetch(src); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/svg+xml'); + }); + + it('generates valid srcset for responsive images', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerio.load(html); + const img = $('#responsive img'); + const widths = img + .attr('srcset')! + .split(', ') + .map((entry) => entry.split(' ')[1]); + assert.deepEqual(widths, ['640w', '750w', '828w', '1080w', '1200w', '1920w']); + }); + }); +}); diff --git a/packages/integrations/vercel/test/integration-assets.test.js b/packages/integrations/vercel/test/integration-assets.test.js deleted file mode 100644 index 4bc4a570a486..000000000000 --- a/packages/integrations/vercel/test/integration-assets.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('Assets generated by integrations', () => { - it('moves static assets generated by integrations to the correct location: static output', async () => { - const fixture = await loadFixture({ - root: './fixtures/integration-assets/', - }); - await fixture.build(); - const sitemap = await fixture.readFile('../.vercel/output/static/sitemap-index.xml'); - assert(sitemap.includes('')); - }); - - it('moves static assets generated by integrations to the correct location: server output', async () => { - const fixture = await loadFixture({ - root: './fixtures/integration-assets/', - output: 'server', - }); - await fixture.build(); - const sitemap = await fixture.readFile('../.vercel/output/static/sitemap-index.xml'); - assert(sitemap.includes('')); - }); -}); diff --git a/packages/integrations/vercel/test/integration-assets.test.ts b/packages/integrations/vercel/test/integration-assets.test.ts new file mode 100644 index 000000000000..246b59e8ca99 --- /dev/null +++ b/packages/integrations/vercel/test/integration-assets.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { loadFixture } from './test-utils.ts'; + +describe('Assets generated by integrations', () => { + it('moves static assets generated by integrations to the correct location: static output', async () => { + const fixture = await loadFixture({ + root: './fixtures/integration-assets/', + }); + await fixture.build({}); + const sitemap = await fixture.readFile('../.vercel/output/static/sitemap-index.xml'); + assert(sitemap.includes('')); + }); + + it('moves static assets generated by integrations to the correct location: server output', async () => { + const fixture = await loadFixture({ + root: './fixtures/integration-assets/', + output: 'server', + }); + await fixture.build({}); + const sitemap = await fixture.readFile('../.vercel/output/static/sitemap-index.xml'); + assert(sitemap.includes('')); + }); +}); diff --git a/packages/integrations/vercel/test/isr.test.js b/packages/integrations/vercel/test/isr.test.js deleted file mode 100644 index 0b8e2d70c9be..000000000000 --- a/packages/integrations/vercel/test/isr.test.js +++ /dev/null @@ -1,75 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('ISR', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/isr/', - }); - await fixture.build(); - }); - - it('generates expected prerender config', async () => { - const vcConfig = JSON.parse( - await fixture.readFile('../.vercel/output/functions/_isr.prerender-config.json'), - ); - assert.deepEqual(vcConfig, { - expiration: 120, - bypassToken: '1c9e601d-9943-4e7c-9575-005556d774a8', - allowQuery: ['x_astro_path'], - passQuery: true, - }); - }); - - it('generates expected routes', async () => { - const deploymentConfig = JSON.parse(await fixture.readFile('../.vercel/output/config.json')); - // the first two are /_astro/*, and filesystem routes - assert.deepEqual(deploymentConfig.routes.slice(2), [ - { - src: '^/two$', - dest: '_render', - }, - { - src: '^/excluded/([^/]+?)$', - dest: '_render', - }, - { - src: '^/excluded(?:/(.*?))?$', - dest: '_render', - }, - { - src: '^/api/([^/]+?)$', - dest: '_render', - }, - { - src: '^/api$', - dest: '_render', - }, - { - src: '^/_server-islands/([^/]+?)/?$', - dest: '_render', - }, - { - src: '^/_image/?$', - dest: '_render', - }, - { - src: '^/one/?$', - dest: '/_isr?x_astro_path=$0', - }, - { - src: '^/404/?$', - dest: '/_isr?x_astro_path=$0', - }, - { - dest: '_render', - src: '^/.*$', - status: 404, - }, - ]); - }); -}); diff --git a/packages/integrations/vercel/test/isr.test.ts b/packages/integrations/vercel/test/isr.test.ts new file mode 100644 index 000000000000..94edc0505b50 --- /dev/null +++ b/packages/integrations/vercel/test/isr.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('ISR', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/isr/', + }); + await fixture.build({}); + }); + + it('generates expected prerender config', async () => { + const vcConfig = JSON.parse( + await fixture.readFile('../.vercel/output/functions/_isr.prerender-config.json'), + ); + assert.deepEqual(vcConfig, { + expiration: 120, + bypassToken: '1c9e601d-9943-4e7c-9575-005556d774a8', + allowQuery: ['x_astro_path'], + passQuery: true, + }); + }); + + it('generates expected routes', async () => { + const deploymentConfig = JSON.parse(await fixture.readFile('../.vercel/output/config.json')); + // the first two are /_astro/*, and filesystem routes + assert.deepEqual(deploymentConfig.routes.slice(2), [ + { + src: '^/two$', + dest: '_render', + }, + { + src: '^/excluded/([^/]+?)$', + dest: '_render', + }, + { + src: '^/excluded(?:/(.*?))?$', + dest: '_render', + }, + { + src: '^/api/([^/]+?)$', + dest: '_render', + }, + { + src: '^/api$', + dest: '_render', + }, + { + src: '^/_server-islands/([^/]+?)/?$', + dest: '_render', + }, + { + src: '^/_image/?$', + dest: '_render', + }, + { + src: '^/one/?$', + dest: '/_isr?x_astro_path=$0', + }, + { + src: '^/404/?$', + dest: '/_isr?x_astro_path=$0', + }, + { + dest: '_render', + src: '^/.*$', + status: 404, + }, + ]); + }); +}); diff --git a/packages/integrations/vercel/test/max-duration.test.js b/packages/integrations/vercel/test/max-duration.test.js deleted file mode 100644 index d5e26fc1a999..000000000000 --- a/packages/integrations/vercel/test/max-duration.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('maxDuration', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/max-duration/', - }); - await fixture.build(); - }); - - it('makes it to vercel function configuration', async () => { - const vcConfig = JSON.parse( - await fixture.readFile('../.vercel/output/functions/_render.func/.vc-config.json'), - ); - assert.equal(vcConfig.maxDuration, 60); - }); -}); diff --git a/packages/integrations/vercel/test/max-duration.test.ts b/packages/integrations/vercel/test/max-duration.test.ts new file mode 100644 index 000000000000..ba331e11bf90 --- /dev/null +++ b/packages/integrations/vercel/test/max-duration.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('maxDuration', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/max-duration/', + }); + await fixture.build({}); + }); + + it('makes it to vercel function configuration', async () => { + const vcConfig = JSON.parse( + await fixture.readFile('../.vercel/output/functions/_render.func/.vc-config.json'), + ); + assert.equal(vcConfig.maxDuration, 60); + }); +}); diff --git a/packages/integrations/vercel/test/path-override-security.test.js b/packages/integrations/vercel/test/path-override-security.test.js deleted file mode 100644 index b72fe702ea7d..000000000000 --- a/packages/integrations/vercel/test/path-override-security.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -async function loadFunctionModule(fixture, functionName) { - const functionConfig = JSON.parse( - await fixture.readFile(`../.vercel/output/functions/${functionName}.func/.vc-config.json`), - ); - const functionEntry = new URL( - `../.vercel/output/functions/${functionName}.func/${functionConfig.handler}`, - fixture.config.outDir, - ); - - return import(functionEntry); -} - -describe('Vercel serverless path override security', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - process.env.PRERENDER = true; - fixture = await loadFixture({ - root: './fixtures/serverless-with-dynamic-routes/', - output: 'server', - }); - await fixture.build(); - }); - - it('ignores untrusted x_astro_path query param on _render', async () => { - const renderFunction = await loadFunctionModule(fixture, '_render'); - const response = await renderFunction.default.fetch( - new Request('https://example.com/api/public?x_astro_path=/api/private'), - ); - const body = await response.json(); - - assert.equal(body.id, 'public'); - }); - - it('ignores untrusted x-astro-path header on _render', async () => { - const renderFunction = await loadFunctionModule(fixture, '_render'); - const response = await renderFunction.default.fetch( - new Request('https://example.com/api/public', { - headers: { - 'x-astro-path': '/api/private', - }, - }), - ); - const body = await response.json(); - - assert.equal(body.id, 'public'); - }); -}); diff --git a/packages/integrations/vercel/test/path-override-security.test.ts b/packages/integrations/vercel/test/path-override-security.test.ts new file mode 100644 index 000000000000..9e5a5eb62eda --- /dev/null +++ b/packages/integrations/vercel/test/path-override-security.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +async function loadFunctionModule(fixture: Fixture, functionName: string) { + const functionConfig = JSON.parse( + await fixture.readFile(`../.vercel/output/functions/${functionName}.func/.vc-config.json`), + ); + const functionEntry = new URL( + `../.vercel/output/functions/${functionName}.func/${functionConfig.handler}`, + fixture.config.outDir, + ); + + return import(functionEntry.href); +} + +describe('Vercel serverless path override security', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/serverless-with-dynamic-routes/', + output: 'server', + }); + await fixture.build({}); + }); + + it('ignores untrusted x_astro_path query param on _render', async () => { + const renderFunction = await loadFunctionModule(fixture, '_render'); + const response = await renderFunction.default.fetch( + new Request('https://example.com/api/public?x_astro_path=/api/private'), + ); + const body = await response.json(); + + assert.equal(body.id, 'public'); + }); + + it('ignores untrusted x-astro-path header on _render', async () => { + const renderFunction = await loadFunctionModule(fixture, '_render'); + const response = await renderFunction.default.fetch( + new Request('https://example.com/api/public', { + headers: { + 'x-astro-path': '/api/private', + }, + }), + ); + const body = await response.json(); + + assert.equal(body.id, 'public'); + }); +}); diff --git a/packages/integrations/vercel/test/prerendered-error-pages.test.js b/packages/integrations/vercel/test/prerendered-error-pages.test.js deleted file mode 100644 index 79a1f4aafe0f..000000000000 --- a/packages/integrations/vercel/test/prerendered-error-pages.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('prerendered error pages routing', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/prerendered-error-pages/', - }); - await fixture.build(); - }); - - it('falls back to 404.html', async () => { - const deploymentConfig = JSON.parse(await fixture.readFile('../.vercel/output/config.json')); - assert.deepEqual( - deploymentConfig.routes.find((r) => r.status === 404), - { - src: '^/.*$', - dest: '/404.html', - status: 404, - }, - ); - }); -}); diff --git a/packages/integrations/vercel/test/prerendered-error-pages.test.ts b/packages/integrations/vercel/test/prerendered-error-pages.test.ts new file mode 100644 index 000000000000..dd7d56667813 --- /dev/null +++ b/packages/integrations/vercel/test/prerendered-error-pages.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture, getVercelConfig } from './test-utils.ts'; + +describe('prerendered error pages routing', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/prerendered-error-pages/', + }); + await fixture.build({}); + }); + + it('falls back to 404.html', async () => { + const deploymentConfig = await getVercelConfig(fixture); + assert.deepEqual( + deploymentConfig.routes.find((r) => r.status === 404), + { + src: '^/.*$', + dest: '/404.html', + status: 404, + }, + ); + }); +}); diff --git a/packages/integrations/vercel/test/redirects-serverless.test.js b/packages/integrations/vercel/test/redirects-serverless.test.js deleted file mode 100644 index 8d7dcf75b403..000000000000 --- a/packages/integrations/vercel/test/redirects-serverless.test.js +++ /dev/null @@ -1,29 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('Redirects Serverless', () => { - /** @type {import('astro/test/test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/redirects-serverless/', - redirects: { - '/one': '/', - '/other': '/subpage', - }, - }); - await fixture.build(); - }); - - it('does not create .html files', async () => { - let hasErrored = false; - try { - await fixture.readFile('../.vercel/output/static/other/index.html'); - } catch { - hasErrored = true; - } - assert.equal(hasErrored, true, 'this file should not exist'); - }); -}); diff --git a/packages/integrations/vercel/test/redirects-serverless.test.ts b/packages/integrations/vercel/test/redirects-serverless.test.ts new file mode 100644 index 000000000000..6536d7f529bd --- /dev/null +++ b/packages/integrations/vercel/test/redirects-serverless.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('Redirects Serverless', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/redirects-serverless/', + redirects: { + '/one': '/', + '/other': '/subpage', + }, + }); + await fixture.build({}); + }); + + it('does not create .html files', async () => { + let hasErrored = false; + try { + await fixture.readFile('../.vercel/output/static/other/index.html'); + } catch { + hasErrored = true; + } + assert.equal(hasErrored, true, 'this file should not exist'); + }); +}); diff --git a/packages/integrations/vercel/test/redirects.test.js b/packages/integrations/vercel/test/redirects.test.js deleted file mode 100644 index a76ff6ad9ed6..000000000000 --- a/packages/integrations/vercel/test/redirects.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('Redirects', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/redirects/', - redirects: { - '/one': '/', - '/two': '/', - '/three': { - status: 302, - destination: '/', - }, - '/four': { - status: 302, - destination: 'http://example.com', - }, - '/blog/[...slug]': '/team/articles/[...slug]', - '/Basic/http-2-0.html': '/posts/http2', - }, - trailingSlash: 'always', - }); - await fixture.build(); - }); - - async function getConfig() { - const json = await fixture.readFile('../.vercel/output/config.json'); - const config = JSON.parse(json); - return config; - } - - it('define static routes', async () => { - const config = await getConfig(); - const oneRoute = config.routes.find((r) => r.src === '^/one$'); - assert.equal(oneRoute.headers.Location, '/'); - assert.equal(oneRoute.status, 301); - - const twoRoute = config.routes.find((r) => r.src === '^/two$'); - assert.equal(twoRoute.headers.Location, '/'); - assert.equal(twoRoute.status, 301); - - const threeRoute = config.routes.find((r) => r.src === '^/three$'); - assert.equal(threeRoute.headers.Location, '/'); - assert.equal(threeRoute.status, 302); - - const fourRoute = config.routes.find((r) => r.src === '^/four$'); - assert.equal(fourRoute.headers.Location, 'http://example.com'); - assert.equal(fourRoute.status, 302); - }); - - it('define redirects for static files', async () => { - const config = await getConfig(); - - const staticRoute = config.routes.find((r) => r.src === '^/Basic/http-2-0\\.html$'); - assert.notEqual(staticRoute, undefined); - assert.equal(staticRoute.headers.Location, '/posts/http2'); - assert.equal(staticRoute.status, 301); - }); - - it('defines dynamic routes', async () => { - const config = await getConfig(); - - const blogRoute = config.routes.find((r) => r.src.startsWith('^/blog')); - assert.notEqual(blogRoute, undefined); - assert.equal(blogRoute.headers.Location.startsWith('/team/articles'), true); - assert.equal(blogRoute.status, 301); - }); - - it('throws an error for invalid redirects', async () => { - const fails = await loadFixture({ - root: './fixtures/redirects/', - redirects: { - // Invalid source syntax - '/blog/(![...slug]': '/team/articles/[...slug]', - }, - }); - await assert.rejects(() => fails.build(), { - name: 'AstroUserError', - message: - 'Error generating redirects: Redirect at index 0 has invalid `source` regular expression "/blog/(!:slug*".', - }); - }); -}); diff --git a/packages/integrations/vercel/test/redirects.test.ts b/packages/integrations/vercel/test/redirects.test.ts new file mode 100644 index 000000000000..e47c93e5421b --- /dev/null +++ b/packages/integrations/vercel/test/redirects.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture, getVercelConfig } from './test-utils.ts'; + +describe('Redirects', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/redirects/', + redirects: { + '/one': '/', + '/two': '/', + '/three': { + status: 302, + destination: '/', + }, + '/four': { + status: 302, + destination: 'http://example.com', + }, + '/blog/[...slug]': '/team/articles/[...slug]', + '/Basic/http-2-0.html': '/posts/http2', + }, + trailingSlash: 'always', + }); + await fixture.build({}); + }); + + it('define static routes', async () => { + const config = await getVercelConfig(fixture); + const oneRoute = config.routes.find((r) => r.src === '^/one$')!; + assert.equal(oneRoute.headers['Location'], '/'); + assert.equal(oneRoute.status, 301); + + const twoRoute = config.routes.find((r) => r.src === '^/two$')!; + assert.equal(twoRoute.headers['Location'], '/'); + assert.equal(twoRoute.status, 301); + + const threeRoute = config.routes.find((r) => r.src === '^/three$')!; + assert.equal(threeRoute.headers['Location'], '/'); + assert.equal(threeRoute.status, 302); + + const fourRoute = config.routes.find((r) => r.src === '^/four$')!; + assert.equal(fourRoute.headers['Location'], 'http://example.com'); + assert.equal(fourRoute.status, 302); + }); + + it('define redirects for static files', async () => { + const config = await getVercelConfig(fixture); + + const staticRoute = config.routes.find((r) => r.src === '^/Basic/http-2-0\\.html$')!; + assert.notEqual(staticRoute, undefined); + assert.equal(staticRoute.headers['Location'], '/posts/http2'); + assert.equal(staticRoute.status, 301); + }); + + it('defines dynamic routes', async () => { + const config = await getVercelConfig(fixture); + + const blogRoute = config.routes.find((r) => r.src.startsWith('^/blog'))!; + assert.notEqual(blogRoute, undefined); + assert.equal(blogRoute.headers['Location'].startsWith('/team/articles'), true); + assert.equal(blogRoute.status, 301); + }); + + it('throws an error for invalid redirects', async () => { + const fails = await loadFixture({ + root: './fixtures/redirects/', + redirects: { + // Invalid source syntax + '/blog/(![...slug]': '/team/articles/[...slug]', + }, + }); + await assert.rejects(() => fails.build({}), { + name: 'AstroUserError', + message: + 'Error generating redirects: Redirect at index 0 has invalid `source` regular expression "/blog/(!:slug*".', + }); + }); +}); diff --git a/packages/integrations/vercel/test/server-islands.test.js b/packages/integrations/vercel/test/server-islands.test.js deleted file mode 100644 index f4f1767075d8..000000000000 --- a/packages/integrations/vercel/test/server-islands.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('Server Islands', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/server-islands/', - }); - await fixture.build(); - }); - - it('server islands route is in the config', async () => { - const config = JSON.parse(await fixture.readFile('../.vercel/output/config.json')); - let found = null; - for (const route of config.routes) { - if (route.src?.includes('_server-islands')) { - found = route; - break; - } - } - assert.notEqual(found, null, 'Default server islands route included'); - }); -}); diff --git a/packages/integrations/vercel/test/server-islands.test.ts b/packages/integrations/vercel/test/server-islands.test.ts new file mode 100644 index 000000000000..e5787e536720 --- /dev/null +++ b/packages/integrations/vercel/test/server-islands.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture, getVercelConfig } from './test-utils.ts'; + +describe('Server Islands', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/server-islands/', + }); + await fixture.build({}); + }); + + it('server islands route is in the config', async () => { + const config = await getVercelConfig(fixture); + let found = null; + for (const route of config.routes) { + if (route.src?.includes('_server-islands')) { + found = route; + break; + } + } + assert.notEqual(found, null, 'Default server islands route included'); + }); +}); diff --git a/packages/integrations/vercel/test/serverless-prerender.test.js b/packages/integrations/vercel/test/serverless-prerender.test.js deleted file mode 100644 index 662e74ac39fd..000000000000 --- a/packages/integrations/vercel/test/serverless-prerender.test.js +++ /dev/null @@ -1,59 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('Serverless prerender', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - process.env.PRERENDER = true; - fixture = await loadFixture({ - root: './fixtures/serverless-prerender/', - }); - await fixture.build(); - }); - - it('build successful', async () => { - assert.ok(await fixture.readFile('../.vercel/output/static/index.html')); - }); - - it('outDir is tree-shaken if not needed', async () => { - const [file] = await fixture.glob( - '../.vercel/output/functions/_render.func/packages/vercel/test/fixtures/serverless-prerender/.vercel/output/_functions/pages/_image.astro.mjs', - ); - try { - await fixture.readFile(file); - assert.fail(); - } catch { - assert.ok('Function do be three-shaken'); - } - }); - - // TODO: The path here seems to be inconsistent? - it.skip('includeFiles work', async () => { - assert.ok( - await fixture.readFile( - '../.vercel/output/functions/render.func/packages/vercel/test/fixtures/serverless-prerender/dist/middleware.mjs', - ), - ); - }); -}); - -describe('Serverless hybrid rendering', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - process.env.PRERENDER = true; - fixture = await loadFixture({ - root: './fixtures/serverless-prerender/', - output: 'static', - }); - await fixture.build(); - }); - - it('build successful', async () => { - assert.ok(await fixture.readFile('../.vercel/output/static/index.html')); - }); -}); diff --git a/packages/integrations/vercel/test/serverless-prerender.test.ts b/packages/integrations/vercel/test/serverless-prerender.test.ts new file mode 100644 index 000000000000..7809129a0a73 --- /dev/null +++ b/packages/integrations/vercel/test/serverless-prerender.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('Serverless prerender', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/serverless-prerender/', + }); + await fixture.build({}); + }); + + it('build successful', async () => { + assert.ok(await fixture.readFile('../.vercel/output/static/index.html')); + }); + + it('outDir is tree-shaken if not needed', async () => { + const [file] = await fixture.glob( + '../.vercel/output/functions/_render.func/packages/vercel/test/fixtures/serverless-prerender/.vercel/output/_functions/pages/_image.astro.mjs', + ); + try { + await fixture.readFile(file); + assert.fail(); + } catch { + assert.ok('Function do be three-shaken'); + } + }); + + // TODO: The path here seems to be inconsistent? + it.skip('includeFiles work', async () => { + assert.ok( + await fixture.readFile( + '../.vercel/output/functions/render.func/packages/vercel/test/fixtures/serverless-prerender/dist/middleware.mjs', + ), + ); + }); +}); + +describe('Serverless hybrid rendering', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/serverless-prerender/', + output: 'static', + }); + await fixture.build({}); + }); + + it('build successful', async () => { + assert.ok(await fixture.readFile('../.vercel/output/static/index.html')); + }); +}); diff --git a/packages/integrations/vercel/test/serverless-with-dynamic-routes.test.js b/packages/integrations/vercel/test/serverless-with-dynamic-routes.test.js deleted file mode 100644 index b402ad0bb2e6..000000000000 --- a/packages/integrations/vercel/test/serverless-with-dynamic-routes.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('Serverless with dynamic routes', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - process.env.PRERENDER = true; - fixture = await loadFixture({ - root: './fixtures/serverless-with-dynamic-routes/', - output: 'server', - }); - await fixture.build(); - }); - - it('build successful', async () => { - assert.ok(await fixture.readFile('../.vercel/output/static/index.html')); - assert.ok(await fixture.readFile('../.vercel/output/functions/_render.func/.vc-config.json')); - }); -}); diff --git a/packages/integrations/vercel/test/serverless-with-dynamic-routes.test.ts b/packages/integrations/vercel/test/serverless-with-dynamic-routes.test.ts new file mode 100644 index 000000000000..8c8c6a8fc572 --- /dev/null +++ b/packages/integrations/vercel/test/serverless-with-dynamic-routes.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('Serverless with dynamic routes', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/serverless-with-dynamic-routes/', + output: 'server', + }); + await fixture.build({}); + }); + + it('build successful', async () => { + assert.ok(await fixture.readFile('../.vercel/output/static/index.html')); + assert.ok(await fixture.readFile('../.vercel/output/functions/_render.func/.vc-config.json')); + }); +}); diff --git a/packages/integrations/vercel/test/static-assets.test.js b/packages/integrations/vercel/test/static-assets.test.js deleted file mode 100644 index 8e8b18c8e769..000000000000 --- a/packages/integrations/vercel/test/static-assets.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('Static Assets', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - const VALID_CACHE_CONTROL = 'public, max-age=31536000, immutable'; - - async function build({ adapter, assets, output }) { - fixture = await loadFixture({ - root: './fixtures/static-assets/', - output, - adapter, - build: { - assets, - }, - }); - await fixture.build(); - } - - async function getConfig() { - const json = await fixture.readFile('../.vercel/output/config.json'); - const config = JSON.parse(json); - - return config; - } - - async function getAssets() { - return fixture.config.build.assets; - } - - async function checkValidCacheControl(assets) { - const config = await getConfig(); - const theAssets = assets ?? (await getAssets()); - - const route = config.routes.find((r) => r.src === `^/${theAssets}/(.*)$`); - assert.equal(route.headers['cache-control'], VALID_CACHE_CONTROL); - assert.equal(route.continue, true); - } - - describe('static adapter', () => { - it('has cache control', async () => { - const { default: vercel } = await import('@astrojs/vercel'); - await build({ - adapter: vercel(), - }); - await checkValidCacheControl(); - }); - - it('has cache control other assets', async () => { - const { default: vercel } = await import('@astrojs/vercel'); - const assets = '_foo'; - await build({ - adapter: vercel(), - assets, - }); - await checkValidCacheControl(assets); - }); - }); - - describe('serverless adapter', () => { - it('has cache control', async () => { - const { default: vercel } = await import('@astrojs/vercel'); - await build({ - output: 'server', - adapter: vercel(), - }); - await checkValidCacheControl(); - }); - - it('has cache control other assets', async () => { - const { default: vercel } = await import('@astrojs/vercel'); - const assets = '_foo'; - await build({ - output: 'server', - adapter: vercel(), - assets, - }); - await checkValidCacheControl(assets); - }); - }); -}); diff --git a/packages/integrations/vercel/test/static-assets.test.ts b/packages/integrations/vercel/test/static-assets.test.ts new file mode 100644 index 000000000000..3d5578f3cb86 --- /dev/null +++ b/packages/integrations/vercel/test/static-assets.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + type Fixture, + type AstroInlineConfig, + loadFixture, + getVercelConfig, +} from './test-utils.ts'; + +describe('Static Assets', () => { + let fixture: Fixture; + + const VALID_CACHE_CONTROL = 'public, max-age=31536000, immutable'; + + async function build({ + adapter, + assets, + output, + }: { + adapter?: AstroInlineConfig['adapter']; + assets?: string; + output?: AstroInlineConfig['output']; + }) { + fixture = await loadFixture({ + root: './fixtures/static-assets/', + output, + adapter, + build: { + assets, + }, + }); + await fixture.build({}); + } + + async function getAssets() { + return fixture.config.build.assets; + } + + async function checkValidCacheControl(assets?: string) { + const config = await getVercelConfig(fixture); + const theAssets = assets ?? (await getAssets()); + + const route = config.routes.find((r) => r.src === `^/${theAssets}/(.*)$`); + assert.equal(route!.headers['cache-control'], VALID_CACHE_CONTROL); + assert.equal(route!.continue, true); + } + + describe('static adapter', () => { + it('has cache control', async () => { + const { default: vercel } = await import('@astrojs/vercel'); + await build({ + adapter: vercel(), + }); + await checkValidCacheControl(); + }); + + it('has cache control other assets', async () => { + const { default: vercel } = await import('@astrojs/vercel'); + const assets = '_foo'; + await build({ + adapter: vercel(), + assets, + }); + await checkValidCacheControl(assets); + }); + }); + + describe('serverless adapter', () => { + it('has cache control', async () => { + const { default: vercel } = await import('@astrojs/vercel'); + await build({ + output: 'server', + adapter: vercel(), + }); + await checkValidCacheControl(); + }); + + it('has cache control other assets', async () => { + const { default: vercel } = await import('@astrojs/vercel'); + const assets = '_foo'; + await build({ + output: 'server', + adapter: vercel(), + assets, + }); + await checkValidCacheControl(assets); + }); + }); +}); diff --git a/packages/integrations/vercel/test/static-headers.test.js b/packages/integrations/vercel/test/static-headers.test.js deleted file mode 100644 index 26fee84a4a09..000000000000 --- a/packages/integrations/vercel/test/static-headers.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('Static headers', () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static-headers', - }); - await fixture.build(); - }); - - it('CSP headers are added when CSP is enabled', async () => { - const config = JSON.parse(await fixture.readFile('../.vercel/output/config.json')); - const routes = config.routes; - - const headers = routes.find((x) => x.src === '/').headers; - - assert.ok(headers['content-security-policy'], 'the index must have CSP headers'); - assert.ok( - headers['content-security-policy'].includes('script-src'), - 'must contain the script-src directive because of the server island', - ); - }); -}); diff --git a/packages/integrations/vercel/test/static-headers.test.ts b/packages/integrations/vercel/test/static-headers.test.ts new file mode 100644 index 000000000000..9074353efccd --- /dev/null +++ b/packages/integrations/vercel/test/static-headers.test.ts @@ -0,0 +1,27 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture, getVercelConfig } from './test-utils.ts'; + +describe('Static headers', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static-headers', + }); + await fixture.build({}); + }); + + it('CSP headers are added when CSP is enabled', async () => { + const config = await getVercelConfig(fixture); + const routes = config.routes; + + const headers = routes.find((x) => x.src === '/')!.headers; + + assert.ok(headers['content-security-policy'], 'the index must have CSP headers'); + assert.ok( + headers['content-security-policy'].includes('script-src'), + 'must contain the script-src directive because of the server island', + ); + }); +}); diff --git a/packages/integrations/vercel/test/static.test.js b/packages/integrations/vercel/test/static.test.js deleted file mode 100644 index 8702cf52d8db..000000000000 --- a/packages/integrations/vercel/test/static.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('static routing', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/static/', - }); - await fixture.build(); - }); - - it('falls back to 404.html', async () => { - const deploymentConfig = JSON.parse(await fixture.readFile('../.vercel/output/config.json')); - // change the index if necessary - assert.deepEqual(deploymentConfig.routes[2], { - src: '^/.*$', - dest: '/404.html', - status: 404, - }); - }); -}); diff --git a/packages/integrations/vercel/test/static.test.ts b/packages/integrations/vercel/test/static.test.ts new file mode 100644 index 000000000000..bf86d679046d --- /dev/null +++ b/packages/integrations/vercel/test/static.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('static routing', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + }); + await fixture.build({}); + }); + + it('falls back to 404.html', async () => { + const deploymentConfig = JSON.parse(await fixture.readFile('../.vercel/output/config.json')); + // change the index if necessary + assert.deepEqual(deploymentConfig.routes[2], { + src: '^/.*$', + dest: '/404.html', + status: 404, + }); + }); +}); diff --git a/packages/integrations/vercel/test/streaming.test.js b/packages/integrations/vercel/test/streaming.test.js deleted file mode 100644 index 1e4b0f111e05..000000000000 --- a/packages/integrations/vercel/test/streaming.test.js +++ /dev/null @@ -1,22 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('streaming', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/streaming/', - }); - await fixture.build(); - }); - - it('makes it to vercel function configuration', async () => { - const vcConfig = JSON.parse( - await fixture.readFile('../.vercel/output/functions/_render.func/.vc-config.json'), - ); - assert.equal(vcConfig.supportsResponseStreaming, true); - }); -}); diff --git a/packages/integrations/vercel/test/streaming.test.ts b/packages/integrations/vercel/test/streaming.test.ts new file mode 100644 index 000000000000..cd57ec07d272 --- /dev/null +++ b/packages/integrations/vercel/test/streaming.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('streaming', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/streaming/', + }); + await fixture.build({}); + }); + + it('makes it to vercel function configuration', async () => { + const vcConfig = JSON.parse( + await fixture.readFile('../.vercel/output/functions/_render.func/.vc-config.json'), + ); + assert.equal(vcConfig.supportsResponseStreaming, true); + }); +}); diff --git a/packages/integrations/vercel/test/test-image-service.js b/packages/integrations/vercel/test/test-image-service.js deleted file mode 100644 index e3c5b4b6e29c..000000000000 --- a/packages/integrations/vercel/test/test-image-service.js +++ /dev/null @@ -1,32 +0,0 @@ -import { fileURLToPath } from 'node:url'; -import { baseService } from 'astro/assets'; - -/** - * stub image service that returns images as-is without optimization - * @param {{ foo?: string }} [config] - */ -export function testImageService(config = {}) { - return { - entrypoint: fileURLToPath(import.meta.url), - config, - }; -} - -/** @type {import("../dist/@types/astro").LocalImageService} */ -export default { - ...baseService, - propertiesToHash: [...baseService.propertiesToHash, 'data-custom'], - getHTMLAttributes(options, serviceConfig) { - options['data-service'] = 'my-custom-service'; - if (serviceConfig.service.config.foo) { - options['data-service-config'] = serviceConfig.service.config.foo; - } - return baseService.getHTMLAttributes(options); - }, - async transform(buffer, transform) { - return { - data: buffer, - format: transform.format, - }; - }, -}; diff --git a/packages/integrations/vercel/test/test-utils.js b/packages/integrations/vercel/test/test-utils.js deleted file mode 100644 index 8e70e9a1c82a..000000000000 --- a/packages/integrations/vercel/test/test-utils.js +++ /dev/null @@ -1,8 +0,0 @@ -import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; - -export function loadFixture(config) { - if (config?.root) { - config.root = new URL(config.root, import.meta.url); - } - return baseLoadFixture(config); -} diff --git a/packages/integrations/vercel/test/test-utils.ts b/packages/integrations/vercel/test/test-utils.ts new file mode 100644 index 000000000000..06cad424e9aa --- /dev/null +++ b/packages/integrations/vercel/test/test-utils.ts @@ -0,0 +1,32 @@ +import { + loadFixture as baseLoadFixture, + type Fixture, + type DevServer, + type AstroInlineConfig, +} from '../../../astro/test/test-utils.js'; + +export type { Fixture, DevServer, AstroInlineConfig }; + +export interface VercelOutputConfig { + version: number; + routes: Array<{ + src: string; + dest: string; + status: number; + headers: Record; + continue: boolean; + handle: string; + }>; +} + +export async function getVercelConfig(fixture: Fixture): Promise { + const json = await fixture.readFile('../.vercel/output/config.json'); + return JSON.parse(json); +} + +export function loadFixture(config: AstroInlineConfig) { + if (config?.root) { + config.root = new URL(config.root as string, import.meta.url).toString(); + } + return baseLoadFixture(config); +} diff --git a/packages/integrations/vercel/test/web-analytics.test.js b/packages/integrations/vercel/test/web-analytics.test.js deleted file mode 100644 index d5056d0acb90..000000000000 --- a/packages/integrations/vercel/test/web-analytics.test.js +++ /dev/null @@ -1,26 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; - -describe('Vercel Web Analytics', () => { - describe('output: static', () => { - /** @type {import('./test-utils.js').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/with-web-analytics-enabled/output-as-static/', - output: 'static', - }); - await fixture.build(); - }); - - it('ensures that Vercel Web Analytics is present in the header', async () => { - const pageOne = await fixture.readFile('../.vercel/output/static/one/index.html'); - const pageTwo = await fixture.readFile('../.vercel/output/static/two/index.html'); - - assert.match(pageOne, /\/_vercel\/insights\/script.js/); - assert.match(pageTwo, /\/_vercel\/insights\/script.js/); - }); - }); -}); diff --git a/packages/integrations/vercel/test/web-analytics.test.ts b/packages/integrations/vercel/test/web-analytics.test.ts new file mode 100644 index 000000000000..cd85327b56ff --- /dev/null +++ b/packages/integrations/vercel/test/web-analytics.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('Vercel Web Analytics', () => { + describe('output: static', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/with-web-analytics-enabled/output-as-static/', + output: 'static', + }); + await fixture.build({}); + }); + + it('ensures that Vercel Web Analytics is present in the header', async () => { + const pageOne = await fixture.readFile('../.vercel/output/static/one/index.html'); + const pageTwo = await fixture.readFile('../.vercel/output/static/two/index.html'); + + assert.match(pageOne, /\/_vercel\/insights\/script.js/); + assert.match(pageTwo, /\/_vercel\/insights\/script.js/); + }); + }); +}); diff --git a/packages/integrations/vercel/tsconfig.test.json b/packages/integrations/vercel/tsconfig.test.json new file mode 100644 index 000000000000..853326403557 --- /dev/null +++ b/packages/integrations/vercel/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true, + "rootDir": "." + }, + "references": [{ "path": "../../astro/tsconfig.test.json" }] +} diff --git a/packages/integrations/vue/package.json b/packages/integrations/vue/package.json index 4f4f15c36156..7f790965592e 100644 --- a/packages/integrations/vue/package.json +++ b/packages/integrations/vue/package.json @@ -35,7 +35,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && astro-scripts build \"src/editor.cts\" --force-cjs --no-clean-dist && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\" && astro-scripts build \"src/editor.cts\" --force-cjs --no-clean-dist", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "dependencies": { "@vitejs/plugin-vue": "^6.0.5", diff --git a/packages/integrations/vue/test/app-entrypoint-css.test.js b/packages/integrations/vue/test/app-entrypoint-css.test.js deleted file mode 100644 index 878aa8e486c3..000000000000 --- a/packages/integrations/vue/test/app-entrypoint-css.test.js +++ /dev/null @@ -1,67 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from './test-utils.js'; - -describe('App Entrypoint CSS', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/app-entrypoint-css/', - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('injects styles referenced in appEntrypoint', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - // test 1: basic component renders - assert.equal($('#foo > #bar').text(), 'works'); - // test 2: injects the global style on the page - assert.equal($('style').first().text().trim(), ':root{background-color:red}'); - }); - - it('does not inject styles to pages without a Vue component', async () => { - const html = await fixture.readFile('/unrelated/index.html'); - const $ = cheerioLoad(html); - - assert.equal($('style').length, 0); - assert.equal($('link[rel="stylesheet"]').length, 0); - }); - }); - - describe('dev', () => { - let devServer; - before(async () => { - devServer = await fixture.startDevServer(); - }); - after(async () => { - await devServer.stop(); - }); - - it('loads during SSR', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerioLoad(html); - - // test 1: basic component renders - assert.equal($('#foo > #bar').text(), 'works'); - // test 2: injects the global style on the page - assert.equal($('style').first().text().replace(/\s+/g, ''), ':root{background-color:red;}'); - }); - - it('does not inject styles to pages without a Vue component', async () => { - const html = await fixture.fetch('/unrelated').then((res) => res.text()); - const $ = cheerioLoad(html); - - assert.equal($('style').length, 0); - assert.equal($('link[rel="stylesheet"]').length, 0); - }); - }); -}); diff --git a/packages/integrations/vue/test/app-entrypoint-css.test.ts b/packages/integrations/vue/test/app-entrypoint-css.test.ts new file mode 100644 index 000000000000..d3b1e644cdb6 --- /dev/null +++ b/packages/integrations/vue/test/app-entrypoint-css.test.ts @@ -0,0 +1,66 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { type Fixture, type DevServer, loadFixture } from './test-utils.ts'; + +describe('App Entrypoint CSS', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/app-entrypoint-css/', + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('injects styles referenced in appEntrypoint', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + // test 1: basic component renders + assert.equal($('#foo > #bar').text(), 'works'); + // test 2: injects the global style on the page + assert.equal($('style').first().text().trim(), ':root{background-color:red}'); + }); + + it('does not inject styles to pages without a Vue component', async () => { + const html = await fixture.readFile('/unrelated/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('style').length, 0); + assert.equal($('link[rel="stylesheet"]').length, 0); + }); + }); + + describe('dev', () => { + let devServer: DevServer; + before(async () => { + devServer = await fixture.startDevServer(); + }); + after(async () => { + await devServer.stop(); + }); + + it('loads during SSR', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + // test 1: basic component renders + assert.equal($('#foo > #bar').text(), 'works'); + // test 2: injects the global style on the page + assert.equal($('style').first().text().replace(/\s+/g, ''), ':root{background-color:red;}'); + }); + + it('does not inject styles to pages without a Vue component', async () => { + const html = await fixture.fetch('/unrelated').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('style').length, 0); + assert.equal($('link[rel="stylesheet"]').length, 0); + }); + }); +}); diff --git a/packages/integrations/vue/test/app-entrypoint.test.js b/packages/integrations/vue/test/app-entrypoint.test.js deleted file mode 100644 index c2dd5f5701f3..000000000000 --- a/packages/integrations/vue/test/app-entrypoint.test.js +++ /dev/null @@ -1,199 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { load as cheerioLoad } from 'cheerio'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from './test-utils.js'; - -describe('App Entrypoint', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/app-entrypoint/', - }); - await fixture.build(); - }); - - it('loads during SSR', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - // test 1: basic component renders - assert.equal($('#foo > #bar').text(), 'works'); - - // test 2: component with multiple script blocks renders and exports - // values from non setup block correctly - assert.equal($('#multiple-script-blocks').text(), '2 4'); - - // test 3: component using generics renders - assert.equal($('#generics').text(), 'generic'); - - // test 4: component using generics and multiple script blocks renders - assert.equal($('#generics-and-blocks').text(), '1 3!!!'); - }); - - it('loads svg components without transforming them to assets', async () => { - const data = await fixture.readFile('/index.html'); - const { document } = parseHTML(data); - const client = document.querySelector('astro-island svg'); - - assert.notEqual(client, undefined); - }); -}); - -describe('App Entrypoint no export default (dev)', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/app-entrypoint-no-export-default/', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('loads during SSR', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const { document } = parseHTML(html); - const bar = document.querySelector('#foo > #bar'); - assert.notEqual(bar, undefined); - assert.equal(bar.textContent, 'works'); - }); - - it('loads svg components without transforming them to assets', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const { document } = parseHTML(html); - const client = document.querySelector('astro-island svg'); - - assert.notEqual(client, undefined); - }); -}); - -describe('App Entrypoint no export default', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/app-entrypoint-no-export-default/', - }); - await fixture.build(); - }); - - it('loads during SSR', async () => { - const data = await fixture.readFile('/index.html'); - const { document } = parseHTML(data); - const bar = document.querySelector('#foo > #bar'); - assert.notEqual(bar, undefined); - assert.equal(bar.textContent, 'works'); - }); - - it('component not included in renderer bundle', async () => { - const data = await fixture.readFile('/index.html'); - const { document } = parseHTML(data); - const island = document.querySelector('astro-island'); - const client = island.getAttribute('renderer-url'); - assert.notEqual(client, undefined); - const js = await fixture.readFile(client); - assert.doesNotMatch(js, /\w+\.component\("Bar"/g); - }); - - it('loads svg components without transforming them to assets', async () => { - const data = await fixture.readFile('/index.html'); - const { document } = parseHTML(data); - const client = document.querySelector('astro-island svg'); - - assert.notEqual(client, undefined); - }); -}); - -describe('App Entrypoint relative', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/app-entrypoint-relative/', - }); - await fixture.build(); - }); - - it('loads during SSR', async () => { - const data = await fixture.readFile('/index.html'); - const { document } = parseHTML(data); - const bar = document.querySelector('#foo > #bar'); - assert.notEqual(bar, undefined); - assert.equal(bar.textContent, 'works'); - }); - - it('component not included in renderer bundle', async () => { - const data = await fixture.readFile('/index.html'); - const { document } = parseHTML(data); - const island = document.querySelector('astro-island'); - const client = island.getAttribute('renderer-url'); - assert.notEqual(client, undefined); - - const js = await fixture.readFile(client); - assert.doesNotMatch(js, /\w+\.component\("Bar"/g); - }); -}); - -describe('App Entrypoint /src/absolute', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/app-entrypoint-src-absolute/', - }); - await fixture.build(); - }); - - it('loads during SSR', async () => { - const data = await fixture.readFile('/index.html'); - const { document } = parseHTML(data); - const bar = document.querySelector('#foo > #bar'); - assert.notEqual(bar, undefined); - assert.equal(bar.textContent, 'works'); - }); - - it('component not included in renderer bundle', async () => { - const data = await fixture.readFile('/index.html'); - const { document } = parseHTML(data); - const island = document.querySelector('astro-island'); - const client = island.getAttribute('renderer-url'); - assert.notEqual(client, undefined); - - const js = await fixture.readFile(client); - assert.doesNotMatch(js, /\w+\.component\("Bar"/g); - }); -}); - -describe('App Entrypoint async', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/app-entrypoint-async/', - }); - await fixture.build(); - }); - - it('loads during SSR', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerioLoad(html); - - // test 1: component before await renders - assert.equal($('#foo > #bar').text(), 'works'); - - // test 2: component after await renders - assert.equal($('#foo > #baz').text(), 'works'); - }); -}); diff --git a/packages/integrations/vue/test/app-entrypoint.test.ts b/packages/integrations/vue/test/app-entrypoint.test.ts new file mode 100644 index 000000000000..56823cabbc93 --- /dev/null +++ b/packages/integrations/vue/test/app-entrypoint.test.ts @@ -0,0 +1,193 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { parseHTML } from 'linkedom'; +import { type Fixture, type DevServer, loadFixture } from './test-utils.ts'; + +describe('App Entrypoint', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/app-entrypoint/', + }); + await fixture.build(); + }); + + it('loads during SSR', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + // test 1: basic component renders + assert.equal($('#foo > #bar').text(), 'works'); + + // test 2: component with multiple script blocks renders and exports + // values from non setup block correctly + assert.equal($('#multiple-script-blocks').text(), '2 4'); + + // test 3: component using generics renders + assert.equal($('#generics').text(), 'generic'); + + // test 4: component using generics and multiple script blocks renders + assert.equal($('#generics-and-blocks').text(), '1 3!!!'); + }); + + it('loads svg components without transforming them to assets', async () => { + const data = await fixture.readFile('/index.html'); + const { document } = parseHTML(data); + const client = document.querySelector('astro-island svg'); + + assert.notEqual(client, undefined); + }); +}); + +describe('App Entrypoint no export default (dev)', () => { + let fixture: Fixture; + let devServer: DevServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/app-entrypoint-no-export-default/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('loads during SSR', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const { document } = parseHTML(html); + const bar = document.querySelector('#foo > #bar')!; + assert.notEqual(bar, undefined); + assert.equal(bar.textContent, 'works'); + }); + + it('loads svg components without transforming them to assets', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const { document } = parseHTML(html); + const client = document.querySelector('astro-island svg'); + + assert.notEqual(client, undefined); + }); +}); + +describe('App Entrypoint no export default', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/app-entrypoint-no-export-default/', + }); + await fixture.build(); + }); + + it('loads during SSR', async () => { + const data = await fixture.readFile('/index.html'); + const { document } = parseHTML(data); + const bar = document.querySelector('#foo > #bar')!; + assert.notEqual(bar, undefined); + assert.equal(bar.textContent, 'works'); + }); + + it('component not included in renderer bundle', async () => { + const data = await fixture.readFile('/index.html'); + const { document } = parseHTML(data); + const island = document.querySelector('astro-island')!; + const client = island.getAttribute('renderer-url')!; + assert.notEqual(client, undefined); + const js = await fixture.readFile(client); + assert.doesNotMatch(js, /\w+\.component\("Bar"/g); + }); + + it('loads svg components without transforming them to assets', async () => { + const data = await fixture.readFile('/index.html'); + const { document } = parseHTML(data); + const client = document.querySelector('astro-island svg'); + + assert.notEqual(client, undefined); + }); +}); + +describe('App Entrypoint relative', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/app-entrypoint-relative/', + }); + await fixture.build(); + }); + + it('loads during SSR', async () => { + const data = await fixture.readFile('/index.html'); + const { document } = parseHTML(data); + const bar = document.querySelector('#foo > #bar')!; + assert.notEqual(bar, undefined); + assert.equal(bar.textContent, 'works'); + }); + + it('component not included in renderer bundle', async () => { + const data = await fixture.readFile('/index.html'); + const { document } = parseHTML(data); + const island = document.querySelector('astro-island')!; + const client = island.getAttribute('renderer-url')!; + assert.notEqual(client, undefined); + + const js = await fixture.readFile(client); + assert.doesNotMatch(js, /\w+\.component\("Bar"/g); + }); +}); + +describe('App Entrypoint /src/absolute', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/app-entrypoint-src-absolute/', + }); + await fixture.build(); + }); + + it('loads during SSR', async () => { + const data = await fixture.readFile('/index.html'); + const { document } = parseHTML(data); + const bar = document.querySelector('#foo > #bar')!; + assert.notEqual(bar, undefined); + assert.equal(bar.textContent, 'works'); + }); + + it('component not included in renderer bundle', async () => { + const data = await fixture.readFile('/index.html'); + const { document } = parseHTML(data); + const island = document.querySelector('astro-island')!; + const client = island.getAttribute('renderer-url')!; + assert.notEqual(client, undefined); + + const js = await fixture.readFile(client); + assert.doesNotMatch(js, /\w+\.component\("Bar"/g); + }); +}); + +describe('App Entrypoint async', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/app-entrypoint-async/', + }); + await fixture.build(); + }); + + it('loads during SSR', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + // test 1: component before await renders + assert.equal($('#foo > #bar').text(), 'works'); + + // test 2: component after await renders + assert.equal($('#foo > #baz').text(), 'works'); + }); +}); diff --git a/packages/integrations/vue/test/basics.test.js b/packages/integrations/vue/test/basics.test.js deleted file mode 100644 index 84b274b0d40c..000000000000 --- a/packages/integrations/vue/test/basics.test.js +++ /dev/null @@ -1,51 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { parseHTML } from 'linkedom'; -import { loadFixture } from './test-utils.js'; - -describe('Basics', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/basics/', - }); - await fixture.build(); - }); - - it('Slots are added without the slot attribute', async () => { - const data = await fixture.readFile('/index.html'); - const { document } = parseHTML(data); - const bar = document.querySelector('#foo'); - - assert.notEqual(bar, undefined); - assert.equal(bar.getAttribute('slot'), null); - }); - - it('Can show images from public', async () => { - const data = await fixture.readFile('/public/index.html'); - const { document } = parseHTML(data); - const img = document.querySelector('img'); - - assert.notEqual(img, undefined); - assert.equal(img.getAttribute('src'), '/light_walrus.avif'); - }); - - it('Should generate unique ids when using useId()', async () => { - const data = await fixture.readFile('/index.html'); - const { document } = parseHTML(data); - - const els = document.querySelectorAll('.vue-use-id'); - assert.equal(els.length, 2); - assert.notEqual(els[0].getAttribute('id'), els[1].getAttribute('id')); - }); - - it('Can load Vue JSX', async () => { - const data = await fixture.readFile('/jsx/index.html'); - const { document } = parseHTML(data); - - const allPreValues = [...document.querySelectorAll('pre')].map((e) => e.textContent); - assert.deepEqual(allPreValues, ['2345', '0', '1', '1', '1', '10', '100', '1000']); - }); -}); diff --git a/packages/integrations/vue/test/basics.test.ts b/packages/integrations/vue/test/basics.test.ts new file mode 100644 index 000000000000..e1dd222c8fbc --- /dev/null +++ b/packages/integrations/vue/test/basics.test.ts @@ -0,0 +1,50 @@ +import * as assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('Basics', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/basics/', + }); + await fixture.build(); + }); + + it('Slots are added without the slot attribute', async () => { + const data = await fixture.readFile('/index.html'); + const { document } = parseHTML(data); + const bar = document.querySelector('#foo')!; + + assert.notEqual(bar, undefined); + assert.equal(bar.getAttribute('slot'), null); + }); + + it('Can show images from public', async () => { + const data = await fixture.readFile('/public/index.html'); + const { document } = parseHTML(data); + const img = document.querySelector('img')!; + + assert.notEqual(img, undefined); + assert.equal(img.getAttribute('src'), '/light_walrus.avif'); + }); + + it('Should generate unique ids when using useId()', async () => { + const data = await fixture.readFile('/index.html'); + const { document } = parseHTML(data); + + const els = document.querySelectorAll('.vue-use-id'); + assert.equal(els.length, 2); + assert.notEqual(els[0].getAttribute('id'), els[1].getAttribute('id')); + }); + + it('Can load Vue JSX', async () => { + const data = await fixture.readFile('/jsx/index.html'); + const { document } = parseHTML(data); + + const allPreValues = [...document.querySelectorAll('pre')].map((e) => e.textContent); + assert.deepEqual(allPreValues, ['2345', '0', '1', '1', '1', '10', '100', '1000']); + }); +}); diff --git a/packages/integrations/vue/test/check.test.js b/packages/integrations/vue/test/check.test.ts similarity index 100% rename from packages/integrations/vue/test/check.test.js rename to packages/integrations/vue/test/check.test.ts diff --git a/packages/integrations/vue/test/test-utils.js b/packages/integrations/vue/test/test-utils.js deleted file mode 100644 index 512fe28dcba8..000000000000 --- a/packages/integrations/vue/test/test-utils.js +++ /dev/null @@ -1,16 +0,0 @@ -import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; - -/** - * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture - */ - -export function loadFixture(inlineConfig) { - if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }"); - - // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath - // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test` - return baseLoadFixture({ - ...inlineConfig, - root: new URL(inlineConfig.root, import.meta.url).toString(), - }); -} diff --git a/packages/integrations/vue/test/test-utils.ts b/packages/integrations/vue/test/test-utils.ts new file mode 100644 index 000000000000..7164a3ce730c --- /dev/null +++ b/packages/integrations/vue/test/test-utils.ts @@ -0,0 +1,19 @@ +import { + loadFixture as baseLoadFixture, + type Fixture, + type DevServer, + type AstroInlineConfig, +} from '../../../astro/test/test-utils.js'; + +export type { Fixture, DevServer }; + +export function loadFixture(inlineConfig: AstroInlineConfig) { + if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }"); + + // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath + // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test` + return baseLoadFixture({ + ...inlineConfig, + root: new URL(inlineConfig.root, import.meta.url).toString(), + }); +} diff --git a/packages/integrations/vue/test/toTsx.test.js b/packages/integrations/vue/test/toTsx.test.ts similarity index 100% rename from packages/integrations/vue/test/toTsx.test.js rename to packages/integrations/vue/test/toTsx.test.ts diff --git a/packages/integrations/vue/tsconfig.test.json b/packages/integrations/vue/tsconfig.test.json new file mode 100644 index 000000000000..27c89c5fe7a7 --- /dev/null +++ b/packages/integrations/vue/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../../astro/tsconfig.test.json" + } + ] +} diff --git a/packages/internal-helpers/package.json b/packages/internal-helpers/package.json index 018b9d44cafc..feadc3dd8610 100644 --- a/packages/internal-helpers/package.json +++ b/packages/internal-helpers/package.json @@ -46,7 +46,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc -p tsconfig.test.json --noEmit" }, "dependencies": { "picomatch": "^4.0.3" diff --git a/packages/internal-helpers/test/create-filter.test.js b/packages/internal-helpers/test/create-filter.test.ts similarity index 100% rename from packages/internal-helpers/test/create-filter.test.js rename to packages/internal-helpers/test/create-filter.test.ts diff --git a/packages/internal-helpers/test/path.test.js b/packages/internal-helpers/test/path.test.js deleted file mode 100644 index c4359f05a5fd..000000000000 --- a/packages/internal-helpers/test/path.test.js +++ /dev/null @@ -1,836 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { isParentDirectory, isRemotePath, normalizePathname } from '../dist/path.js'; - -describe('isRemotePath', () => { - const remotePaths = [ - // Standard remote protocols - 'https://example.com/foo/bar.js', - 'http://example.com/foo/bar.js', - '//example.com/foo/bar.js', - 'ws://example.com/foo/bar.js', - 'wss://example.com/foo/bar.js', - 'ftp://example.com/foo/bar.js', - 'sftp://example.com/foo/bar.js', - 'mailto:example@example.com', - 'data:someCode', - 'data:image/png;base64,iVBORw0KGgo', - 'data:text/html,', - - // Backslash bypass attempts - '\\\\example.com/foo/bar.js', - '\\example.com/foo/bar.js', - '\\\\\\example.com/foo/bar.js', - '\\\\\\\\example.com/foo/bar.js', - '\\raw.githubusercontent.com/test.svg', - '\\\\raw.githubusercontent.com/test.svg', - - // URL-encoded backslash attempts - '%5C%5Cexample.com/foo/bar.js', - '%5Cexample.com/foo/bar.js', - '%5c%5cexample.com/foo/bar.js', - '%5cexample.com/foo/bar.js', - '%5C%5C%5Cexample.com/foo/bar.js', - '%5C%5C%5C%5Cexample.com/foo/bar.js', - - // Mixed encoding - '%5C\\example.com/foo/bar.js', - '\\%5Cexample.com/foo/bar.js', - '%5c\\example.com/test', - - // Mixed forward and backslashes - '\\//example.com/foo/bar.js', - '\\//\\example.com/foo/bar.js', - '/\\example.com/foo/bar.js', // Forward then backslash - suspicious - '/\\\\example.com/foo/bar.js', // Forward then double backslash - suspicious - - // Protocol with backslashes - 'http:\\\\example.com/foo/bar.js', - 'https:\\\\example.com/foo/bar.js', - 'http:\\example.com/foo/bar.js', - 'https:\\example.com/foo/bar.js', - 'ftp:\\\\example.com/foo/bar.js', - 'ws:\\\\example.com/foo/bar.js', - 'wss:\\\\example.com/test', // WSS with backslashes - 'sftp:\\\\example.com/test', // SFTP with backslashes - 'HTTP:\\\\example.com/test', - 'HtTp:\\\\example.com/test', - - // Unicode escapes - '\u005C\u005Cexample.com/test', - '\u005Cexample.com/test', - '\\u005C\\u005Cexample.com/test', - - // Null byte injection - '\\example.com%00.jpg', - '%5Cexample.com%00.jpg', - '\\example.com\x00.jpg', - - // Whitespace injection - '\\\texample.com/test', - '\\ example.com/test', - '\\%09example.com/test', - '\\%20example.com/test', - - // Newline/carriage return injection - '\\\nexample.com/test', - '\\\rexample.com/test', - '\\%0Aexample.com/test', - '\\%0Dexample.com/test', - - // IP addresses - 'http://192.168.1.1/test', - '//192.168.1.1/test', - '\\\\192.168.1.1/test', - 'http://[::1]/test', - 'http://[2001:db8::1]/test', - '//[::1]/test', - '\\\\[::1]/test', - - // Localhost - 'http://localhost/test', - '//localhost/test', - '\\\\localhost/test', - 'http://127.0.0.1/test', - '\\\\127.0.0.1/test', - - // With ports - 'http://example.com:8080/test', - '//example.com:8080/test', - '\\\\example.com:8080/test', - 'http:\\\\example.com:8080/test', - - // With auth - basic credential attacks - 'http://user:pass@example.com/test', - '//user:pass@example.com/test', - '\\\\user:pass@example.com/test', - - // Credential injection attempts to look like local paths - '//admin:admin@/var/www/html', // Protocol-relative with path-like ending - '\\\\admin:password@C:\\Windows\\System32', // UNC-style with Windows path - 'user:pass@/home/user/file.js', // No protocol but has creds and Unix path - 'admin:admin@C:\\Users\\Public', // No protocol but has creds and Windows path - '//user@/local/path', // Single user@ with local-looking path - '\\\\user@C:\\Program Files', // Backslash variant - - // Encoded credentials to bypass detection - 'http://%75ser:%70ass@example.com', // URL-encoded "user:pass" - 'http://user%3Apass@example.com', // Encoded colon in creds - 'http://user:pass%40example.com', // Encoded @ in password - '//%75%73%65%72:%70%61%73%73@example.com', // Fully encoded creds - '\\\\%75ser:%70ass@example.com', // Backslash with encoded creds - - // Double/triple encoding credentials - 'http://%2575ser:%2570ass@example.com', // Double encoded - 'http://%252575ser:%252570ass@example.com', // Triple encoded - - // Credentials with special characters trying to break parsing - 'http://user:p@ss@example.com', // @ in password - 'http://user:pass:extra@example.com', // Multiple colons - 'http://user::@example.com', // Empty password with double colon - 'http://:password@example.com', // Empty username - 'http://@example.com', // Just @ symbol - '//user:@example.com', // Empty password - '//:pass@example.com', // Empty username protocol-relative - - // Credentials with path traversal - 'http://user:../../../etc/passwd@example.com', // Path traversal in password - 'http://../../admin:pass@example.com', // Path traversal in username - '//user:pass@example.com/../../etc/passwd', // Creds with traversal after - - // Credentials with null bytes and special chars - 'http://user%00:pass@example.com', // Null byte in username - 'http://user:pass%00@example.com', // Null byte in password - 'http://user\x00:pass@example.com', // Hex null byte - 'http://user:pass\0@example.com', // Escaped null - - // OAuth/API key patterns that might be confused - 'http://oauth2:CLIENT_SECRET_HERE@example.com', - 'http://api_key:SECRET_KEY_123@example.com', - '//token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9@example.com', // JWT-like - - // Credentials with port confusion - 'http://user:pass@example.com:8080', // Standard with port - 'http://user:8080@example.com', // Port-like password - 'http://admin:3306@localhost', // MySQL port as password - '//root:22@server.com', // SSH port as password - - // Unicode in credentials - 'http://üser:pāss@example.com', // Unicode username/password - 'http://用户:密码@example.com', // Chinese characters - 'http://админ:пароль@example.com', // Cyrillic - 'http://\u0075ser:\u0070ass@example.com', // Unicode escapes - - // Homograph attacks in credentials - 'http://аdmin:pаssword@example.com', // Cyrillic 'а' looks like Latin 'a' - 'http://adⅿin:password@example.com', // Unicode small m lookalike - - // Large credentials trying to overflow - 'http://' + 'a'.repeat(1000) + ':' + 'b'.repeat(1000) + '@example.com', - '//' + 'x'.repeat(10000) + '@example.com', // Massive username - - // Mixed slashes with credentials - 'http:\\//user:pass@example.com', // Mixed slashes in protocol - 'http://user:pass@example.com\\path', // Mixed slashes in path - '\\//user:pass@example.com', // Backslash then protocol-relative - - // Credentials to bypass startsWith checks - // These try to trick checks like if(path.startsWith('/home')) or if(path.startsWith('src/')) - '//user:pass@/home/user/file.js', // Looks like /home but has protocol-relative with creds - '//token@/usr/local/bin', // Looks like /usr but is protocol-relative - '//api:key@/etc/passwd', // Looks like /etc but has creds - '//admin@/var/www/html/index.html', // Looks like /var but has creds - '//root@src/index.js', // Looks like src/ but protocol-relative with creds - '//user@public/assets/logo.png', // Looks like public/ but has creds - '//deploy@dist/bundle.js', // Looks like dist/ but has creds - '//ci:token@node_modules/package', // Looks like node_modules/ but has creds - - // Using credentials patterns that parse as valid URLs - 'user:pass@localhost/admin', // Credentials with localhost - 'admin:admin@127.0.0.1:8080', // Credentials with IP and port - 'root:toor@evil.com/payload', // Clear credential pattern - - // Credentials with localhost/127.0.0.1 to look local - '//admin:admin@localhost/admin', // Localhost but still remote protocol - '//root:toor@127.0.0.1/phpmyadmin', // Loopback but still remote - '//user@localhost:3000/api', // Localhost with port - '//dev:dev@[::1]/graphql', // IPv6 localhost - '//test@0.0.0.0/test', // 0.0.0.0 binding address - - // File URLs with @ trying to look like local paths - 'file://user@/etc/hosts', // file:// but with user@ - 'file://admin:pass@localhost/C:/Windows/System32', // file:// localhost Windows - 'file://root@127.0.0.1/home/user', // file:// with IP - - // Encoded credentials in paths that might decode to look local - '//%2F%2Fhome/user', // Encoded // at start: //home/user - '//%2Fetc%2Fpasswd@evil.com', // Encoded /etc/passwd@evil.com - '%2F%2Fadmin:admin@/var/log', // Encoded //admin:admin@/var/log - '//%252Fhome%252Fuser@evil.com', // Double encoded /home/user - - // Vite-like paths with credentials (trying to bypass Vite path detection) - '//user@/@fs/home/user/project', // Vite /@fs/ with creds before - '//admin@/@id/virtual:module', // Vite /@id/ with creds - '//dev@/@vite/client', // Vite client with creds - '//@react-refresh', // Vite React refresh with just @ - - // Query strings and fragments - 'http://example.com/test?param=value', - '\\\\example.com/test?param=value', - 'http://example.com/test#fragment', - '\\\\example.com/test#fragment', - - // Edge cases with dots - 'http://example..com/test', - '\\\\example..com/test', - 'http://.example.com/test', - '\\\\.example.com/test', - - // Long domains - '\\\\' + 'a'.repeat(1000) + '.com/test', - 'http://' + 'a'.repeat(1000) + '.com/test', - - // Minimal cases - '\\', // Single backslash - '\\\\', // Double backslash - '//', // Protocol-relative - 'http://', - 'https://', - 'data:', - - // Path traversal - 'http://example.com/./../../test', - '\\\\example.com/./../../test', - 'http://example.com/%2e%2e/test', - '\\\\example.com/%2e%2e/test', - - // Windows special paths - '\\\\?\\C:\\test', - '\\\\.\\pipe\\test', - '\\\\LOCALHOST\\share', - '\\\\127.0.0.1\\share', - - // Punycode and Unicode domains - 'http://xn--e1afmkfd.xn--p1ai/test', - '\\\\xn--e1afmkfd.xn--p1ai/test', - 'http://例え.jp/test', - '\\\\例え.jp/test', - - // Case variations - 'HtTp://example.com/test', - 'HTTP://example.com/test', - 'DATA:text/plain,hello', // Uppercase data URL - 'Data:text/plain,test', // Mixed case data URL - 'dAtA:text/plain,test', // Weird case data URL - - // Mixed slashes in protocols - 'http:\\//example.com/test', - 'https:/\\example.com/test', - 'ftp://\\example.com/test', - 'ws:\\//example.com/test', - - // Brackets - 'http://[example.com]/test', - '\\\\[example.com]/test', - 'http://(example.com)/test', - '\\\\(example.com)/test', - - // Malformed but has protocol prefix (conservative: treated as remote) - 'http:%2F%2Fexample.com/test', // Encoded slashes after colon - - // JavaScript and other dangerous protocols - 'javascript:alert(1)', - 'JavaScript:alert(1)', // Case variant - 'vbscript:msgbox', - 'data:application/javascript,alert(1)', - 'jar:http://example.com/evil.jar!/', - 'view-source:http://example.com', - 'about:blank', - 'blob:http://example.com/uuid', - 'filesystem:http://example.com/temp/', - - // Browser/app specific protocols - 'chrome://settings', - 'chrome-extension://abc/page.html', - 'moz-extension://abc/page.html', - 'safari-extension://abc/page.html', - 'opera://settings', - 'edge://settings', - 'resource://gre/modules/', - - // Other network protocols - 'git://github.com/user/repo.git', - 'ssh://user@host.com', - 'telnet://host.com', - 'gopher://example.com', - 'redis://localhost:6379', - 'mongodb://localhost:27017', - 'postgresql://localhost:5432', - 'mysql://localhost:3306', - 'ldap://example.com', - 'nntp://news.example.com', - - // Mobile/communication protocols - 'tel:+1234567890', - 'sms:+1234567890', - 'mailto:test@test.com', // Already in list but keeping for completeness - - // Authority confusion with @ - 'http://google.com@evil.com', - 'http://user:pass@good.com@evil.com', - '//google.com@evil.com', - '\\\\google.com@evil.com', - - // Windows UNC paths that could be URL bypass attempts - '\\\\example.com\\share\\file.js', // Could be //example.com - '\\\\evil.com\\payload', // Could be //evil.com - '\\\\localhost\\share\\file.js', // Even localhost could be suspicious - '\\\\127.0.0.1\\c$\\windows', // IP-based UNC - '\\\\LOCALHOST\\pipe\\test', // Uppercase variant - '\\Program Files\\app', // Single backslash - ambiguous - '\\Users\\Public\\Documents', // Single backslash - ambiguous - '\\\\?\\C:\\very\\long\\path', // Windows long path (treating as remote for safety) - '\\\\.\\COM1', // Device path (treating as remote for safety) - '\\\\.\\pipe\\pipename', // Named pipe (treating as remote for safety) - - // Encoded @ attempts - 'http://example.com%40evil.com/path', - 'http://example.com%2540evil.com/path', // Double encoded @ - - // IP address encoding tricks - 'http://2130706433/', // 127.0.0.1 as decimal - 'http://0x7f.0x0.0x0.0x1/', // 127.0.0.1 as hex - 'http://0177.0.0.1/', // 127.0.0.1 partial octal - 'http://127.1/', // Short form IP - 'http://127.0.1/', // Another short form - 'http://[::ffff:127.0.0.1]/', // IPv4-mapped IPv6 - - 'http://\0example.com', // Null before domain - - // Multiple slashes and dots - 'http:///example.com', // Triple slash - 'http:////example.com', // Quad slash - 'http://example.com..', // Double dots at end - 'http://example.com./', // Dot slash at end - 'http://example.com./.', // Multiple dots - 'http://.example.com', // Leading dot (handled earlier but different context) - - // Relative URLs that look suspicious - 'http:example.com', // Missing slashes (relative URL in HTTP context) - 'https:example.com', // Missing slashes - '//http://example.com', // Protocol-relative with protocol - '////example.com', // Multiple slashes - - // Case sensitivity edge cases for data URLs - 'DATA:,test', - 'dAtA:,test', - 'DaTa:,test', - - 'http:/\\example.com', // Mixed slash backslash (this is actually http:/\example.com) - ]; - - const localPaths = [ - // Standard Unix/Linux absolute paths - '/local/path/file.js', - '/usr/local/bin/node', - '/home/user/projects/app.js', - '/var/www/html/index.html', - '/opt/application/config.json', - '/tmp/build-output.js', - '/dev/null', - '/proc/self/exe', - '/etc/hosts', - - // macOS specific paths - '/System/Library/Frameworks', - '/Applications/App.app/Contents', - '/Users/username/Documents', - '/Volumes/External Drive/file.js', - '/private/tmp/file.js', - '/Library/Application Support/app', - - // Standard relative paths - 'relative/path/file.js', - './relative/path/file.js', - '../relative/path/file.js', - '../../parent/parent/file.js', - './file.js', - '../file.js', - 'file.js', - 'index.html', - 'src/components/Button.tsx', - 'node_modules/package/dist/index.js', - 'dist/assets/index-abc123.js', - - // Single dot paths - '.', - './', - './.', - - // Double dot paths - '..', - '../', - '../.', - - // Windows absolute paths (various formats) - 'C:\\windows\\path\\file.js', - 'C:/windows/path/file.js', // Forward slashes on Windows - 'D:\\Program Files\\app\\main.exe', - 'E:/Projects/web/index.html', - 'Z:\\network\\share\\file.doc', - - // Windows drive-relative paths (uncommon but valid) - 'C:file.txt', // Relative to current directory on C: - 'D:folder\\file.js', - - // file:// protocol is local (all variations) - 'file://example.com/foo/bar.js', - 'file:', // Just file protocol - 'file://', // File with slashes - 'file:///', // File with triple slash (absolute path) - 'file:////', // File with quad slash - 'file://///server/share', // UNC path via file protocol - 'File://example.com', // Uppercase file - 'FILE://example.com', // All caps file - 'fILe://example.com', // Mixed case file - - // file:// with backslashes is still local - 'file:\\\\example.com/test', - - // file:// URLs with legitimate @ symbols (NOT credentials) - 'file:///home/user/package@1.0.0.tgz', // NPM package file - 'file:///Users/dev/icon@2x.png', // Retina image file - 'file:///C:/Projects/@company/app/index.js', // Scoped package path - 'file:///var/cache/@cache_key.dat', // Cache file with @ - 'file://localhost/home/backup@2024.sql', // Backup file - 'file:///opt/app/sprite@mobile.css', // Responsive asset - 'file:///D:/Work/email@example.com.txt', // Email as filename - 'file:///home/user/@types/node/index.d.ts', // TypeScript defs - 'file:///app/test@integration.spec.js', // Test file - 'file:///Users/john/logo@dark@2x.png', // Multiple @ in name - - // Vite-specific paths (all should be local) - '/@fs/local/path/file.js', - '/@fs/C:/Users/project/src/main.js', - '/@fs/Users/mac/project/src/app.vue', - '/@id/local/path/file.js', - '/@id/__x00__virtual:file', - '/@vite/client', - '/@vite/env', - '/@react-refresh', - '/node_modules/.vite/deps/vue.js', - '/node_modules/.vite/deps/_metadata.json', - '/__vite_ping', - '/src/main.ts?t=1234567890', - '/src/assets/logo.png?import', - '/src/styles.css?direct', - '/@modules/my-package', - '/~partytown/debug/partytown.js', - - // Fragment and query strings without protocols (local) - '#http://evil.com', - '#//evil.com', - '?http://evil.com', - '?//evil.com', - - // Paths with spaces (valid local paths) - '/path with spaces/file.js', - 'C:\\Program Files (x86)\\app\\file.exe', - './folder with spaces/index.html', - '/Users/John Doe/Documents/file.txt', - 'My Documents\\Projects\\app.js', - - // Paths with special characters - '/path/to/file-name_2023.test.js', - '/path/to/file@2x.png', - '/path/to/file#1.js', - // Legitimate @ in filenames (NOT credentials) - 'package@1.0.0.tgz', // NPM package versioning - 'user@2x.png', // Retina image naming - 'icon@3x.png', // iOS asset naming - 'logo@2x@dark.png', // Multiple @ in filename - '@babel/core/lib/index.js', // Scoped package path - '/@babel/preset-env', // Scoped package in node_modules - 'node_modules/@types/node/index.d.ts', // TypeScript definitions - './@company/shared-ui/Button.tsx', // Monorepo package - 'packages/@my-org/utils/index.js', // Lerna/workspace package - 'email@example.com.txt', // Email as filename - 'backup@2023-12-01.sql', // Backup file naming - 'snapshot@latest.json', // Version/tag in filename - 'test@integration.spec.js', // Test file naming - 'sprite@mobile.css', // Responsive asset naming - '/var/cache/nginx/@cache_key', // Cache files with @ - '/path/to/[bracketed]/file.js', - '/path/to/(parentheses)/file.js', - '/path/to/file$.js', - '/path/to/file+plus.js', - '/path/to/file=equals.js', - '/path/to/file&ersand.js', - '/path/to/file,comma.js', - '/path/to/file;semicolon.js', - "/path/to/file'quote.js", - '/path/to/file`backtick.js', - 'C:\\Users\\user!\\file%.txt', - - // Paths with Unicode characters - '/用户/文档/文件.js', - '/путь/к/файлу.js', - '/مسار/إلى/ملف.js', - '/パス/ファイル.js', - '/경로/파일.js', - 'C:\\文档\\项目\\app.js', - - // Query parameters on local paths (common in dev servers) - '/src/main.js?v=12345', - '/assets/style.css?inline', - '/image.png?w=500&h=300', - './component.vue?type=template', - '../styles/theme.scss?module', - - // Hash fragments on local paths - '/docs/guide.html#introduction', - '/app.js#section', - './page.html#top', - 'index.html#/route/path', - - // Edge case: paths that look like URLs but aren't - 'http', // Just the word http as a filename - 'https', // Just the word https as a filename - 'ftp', // Just the word ftp as a filename - 'ws', // Just the word ws as a filename - 'C:http', // Windows drive with filename http - './http', // Relative path to file named http - '../https', // Parent directory file named https - - // Paths starting with URL-like strings but no protocol separator - 'httpserver/file.js', - 'https_server/file.js', - 'ftpd/config.json', - 'wss_module/index.js', - 'data-processor/file.js', - 'javascript-files/app.js', - - // Build tool specific paths - '/.next/static/chunks/main.js', - '/_next/data/buildid/page.json', - '/.nuxt/dist/client/app.js', - '/public/build/bundle.js', - '/static/js/main.chunk.js', - '/dist/assets/index.js', - '/_app/immutable/chunks/index.js', // SvelteKit - '/.svelte-kit/generated/client/app.js', - - // Package manager paths - 'node_modules/react/index.js', - '.pnpm/react@18.0.0/node_modules/react/index.js', - '.yarn/cache/package.zip', - 'bower_components/jquery/dist/jquery.js', - - // Paths with multiple dots - '../../../file.js', - './././file.js', - '.../weird/path.js', // Triple dots (valid but unusual) - 'file...js', // Multiple dots in filename - 'file.test.spec.js', // Multiple extensions - - // Invalid/malformed encodings (should handle gracefully) - '%%36%38ttp://example.com', // Invalid double % - '%GGexample.com', // Invalid hex characters - '%1', // Incomplete encoding - '%', // Just a percent sign - '%%%', // Multiple percent signs - - // Empty string - '', - ]; - - it('should correctly identify remote paths', () => { - remotePaths.forEach((path) => { - assert.equal(isRemotePath(path), true, `Expected "${path}" to be remote`); - }); - }); - - it('should correctly identify local paths', () => { - localPaths.forEach((path) => { - assert.equal(isRemotePath(path), false, `Expected "${path}" to be local`); - }); - }); -}); - -describe('isParentDirectory', () => { - it('should correctly identify parent-child relationships', () => { - const validCases = [ - // Unix absolute paths - ['/home', '/home/user'], - ['/home', '/home/user/documents'], - ['/home/user', '/home/user/documents/file.txt'], - ['/var', '/var/www/html/index.html'], - ['/usr/local', '/usr/local/bin/node'], - ['/', '/home'], - ['/', '/usr/local/bin'], - - // Unix relative paths - ['src', 'src/components'], - ['src', 'src/components/Button.tsx'], - ['.', './file.js'], - ['.', './src/index.js'], - - // Windows absolute paths - ['C:\\Users', 'C:\\Users\\Admin'], - ['C:\\Users', 'C:\\Users\\Admin\\Documents'], - ['C:\\', 'C:\\Windows\\System32'], - ['D:\\Projects', 'D:\\Projects\\app\\src\\main.js'], - ['C:/', 'C:/Windows/System32'], // Forward slashes on Windows - - // Windows relative paths - ['src', 'src\\components'], - ['.', '.\\file.js'], - - // Mixed slashes (normalized internally) - ['C:/Users', 'C:\\Users\\Admin\\Documents'], - ['/home/user', '/home/user\\documents'], - - // Paths with single dots that resolve correctly - ['/home', '/home/./user'], - ['src', 'src/./components'], - - // Case insensitive for Windows - ['c:\\users', 'C:\\Users\\Admin'], - ['C:\\USERS', 'c:\\users\\admin'], - - // Very long paths (valid parent-child) - ['/' + 'a'.repeat(1000), '/' + 'a'.repeat(1000) + '/b'], - ]; - - validCases.forEach(([parent, child]) => { - assert.equal( - isParentDirectory(parent, child), - true, - `Expected "${parent}" to be parent of "${child}"`, - ); - }); - }); - - it('should correctly reject non-parent relationships', () => { - const invalidCases = [ - // Different directories - ['/home', '/usr'], - ['/home/user', '/home/otheruser'], - ['src/components', 'src/utils'], - ['C:\\Users', 'C:\\Windows'], - - // Child is not descendant - ['/home/user/documents', '/home/user'], // Parent longer than child - ['src/components/Button', 'src/components'], // Parent longer - ['/home/user', '/home'], // Reversed relationship - - // Different drives on Windows - ['C:\\Users', 'D:\\Users'], - ['C:\\', 'D:\\'], - - // Absolute vs relative - ['/home', 'home'], - ['C:\\Users', 'Users'], - ['home', '/home'], - - // Path traversal attempts - ['/home', '/etc/../home/user'], // Resolves to /home/user but starts elsewhere - ['/restricted', '/restricted/../../../etc/passwd'], // Traversal outside - - // Empty or null paths - ['', '/home'], - ['/home', ''], - ['', ''], - [null, '/home'], - ['/home', null], - [undefined, '/home'], - - // Same path (not parent-child) - ['/home/user', '/home/user'], - ['src', 'src'], - ['C:\\Users', 'C:\\Users'], - - // Partial name match but not parent - ['/home', '/homepage'], - ['/home', '/home2'], - ['src', 'src2'], - ['test', 'test-utils'], - - // Special characters and null bytes - ['/home\0', '/home/user'], - ['/home', '/home\0/user'], - ['/home', '/home/\0user'], - ]; - - invalidCases.forEach(([parent, child]) => { - assert.equal( - isParentDirectory(parent, child), - false, - `Expected "${parent}" NOT to be parent of "${child}"`, - ); - }); - }); - - it('should handle adversarial inputs safely', () => { - const adversarialCases = [ - // Path traversal attacks - ['/safe', '/safe/../../../etc/passwd'], - ['/app', '/app/../../../../root/.ssh'], - ['C:\\Safe', 'C:\\Safe\\..\\..\\..\\Windows\\System32'], - - // URL-like paths - ['http://evil.com', 'http://evil.com/payload'], - ['//evil.com', '//evil.com/hack'], - ['file://host', 'file://host/etc/passwd'], - - // Encoded paths - ['/home', '/%2e%2e/home/user'], // Encoded .. - ['/home', '/home%2Fuser'], // Encoded / - - // Very long paths that don't match - ['/short', '/' + 'a'.repeat(10000)], - - // Symlink-like patterns - ['/real', '/real/../../symlink/target'], - ['/app', '/app/node_modules/.bin/../../../outside'], - - // Credentials in paths - ['/home/safe', 'file:///home/safe/user:@/etc/passwd'], - ['C:\\Safe', 'file:///C:\\Safe\\user:@\\Windows\\System32'], - ]; - - adversarialCases.forEach(([parent, child]) => { - // Should safely return false for all adversarial inputs - assert.equal( - isParentDirectory(parent, child), - false, - `Expected adversarial input "${parent}" vs "${child}" to return false safely`, - ); - }); - }); - - it('should handle edge cases correctly', () => { - // Root paths - assert.equal(isParentDirectory('/', '/home'), true); - assert.equal(isParentDirectory('/', '/'), false); // Same path - assert.equal(isParentDirectory('C:\\', 'C:\\Windows'), true); - assert.equal(isParentDirectory('C:\\', 'C:\\'), false); - - // Current directory - assert.equal(isParentDirectory('.', './src'), true); - assert.equal(isParentDirectory('.', '.'), false); - - // Parent directory references not allowed (.. paths rejected) - assert.equal(isParentDirectory('..', '../src'), false); - assert.equal(isParentDirectory('../..', '../../src/main.js'), false); - - // Trailing slashes - assert.equal(isParentDirectory('/home/', '/home/user'), true); - assert.equal(isParentDirectory('/home', '/home/user/'), true); - assert.equal(isParentDirectory('/home/', '/home/user/'), true); - - // Multiple slashes - assert.equal(isParentDirectory('/home', '/home//user'), true); - assert.equal(isParentDirectory('/home', '/home///user///docs'), true); - }); -}); - -describe('normalizePathname', () => { - describe('file format', () => { - it('should strip .html extension', () => { - assert.equal(normalizePathname('/about.html', 'file', 'ignore'), '/about'); - assert.equal(normalizePathname('/blog/post.html', 'file', 'ignore'), '/blog/post'); - assert.equal( - normalizePathname('/deeply/nested/page.html', 'file', 'ignore'), - '/deeply/nested/page', - ); - }); - - it('should not strip .html from root', () => { - assert.equal(normalizePathname('/.html', 'file', 'ignore'), '/.html'); - }); - - it('should return root for mangled index paths', () => { - // When base is removed from index paths in file format, they can get mangled - assert.equal(normalizePathname('/html', 'file', 'ignore'), '/'); - assert.equal(normalizePathname('/something', 'file', 'ignore'), '/'); - }); - - it('should handle root path', () => { - assert.equal(normalizePathname('/', 'file', 'ignore'), '/'); - }); - }); - - describe('directory format', () => { - it('should strip trailing slash when trailingSlash is ignore', () => { - assert.equal(normalizePathname('/about/', 'directory', 'ignore'), '/about'); - assert.equal(normalizePathname('/blog/post/', 'directory', 'ignore'), '/blog/post'); - }); - - it('should not strip trailing slash when trailingSlash is always', () => { - assert.equal(normalizePathname('/about/', 'directory', 'always'), '/about/'); - assert.equal(normalizePathname('/blog/post/', 'directory', 'always'), '/blog/post/'); - }); - - it('should not strip trailing slash when trailingSlash is never', () => { - assert.equal(normalizePathname('/about/', 'directory', 'never'), '/about/'); - }); - - it('should not strip trailing slash from root', () => { - assert.equal(normalizePathname('/', 'directory', 'ignore'), '/'); - }); - - it('should handle paths without trailing slash', () => { - assert.equal(normalizePathname('/about', 'directory', 'ignore'), '/about'); - assert.equal(normalizePathname('/about', 'directory', 'always'), '/about'); - }); - }); - - describe('preserve format', () => { - it('should behave same as directory format', () => { - assert.equal(normalizePathname('/about/', 'preserve', 'ignore'), '/about'); - assert.equal(normalizePathname('/about/', 'preserve', 'always'), '/about/'); - assert.equal(normalizePathname('/', 'preserve', 'ignore'), '/'); - }); - }); -}); diff --git a/packages/internal-helpers/test/path.test.ts b/packages/internal-helpers/test/path.test.ts new file mode 100644 index 000000000000..2981450c08b6 --- /dev/null +++ b/packages/internal-helpers/test/path.test.ts @@ -0,0 +1,839 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isParentDirectory, isRemotePath, normalizePathname } from '../dist/path.js'; + +describe('isRemotePath', () => { + const remotePaths = [ + // Standard remote protocols + 'https://example.com/foo/bar.js', + 'http://example.com/foo/bar.js', + '//example.com/foo/bar.js', + 'ws://example.com/foo/bar.js', + 'wss://example.com/foo/bar.js', + 'ftp://example.com/foo/bar.js', + 'sftp://example.com/foo/bar.js', + 'mailto:example@example.com', + 'data:someCode', + 'data:image/png;base64,iVBORw0KGgo', + 'data:text/html,', + + // Backslash bypass attempts + '\\\\example.com/foo/bar.js', + '\\example.com/foo/bar.js', + '\\\\\\example.com/foo/bar.js', + '\\\\\\\\example.com/foo/bar.js', + '\\raw.githubusercontent.com/test.svg', + '\\\\raw.githubusercontent.com/test.svg', + + // URL-encoded backslash attempts + '%5C%5Cexample.com/foo/bar.js', + '%5Cexample.com/foo/bar.js', + '%5c%5cexample.com/foo/bar.js', + '%5cexample.com/foo/bar.js', + '%5C%5C%5Cexample.com/foo/bar.js', + '%5C%5C%5C%5Cexample.com/foo/bar.js', + + // Mixed encoding + '%5C\\example.com/foo/bar.js', + '\\%5Cexample.com/foo/bar.js', + '%5c\\example.com/test', + + // Mixed forward and backslashes + '\\//example.com/foo/bar.js', + '\\//\\example.com/foo/bar.js', + '/\\example.com/foo/bar.js', // Forward then backslash - suspicious + '/\\\\example.com/foo/bar.js', // Forward then double backslash - suspicious + + // Protocol with backslashes + 'http:\\\\example.com/foo/bar.js', + 'https:\\\\example.com/foo/bar.js', + 'http:\\example.com/foo/bar.js', + 'https:\\example.com/foo/bar.js', + 'ftp:\\\\example.com/foo/bar.js', + 'ws:\\\\example.com/foo/bar.js', + 'wss:\\\\example.com/test', // WSS with backslashes + 'sftp:\\\\example.com/test', // SFTP with backslashes + 'HTTP:\\\\example.com/test', + 'HtTp:\\\\example.com/test', + + // Unicode escapes + '\u005C\u005Cexample.com/test', + '\u005Cexample.com/test', + '\\u005C\\u005Cexample.com/test', + + // Null byte injection + '\\example.com%00.jpg', + '%5Cexample.com%00.jpg', + '\\example.com\x00.jpg', + + // Whitespace injection + '\\\texample.com/test', + '\\ example.com/test', + '\\%09example.com/test', + '\\%20example.com/test', + + // Newline/carriage return injection + '\\\nexample.com/test', + '\\\rexample.com/test', + '\\%0Aexample.com/test', + '\\%0Dexample.com/test', + + // IP addresses + 'http://192.168.1.1/test', + '//192.168.1.1/test', + '\\\\192.168.1.1/test', + 'http://[::1]/test', + 'http://[2001:db8::1]/test', + '//[::1]/test', + '\\\\[::1]/test', + + // Localhost + 'http://localhost/test', + '//localhost/test', + '\\\\localhost/test', + 'http://127.0.0.1/test', + '\\\\127.0.0.1/test', + + // With ports + 'http://example.com:8080/test', + '//example.com:8080/test', + '\\\\example.com:8080/test', + 'http:\\\\example.com:8080/test', + + // With auth - basic credential attacks + 'http://user:pass@example.com/test', + '//user:pass@example.com/test', + '\\\\user:pass@example.com/test', + + // Credential injection attempts to look like local paths + '//admin:admin@/var/www/html', // Protocol-relative with path-like ending + '\\\\admin:password@C:\\Windows\\System32', // UNC-style with Windows path + 'user:pass@/home/user/file.js', // No protocol but has creds and Unix path + 'admin:admin@C:\\Users\\Public', // No protocol but has creds and Windows path + '//user@/local/path', // Single user@ with local-looking path + '\\\\user@C:\\Program Files', // Backslash variant + + // Encoded credentials to bypass detection + 'http://%75ser:%70ass@example.com', // URL-encoded "user:pass" + 'http://user%3Apass@example.com', // Encoded colon in creds + 'http://user:pass%40example.com', // Encoded @ in password + '//%75%73%65%72:%70%61%73%73@example.com', // Fully encoded creds + '\\\\%75ser:%70ass@example.com', // Backslash with encoded creds + + // Double/triple encoding credentials + 'http://%2575ser:%2570ass@example.com', // Double encoded + 'http://%252575ser:%252570ass@example.com', // Triple encoded + + // Credentials with special characters trying to break parsing + 'http://user:p@ss@example.com', // @ in password + 'http://user:pass:extra@example.com', // Multiple colons + 'http://user::@example.com', // Empty password with double colon + 'http://:password@example.com', // Empty username + 'http://@example.com', // Just @ symbol + '//user:@example.com', // Empty password + '//:pass@example.com', // Empty username protocol-relative + + // Credentials with path traversal + 'http://user:../../../etc/passwd@example.com', // Path traversal in password + 'http://../../admin:pass@example.com', // Path traversal in username + '//user:pass@example.com/../../etc/passwd', // Creds with traversal after + + // Credentials with null bytes and special chars + 'http://user%00:pass@example.com', // Null byte in username + 'http://user:pass%00@example.com', // Null byte in password + 'http://user\x00:pass@example.com', // Hex null byte + 'http://user:pass\0@example.com', // Escaped null + + // OAuth/API key patterns that might be confused + 'http://oauth2:CLIENT_SECRET_HERE@example.com', + 'http://api_key:SECRET_KEY_123@example.com', + '//token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9@example.com', // JWT-like + + // Credentials with port confusion + 'http://user:pass@example.com:8080', // Standard with port + 'http://user:8080@example.com', // Port-like password + 'http://admin:3306@localhost', // MySQL port as password + '//root:22@server.com', // SSH port as password + + // Unicode in credentials + 'http://üser:pāss@example.com', // Unicode username/password + 'http://用户:密码@example.com', // Chinese characters + 'http://админ:пароль@example.com', // Cyrillic + 'http://\u0075ser:\u0070ass@example.com', // Unicode escapes + + // Homograph attacks in credentials + 'http://аdmin:pаssword@example.com', // Cyrillic 'а' looks like Latin 'a' + 'http://adⅿin:password@example.com', // Unicode small m lookalike + + // Large credentials trying to overflow + 'http://' + 'a'.repeat(1000) + ':' + 'b'.repeat(1000) + '@example.com', + '//' + 'x'.repeat(10000) + '@example.com', // Massive username + + // Mixed slashes with credentials + 'http:\\//user:pass@example.com', // Mixed slashes in protocol + 'http://user:pass@example.com\\path', // Mixed slashes in path + '\\//user:pass@example.com', // Backslash then protocol-relative + + // Credentials to bypass startsWith checks + // These try to trick checks like if(path.startsWith('/home')) or if(path.startsWith('src/')) + '//user:pass@/home/user/file.js', // Looks like /home but has protocol-relative with creds + '//token@/usr/local/bin', // Looks like /usr but is protocol-relative + '//api:key@/etc/passwd', // Looks like /etc but has creds + '//admin@/var/www/html/index.html', // Looks like /var but has creds + '//root@src/index.js', // Looks like src/ but protocol-relative with creds + '//user@public/assets/logo.png', // Looks like public/ but has creds + '//deploy@dist/bundle.js', // Looks like dist/ but has creds + '//ci:token@node_modules/package', // Looks like node_modules/ but has creds + + // Using credentials patterns that parse as valid URLs + 'user:pass@localhost/admin', // Credentials with localhost + 'admin:admin@127.0.0.1:8080', // Credentials with IP and port + 'root:toor@evil.com/payload', // Clear credential pattern + + // Credentials with localhost/127.0.0.1 to look local + '//admin:admin@localhost/admin', // Localhost but still remote protocol + '//root:toor@127.0.0.1/phpmyadmin', // Loopback but still remote + '//user@localhost:3000/api', // Localhost with port + '//dev:dev@[::1]/graphql', // IPv6 localhost + '//test@0.0.0.0/test', // 0.0.0.0 binding address + + // File URLs with @ trying to look like local paths + 'file://user@/etc/hosts', // file:// but with user@ + 'file://admin:pass@localhost/C:/Windows/System32', // file:// localhost Windows + 'file://root@127.0.0.1/home/user', // file:// with IP + + // Encoded credentials in paths that might decode to look local + '//%2F%2Fhome/user', // Encoded // at start: //home/user + '//%2Fetc%2Fpasswd@evil.com', // Encoded /etc/passwd@evil.com + '%2F%2Fadmin:admin@/var/log', // Encoded //admin:admin@/var/log + '//%252Fhome%252Fuser@evil.com', // Double encoded /home/user + + // Vite-like paths with credentials (trying to bypass Vite path detection) + '//user@/@fs/home/user/project', // Vite /@fs/ with creds before + '//admin@/@id/virtual:module', // Vite /@id/ with creds + '//dev@/@vite/client', // Vite client with creds + '//@react-refresh', // Vite React refresh with just @ + + // Query strings and fragments + 'http://example.com/test?param=value', + '\\\\example.com/test?param=value', + 'http://example.com/test#fragment', + '\\\\example.com/test#fragment', + + // Edge cases with dots + 'http://example..com/test', + '\\\\example..com/test', + 'http://.example.com/test', + '\\\\.example.com/test', + + // Long domains + '\\\\' + 'a'.repeat(1000) + '.com/test', + 'http://' + 'a'.repeat(1000) + '.com/test', + + // Minimal cases + '\\', // Single backslash + '\\\\', // Double backslash + '//', // Protocol-relative + 'http://', + 'https://', + 'data:', + + // Path traversal + 'http://example.com/./../../test', + '\\\\example.com/./../../test', + 'http://example.com/%2e%2e/test', + '\\\\example.com/%2e%2e/test', + + // Windows special paths + '\\\\?\\C:\\test', + '\\\\.\\pipe\\test', + '\\\\LOCALHOST\\share', + '\\\\127.0.0.1\\share', + + // Punycode and Unicode domains + 'http://xn--e1afmkfd.xn--p1ai/test', + '\\\\xn--e1afmkfd.xn--p1ai/test', + 'http://例え.jp/test', + '\\\\例え.jp/test', + + // Case variations + 'HtTp://example.com/test', + 'HTTP://example.com/test', + 'DATA:text/plain,hello', // Uppercase data URL + 'Data:text/plain,test', // Mixed case data URL + 'dAtA:text/plain,test', // Weird case data URL + + // Mixed slashes in protocols + 'http:\\//example.com/test', + 'https:/\\example.com/test', + 'ftp://\\example.com/test', + 'ws:\\//example.com/test', + + // Brackets + 'http://[example.com]/test', + '\\\\[example.com]/test', + 'http://(example.com)/test', + '\\\\(example.com)/test', + + // Malformed but has protocol prefix (conservative: treated as remote) + 'http:%2F%2Fexample.com/test', // Encoded slashes after colon + + // JavaScript and other dangerous protocols + 'javascript:alert(1)', + 'JavaScript:alert(1)', // Case variant + 'vbscript:msgbox', + 'data:application/javascript,alert(1)', + 'jar:http://example.com/evil.jar!/', + 'view-source:http://example.com', + 'about:blank', + 'blob:http://example.com/uuid', + 'filesystem:http://example.com/temp/', + + // Browser/app specific protocols + 'chrome://settings', + 'chrome-extension://abc/page.html', + 'moz-extension://abc/page.html', + 'safari-extension://abc/page.html', + 'opera://settings', + 'edge://settings', + 'resource://gre/modules/', + + // Other network protocols + 'git://github.com/user/repo.git', + 'ssh://user@host.com', + 'telnet://host.com', + 'gopher://example.com', + 'redis://localhost:6379', + 'mongodb://localhost:27017', + 'postgresql://localhost:5432', + 'mysql://localhost:3306', + 'ldap://example.com', + 'nntp://news.example.com', + + // Mobile/communication protocols + 'tel:+1234567890', + 'sms:+1234567890', + 'mailto:test@test.com', // Already in list but keeping for completeness + + // Authority confusion with @ + 'http://google.com@evil.com', + 'http://user:pass@good.com@evil.com', + '//google.com@evil.com', + '\\\\google.com@evil.com', + + // Windows UNC paths that could be URL bypass attempts + '\\\\example.com\\share\\file.js', // Could be //example.com + '\\\\evil.com\\payload', // Could be //evil.com + '\\\\localhost\\share\\file.js', // Even localhost could be suspicious + '\\\\127.0.0.1\\c$\\windows', // IP-based UNC + '\\\\LOCALHOST\\pipe\\test', // Uppercase variant + '\\Program Files\\app', // Single backslash - ambiguous + '\\Users\\Public\\Documents', // Single backslash - ambiguous + '\\\\?\\C:\\very\\long\\path', // Windows long path (treating as remote for safety) + '\\\\.\\COM1', // Device path (treating as remote for safety) + '\\\\.\\pipe\\pipename', // Named pipe (treating as remote for safety) + + // Encoded @ attempts + 'http://example.com%40evil.com/path', + 'http://example.com%2540evil.com/path', // Double encoded @ + + // IP address encoding tricks + 'http://2130706433/', // 127.0.0.1 as decimal + 'http://0x7f.0x0.0x0.0x1/', // 127.0.0.1 as hex + 'http://0177.0.0.1/', // 127.0.0.1 partial octal + 'http://127.1/', // Short form IP + 'http://127.0.1/', // Another short form + 'http://[::ffff:127.0.0.1]/', // IPv4-mapped IPv6 + + 'http://\0example.com', // Null before domain + + // Multiple slashes and dots + 'http:///example.com', // Triple slash + 'http:////example.com', // Quad slash + 'http://example.com..', // Double dots at end + 'http://example.com./', // Dot slash at end + 'http://example.com./.', // Multiple dots + 'http://.example.com', // Leading dot (handled earlier but different context) + + // Relative URLs that look suspicious + 'http:example.com', // Missing slashes (relative URL in HTTP context) + 'https:example.com', // Missing slashes + '//http://example.com', // Protocol-relative with protocol + '////example.com', // Multiple slashes + + // Case sensitivity edge cases for data URLs + 'DATA:,test', + 'dAtA:,test', + 'DaTa:,test', + + 'http:/\\example.com', // Mixed slash backslash (this is actually http:/\example.com) + ]; + + const localPaths = [ + // Standard Unix/Linux absolute paths + '/local/path/file.js', + '/usr/local/bin/node', + '/home/user/projects/app.js', + '/var/www/html/index.html', + '/opt/application/config.json', + '/tmp/build-output.js', + '/dev/null', + '/proc/self/exe', + '/etc/hosts', + + // macOS specific paths + '/System/Library/Frameworks', + '/Applications/App.app/Contents', + '/Users/username/Documents', + '/Volumes/External Drive/file.js', + '/private/tmp/file.js', + '/Library/Application Support/app', + + // Standard relative paths + 'relative/path/file.js', + './relative/path/file.js', + '../relative/path/file.js', + '../../parent/parent/file.js', + './file.js', + '../file.js', + 'file.js', + 'index.html', + 'src/components/Button.tsx', + 'node_modules/package/dist/index.js', + 'dist/assets/index-abc123.js', + + // Single dot paths + '.', + './', + './.', + + // Double dot paths + '..', + '../', + '../.', + + // Windows absolute paths (various formats) + 'C:\\windows\\path\\file.js', + 'C:/windows/path/file.js', // Forward slashes on Windows + 'D:\\Program Files\\app\\main.exe', + 'E:/Projects/web/index.html', + 'Z:\\network\\share\\file.doc', + + // Windows drive-relative paths (uncommon but valid) + 'C:file.txt', // Relative to current directory on C: + 'D:folder\\file.js', + + // file:// protocol is local (all variations) + 'file://example.com/foo/bar.js', + 'file:', // Just file protocol + 'file://', // File with slashes + 'file:///', // File with triple slash (absolute path) + 'file:////', // File with quad slash + 'file://///server/share', // UNC path via file protocol + 'File://example.com', // Uppercase file + 'FILE://example.com', // All caps file + 'fILe://example.com', // Mixed case file + + // file:// with backslashes is still local + 'file:\\\\example.com/test', + + // file:// URLs with legitimate @ symbols (NOT credentials) + 'file:///home/user/package@1.0.0.tgz', // NPM package file + 'file:///Users/dev/icon@2x.png', // Retina image file + 'file:///C:/Projects/@company/app/index.js', // Scoped package path + 'file:///var/cache/@cache_key.dat', // Cache file with @ + 'file://localhost/home/backup@2024.sql', // Backup file + 'file:///opt/app/sprite@mobile.css', // Responsive asset + 'file:///D:/Work/email@example.com.txt', // Email as filename + 'file:///home/user/@types/node/index.d.ts', // TypeScript defs + 'file:///app/test@integration.spec.js', // Test file + 'file:///Users/john/logo@dark@2x.png', // Multiple @ in name + + // Vite-specific paths (all should be local) + '/@fs/local/path/file.js', + '/@fs/C:/Users/project/src/main.js', + '/@fs/Users/mac/project/src/app.vue', + '/@id/local/path/file.js', + '/@id/__x00__virtual:file', + '/@vite/client', + '/@vite/env', + '/@react-refresh', + '/node_modules/.vite/deps/vue.js', + '/node_modules/.vite/deps/_metadata.json', + '/__vite_ping', + '/src/main.ts?t=1234567890', + '/src/assets/logo.png?import', + '/src/styles.css?direct', + '/@modules/my-package', + '/~partytown/debug/partytown.js', + + // Fragment and query strings without protocols (local) + '#http://evil.com', + '#//evil.com', + '?http://evil.com', + '?//evil.com', + + // Paths with spaces (valid local paths) + '/path with spaces/file.js', + 'C:\\Program Files (x86)\\app\\file.exe', + './folder with spaces/index.html', + '/Users/John Doe/Documents/file.txt', + 'My Documents\\Projects\\app.js', + + // Paths with special characters + '/path/to/file-name_2023.test.js', + '/path/to/file@2x.png', + '/path/to/file#1.js', + // Legitimate @ in filenames (NOT credentials) + 'package@1.0.0.tgz', // NPM package versioning + 'user@2x.png', // Retina image naming + 'icon@3x.png', // iOS asset naming + 'logo@2x@dark.png', // Multiple @ in filename + '@babel/core/lib/index.js', // Scoped package path + '/@babel/preset-env', // Scoped package in node_modules + 'node_modules/@types/node/index.d.ts', // TypeScript definitions + './@company/shared-ui/Button.tsx', // Monorepo package + 'packages/@my-org/utils/index.js', // Lerna/workspace package + 'email@example.com.txt', // Email as filename + 'backup@2023-12-01.sql', // Backup file naming + 'snapshot@latest.json', // Version/tag in filename + 'test@integration.spec.js', // Test file naming + 'sprite@mobile.css', // Responsive asset naming + '/var/cache/nginx/@cache_key', // Cache files with @ + '/path/to/[bracketed]/file.js', + '/path/to/(parentheses)/file.js', + '/path/to/file$.js', + '/path/to/file+plus.js', + '/path/to/file=equals.js', + '/path/to/file&ersand.js', + '/path/to/file,comma.js', + '/path/to/file;semicolon.js', + "/path/to/file'quote.js", + '/path/to/file`backtick.js', + 'C:\\Users\\user!\\file%.txt', + + // Paths with Unicode characters + '/用户/文档/文件.js', + '/путь/к/файлу.js', + '/مسار/إلى/ملف.js', + '/パス/ファイル.js', + '/경로/파일.js', + 'C:\\文档\\项目\\app.js', + + // Query parameters on local paths (common in dev servers) + '/src/main.js?v=12345', + '/assets/style.css?inline', + '/image.png?w=500&h=300', + './component.vue?type=template', + '../styles/theme.scss?module', + + // Hash fragments on local paths + '/docs/guide.html#introduction', + '/app.js#section', + './page.html#top', + 'index.html#/route/path', + + // Edge case: paths that look like URLs but aren't + 'http', // Just the word http as a filename + 'https', // Just the word https as a filename + 'ftp', // Just the word ftp as a filename + 'ws', // Just the word ws as a filename + 'C:http', // Windows drive with filename http + './http', // Relative path to file named http + '../https', // Parent directory file named https + + // Paths starting with URL-like strings but no protocol separator + 'httpserver/file.js', + 'https_server/file.js', + 'ftpd/config.json', + 'wss_module/index.js', + 'data-processor/file.js', + 'javascript-files/app.js', + + // Build tool specific paths + '/.next/static/chunks/main.js', + '/_next/data/buildid/page.json', + '/.nuxt/dist/client/app.js', + '/public/build/bundle.js', + '/static/js/main.chunk.js', + '/dist/assets/index.js', + '/_app/immutable/chunks/index.js', // SvelteKit + '/.svelte-kit/generated/client/app.js', + + // Package manager paths + 'node_modules/react/index.js', + '.pnpm/react@18.0.0/node_modules/react/index.js', + '.yarn/cache/package.zip', + 'bower_components/jquery/dist/jquery.js', + + // Paths with multiple dots + '../../../file.js', + './././file.js', + '.../weird/path.js', // Triple dots (valid but unusual) + 'file...js', // Multiple dots in filename + 'file.test.spec.js', // Multiple extensions + + // Invalid/malformed encodings (should handle gracefully) + '%%36%38ttp://example.com', // Invalid double % + '%GGexample.com', // Invalid hex characters + '%1', // Incomplete encoding + '%', // Just a percent sign + '%%%', // Multiple percent signs + + // Empty string + '', + ]; + + it('should correctly identify remote paths', () => { + remotePaths.forEach((path) => { + assert.equal(isRemotePath(path), true, `Expected "${path}" to be remote`); + }); + }); + + it('should correctly identify local paths', () => { + localPaths.forEach((path) => { + assert.equal(isRemotePath(path), false, `Expected "${path}" to be local`); + }); + }); +}); + +describe('isParentDirectory', () => { + it('should correctly identify parent-child relationships', () => { + const validCases = [ + // Unix absolute paths + ['/home', '/home/user'], + ['/home', '/home/user/documents'], + ['/home/user', '/home/user/documents/file.txt'], + ['/var', '/var/www/html/index.html'], + ['/usr/local', '/usr/local/bin/node'], + ['/', '/home'], + ['/', '/usr/local/bin'], + + // Unix relative paths + ['src', 'src/components'], + ['src', 'src/components/Button.tsx'], + ['.', './file.js'], + ['.', './src/index.js'], + + // Windows absolute paths + ['C:\\Users', 'C:\\Users\\Admin'], + ['C:\\Users', 'C:\\Users\\Admin\\Documents'], + ['C:\\', 'C:\\Windows\\System32'], + ['D:\\Projects', 'D:\\Projects\\app\\src\\main.js'], + ['C:/', 'C:/Windows/System32'], // Forward slashes on Windows + + // Windows relative paths + ['src', 'src\\components'], + ['.', '.\\file.js'], + + // Mixed slashes (normalized internally) + ['C:/Users', 'C:\\Users\\Admin\\Documents'], + ['/home/user', '/home/user\\documents'], + + // Paths with single dots that resolve correctly + ['/home', '/home/./user'], + ['src', 'src/./components'], + + // Case insensitive for Windows + ['c:\\users', 'C:\\Users\\Admin'], + ['C:\\USERS', 'c:\\users\\admin'], + + // Very long paths (valid parent-child) + ['/' + 'a'.repeat(1000), '/' + 'a'.repeat(1000) + '/b'], + ]; + + validCases.forEach(([parent, child]) => { + assert.equal( + isParentDirectory(parent, child), + true, + `Expected "${parent}" to be parent of "${child}"`, + ); + }); + }); + + it('should correctly reject non-parent relationships', () => { + const invalidCases: Array<[string, string]> = [ + // Different directories + ['/home', '/usr'], + ['/home/user', '/home/otheruser'], + ['src/components', 'src/utils'], + ['C:\\Users', 'C:\\Windows'], + + // Child is not descendant + ['/home/user/documents', '/home/user'], // Parent longer than child + ['src/components/Button', 'src/components'], // Parent longer + ['/home/user', '/home'], // Reversed relationship + + // Different drives on Windows + ['C:\\Users', 'D:\\Users'], + ['C:\\', 'D:\\'], + + // Absolute vs relative + ['/home', 'home'], + ['C:\\Users', 'Users'], + ['home', '/home'], + + // Path traversal attempts + ['/home', '/etc/../home/user'], // Resolves to /home/user but starts elsewhere + ['/restricted', '/restricted/../../../etc/passwd'], // Traversal outside + + // Empty or null paths + ['', '/home'], + ['/home', ''], + ['', ''], + // @ts-expect-error: expected to handle null/undefined gracefully + [null, '/home'], + // @ts-expect-error: expected to handle null/undefined gracefully + ['/home', null], + // @ts-expect-error: expected to handle null/undefined gracefully + [undefined, '/home'], + + // Same path (not parent-child) + ['/home/user', '/home/user'], + ['src', 'src'], + ['C:\\Users', 'C:\\Users'], + + // Partial name match but not parent + ['/home', '/homepage'], + ['/home', '/home2'], + ['src', 'src2'], + ['test', 'test-utils'], + + // Special characters and null bytes + ['/home\0', '/home/user'], + ['/home', '/home\0/user'], + ['/home', '/home/\0user'], + ]; + + invalidCases.forEach(([parent, child]) => { + assert.equal( + isParentDirectory(parent, child), + false, + `Expected "${parent}" NOT to be parent of "${child}"`, + ); + }); + }); + + it('should handle adversarial inputs safely', () => { + const adversarialCases = [ + // Path traversal attacks + ['/safe', '/safe/../../../etc/passwd'], + ['/app', '/app/../../../../root/.ssh'], + ['C:\\Safe', 'C:\\Safe\\..\\..\\..\\Windows\\System32'], + + // URL-like paths + ['http://evil.com', 'http://evil.com/payload'], + ['//evil.com', '//evil.com/hack'], + ['file://host', 'file://host/etc/passwd'], + + // Encoded paths + ['/home', '/%2e%2e/home/user'], // Encoded .. + ['/home', '/home%2Fuser'], // Encoded / + + // Very long paths that don't match + ['/short', '/' + 'a'.repeat(10000)], + + // Symlink-like patterns + ['/real', '/real/../../symlink/target'], + ['/app', '/app/node_modules/.bin/../../../outside'], + + // Credentials in paths + ['/home/safe', 'file:///home/safe/user:@/etc/passwd'], + ['C:\\Safe', 'file:///C:\\Safe\\user:@\\Windows\\System32'], + ]; + + adversarialCases.forEach(([parent, child]) => { + // Should safely return false for all adversarial inputs + assert.equal( + isParentDirectory(parent, child), + false, + `Expected adversarial input "${parent}" vs "${child}" to return false safely`, + ); + }); + }); + + it('should handle edge cases correctly', () => { + // Root paths + assert.equal(isParentDirectory('/', '/home'), true); + assert.equal(isParentDirectory('/', '/'), false); // Same path + assert.equal(isParentDirectory('C:\\', 'C:\\Windows'), true); + assert.equal(isParentDirectory('C:\\', 'C:\\'), false); + + // Current directory + assert.equal(isParentDirectory('.', './src'), true); + assert.equal(isParentDirectory('.', '.'), false); + + // Parent directory references not allowed (.. paths rejected) + assert.equal(isParentDirectory('..', '../src'), false); + assert.equal(isParentDirectory('../..', '../../src/main.js'), false); + + // Trailing slashes + assert.equal(isParentDirectory('/home/', '/home/user'), true); + assert.equal(isParentDirectory('/home', '/home/user/'), true); + assert.equal(isParentDirectory('/home/', '/home/user/'), true); + + // Multiple slashes + assert.equal(isParentDirectory('/home', '/home//user'), true); + assert.equal(isParentDirectory('/home', '/home///user///docs'), true); + }); +}); + +describe('normalizePathname', () => { + describe('file format', () => { + it('should strip .html extension', () => { + assert.equal(normalizePathname('/about.html', 'file', 'ignore'), '/about'); + assert.equal(normalizePathname('/blog/post.html', 'file', 'ignore'), '/blog/post'); + assert.equal( + normalizePathname('/deeply/nested/page.html', 'file', 'ignore'), + '/deeply/nested/page', + ); + }); + + it('should not strip .html from root', () => { + assert.equal(normalizePathname('/.html', 'file', 'ignore'), '/.html'); + }); + + it('should return root for mangled index paths', () => { + // When base is removed from index paths in file format, they can get mangled + assert.equal(normalizePathname('/html', 'file', 'ignore'), '/'); + assert.equal(normalizePathname('/something', 'file', 'ignore'), '/'); + }); + + it('should handle root path', () => { + assert.equal(normalizePathname('/', 'file', 'ignore'), '/'); + }); + }); + + describe('directory format', () => { + it('should strip trailing slash when trailingSlash is ignore', () => { + assert.equal(normalizePathname('/about/', 'directory', 'ignore'), '/about'); + assert.equal(normalizePathname('/blog/post/', 'directory', 'ignore'), '/blog/post'); + }); + + it('should not strip trailing slash when trailingSlash is always', () => { + assert.equal(normalizePathname('/about/', 'directory', 'always'), '/about/'); + assert.equal(normalizePathname('/blog/post/', 'directory', 'always'), '/blog/post/'); + }); + + it('should not strip trailing slash when trailingSlash is never', () => { + assert.equal(normalizePathname('/about/', 'directory', 'never'), '/about/'); + }); + + it('should not strip trailing slash from root', () => { + assert.equal(normalizePathname('/', 'directory', 'ignore'), '/'); + }); + + it('should handle paths without trailing slash', () => { + assert.equal(normalizePathname('/about', 'directory', 'ignore'), '/about'); + assert.equal(normalizePathname('/about', 'directory', 'always'), '/about'); + }); + }); + + describe('preserve format', () => { + it('should behave same as directory format', () => { + assert.equal(normalizePathname('/about/', 'preserve', 'ignore'), '/about'); + assert.equal(normalizePathname('/about/', 'preserve', 'always'), '/about/'); + assert.equal(normalizePathname('/', 'preserve', 'ignore'), '/'); + }); + }); +}); diff --git a/packages/internal-helpers/test/request.test.js b/packages/internal-helpers/test/request.test.js deleted file mode 100644 index 8741dbdddb62..000000000000 --- a/packages/internal-helpers/test/request.test.js +++ /dev/null @@ -1,223 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - getClientIpAddress, - getFirstForwardedValue, - getValidatedIpFromHeader, - isValidIpAddress, -} from '../dist/request.js'; - -describe('getFirstForwardedValue', () => { - it('should return the first value from a comma-separated string', () => { - assert.equal(getFirstForwardedValue('a, b, c'), 'a'); - }); - - it('should return the only value when there is a single value', () => { - assert.equal(getFirstForwardedValue('203.0.113.50'), '203.0.113.50'); - }); - - it('should trim whitespace from the returned value', () => { - assert.equal(getFirstForwardedValue(' 203.0.113.50 , 10.0.0.1 '), '203.0.113.50'); - }); - - it('should return undefined for undefined input', () => { - assert.equal(getFirstForwardedValue(undefined), undefined); - }); - - it('should return undefined for null input', () => { - assert.equal(getFirstForwardedValue(null), undefined); - }); - - it('should handle an empty string', () => { - assert.equal(getFirstForwardedValue(''), ''); - }); - - it('should handle a string array by joining and splitting', () => { - // .toString() on a string array produces "a,b", then split(',') works - assert.equal(getFirstForwardedValue(['203.0.113.50', '10.0.0.1']), '203.0.113.50'); - }); - - it('should handle an IPv6 address', () => { - assert.equal(getFirstForwardedValue('2001:db8::1'), '2001:db8::1'); - }); -}); - -describe('isValidIpAddress', () => { - const validAddresses = [ - // IPv4 - '127.0.0.1', - '0.0.0.0', - '255.255.255.255', - '192.168.1.1', - '10.0.0.1', - '203.0.113.50', - - // IPv6 - '::1', - '::', - '2001:db8::1', - 'fe80::1', - '::ffff:192.0.2.1', - '2001:0db8:0000:0000:0000:0000:0000:0001', - 'fd12:3456:789a::1', - ]; - - const invalidAddresses = [ - // Injection payloads - '', - "'; DROP TABLE users; --", - '../../etc/passwd', - '', - - // Arbitrary strings - 'not-an-ip', - 'hello world', - 'localhost', - 'example.com', - - // Empty / whitespace - '', - ' ', - - // Oversized - '1'.repeat(46), - - // Path-like - '/home/user', - 'C:\\Windows', - - // URL-like - 'http://evil.com', - ]; - - it('should accept valid IP addresses', () => { - for (const addr of validAddresses) { - assert.equal(isValidIpAddress(addr), true, `Expected "${addr}" to be valid`); - } - }); - - it('should reject non-IP strings', () => { - for (const addr of invalidAddresses) { - assert.equal(isValidIpAddress(addr), false, `Expected "${addr}" to be invalid`); - } - }); -}); - -describe('getValidatedIpFromHeader', () => { - it('should return a valid IP from a single-value header', () => { - assert.equal(getValidatedIpFromHeader('203.0.113.50'), '203.0.113.50'); - }); - - it('should return the first valid IP from a multi-value header', () => { - assert.equal(getValidatedIpFromHeader('203.0.113.50, 10.0.0.1'), '203.0.113.50'); - }); - - it('should return undefined for non-IP header values', () => { - assert.equal(getValidatedIpFromHeader(''), undefined); - }); - - it('should return undefined for null', () => { - assert.equal(getValidatedIpFromHeader(null), undefined); - }); - - it('should return undefined for undefined', () => { - assert.equal(getValidatedIpFromHeader(undefined), undefined); - }); - - it('should return undefined for empty string', () => { - assert.equal(getValidatedIpFromHeader(''), undefined); - }); - - it('should handle IPv6 addresses', () => { - assert.equal(getValidatedIpFromHeader('2001:db8::1'), '2001:db8::1'); - }); -}); - -describe('getClientIpAddress', () => { - /** - * Helper to create a minimal Request with given headers. - */ - function makeRequest(headers = {}) { - return new Request('https://example.com', { headers }); - } - - it('should return the IP when x-forwarded-for contains a single address', () => { - const request = makeRequest({ 'x-forwarded-for': '203.0.113.50' }); - assert.equal(getClientIpAddress(request), '203.0.113.50'); - }); - - it('should return the first IP when x-forwarded-for contains multiple addresses', () => { - const request = makeRequest({ - 'x-forwarded-for': '203.0.113.50, 70.41.3.18, 150.172.238.178', - }); - assert.equal(getClientIpAddress(request), '203.0.113.50'); - }); - - it('should trim whitespace around addresses', () => { - const request = makeRequest({ 'x-forwarded-for': ' 203.0.113.50 , 70.41.3.18 ' }); - assert.equal(getClientIpAddress(request), '203.0.113.50'); - }); - - it('should return undefined when x-forwarded-for header is absent', () => { - const request = makeRequest(); - assert.equal(getClientIpAddress(request), undefined); - }); - - it('should return undefined when x-forwarded-for header is empty', () => { - const request = makeRequest({ 'x-forwarded-for': '' }); - assert.equal(getClientIpAddress(request), undefined); - }); - - it('should handle an IPv6 address', () => { - const request = makeRequest({ 'x-forwarded-for': '2001:db8::1' }); - assert.equal(getClientIpAddress(request), '2001:db8::1'); - }); - - it('should return the first IPv6 address from a mixed list', () => { - const request = makeRequest({ 'x-forwarded-for': '2001:db8::1, 203.0.113.50' }); - assert.equal(getClientIpAddress(request), '2001:db8::1'); - }); - - it('should handle IPv4-mapped IPv6 address', () => { - const request = makeRequest({ 'x-forwarded-for': '::ffff:192.0.2.1' }); - assert.equal(getClientIpAddress(request), '::ffff:192.0.2.1'); - }); - - it('should handle the loopback address', () => { - const request = makeRequest({ 'x-forwarded-for': '127.0.0.1' }); - assert.equal(getClientIpAddress(request), '127.0.0.1'); - }); - - it('should return undefined for whitespace-only values', () => { - const request = makeRequest({ 'x-forwarded-for': ' , ' }); - assert.equal(getClientIpAddress(request), undefined); - }); - - it('should not be affected by other headers', () => { - const request = makeRequest({ - 'x-real-ip': '10.0.0.1', - forwarded: 'for=10.0.0.2', - }); - assert.equal(getClientIpAddress(request), undefined); - }); - - it('should reject HTML injection in x-forwarded-for', () => { - const request = makeRequest({ 'x-forwarded-for': '' }); - assert.equal(getClientIpAddress(request), undefined); - }); - - it('should reject SQL injection in x-forwarded-for', () => { - const request = makeRequest({ 'x-forwarded-for': "'; DROP TABLE users; --" }); - assert.equal(getClientIpAddress(request), undefined); - }); - - it('should reject path traversal in x-forwarded-for', () => { - const request = makeRequest({ 'x-forwarded-for': '../../etc/passwd' }); - assert.equal(getClientIpAddress(request), undefined); - }); - - it('should reject oversized x-forwarded-for values', () => { - const request = makeRequest({ 'x-forwarded-for': '1'.repeat(100) }); - assert.equal(getClientIpAddress(request), undefined); - }); -}); diff --git a/packages/internal-helpers/test/request.test.ts b/packages/internal-helpers/test/request.test.ts new file mode 100644 index 000000000000..2029367af9b9 --- /dev/null +++ b/packages/internal-helpers/test/request.test.ts @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + getClientIpAddress, + getFirstForwardedValue, + getValidatedIpFromHeader, + isValidIpAddress, +} from '../dist/request.js'; + +describe('getFirstForwardedValue', () => { + it('should return the first value from a comma-separated string', () => { + assert.equal(getFirstForwardedValue('a, b, c'), 'a'); + }); + + it('should return the only value when there is a single value', () => { + assert.equal(getFirstForwardedValue('203.0.113.50'), '203.0.113.50'); + }); + + it('should trim whitespace from the returned value', () => { + assert.equal(getFirstForwardedValue(' 203.0.113.50 , 10.0.0.1 '), '203.0.113.50'); + }); + + it('should return undefined for undefined input', () => { + assert.equal(getFirstForwardedValue(undefined), undefined); + }); + + it('should return undefined for null input', () => { + assert.equal(getFirstForwardedValue(null), undefined); + }); + + it('should handle an empty string', () => { + assert.equal(getFirstForwardedValue(''), ''); + }); + + it('should handle a string array by joining and splitting', () => { + // .toString() on a string array produces "a,b", then split(',') works + assert.equal(getFirstForwardedValue(['203.0.113.50', '10.0.0.1']), '203.0.113.50'); + }); + + it('should handle an IPv6 address', () => { + assert.equal(getFirstForwardedValue('2001:db8::1'), '2001:db8::1'); + }); +}); + +describe('isValidIpAddress', () => { + const validAddresses = [ + // IPv4 + '127.0.0.1', + '0.0.0.0', + '255.255.255.255', + '192.168.1.1', + '10.0.0.1', + '203.0.113.50', + + // IPv6 + '::1', + '::', + '2001:db8::1', + 'fe80::1', + '::ffff:192.0.2.1', + '2001:0db8:0000:0000:0000:0000:0000:0001', + 'fd12:3456:789a::1', + ]; + + const invalidAddresses = [ + // Injection payloads + '', + "'; DROP TABLE users; --", + '../../etc/passwd', + '', + + // Arbitrary strings + 'not-an-ip', + 'hello world', + 'localhost', + 'example.com', + + // Empty / whitespace + '', + ' ', + + // Oversized + '1'.repeat(46), + + // Path-like + '/home/user', + 'C:\\Windows', + + // URL-like + 'http://evil.com', + ]; + + it('should accept valid IP addresses', () => { + for (const addr of validAddresses) { + assert.equal(isValidIpAddress(addr), true, `Expected "${addr}" to be valid`); + } + }); + + it('should reject non-IP strings', () => { + for (const addr of invalidAddresses) { + assert.equal(isValidIpAddress(addr), false, `Expected "${addr}" to be invalid`); + } + }); +}); + +describe('getValidatedIpFromHeader', () => { + it('should return a valid IP from a single-value header', () => { + assert.equal(getValidatedIpFromHeader('203.0.113.50'), '203.0.113.50'); + }); + + it('should return the first valid IP from a multi-value header', () => { + assert.equal(getValidatedIpFromHeader('203.0.113.50, 10.0.0.1'), '203.0.113.50'); + }); + + it('should return undefined for non-IP header values', () => { + assert.equal(getValidatedIpFromHeader(''), undefined); + }); + + it('should return undefined for null', () => { + assert.equal(getValidatedIpFromHeader(null), undefined); + }); + + it('should return undefined for undefined', () => { + assert.equal(getValidatedIpFromHeader(undefined), undefined); + }); + + it('should return undefined for empty string', () => { + assert.equal(getValidatedIpFromHeader(''), undefined); + }); + + it('should handle IPv6 addresses', () => { + assert.equal(getValidatedIpFromHeader('2001:db8::1'), '2001:db8::1'); + }); +}); + +describe('getClientIpAddress', () => { + /** + * Helper to create a minimal Request with given headers. + */ + function makeRequest(headers: HeadersInit = {}) { + return new Request('https://example.com', { headers }); + } + + it('should return the IP when x-forwarded-for contains a single address', () => { + const request = makeRequest({ 'x-forwarded-for': '203.0.113.50' }); + assert.equal(getClientIpAddress(request), '203.0.113.50'); + }); + + it('should return the first IP when x-forwarded-for contains multiple addresses', () => { + const request = makeRequest({ + 'x-forwarded-for': '203.0.113.50, 70.41.3.18, 150.172.238.178', + }); + assert.equal(getClientIpAddress(request), '203.0.113.50'); + }); + + it('should trim whitespace around addresses', () => { + const request = makeRequest({ 'x-forwarded-for': ' 203.0.113.50 , 70.41.3.18 ' }); + assert.equal(getClientIpAddress(request), '203.0.113.50'); + }); + + it('should return undefined when x-forwarded-for header is absent', () => { + const request = makeRequest(); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should return undefined when x-forwarded-for header is empty', () => { + const request = makeRequest({ 'x-forwarded-for': '' }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should handle an IPv6 address', () => { + const request = makeRequest({ 'x-forwarded-for': '2001:db8::1' }); + assert.equal(getClientIpAddress(request), '2001:db8::1'); + }); + + it('should return the first IPv6 address from a mixed list', () => { + const request = makeRequest({ 'x-forwarded-for': '2001:db8::1, 203.0.113.50' }); + assert.equal(getClientIpAddress(request), '2001:db8::1'); + }); + + it('should handle IPv4-mapped IPv6 address', () => { + const request = makeRequest({ 'x-forwarded-for': '::ffff:192.0.2.1' }); + assert.equal(getClientIpAddress(request), '::ffff:192.0.2.1'); + }); + + it('should handle the loopback address', () => { + const request = makeRequest({ 'x-forwarded-for': '127.0.0.1' }); + assert.equal(getClientIpAddress(request), '127.0.0.1'); + }); + + it('should return undefined for whitespace-only values', () => { + const request = makeRequest({ 'x-forwarded-for': ' , ' }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should not be affected by other headers', () => { + const request = makeRequest({ + 'x-real-ip': '10.0.0.1', + forwarded: 'for=10.0.0.2', + }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should reject HTML injection in x-forwarded-for', () => { + const request = makeRequest({ 'x-forwarded-for': '' }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should reject SQL injection in x-forwarded-for', () => { + const request = makeRequest({ 'x-forwarded-for': "'; DROP TABLE users; --" }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should reject path traversal in x-forwarded-for', () => { + const request = makeRequest({ 'x-forwarded-for': '../../etc/passwd' }); + assert.equal(getClientIpAddress(request), undefined); + }); + + it('should reject oversized x-forwarded-for values', () => { + const request = makeRequest({ 'x-forwarded-for': '1'.repeat(100) }); + assert.equal(getClientIpAddress(request), undefined); + }); +}); diff --git a/packages/internal-helpers/tsconfig.test.json b/packages/internal-helpers/tsconfig.test.json new file mode 100644 index 000000000000..7d6bc4428b35 --- /dev/null +++ b/packages/internal-helpers/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [{ "path": "../astro/tsconfig.test.json" }] +} diff --git a/packages/language-tools/astro-check/package.json b/packages/language-tools/astro-check/package.json index 6c4377dbfa27..42901ee1ee4e 100644 --- a/packages/language-tools/astro-check/package.json +++ b/packages/language-tools/astro-check/package.json @@ -40,7 +40,7 @@ "@types/node": "^20.9.0", "@types/yargs": "^17.0.35", "astro-scripts": "workspace:*", - "tinyglobby": "^0.2.15", + "tinyglobby": "^0.2.16", "tsx": "^4.21.0" }, "peerDependencies": { diff --git a/packages/language-tools/language-server/package.json b/packages/language-tools/language-server/package.json index 3773a532aa8f..5a06d28c7874 100644 --- a/packages/language-tools/language-server/package.json +++ b/packages/language-tools/language-server/package.json @@ -34,7 +34,7 @@ "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", - "tinyglobby": "^0.2.15", + "tinyglobby": "^0.2.16", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", diff --git a/packages/language-tools/language-server/src/check.ts b/packages/language-tools/language-server/src/check.ts index d49ac9f1c15c..245c4051e27c 100644 --- a/packages/language-tools/language-server/src/check.ts +++ b/packages/language-tools/language-server/src/check.ts @@ -33,12 +33,18 @@ export interface CheckResult { export class AstroCheck { private ts!: typeof import('typescript'); public linter!: ReturnType<(typeof kit)['createTypeScriptChecker']>; + private readonly workspacePath: string; + private readonly typescriptPath: string | undefined; + private readonly tsconfigPath: string | undefined; constructor( - private readonly workspacePath: string, - private readonly typescriptPath: string | undefined, - private readonly tsconfigPath: string | undefined, + workspacePath: string, + typescriptPath: string | undefined, + tsconfigPath: string | undefined, ) { + this.workspacePath = workspacePath; + this.typescriptPath = typescriptPath; + this.tsconfigPath = tsconfigPath; this.initialize(); } diff --git a/packages/language-tools/language-server/src/core/frontmatterHolders.ts b/packages/language-tools/language-server/src/core/frontmatterHolders.ts index aa627de1ec5e..66292c1e6cab 100644 --- a/packages/language-tools/language-server/src/core/frontmatterHolders.ts +++ b/packages/language-tools/language-server/src/core/frontmatterHolders.ts @@ -95,13 +95,21 @@ export class FrontmatterHolder implements VirtualCode { mappings: CodeMapping[]; embeddedCodes: VirtualCode[]; public hasFrontmatter = false; + public fileName: string; + public languageId: string; + public snapshot: ts.IScriptSnapshot; + public collection: string | undefined; constructor( - public fileName: string, - public languageId: string, - public snapshot: ts.IScriptSnapshot, - public collection: string | undefined, + fileName: string, + languageId: string, + snapshot: ts.IScriptSnapshot, + collection: string | undefined, ) { + this.fileName = fileName; + this.languageId = languageId; + this.snapshot = snapshot; + this.collection = collection; this.mappings = [ { sourceOffsets: [0], @@ -121,8 +129,8 @@ export class FrontmatterHolder implements VirtualCode { this.embeddedCodes = []; this.snapshot = snapshot; - // If the file is not part of a collection, we don't need to do anything if (!this.collection) { + // If the file is not part of a collection, we don't need to do anything return; } diff --git a/packages/language-tools/language-server/src/core/index.ts b/packages/language-tools/language-server/src/core/index.ts index b8ec8699de8a..0d1f18464113 100644 --- a/packages/language-tools/language-server/src/core/index.ts +++ b/packages/language-tools/language-server/src/core/index.ts @@ -149,11 +149,12 @@ export class AstroVirtualCode implements VirtualCode { compilerDiagnostics!: DiagnosticMessage[]; htmlDocument!: HTMLDocument; codegenStacks = []; + public fileName: string; + public snapshot: ts.IScriptSnapshot; - constructor( - public fileName: string, - public snapshot: ts.IScriptSnapshot, - ) { + constructor(fileName: string, snapshot: ts.IScriptSnapshot) { + this.fileName = fileName; + this.snapshot = snapshot; this.mappings = [ { sourceOffsets: [0], diff --git a/packages/language-tools/language-server/src/core/svelte.ts b/packages/language-tools/language-server/src/core/svelte.ts index 1418159a9adb..8d7275216bd2 100644 --- a/packages/language-tools/language-server/src/core/svelte.ts +++ b/packages/language-tools/language-server/src/core/svelte.ts @@ -45,11 +45,12 @@ class SvelteVirtualCode implements VirtualCode { mappings!: Mapping[]; embeddedCodes!: VirtualCode[]; codegenStacks = []; + public fileName: string; + public snapshot: ts.IScriptSnapshot; - constructor( - public fileName: string, - public snapshot: ts.IScriptSnapshot, - ) { + constructor(fileName: string, snapshot: ts.IScriptSnapshot) { + this.fileName = fileName; + this.snapshot = snapshot; this.mappings = []; this.embeddedCodes = []; diff --git a/packages/language-tools/language-server/src/core/vue.ts b/packages/language-tools/language-server/src/core/vue.ts index 3be6edc3e5c9..26beb97a8d52 100644 --- a/packages/language-tools/language-server/src/core/vue.ts +++ b/packages/language-tools/language-server/src/core/vue.ts @@ -45,11 +45,12 @@ class VueVirtualCode implements VirtualCode { mappings!: Mapping[]; embeddedCodes!: VirtualCode[]; codegenStacks = []; + public fileName: string; + public snapshot: ts.IScriptSnapshot; - constructor( - public fileName: string, - public snapshot: ts.IScriptSnapshot, - ) { + constructor(fileName: string, snapshot: ts.IScriptSnapshot) { + this.fileName = fileName; + this.snapshot = snapshot; this.mappings = []; this.embeddedCodes = []; diff --git a/packages/language-tools/language-server/test/content-intellisense/caching.test.ts b/packages/language-tools/language-server/test/content-intellisense/caching.test.ts index 92c9cdd41751..8f3a33efb16b 100644 --- a/packages/language-tools/language-server/test/content-intellisense/caching.test.ts +++ b/packages/language-tools/language-server/test/content-intellisense/caching.test.ts @@ -9,60 +9,55 @@ import { fixtureDir } from '../utils.ts'; const contentSchemaPath = path.resolve(fixtureDir, '.astro', 'collections', 'caching.schema.json'); -describe( - 'Content Intellisense - Caching', - { skip: Number.parseInt(process.versions.node) === 20 }, - async () => { - let languageServer: LanguageServer; - - before(async () => { - languageServer = await getLanguageServer(); - }); - - it('Properly updates the schema when they are updated', async () => { - const document = await languageServer.handle.openTextDocument( - path.join(fixtureDir, 'src', 'content', 'caching', 'caching.md'), - 'markdown', - ); - - const hover = await languageServer.handle.sendHoverRequest( - document.uri, - Position.create(1, 1), - ); - - assert.deepStrictEqual(hover?.contents, { - kind: 'markdown', - value: 'I will be changed', - }); - - fs.writeFileSync( - contentSchemaPath, - fs.readFileSync(contentSchemaPath, 'utf-8').replaceAll('I will be changed', 'I am changed'), - ); - - await languageServer.handle.didChangeWatchedFiles([ - { - uri: URI.file(contentSchemaPath).toString(), - type: 2, - }, - ]); - - const hover2 = await languageServer.handle.sendHoverRequest( - document.uri, - Position.create(1, 1), - ); - - assert.deepStrictEqual(hover2?.contents, { - kind: 'markdown', - value: 'I am changed', - }); +describe('Content Intellisense - Caching', { + skip: Number.parseInt(process.versions.node) === 20, +}, async () => { + let languageServer: LanguageServer; + + before(async () => { + languageServer = await getLanguageServer(); + }); + + it('Properly updates the schema when they are updated', async () => { + const document = await languageServer.handle.openTextDocument( + path.join(fixtureDir, 'src', 'content', 'caching', 'caching.md'), + 'markdown', + ); + + const hover = await languageServer.handle.sendHoverRequest(document.uri, Position.create(1, 1)); + + assert.deepStrictEqual(hover?.contents, { + kind: 'markdown', + value: 'I will be changed', }); - after(async () => { - fs.writeFileSync( - contentSchemaPath, - fs.readFileSync(contentSchemaPath, 'utf-8').replaceAll('I am changed', 'I will be changed'), - ); + fs.writeFileSync( + contentSchemaPath, + fs.readFileSync(contentSchemaPath, 'utf-8').replaceAll('I will be changed', 'I am changed'), + ); + + await languageServer.handle.didChangeWatchedFiles([ + { + uri: URI.file(contentSchemaPath).toString(), + type: 2, + }, + ]); + + const hover2 = await languageServer.handle.sendHoverRequest( + document.uri, + Position.create(1, 1), + ); + + assert.deepStrictEqual(hover2?.contents, { + kind: 'markdown', + value: 'I am changed', }); - }, -); + }); + + after(async () => { + fs.writeFileSync( + contentSchemaPath, + fs.readFileSync(contentSchemaPath, 'utf-8').replaceAll('I am changed', 'I will be changed'), + ); + }); +}); diff --git a/packages/language-tools/language-server/test/content-intellisense/completions.test.ts b/packages/language-tools/language-server/test/content-intellisense/completions.test.ts index e6bbf0d60d94..2725bb4e64cb 100644 --- a/packages/language-tools/language-server/test/content-intellisense/completions.test.ts +++ b/packages/language-tools/language-server/test/content-intellisense/completions.test.ts @@ -5,45 +5,43 @@ import { Position } from '@volar/language-server'; import { getLanguageServer, type LanguageServer } from '../server.ts'; import { fixtureDir } from '../utils.ts'; -describe( - 'Content Intellisense - Completions', - { skip: Number.parseInt(process.versions.node) === 20 }, - async () => { - let languageServer: LanguageServer; - - before(async () => { - languageServer = await getLanguageServer(); - }); - - it('Provide completions for collection properties', async () => { - const document = await languageServer.handle.openTextDocument( - path.join(fixtureDir, 'src', 'content', 'blog', 'completions.md'), - 'markdown', - ); - - const completions = await languageServer.handle.sendCompletionRequest( - document.uri, - Position.create(1, 1), - ); - - // We don't do any mapping ourselves here, so we'll just check if the labels are correct. - const labels = (completions?.items ?? []).map((item) => item.label); - ['title', 'description', 'tags', 'type'].forEach((m) => assert.ok(labels.includes(m))); - }); - - it('Provide completions for collection property values', async () => { - const document = await languageServer.handle.openTextDocument( - path.join(fixtureDir, 'src', 'content', 'blog', 'completions-values.md'), - 'markdown', - ); - - const completions = await languageServer.handle.sendCompletionRequest( - document.uri, - Position.create(1, 7), - ); - - const labels = (completions?.items ?? []).map((item) => item.label); - ['blog'].forEach((m) => assert.ok(labels.includes(m))); - }); - }, -); +describe('Content Intellisense - Completions', { + skip: Number.parseInt(process.versions.node) === 20, +}, async () => { + let languageServer: LanguageServer; + + before(async () => { + languageServer = await getLanguageServer(); + }); + + it('Provide completions for collection properties', async () => { + const document = await languageServer.handle.openTextDocument( + path.join(fixtureDir, 'src', 'content', 'blog', 'completions.md'), + 'markdown', + ); + + const completions = await languageServer.handle.sendCompletionRequest( + document.uri, + Position.create(1, 1), + ); + + // We don't do any mapping ourselves here, so we'll just check if the labels are correct. + const labels = (completions?.items ?? []).map((item) => item.label); + ['title', 'description', 'tags', 'type'].forEach((m) => assert.ok(labels.includes(m))); + }); + + it('Provide completions for collection property values', async () => { + const document = await languageServer.handle.openTextDocument( + path.join(fixtureDir, 'src', 'content', 'blog', 'completions-values.md'), + 'markdown', + ); + + const completions = await languageServer.handle.sendCompletionRequest( + document.uri, + Position.create(1, 7), + ); + + const labels = (completions?.items ?? []).map((item) => item.label); + ['blog'].forEach((m) => assert.ok(labels.includes(m))); + }); +}); diff --git a/packages/language-tools/language-server/test/content-intellisense/definitions.test.ts b/packages/language-tools/language-server/test/content-intellisense/definitions.test.ts index 2772d67573a4..2aff28e99bed 100644 --- a/packages/language-tools/language-server/test/content-intellisense/definitions.test.ts +++ b/packages/language-tools/language-server/test/content-intellisense/definitions.test.ts @@ -6,49 +6,47 @@ import { Position } from '@volar/language-server'; import { getLanguageServer, type LanguageServer } from '../server.ts'; import { fixtureDir } from '../utils.ts'; -describe( - 'Content Intellisense - Go To Everywhere', - { skip: Number.parseInt(process.versions.node) === 20 }, - async () => { - let languageServer: LanguageServer; - - before(async () => { - languageServer = await getLanguageServer(); +describe('Content Intellisense - Go To Everywhere', { + skip: Number.parseInt(process.versions.node) === 20, +}, async () => { + let languageServer: LanguageServer; + + before(async () => { + languageServer = await getLanguageServer(); + }); + + it('Provide definitions for keys', async () => { + const document = await languageServer.handle.openTextDocument( + path.join(fixtureDir, 'src', 'content', 'blog', 'definitions.md'), + 'markdown', + ); + + const definitions = (await languageServer.handle.sendDefinitionRequest( + document.uri, + Position.create(1, 2), + )) as LocationLink[]; + + const targetUris = definitions?.map((definition) => definition.targetUri); + assert.strictEqual( + targetUris.every((uri) => uri.endsWith('config.ts')), + true, + ); + + const { targetRange, targetSelectionRange, originSelectionRange } = definitions[0]; + + assert.deepStrictEqual(targetRange, { + start: { line: 7, character: 2 }, + end: { line: 7, character: 65 }, }); - it('Provide definitions for keys', async () => { - const document = await languageServer.handle.openTextDocument( - path.join(fixtureDir, 'src', 'content', 'blog', 'definitions.md'), - 'markdown', - ); - - const definitions = (await languageServer.handle.sendDefinitionRequest( - document.uri, - Position.create(1, 2), - )) as LocationLink[]; - - const targetUris = definitions?.map((definition) => definition.targetUri); - assert.strictEqual( - targetUris.every((uri) => uri.endsWith('config.ts')), - true, - ); - - const { targetRange, targetSelectionRange, originSelectionRange } = definitions[0]; - - assert.deepStrictEqual(targetRange, { - start: { line: 7, character: 2 }, - end: { line: 7, character: 65 }, - }); - - assert.deepStrictEqual(targetSelectionRange, { - start: { line: 7, character: 2 }, - end: { line: 7, character: 7 }, - }); - - assert.deepStrictEqual(originSelectionRange, { - start: { line: 1, character: 0 }, - end: { line: 1, character: 5 }, - }); + assert.deepStrictEqual(targetSelectionRange, { + start: { line: 7, character: 2 }, + end: { line: 7, character: 7 }, }); - }, -); + + assert.deepStrictEqual(originSelectionRange, { + start: { line: 1, character: 0 }, + end: { line: 1, character: 5 }, + }); + }); +}); diff --git a/packages/language-tools/language-server/test/content-intellisense/diagnostics.test.ts b/packages/language-tools/language-server/test/content-intellisense/diagnostics.test.ts index bea456c12b4b..6ce3904d206f 100644 --- a/packages/language-tools/language-server/test/content-intellisense/diagnostics.test.ts +++ b/packages/language-tools/language-server/test/content-intellisense/diagnostics.test.ts @@ -7,94 +7,92 @@ import { getLanguageServer, type LanguageServer } from '../server.ts'; import { fixtureDir } from '../utils.ts'; // TODO: We can't sync the fixture with these mistakes at all, as such we can't run these tests. -describe.skip( - 'Content Intellisense - Diagnostics', - { skip: Number.parseInt(process.versions.node) === 20 }, - async () => { - let languageServer: LanguageServer; - - before(async () => { - languageServer = await getLanguageServer(); +describe.skip('Content Intellisense - Diagnostics', { + skip: Number.parseInt(process.versions.node) === 20, +}, async () => { + let languageServer: LanguageServer; + + before(async () => { + languageServer = await getLanguageServer(); + }); + + it('Report errors for missing entries in frontmatter', async () => { + const document = await languageServer.handle.openTextDocument( + path.join(fixtureDir, 'src', 'content', 'blog', 'missing_property.md'), + 'markdown', + ); + const diagnostics = (await languageServer.handle.sendDocumentDiagnosticRequest( + document.uri, + )) as FullDocumentDiagnosticReport; + + assert.strictEqual(diagnostics.items.length, 1); + + const firstDiagnostic = diagnostics.items[0]; + + // The data is not super relevant to the test, so we'll throw it out. + delete firstDiagnostic.data; + + assert.deepStrictEqual(firstDiagnostic, { + code: 0, + message: 'Missing property "description".', + range: { + start: Position.create(0, 0), + end: Position.create(2, 3), + }, + severity: DiagnosticSeverity.Error, + source: 'astro', }); - - it('Report errors for missing entries in frontmatter', async () => { - const document = await languageServer.handle.openTextDocument( - path.join(fixtureDir, 'src', 'content', 'blog', 'missing_property.md'), - 'markdown', - ); - const diagnostics = (await languageServer.handle.sendDocumentDiagnosticRequest( - document.uri, - )) as FullDocumentDiagnosticReport; - - assert.strictEqual(diagnostics.items.length, 1); - - const firstDiagnostic = diagnostics.items[0]; - - // The data is not super relevant to the test, so we'll throw it out. - delete firstDiagnostic.data; - - assert.deepStrictEqual(firstDiagnostic, { - code: 0, - message: 'Missing property "description".', - range: { - start: Position.create(0, 0), - end: Position.create(2, 3), - }, - severity: DiagnosticSeverity.Error, - source: 'astro', - }); - }); - - it('Report errors for invalid types in frontmatter', async () => { - const document = await languageServer.handle.openTextDocument( - path.join(fixtureDir, 'src', 'content', 'blog', 'type_error.md'), - 'markdown', - ); - const diagnostics = (await languageServer.handle.sendDocumentDiagnosticRequest( - document.uri, - )) as FullDocumentDiagnosticReport; - - assert.strictEqual(diagnostics.items.length, 1); - - const firstDiagnostic = diagnostics.items[0]; - - delete firstDiagnostic.data; - - assert.deepStrictEqual(firstDiagnostic, { - code: 0, - message: 'Incorrect type. Expected "string".', - range: { - start: Position.create(1, 7), - end: Position.create(1, 8), - }, - severity: DiagnosticSeverity.Error, - source: 'astro', - }); + }); + + it('Report errors for invalid types in frontmatter', async () => { + const document = await languageServer.handle.openTextDocument( + path.join(fixtureDir, 'src', 'content', 'blog', 'type_error.md'), + 'markdown', + ); + const diagnostics = (await languageServer.handle.sendDocumentDiagnosticRequest( + document.uri, + )) as FullDocumentDiagnosticReport; + + assert.strictEqual(diagnostics.items.length, 1); + + const firstDiagnostic = diagnostics.items[0]; + + delete firstDiagnostic.data; + + assert.deepStrictEqual(firstDiagnostic, { + code: 0, + message: 'Incorrect type. Expected "string".', + range: { + start: Position.create(1, 7), + end: Position.create(1, 8), + }, + severity: DiagnosticSeverity.Error, + source: 'astro', }); - - it('Report error for missing frontmatter', async () => { - const document = await languageServer.handle.openTextDocument( - path.join(fixtureDir, 'src', 'content', 'blog', 'no_frontmatter.md'), - 'markdown', - ); - const diagnostics = (await languageServer.handle.sendDocumentDiagnosticRequest( - document.uri, - )) as FullDocumentDiagnosticReport; - - assert.strictEqual(diagnostics.items.length, 1); - - const firstDiagnostic = diagnostics.items[0]; - - delete firstDiagnostic.data; - - assert.deepStrictEqual(firstDiagnostic, { - message: 'Frontmatter is required for this file.', - range: { - start: Position.create(0, 0), - end: Position.create(0, 0), - }, - severity: DiagnosticSeverity.Error, - }); + }); + + it('Report error for missing frontmatter', async () => { + const document = await languageServer.handle.openTextDocument( + path.join(fixtureDir, 'src', 'content', 'blog', 'no_frontmatter.md'), + 'markdown', + ); + const diagnostics = (await languageServer.handle.sendDocumentDiagnosticRequest( + document.uri, + )) as FullDocumentDiagnosticReport; + + assert.strictEqual(diagnostics.items.length, 1); + + const firstDiagnostic = diagnostics.items[0]; + + delete firstDiagnostic.data; + + assert.deepStrictEqual(firstDiagnostic, { + message: 'Frontmatter is required for this file.', + range: { + start: Position.create(0, 0), + end: Position.create(0, 0), + }, + severity: DiagnosticSeverity.Error, }); - }, -); + }); +}); diff --git a/packages/language-tools/language-server/test/content-intellisense/hover.test.ts b/packages/language-tools/language-server/test/content-intellisense/hover.test.ts index 3ceec867555a..d729182dade8 100644 --- a/packages/language-tools/language-server/test/content-intellisense/hover.test.ts +++ b/packages/language-tools/language-server/test/content-intellisense/hover.test.ts @@ -5,31 +5,26 @@ import { Position } from '@volar/language-server'; import { getLanguageServer, type LanguageServer } from '../server.ts'; import { fixtureDir } from '../utils.ts'; -describe( - 'Content Intellisense - Hover', - { skip: Number.parseInt(process.versions.node) === 20 }, - async () => { - let languageServer: LanguageServer; +describe('Content Intellisense - Hover', { + skip: Number.parseInt(process.versions.node) === 20, +}, async () => { + let languageServer: LanguageServer; - before(async () => { - languageServer = await getLanguageServer(); - }); + before(async () => { + languageServer = await getLanguageServer(); + }); - it('Provide hover information for collection properties', async () => { - const document = await languageServer.handle.openTextDocument( - path.join(fixtureDir, 'src', 'content', 'blog', 'hover.md'), - 'markdown', - ); + it('Provide hover information for collection properties', async () => { + const document = await languageServer.handle.openTextDocument( + path.join(fixtureDir, 'src', 'content', 'blog', 'hover.md'), + 'markdown', + ); - const hover = await languageServer.handle.sendHoverRequest( - document.uri, - Position.create(1, 1), - ); + const hover = await languageServer.handle.sendHoverRequest(document.uri, Position.create(1, 1)); - assert.deepStrictEqual(hover?.contents, { - kind: 'markdown', - value: "The blog post's title.", - }); + assert.deepStrictEqual(hover?.contents, { + kind: 'markdown', + value: "The blog post's title.", }); - }, -); + }); +}); diff --git a/packages/language-tools/language-server/test/package.json b/packages/language-tools/language-server/test/package.json index 8dd02c725f41..9489b295a6fc 100644 --- a/packages/language-tools/language-server/test/package.json +++ b/packages/language-tools/language-server/test/package.json @@ -6,9 +6,9 @@ "@astrojs/svelte": "workspace:*", "@astrojs/vue": "workspace:*", "astro": "workspace:*", - "svelte": "^5.54.1" + "svelte": "^5.55.3" }, "devDependencies": { - "tinyglobby": "^0.2.15" + "tinyglobby": "^0.2.16" } } diff --git a/packages/language-tools/ts-plugin/src/frontmatter.ts b/packages/language-tools/ts-plugin/src/frontmatter.ts index 215bfc1fc65b..4fdd5f7ffbde 100644 --- a/packages/language-tools/ts-plugin/src/frontmatter.ts +++ b/packages/language-tools/ts-plugin/src/frontmatter.ts @@ -87,13 +87,21 @@ export class FrontmatterHolder implements VirtualCode { id = 'frontmatter-holder'; mappings: CodeMapping[]; embeddedCodes: VirtualCode[]; + public fileName: string; + public languageId: string; + public snapshot: ts.IScriptSnapshot; + public collection: string | undefined; constructor( - public fileName: string, - public languageId: string, - public snapshot: ts.IScriptSnapshot, - public collection: string | undefined, + fileName: string, + languageId: string, + snapshot: ts.IScriptSnapshot, + collection: string | undefined, ) { + this.fileName = fileName; + this.languageId = languageId; + this.snapshot = snapshot; + this.collection = collection; this.mappings = [ { sourceOffsets: [0], diff --git a/packages/language-tools/ts-plugin/src/language.ts b/packages/language-tools/ts-plugin/src/language.ts index fc643e805980..6f5ba2cf0df7 100644 --- a/packages/language-tools/ts-plugin/src/language.ts +++ b/packages/language-tools/ts-plugin/src/language.ts @@ -44,11 +44,12 @@ export class AstroVirtualCode implements VirtualCode { mappings!: CodeMapping[]; embeddedCodes!: VirtualCode[]; codegenStacks = []; + public fileName: string; + public snapshot: ts.IScriptSnapshot; - constructor( - public fileName: string, - public snapshot: ts.IScriptSnapshot, - ) { + constructor(fileName: string, snapshot: ts.IScriptSnapshot) { + this.fileName = fileName; + this.snapshot = snapshot; this.mappings = [ { sourceOffsets: [0], diff --git a/packages/language-tools/vscode/README.md b/packages/language-tools/vscode/README.md index c0b8933c4b7f..2423adf1ed09 100644 --- a/packages/language-tools/vscode/README.md +++ b/packages/language-tools/vscode/README.md @@ -25,7 +25,7 @@ A TypeScript plugin adding support for importing and exporting Astro components ## Configuration -HTML, CSS and TypeScript settings can be configured through the `html`, `css` and `typescript` namespaces respectively. For example, HTML documentation on hover can be disabled using `'html.hover.documentation': false`. Formatting can be configured through [Prettier's different configuration methods](https://prettier.io/docs/en/configuration.html). +HTML and CSS settings can be configured through the `html` and `css` setting prefixes. TypeScript-related settings appear in the VS Code Settings UI under the **JavaScript and TypeScript (js/ts)** category in recent VS Code versions, but the actual JSON keys use the `typescript.*` namespace (for example, `"typescript.preferences.importModuleSpecifier": "non-relative"`). For example, HTML documentation on hover can be disabled using `"html.hover.documentation": false`. Formatting can be configured through [Prettier's different configuration methods](https://prettier.io/docs/en/configuration.html). ## Troubleshooting diff --git a/packages/language-tools/vscode/package.json b/packages/language-tools/vscode/package.json index 207d7f934a3f..3690e71e38ff 100644 --- a/packages/language-tools/vscode/package.json +++ b/packages/language-tools/vscode/package.json @@ -261,13 +261,13 @@ "js-yaml": "^4.1.1", "kleur": "^4.1.5", "mocha": "^11.7.5", - "ovsx": "^0.10.9", + "ovsx": "^0.10.10", "vscode-languageclient": "^9.0.1", "vscode-tmgrammar-test": "^0.1.3" }, "dependencies": { "@astrojs/compiler": "^2.13.1", - "prettier": "^3.8.1", + "prettier": "^3.8.2", "prettier-plugin-astro": "^0.14.1" } } diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 55b5e17b78ea..b020022483db 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -34,7 +34,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc -p tsconfig.test.json --noEmit" }, "dependencies": { "@astrojs/internal-helpers": "workspace:*", diff --git a/packages/markdown/remark/test/autolinking.test.js b/packages/markdown/remark/test/autolinking.test.js deleted file mode 100644 index 3fd5ad0fcdad..000000000000 --- a/packages/markdown/remark/test/autolinking.test.js +++ /dev/null @@ -1,43 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { createMarkdownProcessor } from '../dist/index.js'; - -describe('autolinking', () => { - describe('plain md', () => { - let processor; - - before(async () => { - processor = await createMarkdownProcessor(); - }); - - it('autolinks URLs starting with a protocol in plain text', async () => { - const markdown = `See https://example.com for more.`; - const { code } = await processor.render(markdown); - - assert.equal( - code.replace(/\n/g, ''), - `

    See https://example.com for more.

    `, - ); - }); - - it('autolinks URLs starting with "www." in plain text', async () => { - const markdown = `See www.example.com for more.`; - const { code } = await processor.render(markdown); - - assert.equal( - code.trim(), - `

    See www.example.com for more.

    `, - ); - }); - - it('does not autolink URLs in code blocks', async () => { - const markdown = `See \`https://example.com\` or \`www.example.com\` for more.`; - const { code } = await processor.render(markdown); - - assert.equal( - code.trim(), - `

    See https://example.com or www.example.com for more.

    `, - ); - }); - }); -}); diff --git a/packages/markdown/remark/test/autolinking.test.ts b/packages/markdown/remark/test/autolinking.test.ts new file mode 100644 index 000000000000..2eb5acf5570e --- /dev/null +++ b/packages/markdown/remark/test/autolinking.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { createMarkdownProcessor, type MarkdownProcessor } from '../dist/index.js'; + +describe('autolinking', () => { + describe('plain md', () => { + let processor: MarkdownProcessor; + + before(async () => { + processor = await createMarkdownProcessor(); + }); + + it('autolinks URLs starting with a protocol in plain text', async () => { + const markdown = `See https://example.com for more.`; + const { code } = await processor.render(markdown); + + assert.equal( + code.replace(/\n/g, ''), + `

    See https://example.com for more.

    `, + ); + }); + + it('autolinks URLs starting with "www." in plain text', async () => { + const markdown = `See www.example.com for more.`; + const { code } = await processor.render(markdown); + + assert.equal( + code.trim(), + `

    See www.example.com for more.

    `, + ); + }); + + it('does not autolink URLs in code blocks', async () => { + const markdown = `See \`https://example.com\` or \`www.example.com\` for more.`; + const { code } = await processor.render(markdown); + + assert.equal( + code.trim(), + `

    See https://example.com or www.example.com for more.

    `, + ); + }); + }); +}); diff --git a/packages/markdown/remark/test/browser.test.js b/packages/markdown/remark/test/browser.test.js deleted file mode 100644 index 824f6fa0becd..000000000000 --- a/packages/markdown/remark/test/browser.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import esbuild from 'esbuild'; - -describe('Bundle for browsers', async () => { - it('esbuild browser build should work', async () => { - try { - const result = await esbuild.build({ - platform: 'browser', - entryPoints: ['@astrojs/markdown-remark'], - bundle: true, - write: false, - }); - assert.ok(result.outputFiles.length > 0); - } catch (error) { - // Capture any esbuild errors and fail the test - assert.fail(error.message); - } - }); -}); diff --git a/packages/markdown/remark/test/browser.test.ts b/packages/markdown/remark/test/browser.test.ts new file mode 100644 index 000000000000..9a7f5b0c3cd8 --- /dev/null +++ b/packages/markdown/remark/test/browser.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import esbuild from 'esbuild'; + +describe('Bundle for browsers', async () => { + it('esbuild browser build should work', async () => { + try { + const result = await esbuild.build({ + platform: 'browser', + entryPoints: ['@astrojs/markdown-remark'], + bundle: true, + write: false, + }); + assert.ok(result.outputFiles.length > 0); + } catch (error) { + // Capture any esbuild errors and fail the test + assert.fail((error as Error).message); + } + }); +}); diff --git a/packages/markdown/remark/test/entities.test.js b/packages/markdown/remark/test/entities.test.js deleted file mode 100644 index 3c244c15abb4..000000000000 --- a/packages/markdown/remark/test/entities.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { createMarkdownProcessor } from '../dist/index.js'; - -describe('entities', async () => { - let processor; - - before(async () => { - processor = await createMarkdownProcessor(); - }); - - it('should not unescape entities in regular Markdown', async () => { - const markdown = `<i>This should NOT be italic</i>`; - const { code } = await processor.render(markdown); - - assert.equal(code, `

    <i>This should NOT be italic</i>

    `); - }); -}); diff --git a/packages/markdown/remark/test/entities.test.ts b/packages/markdown/remark/test/entities.test.ts new file mode 100644 index 000000000000..1ae4aea90df8 --- /dev/null +++ b/packages/markdown/remark/test/entities.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { createMarkdownProcessor, type MarkdownProcessor } from '../dist/index.js'; + +describe('entities', async () => { + let processor: MarkdownProcessor; + + before(async () => { + processor = await createMarkdownProcessor(); + }); + + it('should not unescape entities in regular Markdown', async () => { + const markdown = `<i>This should NOT be italic</i>`; + const { code } = await processor.render(markdown); + + assert.equal(code, `

    <i>This should NOT be italic</i>

    `); + }); +}); diff --git a/packages/markdown/remark/test/frontmatter.test.js b/packages/markdown/remark/test/frontmatter.test.js deleted file mode 100644 index 336245106d1e..000000000000 --- a/packages/markdown/remark/test/frontmatter.test.js +++ /dev/null @@ -1,189 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { extractFrontmatter, parseFrontmatter } from '../dist/index.js'; - -const bom = '\uFEFF'; - -describe('extractFrontmatter', () => { - it('handles YAML', () => { - const yaml = `\nfoo: bar\n`; - assert.equal(extractFrontmatter(`---${yaml}---`), yaml); - assert.equal(extractFrontmatter(`${bom}---${yaml}---`), yaml); - assert.equal(extractFrontmatter(`\n---${yaml}---`), yaml); - assert.equal(extractFrontmatter(`\n \n---${yaml}---`), yaml); - assert.equal(extractFrontmatter(`---${yaml}---\ncontent`), yaml); - assert.equal(extractFrontmatter(`${bom}---${yaml}---\ncontent`), yaml); - assert.equal(extractFrontmatter(`\n\n---${yaml}---\n\ncontent`), yaml); - assert.equal(extractFrontmatter(`\n \n---${yaml}---\n\ncontent`), yaml); - assert.equal(extractFrontmatter(` ---${yaml}---`), undefined); - assert.equal(extractFrontmatter(`---${yaml} ---`), undefined); - assert.equal(extractFrontmatter(`text\n---${yaml}---\n\ncontent`), undefined); - }); - - it('handles TOML', () => { - const toml = `\nfoo = "bar"\n`; - assert.equal(extractFrontmatter(`+++${toml}+++`), toml); - assert.equal(extractFrontmatter(`${bom}+++${toml}+++`), toml); - assert.equal(extractFrontmatter(`\n+++${toml}+++`), toml); - assert.equal(extractFrontmatter(`\n \n+++${toml}+++`), toml); - assert.equal(extractFrontmatter(`+++${toml}+++\ncontent`), toml); - assert.equal(extractFrontmatter(`${bom}+++${toml}+++\ncontent`), toml); - assert.equal(extractFrontmatter(`\n\n+++${toml}+++\n\ncontent`), toml); - assert.equal(extractFrontmatter(`\n \n+++${toml}+++\n\ncontent`), toml); - assert.equal(extractFrontmatter(` +++${toml}+++`), undefined); - assert.equal(extractFrontmatter(`+++${toml} +++`), undefined); - assert.equal(extractFrontmatter(`text\n+++${toml}+++\n\ncontent`), undefined); - }); -}); - -describe('parseFrontmatter', () => { - it('works for YAML', () => { - const yaml = `\nfoo: bar\n`; - assert.deepEqual(parseFrontmatter(`---${yaml}---`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: yaml, - content: '', - }); - assert.deepEqual(parseFrontmatter(`${bom}---${yaml}---`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: yaml, - content: bom, - }); - assert.deepEqual(parseFrontmatter(`\n---${yaml}---`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: yaml, - content: '\n', - }); - assert.deepEqual(parseFrontmatter(`\n \n---${yaml}---`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: yaml, - content: '\n \n', - }); - assert.deepEqual(parseFrontmatter(`---${yaml}---\ncontent`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: yaml, - content: '\ncontent', - }); - assert.deepEqual(parseFrontmatter(`${bom}---${yaml}---\ncontent`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: yaml, - content: `${bom}\ncontent`, - }); - assert.deepEqual(parseFrontmatter(`\n\n---${yaml}---\n\ncontent`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: yaml, - content: '\n\n\n\ncontent', - }); - assert.deepEqual(parseFrontmatter(`\n \n---${yaml}---\n\ncontent`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: yaml, - content: '\n \n\n\ncontent', - }); - assert.deepEqual(parseFrontmatter(` ---${yaml}---`), { - frontmatter: {}, - rawFrontmatter: '', - content: ` ---${yaml}---`, - }); - assert.deepEqual(parseFrontmatter(`---${yaml} ---`), { - frontmatter: {}, - rawFrontmatter: '', - content: `---${yaml} ---`, - }); - assert.deepEqual(parseFrontmatter(`text\n---${yaml}---\n\ncontent`), { - frontmatter: {}, - rawFrontmatter: '', - content: `text\n---${yaml}---\n\ncontent`, - }); - }); - - it('works for TOML', () => { - const toml = `\nfoo = "bar"\n`; - assert.deepEqual(parseFrontmatter(`+++${toml}+++`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: toml, - content: '', - }); - assert.deepEqual(parseFrontmatter(`${bom}+++${toml}+++`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: toml, - content: bom, - }); - assert.deepEqual(parseFrontmatter(`\n+++${toml}+++`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: toml, - content: '\n', - }); - assert.deepEqual(parseFrontmatter(`\n \n+++${toml}+++`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: toml, - content: '\n \n', - }); - assert.deepEqual(parseFrontmatter(`+++${toml}+++\ncontent`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: toml, - content: '\ncontent', - }); - assert.deepEqual(parseFrontmatter(`${bom}+++${toml}+++\ncontent`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: toml, - content: `${bom}\ncontent`, - }); - assert.deepEqual(parseFrontmatter(`\n\n+++${toml}+++\n\ncontent`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: toml, - content: '\n\n\n\ncontent', - }); - assert.deepEqual(parseFrontmatter(`\n \n+++${toml}+++\n\ncontent`), { - frontmatter: { foo: 'bar' }, - rawFrontmatter: toml, - content: '\n \n\n\ncontent', - }); - assert.deepEqual(parseFrontmatter(` +++${toml}+++`), { - frontmatter: {}, - rawFrontmatter: '', - content: ` +++${toml}+++`, - }); - assert.deepEqual(parseFrontmatter(`+++${toml} +++`), { - frontmatter: {}, - rawFrontmatter: '', - content: `+++${toml} +++`, - }); - assert.deepEqual(parseFrontmatter(`text\n+++${toml}+++\n\ncontent`), { - frontmatter: {}, - rawFrontmatter: '', - content: `text\n+++${toml}+++\n\ncontent`, - }); - }); - - it('frontmatter style for YAML', () => { - const yaml = `\nfoo: bar\n`; - const parse1 = (style) => parseFrontmatter(`---${yaml}---`, { frontmatter: style }).content; - assert.deepEqual(parse1('preserve'), `---${yaml}---`); - assert.deepEqual(parse1('remove'), ''); - assert.deepEqual(parse1('empty-with-spaces'), ` \n \n `); - assert.deepEqual(parse1('empty-with-lines'), `\n\n`); - - const parse2 = (style) => - parseFrontmatter(`\n \n---${yaml}---\n\ncontent`, { frontmatter: style }).content; - assert.deepEqual(parse2('preserve'), `\n \n---${yaml}---\n\ncontent`); - assert.deepEqual(parse2('remove'), '\n \n\n\ncontent'); - assert.deepEqual(parse2('empty-with-spaces'), `\n \n \n \n \n\ncontent`); - assert.deepEqual(parse2('empty-with-lines'), `\n \n\n\n\n\ncontent`); - }); - - it('frontmatter style for TOML', () => { - const toml = `\nfoo = "bar"\n`; - const parse1 = (style) => parseFrontmatter(`+++${toml}+++`, { frontmatter: style }).content; - assert.deepEqual(parse1('preserve'), `+++${toml}+++`); - assert.deepEqual(parse1('remove'), ''); - assert.deepEqual(parse1('empty-with-spaces'), ` \n \n `); - assert.deepEqual(parse1('empty-with-lines'), `\n\n`); - - const parse2 = (style) => - parseFrontmatter(`\n \n+++${toml}+++\n\ncontent`, { frontmatter: style }).content; - assert.deepEqual(parse2('preserve'), `\n \n+++${toml}+++\n\ncontent`); - assert.deepEqual(parse2('remove'), '\n \n\n\ncontent'); - assert.deepEqual(parse2('empty-with-spaces'), `\n \n \n \n \n\ncontent`); - assert.deepEqual(parse2('empty-with-lines'), `\n \n\n\n\n\ncontent`); - }); -}); diff --git a/packages/markdown/remark/test/frontmatter.test.ts b/packages/markdown/remark/test/frontmatter.test.ts new file mode 100644 index 000000000000..ee365d19e4de --- /dev/null +++ b/packages/markdown/remark/test/frontmatter.test.ts @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + extractFrontmatter, + parseFrontmatter, + type ParseFrontmatterOptions, +} from '../dist/index.js'; + +type FrontmatterStyle = ParseFrontmatterOptions['frontmatter']; + +const bom = '\uFEFF'; + +describe('extractFrontmatter', () => { + it('handles YAML', () => { + const yaml = `\nfoo: bar\n`; + assert.equal(extractFrontmatter(`---${yaml}---`), yaml); + assert.equal(extractFrontmatter(`${bom}---${yaml}---`), yaml); + assert.equal(extractFrontmatter(`\n---${yaml}---`), yaml); + assert.equal(extractFrontmatter(`\n \n---${yaml}---`), yaml); + assert.equal(extractFrontmatter(`---${yaml}---\ncontent`), yaml); + assert.equal(extractFrontmatter(`${bom}---${yaml}---\ncontent`), yaml); + assert.equal(extractFrontmatter(`\n\n---${yaml}---\n\ncontent`), yaml); + assert.equal(extractFrontmatter(`\n \n---${yaml}---\n\ncontent`), yaml); + assert.equal(extractFrontmatter(` ---${yaml}---`), undefined); + assert.equal(extractFrontmatter(`---${yaml} ---`), undefined); + assert.equal(extractFrontmatter(`text\n---${yaml}---\n\ncontent`), undefined); + }); + + it('handles TOML', () => { + const toml = `\nfoo = "bar"\n`; + assert.equal(extractFrontmatter(`+++${toml}+++`), toml); + assert.equal(extractFrontmatter(`${bom}+++${toml}+++`), toml); + assert.equal(extractFrontmatter(`\n+++${toml}+++`), toml); + assert.equal(extractFrontmatter(`\n \n+++${toml}+++`), toml); + assert.equal(extractFrontmatter(`+++${toml}+++\ncontent`), toml); + assert.equal(extractFrontmatter(`${bom}+++${toml}+++\ncontent`), toml); + assert.equal(extractFrontmatter(`\n\n+++${toml}+++\n\ncontent`), toml); + assert.equal(extractFrontmatter(`\n \n+++${toml}+++\n\ncontent`), toml); + assert.equal(extractFrontmatter(` +++${toml}+++`), undefined); + assert.equal(extractFrontmatter(`+++${toml} +++`), undefined); + assert.equal(extractFrontmatter(`text\n+++${toml}+++\n\ncontent`), undefined); + }); +}); + +describe('parseFrontmatter', () => { + it('works for YAML', () => { + const yaml = `\nfoo: bar\n`; + assert.deepEqual(parseFrontmatter(`---${yaml}---`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: yaml, + content: '', + }); + assert.deepEqual(parseFrontmatter(`${bom}---${yaml}---`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: yaml, + content: bom, + }); + assert.deepEqual(parseFrontmatter(`\n---${yaml}---`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: yaml, + content: '\n', + }); + assert.deepEqual(parseFrontmatter(`\n \n---${yaml}---`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: yaml, + content: '\n \n', + }); + assert.deepEqual(parseFrontmatter(`---${yaml}---\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: yaml, + content: '\ncontent', + }); + assert.deepEqual(parseFrontmatter(`${bom}---${yaml}---\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: yaml, + content: `${bom}\ncontent`, + }); + assert.deepEqual(parseFrontmatter(`\n\n---${yaml}---\n\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: yaml, + content: '\n\n\n\ncontent', + }); + assert.deepEqual(parseFrontmatter(`\n \n---${yaml}---\n\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: yaml, + content: '\n \n\n\ncontent', + }); + assert.deepEqual(parseFrontmatter(` ---${yaml}---`), { + frontmatter: {}, + rawFrontmatter: '', + content: ` ---${yaml}---`, + }); + assert.deepEqual(parseFrontmatter(`---${yaml} ---`), { + frontmatter: {}, + rawFrontmatter: '', + content: `---${yaml} ---`, + }); + assert.deepEqual(parseFrontmatter(`text\n---${yaml}---\n\ncontent`), { + frontmatter: {}, + rawFrontmatter: '', + content: `text\n---${yaml}---\n\ncontent`, + }); + }); + + it('works for TOML', () => { + const toml = `\nfoo = "bar"\n`; + assert.deepEqual(parseFrontmatter(`+++${toml}+++`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '', + }); + assert.deepEqual(parseFrontmatter(`${bom}+++${toml}+++`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: bom, + }); + assert.deepEqual(parseFrontmatter(`\n+++${toml}+++`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '\n', + }); + assert.deepEqual(parseFrontmatter(`\n \n+++${toml}+++`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '\n \n', + }); + assert.deepEqual(parseFrontmatter(`+++${toml}+++\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '\ncontent', + }); + assert.deepEqual(parseFrontmatter(`${bom}+++${toml}+++\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: `${bom}\ncontent`, + }); + assert.deepEqual(parseFrontmatter(`\n\n+++${toml}+++\n\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '\n\n\n\ncontent', + }); + assert.deepEqual(parseFrontmatter(`\n \n+++${toml}+++\n\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '\n \n\n\ncontent', + }); + assert.deepEqual(parseFrontmatter(` +++${toml}+++`), { + frontmatter: {}, + rawFrontmatter: '', + content: ` +++${toml}+++`, + }); + assert.deepEqual(parseFrontmatter(`+++${toml} +++`), { + frontmatter: {}, + rawFrontmatter: '', + content: `+++${toml} +++`, + }); + assert.deepEqual(parseFrontmatter(`text\n+++${toml}+++\n\ncontent`), { + frontmatter: {}, + rawFrontmatter: '', + content: `text\n+++${toml}+++\n\ncontent`, + }); + }); + + it('frontmatter style for YAML', () => { + const yaml = `\nfoo: bar\n`; + const parse1 = (style: FrontmatterStyle) => + parseFrontmatter(`---${yaml}---`, { frontmatter: style }).content; + assert.deepEqual(parse1('preserve'), `---${yaml}---`); + assert.deepEqual(parse1('remove'), ''); + assert.deepEqual(parse1('empty-with-spaces'), ` \n \n `); + assert.deepEqual(parse1('empty-with-lines'), `\n\n`); + + const parse2 = (style: FrontmatterStyle) => + parseFrontmatter(`\n \n---${yaml}---\n\ncontent`, { frontmatter: style }).content; + assert.deepEqual(parse2('preserve'), `\n \n---${yaml}---\n\ncontent`); + assert.deepEqual(parse2('remove'), '\n \n\n\ncontent'); + assert.deepEqual(parse2('empty-with-spaces'), `\n \n \n \n \n\ncontent`); + assert.deepEqual(parse2('empty-with-lines'), `\n \n\n\n\n\ncontent`); + }); + + it('frontmatter style for TOML', () => { + const toml = `\nfoo = "bar"\n`; + const parse1 = (style: FrontmatterStyle) => + parseFrontmatter(`+++${toml}+++`, { frontmatter: style }).content; + assert.deepEqual(parse1('preserve'), `+++${toml}+++`); + assert.deepEqual(parse1('remove'), ''); + assert.deepEqual(parse1('empty-with-spaces'), ` \n \n `); + assert.deepEqual(parse1('empty-with-lines'), `\n\n`); + + const parse2 = (style: FrontmatterStyle) => + parseFrontmatter(`\n \n+++${toml}+++\n\ncontent`, { frontmatter: style }).content; + assert.deepEqual(parse2('preserve'), `\n \n+++${toml}+++\n\ncontent`); + assert.deepEqual(parse2('remove'), '\n \n\n\ncontent'); + assert.deepEqual(parse2('empty-with-spaces'), `\n \n \n \n \n\ncontent`); + assert.deepEqual(parse2('empty-with-lines'), `\n \n\n\n\n\ncontent`); + }); +}); diff --git a/packages/markdown/remark/test/highlight.test.js b/packages/markdown/remark/test/highlight.test.ts similarity index 100% rename from packages/markdown/remark/test/highlight.test.js rename to packages/markdown/remark/test/highlight.test.ts diff --git a/packages/markdown/remark/test/plugins.test.js b/packages/markdown/remark/test/plugins.test.js deleted file mode 100644 index c52955f83902..000000000000 --- a/packages/markdown/remark/test/plugins.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { createMarkdownProcessor } from '../dist/index.js'; - -describe('plugins', () => { - it('should be able to get file path when passing fileURL', async () => { - let context; - - const processor = await createMarkdownProcessor({ - remarkPlugins: [ - () => { - const transformer = (_tree, file) => { - context = file; - }; - return transformer; - }, - ], - }); - - await processor.render(`test`, { - fileURL: new URL('virtual.md', import.meta.url), - }); - - assert.ok(typeof context === 'object'); - assert.equal(context.path, fileURLToPath(new URL('virtual.md', import.meta.url))); - }); -}); diff --git a/packages/markdown/remark/test/plugins.test.ts b/packages/markdown/remark/test/plugins.test.ts new file mode 100644 index 000000000000..9d0249faf813 --- /dev/null +++ b/packages/markdown/remark/test/plugins.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import type { VFile } from 'vfile'; +import { createMarkdownProcessor, type RemarkPlugin } from '../dist/index.js'; + +describe('plugins', () => { + it('should be able to get file path when passing fileURL', async () => { + let context: VFile | undefined; + + const collectFile: RemarkPlugin = () => (_tree, file) => { + context = file; + }; + + const processor = await createMarkdownProcessor({ + remarkPlugins: [collectFile], + }); + + await processor.render(`test`, { + fileURL: new URL('virtual.md', import.meta.url), + }); + + assert.ok(context); + assert.equal(context.path, fileURLToPath(new URL('virtual.md', import.meta.url))); + }); +}); diff --git a/packages/markdown/remark/test/prism.test.js b/packages/markdown/remark/test/prism.test.ts similarity index 100% rename from packages/markdown/remark/test/prism.test.js rename to packages/markdown/remark/test/prism.test.ts diff --git a/packages/markdown/remark/test/remark-collect-images.test.js b/packages/markdown/remark/test/remark-collect-images.test.js deleted file mode 100644 index c8652642cde5..000000000000 --- a/packages/markdown/remark/test/remark-collect-images.test.js +++ /dev/null @@ -1,106 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { visit } from 'unist-util-visit'; -import { createMarkdownProcessor } from '../dist/index.js'; - -describe('collect images', async () => { - let processor; - let processorWithHastProperties; - - before(async () => { - processor = await createMarkdownProcessor({ image: { domains: ['example.com'] } }); - processorWithHastProperties = await createMarkdownProcessor({ - rehypePlugins: [ - () => { - return (tree) => { - visit(tree, 'element', (node) => { - if (node.tagName === 'img') { - node.properties.className = ['image-class']; - node.properties.htmlFor = 'some-id'; - } - }); - }; - }, - ], - }); - }); - - it('should collect inline image paths', async () => { - const markdown = `Hello ![inline image url](./img.png)`; - const fileURL = 'file.md'; - - const { - code, - metadata: { localImagePaths, remoteImagePaths }, - } = await processor.render(markdown, { fileURL }); - - assert.equal( - code, - '

    Hello

    ', - ); - - assert.deepEqual(localImagePaths, ['./img.png']); - assert.deepEqual(remoteImagePaths, []); - }); - - it('should collect allowed remote image paths', async () => { - const markdown = `Hello ![inline remote image url](https://example.com/example.png)`; - const fileURL = 'file.md'; - - const { - code, - metadata: { localImagePaths, remoteImagePaths }, - } = await processor.render(markdown, { fileURL }); - assert.equal( - code, - `

    Hello

    `, - ); - - assert.deepEqual(localImagePaths, []); - assert.deepEqual(remoteImagePaths, ['https://example.com/example.png']); - }); - - it('should not collect other remote image paths', async () => { - const markdown = `Hello ![inline remote image url](https://google.com/google.png)`; - const fileURL = 'file.md'; - - const { - code, - metadata: { localImagePaths, remoteImagePaths }, - } = await processor.render(markdown, { fileURL }); - assert.equal( - code, - `

    Hello inline remote image url

    `, - ); - - assert.deepEqual(localImagePaths, []); - assert.deepEqual(remoteImagePaths, []); - }); - - it('should add image paths from definition', async () => { - const markdown = `Hello ![image ref][img-ref] ![remote image ref][remote-img-ref]\n\n[img-ref]: ./img.webp\n[remote-img-ref]: https://example.com/example.jpg`; - const fileURL = 'file.md'; - - const { code, metadata } = await processor.render(markdown, { fileURL }); - - assert.equal( - code, - '

    Hello

    ', - ); - - assert.deepEqual(metadata.localImagePaths, ['./img.webp']); - assert.deepEqual(metadata.remoteImagePaths, ['https://example.com/example.jpg']); - }); - - it('should preserve className as HTML class attribute', async () => { - const markdown = `Hello ![image with class](./img.png)`; - const fileURL = 'file.md'; - - const { code } = await processorWithHastProperties.render(markdown, { fileURL }); - - assert.equal( - code, - '

    Hello

    ', - ); - }); -}); diff --git a/packages/markdown/remark/test/remark-collect-images.test.ts b/packages/markdown/remark/test/remark-collect-images.test.ts new file mode 100644 index 000000000000..579a7b7848ef --- /dev/null +++ b/packages/markdown/remark/test/remark-collect-images.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { visit } from 'unist-util-visit'; +import { + createMarkdownProcessor, + type MarkdownProcessor, + type RehypePlugin, +} from '../dist/index.js'; + +describe('collect images', async () => { + let processor: MarkdownProcessor; + let processorWithHastProperties: MarkdownProcessor; + + before(async () => { + processor = await createMarkdownProcessor({ image: { domains: ['example.com'] } }); + + const addImageProps: RehypePlugin = () => (tree) => { + visit(tree, 'element', (node) => { + if (node.tagName === 'img') { + node.properties.className = ['image-class']; + node.properties.htmlFor = 'some-id'; + } + }); + }; + + processorWithHastProperties = await createMarkdownProcessor({ + rehypePlugins: [addImageProps], + }); + }); + + it('should collect inline image paths', async () => { + const markdown = `Hello ![inline image url](./img.png)`; + const fileURL = new URL('file.md', import.meta.url); + + const { + code, + metadata: { localImagePaths, remoteImagePaths }, + } = await processor.render(markdown, { fileURL }); + + assert.equal( + code, + '

    Hello

    ', + ); + + assert.deepEqual(localImagePaths, ['./img.png']); + assert.deepEqual(remoteImagePaths, []); + }); + + it('should collect allowed remote image paths', async () => { + const markdown = `Hello ![inline remote image url](https://example.com/example.png)`; + const fileURL = new URL('file.md', import.meta.url); + + const { + code, + metadata: { localImagePaths, remoteImagePaths }, + } = await processor.render(markdown, { fileURL }); + assert.equal( + code, + `

    Hello

    `, + ); + + assert.deepEqual(localImagePaths, []); + assert.deepEqual(remoteImagePaths, ['https://example.com/example.png']); + }); + + it('should not collect other remote image paths', async () => { + const markdown = `Hello ![inline remote image url](https://google.com/google.png)`; + const fileURL = new URL('file.md', import.meta.url); + + const { + code, + metadata: { localImagePaths, remoteImagePaths }, + } = await processor.render(markdown, { fileURL }); + assert.equal( + code, + `

    Hello inline remote image url

    `, + ); + + assert.deepEqual(localImagePaths, []); + assert.deepEqual(remoteImagePaths, []); + }); + + it('should add image paths from definition', async () => { + const markdown = `Hello ![image ref][img-ref] ![remote image ref][remote-img-ref]\n\n[img-ref]: ./img.webp\n[remote-img-ref]: https://example.com/example.jpg`; + const fileURL = new URL('file.md', import.meta.url); + + const { code, metadata } = await processor.render(markdown, { fileURL }); + + assert.equal( + code, + '

    Hello

    ', + ); + + assert.deepEqual(metadata.localImagePaths, ['./img.webp']); + assert.deepEqual(metadata.remoteImagePaths, ['https://example.com/example.jpg']); + }); + + it('should preserve className as HTML class attribute', async () => { + const markdown = `Hello ![image with class](./img.png)`; + const fileURL = new URL('file.md', import.meta.url); + + const { code } = await processorWithHastProperties.render(markdown, { fileURL }); + + assert.equal( + code, + '

    Hello

    ', + ); + }); +}); diff --git a/packages/markdown/remark/test/shiki.test.js b/packages/markdown/remark/test/shiki.test.js deleted file mode 100644 index 56ffe77a5015..000000000000 --- a/packages/markdown/remark/test/shiki.test.js +++ /dev/null @@ -1,331 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { createMarkdownProcessor, createShikiHighlighter } from '../dist/index.js'; -import { clearShikiHighlighterCache } from '../dist/shiki.js'; - -describe('shiki syntax highlighting', () => { - it('does not add is:raw to the output', async () => { - const processor = await createMarkdownProcessor(); - const { code } = await processor.render('```\ntest\n```'); - - assert.ok(!code.includes('is:raw')); - }); - - it('supports light/dark themes', async () => { - const processor = await createMarkdownProcessor({ - shikiConfig: { - themes: { - light: 'github-light', - dark: 'github-dark', - }, - }, - }); - const { code } = await processor.render('```\ntest\n```'); - - // light theme is there: - assert.match(code, /background-color:/); - assert.match(code, /github-light/); - - // dark theme is there: - assert.match(code, /--shiki-dark-bg:/); - assert.match(code, /github-dark/); - }); - - it('createShikiHighlighter works', async () => { - const highlighter = await createShikiHighlighter(); - - const html = await highlighter.codeToHtml('const foo = "bar";', 'js'); - - assert.match(html, /astro-code github-dark/); - assert.match(html, /background-color:#24292e;color:#e1e4e8;/); - }); - - it('createShikiHighlighter works with codeToHast', async () => { - const highlighter = await createShikiHighlighter(); - - const hast = await highlighter.codeToHast('const foo = "bar";', 'js'); - - assert.match(hast.children[0].properties.class, /astro-code github-dark/); - assert.match(hast.children[0].properties.style, /background-color:#24292e;color:#e1e4e8;/); - }); - - it('createShikiHighlighter can reuse the same instance for different languages', async () => { - const langs = [ - 'abap', - 'ada', - 'adoc', - 'angular-html', - 'angular-ts', - 'apache', - 'apex', - 'apl', - 'applescript', - 'ara', - 'asciidoc', - 'asm', - 'astro', - 'awk', - 'ballerina', - 'bash', - 'bat', - 'batch', - 'be', - 'beancount', - 'berry', - 'bibtex', - 'bicep', - 'blade', - 'bsl', - ]; - - const highlighters = new Set(); - for (const lang of langs) { - highlighters.add(await createShikiHighlighter({ langs: [lang] })); - } - - // Ensure that we only have one highlighter instance. - assert.strictEqual(highlighters.size, 1); - - // Ensure that this highlighter instance can highlight different languages. - const highlighter = Array.from(highlighters)[0]; - const html1 = await highlighter.codeToHtml('const foo = "bar";', 'js'); - const html2 = await highlighter.codeToHtml('const foo = "bar";', 'ts'); - assert.match(html1, /color:#F97583/); - assert.match(html2, /color:#F97583/); - }); - - it('diff +/- text has user-select: none', async () => { - const highlighter = await createShikiHighlighter(); - - const html = await highlighter.codeToHtml( - `\ -- const foo = "bar"; -+ const foo = "world";`, - 'diff', - ); - - assert.match(html, /user-select: none/); - assert.match(html, />-<\/span>/); - assert.match(html, />+<\/span>/); - }); - - it('renders attributes', async () => { - const highlighter = await createShikiHighlighter(); - - const html = await highlighter.codeToHtml(`foo`, 'js', { - attributes: { 'data-foo': 'bar', autofocus: true }, - }); - - assert.match(html, /data-foo="bar"/); - assert.match(html, /autofocus(?!=)/); - }); - - it('supports transformers that reads meta', async () => { - const highlighter = await createShikiHighlighter(); - - const html = await highlighter.codeToHtml(`foo`, 'js', { - meta: '{1,3-4}', - transformers: [ - { - pre(node) { - const meta = this.options.meta?.__raw; - if (meta) { - node.properties['data-test'] = meta; - } - }, - }, - ], - }); - - assert.match(html, /data-test="\{1,3-4\}"/); - }); - - it('supports the defaultColor setting', async () => { - const processor = await createMarkdownProcessor({ - shikiConfig: { - themes: { - light: 'github-light', - dark: 'github-dark', - }, - defaultColor: false, - }, - }); - const { code } = await processor.render('```\ntest\n```'); - - // Doesn't have `color` or `background-color` properties. - assert.doesNotMatch(code, /color:/); - }); - - it('the highlighter supports lang alias', async () => { - const highlighter = await createShikiHighlighter({ - langAlias: { - cjs: 'javascript', - }, - }); - - const html = await highlighter.codeToHtml(`let test = "some string"`, 'cjs', { - attributes: { 'data-foo': 'bar', autofocus: true }, - }); - - assert.match(html, /data-language="cjs"/); - }); - - it('the markdown processor support lang alias', async () => { - const processor = await createMarkdownProcessor({ - shikiConfig: { - langAlias: { - cjs: 'javascript', - }, - }, - }); - - const { code } = await processor.render('```cjs\nlet foo = "bar"\n```'); - - assert.match(code, /data-language="cjs"/); - }); - - it("the cached highlighter won't load the same language twice", async () => { - clearShikiHighlighterCache(); - - const theme = 'github-light'; - const highlighter = await createShikiHighlighter({ theme }); - - // loadLanguage is an internal method - const loadLanguageArgs = []; - const originalLoadLanguage = highlighter['loadLanguage']; - highlighter['loadLanguage'] = async (...args) => { - loadLanguageArgs.push(...args); - return await originalLoadLanguage(...args); - }; - - // No languages loaded yet - assert.equal(loadLanguageArgs.length, 0); - - // Load a new language - const h1 = await createShikiHighlighter({ theme, langs: ['js'] }); - assert.equal(loadLanguageArgs.length, 1); - - // Load the same language again - const h2 = await createShikiHighlighter({ theme, langs: ['js'] }); - assert.equal(loadLanguageArgs.length, 1); - - // Load another language - const h3 = await createShikiHighlighter({ theme, langs: ['ts'] }); - assert.equal(loadLanguageArgs.length, 2); - - // Load the same language again - const h4 = await createShikiHighlighter({ theme, langs: ['ts'] }); - assert.equal(loadLanguageArgs.length, 2); - - // All highlighters should be the same instance - assert.equal(new Set([highlighter, h1, h2, h3, h4]).size, 1); - - clearShikiHighlighterCache(); - }); - - it('lazy-loads built-in languages on first use', async () => { - // Create a highlighter with no langs pre-registered — 'ts' is not loaded yet. - const highlighter = await createShikiHighlighter(); - - // Calling codeToHtml with 'ts' triggers lazy loading of the built-in grammar. - const html = await highlighter.codeToHtml('const someTypeScript: number = 5;', 'ts'); - - // Confirms the grammar loaded and tokenized — not a plain-text fallback. - assert.match(html, /astro-code github-dark/); - // Keyword color (const, number) - assert.match(html, /color:#F97583/); - // Type annotation / identifier color - assert.match(html, /color:#79B8FF/); - // Punctuation / default text color - assert.match(html, /color:#E1E4E8/); - }); - - it('uses an integrated (named) theme', async () => { - const highlighter = await createShikiHighlighter({ theme: 'github-light' }); - const html = await highlighter.codeToHtml('const foo = "bar";', 'js'); - - assert.match(html, /astro-code github-light/); - assert.match(html, /background-color:#fff;color:#24292e;/); - }); - - it('uses a custom (ThemeRegistrationRaw) theme', async () => { - // Minimal subset of a custom theme — only the fields Shiki needs to - // derive the pre element's background-color and color. - const serendipityMorning = { - name: 'Serendipity Morning', - type: 'light', - colors: { - 'editor.background': '#FDFDFE', - 'editor.foreground': '#4E5377', - }, - tokenColors: [], - }; - const highlighter = await createShikiHighlighter({ theme: serendipityMorning }); - const html = await highlighter.codeToHtml('const foo = "bar";', 'js'); - - assert.match(html, /background-color:#FDFDFE;color:#4E5377;/); - }); - - it('falls back to plaintext for unknown languages', async () => { - const highlighter = await createShikiHighlighter(); - // Should not throw; unknown lang is silently downgraded to plaintext. - const html = await highlighter.codeToHtml('This language does not exist', 'unknown'); - - assert.match(html, /astro-code/); - assert.match(html, /background-color:#24292e;color:#e1e4e8;/); - }); - - it('highlights a custom language passed as a LanguageRegistration object', async () => { - // Minimal rinfo grammar — same language used in the langs fixture. - // Must be passed as a LanguageRegistration (name + scopeName at top level), - // not the { id, grammar } wrapper used by Astro's config layer. - const riLang = { - name: 'rinfo', - scopeName: 'source.rinfo', - patterns: [{ include: '#lf-rinfo' }], - repository: { - 'lf-rinfo': { patterns: [{ include: '#control' }] }, - control: { - patterns: [ - { name: 'keyword.control.ri', match: '\\b(si|mientras|repetir)\\b' }, - { name: 'keyword.other.ri', match: '\\b(programa|comenzar|fin)\\b' }, - ], - }, - }, - }; - const highlighter = await createShikiHighlighter({ langs: [riLang] }); - const html = await highlighter.codeToHtml('programa Rinfo\ncomenzar\nfin', 'rinfo'); - - // 'programa', 'comenzar', 'fin' are keyword.other.ri — should be tokenized, not plain text. - assert.match(html, /data-language="rinfo"/); - // The output must contain at least one coloured span (grammar was applied, not plaintext fallback). - assert.match(html, /color:#F97583/); - }); - - it('wrap=true adds word-wrap styles', async () => { - const highlighter = await createShikiHighlighter(); - const html = await highlighter.codeToHtml('const foo = "bar";', 'js', { wrap: true }); - - assert.match(html, /white-space: pre-wrap/); - assert.match(html, /word-wrap: break-word/); - assert.match(html, /overflow-x: auto/); - }); - - it('wrap=false adds overflow-x auto but no word-wrap', async () => { - const highlighter = await createShikiHighlighter(); - const html = await highlighter.codeToHtml('const foo = "bar";', 'js', { wrap: false }); - - assert.match(html, /overflow-x: auto/); - assert.doesNotMatch(html, /white-space: pre-wrap/); - assert.doesNotMatch(html, /word-wrap: break-word/); - }); - - it('wrap=null removes all overflow styling', async () => { - const highlighter = await createShikiHighlighter(); - const html = await highlighter.codeToHtml('const foo = "bar";', 'js', { wrap: null }); - - assert.doesNotMatch(html, /overflow-x/); - assert.doesNotMatch(html, /white-space: pre-wrap/); - assert.doesNotMatch(html, /word-wrap: break-word/); - }); -}); diff --git a/packages/markdown/remark/test/shiki.test.ts b/packages/markdown/remark/test/shiki.test.ts new file mode 100644 index 000000000000..bc6d0dcb2fe3 --- /dev/null +++ b/packages/markdown/remark/test/shiki.test.ts @@ -0,0 +1,367 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { Element } from 'hast'; +import type { LanguageRegistration, ThemeRegistration } from 'shiki'; +import { + createMarkdownProcessor, + createShikiHighlighter, + type ShikiHighlighter, +} from '../dist/index.js'; +// @ts-expect-error: `clearShikiHighlighterCache` is marked `@internal` and stripped from the `.d.ts`, but still exists at runtime. +import { clearShikiHighlighterCache } from '../dist/shiki.js'; + +describe('shiki syntax highlighting', () => { + it('does not add is:raw to the output', async () => { + const processor = await createMarkdownProcessor(); + const { code } = await processor.render('```\ntest\n```'); + + assert.ok(!code.includes('is:raw')); + }); + + it('supports light/dark themes', async () => { + const processor = await createMarkdownProcessor({ + shikiConfig: { + themes: { + light: 'github-light', + dark: 'github-dark', + }, + }, + }); + const { code } = await processor.render('```\ntest\n```'); + + // light theme is there: + assert.match(code, /background-color:/); + assert.match(code, /github-light/); + + // dark theme is there: + assert.match(code, /--shiki-dark-bg:/); + assert.match(code, /github-dark/); + }); + + it('createShikiHighlighter works', async () => { + const highlighter = await createShikiHighlighter(); + + const html = await highlighter.codeToHtml('const foo = "bar";', 'js'); + + assert.match(html, /astro-code github-dark/); + assert.match(html, /background-color:#24292e;color:#e1e4e8;/); + }); + + it('createShikiHighlighter works with codeToHast', async () => { + const highlighter = await createShikiHighlighter(); + + const hast = await highlighter.codeToHast('const foo = "bar";', 'js'); + const root = hast.children[0] as Element; + + assert.match(root.properties.class as string, /astro-code github-dark/); + assert.match(root.properties.style as string, /background-color:#24292e;color:#e1e4e8;/); + }); + + it('createShikiHighlighter can reuse the same instance for different languages', async () => { + const langs = [ + 'abap', + 'ada', + 'adoc', + 'angular-html', + 'angular-ts', + 'apache', + 'apex', + 'apl', + 'applescript', + 'ara', + 'asciidoc', + 'asm', + 'astro', + 'awk', + 'ballerina', + 'bash', + 'bat', + 'batch', + 'be', + 'beancount', + 'berry', + 'bibtex', + 'bicep', + 'blade', + 'bsl', + ] as const; + + const highlighters = new Set(); + for (const lang of langs) { + highlighters.add( + await createShikiHighlighter({ + // @ts-expect-error: `langs` is typed as `LanguageRegistration[]`, but Shiki's `createHighlighter` accepts both strings and objects in the array. + langs: [lang], + }), + ); + } + + // Ensure that we only have one highlighter instance. + assert.strictEqual(highlighters.size, 1); + + // Ensure that this highlighter instance can highlight different languages. + const highlighter = Array.from(highlighters)[0]; + const html1 = await highlighter.codeToHtml('const foo = "bar";', 'js'); + const html2 = await highlighter.codeToHtml('const foo = "bar";', 'ts'); + assert.match(html1, /color:#F97583/); + assert.match(html2, /color:#F97583/); + }); + + it('diff +/- text has user-select: none', async () => { + const highlighter = await createShikiHighlighter(); + + const html = await highlighter.codeToHtml( + `\ +- const foo = "bar"; ++ const foo = "world";`, + 'diff', + ); + + assert.match(html, /user-select: none/); + assert.match(html, />-<\/span>/); + assert.match(html, />+<\/span>/); + }); + + it('renders attributes', async () => { + const highlighter = await createShikiHighlighter(); + + const html = await highlighter.codeToHtml(`foo`, 'js', { + attributes: { + 'data-foo': 'bar', + // @ts-expect-error: Shiki's `codeToHtml` accepts boolean attributes as `string | boolean`, but the types are currently incorrect. + autofocus: true, + }, + }); + + assert.match(html, /data-foo="bar"/); + assert.match(html, /autofocus(?!=)/); + }); + + it('supports transformers that reads meta', async () => { + const highlighter = await createShikiHighlighter(); + + const html = await highlighter.codeToHtml(`foo`, 'js', { + meta: '{1,3-4}', + transformers: [ + { + pre(node) { + const meta = this.options.meta?.__raw; + if (meta) { + node.properties['data-test'] = meta; + } + }, + }, + ], + }); + + assert.match(html, /data-test="\{1,3-4\}"/); + }); + + it('supports the defaultColor setting', async () => { + const processor = await createMarkdownProcessor({ + shikiConfig: { + themes: { + light: 'github-light', + dark: 'github-dark', + }, + defaultColor: false, + }, + }); + const { code } = await processor.render('```\ntest\n```'); + + // Doesn't have `color` or `background-color` properties. + assert.doesNotMatch(code, /color:/); + }); + + it('the highlighter supports lang alias', async () => { + const highlighter = await createShikiHighlighter({ + langAlias: { + cjs: 'javascript', + }, + }); + + const html = await highlighter.codeToHtml(`let test = "some string"`, 'cjs', { + attributes: { 'data-foo': 'bar' }, + }); + + assert.match(html, /data-language="cjs"/); + }); + + it('the markdown processor support lang alias', async () => { + const processor = await createMarkdownProcessor({ + shikiConfig: { + langAlias: { + cjs: 'javascript', + }, + }, + }); + + const { code } = await processor.render('```cjs\nlet foo = "bar"\n```'); + + assert.match(code, /data-language="cjs"/); + }); + + it("the cached highlighter won't load the same language twice", async () => { + clearShikiHighlighterCache(); + + const theme = 'github-light'; + interface ShikiHighlighterInternal extends ShikiHighlighter { + loadLanguage(...langs: unknown[]): Promise; + getLoadedLanguages(): string[]; + } + const highlighter = (await createShikiHighlighter({ theme })) as ShikiHighlighterInternal; + + const loadLanguageArgs: unknown[] = []; + const originalLoadLanguage = highlighter.loadLanguage; + highlighter.loadLanguage = async (...args: unknown[]) => { + loadLanguageArgs.push(...args); + return await originalLoadLanguage(...args); + }; + + // No languages loaded yet + assert.equal(loadLanguageArgs.length, 0); + + // Load a new language + const h1 = await createShikiHighlighter({ + theme, + // @ts-expect-error: `langs` is typed as `LanguageRegistration[]`, but Shiki's `createHighlighter` accepts both strings and objects in the array. + langs: ['js'], + }); + assert.equal(loadLanguageArgs.length, 1); + + // Load the same language again + const h2 = await createShikiHighlighter({ + theme, + // @ts-expect-error: `langs` is typed as `LanguageRegistration[]`, but Shiki's `createHighlighter` accepts both strings and objects in the array. + langs: ['js'], + }); + assert.equal(loadLanguageArgs.length, 1); + + // Load another language + const h3 = await createShikiHighlighter({ + theme, + // @ts-expect-error: `langs` is typed as `LanguageRegistration[]`, but Shiki's `createHighlighter` accepts both strings and objects in the array. + langs: ['ts'], + }); + assert.equal(loadLanguageArgs.length, 2); + + // Load the same language again + const h4 = await createShikiHighlighter({ + theme, + // @ts-expect-error: `langs` is typed as `LanguageRegistration[]`, but Shiki's `createHighlighter` accepts both strings and objects in the array. + langs: ['ts'], + }); + assert.equal(loadLanguageArgs.length, 2); + + // All highlighters should be the same instance + assert.equal(new Set([highlighter, h1, h2, h3, h4]).size, 1); + + clearShikiHighlighterCache(); + }); + + it('lazy-loads built-in languages on first use', async () => { + // Create a highlighter with no langs pre-registered — 'ts' is not loaded yet. + const highlighter = await createShikiHighlighter(); + + // Calling codeToHtml with 'ts' triggers lazy loading of the built-in grammar. + const html = await highlighter.codeToHtml('const someTypeScript: number = 5;', 'ts'); + + // Confirms the grammar loaded and tokenized — not a plain-text fallback. + assert.match(html, /astro-code github-dark/); + // Keyword color (const, number) + assert.match(html, /color:#F97583/); + // Type annotation / identifier color + assert.match(html, /color:#79B8FF/); + // Punctuation / default text color + assert.match(html, /color:#E1E4E8/); + }); + + it('uses an integrated (named) theme', async () => { + const highlighter = await createShikiHighlighter({ theme: 'github-light' }); + const html = await highlighter.codeToHtml('const foo = "bar";', 'js'); + + assert.match(html, /astro-code github-light/); + assert.match(html, /background-color:#fff;color:#24292e;/); + }); + + it('uses a custom (ThemeRegistrationRaw) theme', async () => { + // Minimal subset of a custom theme — only the fields Shiki needs to + // derive the pre element's background-color and color. + const serendipityMorning: ThemeRegistration = { + name: 'Serendipity Morning', + type: 'light', + colors: { + 'editor.background': '#FDFDFE', + 'editor.foreground': '#4E5377', + }, + tokenColors: [], + }; + const highlighter = await createShikiHighlighter({ theme: serendipityMorning }); + const html = await highlighter.codeToHtml('const foo = "bar";', 'js'); + + assert.match(html, /background-color:#FDFDFE;color:#4E5377;/); + }); + + it('falls back to plaintext for unknown languages', async () => { + const highlighter = await createShikiHighlighter(); + // Should not throw; unknown lang is silently downgraded to plaintext. + const html = await highlighter.codeToHtml('This language does not exist', 'unknown'); + + assert.match(html, /astro-code/); + assert.match(html, /background-color:#24292e;color:#e1e4e8;/); + }); + + it('highlights a custom language passed as a LanguageRegistration object', async () => { + // Minimal rinfo grammar — same language used in the langs fixture. + // Must be passed as a LanguageRegistration (name + scopeName at top level), + // not the { id, grammar } wrapper used by Astro's config layer. + const riLang: LanguageRegistration = { + name: 'rinfo', + scopeName: 'source.rinfo', + patterns: [{ include: '#lf-rinfo' }], + repository: { + 'lf-rinfo': { patterns: [{ include: '#control' }] }, + control: { + patterns: [ + { name: 'keyword.control.ri', match: '\\b(si|mientras|repetir)\\b' }, + { name: 'keyword.other.ri', match: '\\b(programa|comenzar|fin)\\b' }, + ], + }, + }, + }; + const highlighter = await createShikiHighlighter({ langs: [riLang] }); + const html = await highlighter.codeToHtml('programa Rinfo\ncomenzar\nfin', 'rinfo'); + + // 'programa', 'comenzar', 'fin' are keyword.other.ri — should be tokenized, not plain text. + assert.match(html, /data-language="rinfo"/); + // The output must contain at least one coloured span (grammar was applied, not plaintext fallback). + assert.match(html, /color:#F97583/); + }); + + it('wrap=true adds word-wrap styles', async () => { + const highlighter = await createShikiHighlighter(); + const html = await highlighter.codeToHtml('const foo = "bar";', 'js', { wrap: true }); + + assert.match(html, /white-space: pre-wrap/); + assert.match(html, /word-wrap: break-word/); + assert.match(html, /overflow-x: auto/); + }); + + it('wrap=false adds overflow-x auto but no word-wrap', async () => { + const highlighter = await createShikiHighlighter(); + const html = await highlighter.codeToHtml('const foo = "bar";', 'js', { wrap: false }); + + assert.match(html, /overflow-x: auto/); + assert.doesNotMatch(html, /white-space: pre-wrap/); + assert.doesNotMatch(html, /word-wrap: break-word/); + }); + + it('wrap=null removes all overflow styling', async () => { + const highlighter = await createShikiHighlighter(); + const html = await highlighter.codeToHtml('const foo = "bar";', 'js', { wrap: null }); + + assert.doesNotMatch(html, /overflow-x/); + assert.doesNotMatch(html, /white-space: pre-wrap/); + assert.doesNotMatch(html, /word-wrap: break-word/); + }); +}); diff --git a/packages/markdown/remark/tsconfig.test.json b/packages/markdown/remark/tsconfig.test.json new file mode 100644 index 000000000000..fa4f11e4ae8b --- /dev/null +++ b/packages/markdown/remark/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [{ "path": "../../astro/tsconfig.test.json" }] +} diff --git a/packages/telemetry/CHANGELOG.md b/packages/telemetry/CHANGELOG.md index 3824ff52b99f..c9417c1329ea 100644 --- a/packages/telemetry/CHANGELOG.md +++ b/packages/telemetry/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/telemetry +## 3.3.1 + +### Patch Changes + +- [#16257](https://github.com/withastro/astro/pull/16257) [`e0b240e`](https://github.com/withastro/astro/commit/e0b240edea4db632138def3a9003b4b12e12f765) Thanks [@gameroman](https://github.com/gameroman)! - Removed `debug` dependency + ## 3.3.0 ### Minor Changes diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index dfaa652d6019..daf7277a0a45 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@astrojs/telemetry", - "version": "3.3.0", + "version": "3.3.1", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", @@ -23,7 +23,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "files": [ "dist" @@ -38,7 +39,7 @@ }, "devDependencies": { "@types/dlv": "^1.1.5", - "@types/node": "^18.17.8", + "@types/node": "^22.10.6", "@types/which-pm-runs": "^1.0.2", "astro-scripts": "workspace:*" }, diff --git a/packages/telemetry/src/config.ts b/packages/telemetry/src/config.ts index 359b1e11f86a..6ac6f06af17f 100644 --- a/packages/telemetry/src/config.ts +++ b/packages/telemetry/src/config.ts @@ -33,10 +33,12 @@ function getConfigDir(name: string) { } export class GlobalConfig { + private project: ConfigOptions; private dir: string; private file: string; - constructor(private project: ConfigOptions) { + constructor(project: ConfigOptions) { + this.project = project; this.dir = getConfigDir(this.project.name); this.file = path.join(this.dir, 'config.json'); } diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index c53bf4d73eaf..1941dc7652da 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -25,6 +25,7 @@ interface EventContext extends ProjectInfo { anonymousSessionId: string; } export class AstroTelemetry { + private opts: AstroTelemetryOptions; private _anonymousSessionId: string | undefined; private _anonymousProjectInfo: ProjectInfo | undefined; private config = new GlobalConfig({ name: 'astro' }); @@ -44,7 +45,8 @@ export class AstroTelemetry { return this.env.TELEMETRY_DISABLED; } - constructor(private opts: AstroTelemetryOptions) { + constructor(opts: AstroTelemetryOptions) { + this.opts = opts; // TODO: When the process exits, flush any queued promises // This caused a "cannot exist astro" error when it ran, so it was removed. // process.on('SIGINT', () => this.flush()); diff --git a/packages/telemetry/test/config.test.js b/packages/telemetry/test/config.test.ts similarity index 100% rename from packages/telemetry/test/config.test.js rename to packages/telemetry/test/config.test.ts diff --git a/packages/telemetry/test/index.test.js b/packages/telemetry/test/index.test.js deleted file mode 100644 index 2bfc353614de..000000000000 --- a/packages/telemetry/test/index.test.js +++ /dev/null @@ -1,110 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import { AstroTelemetry } from '../dist/index.js'; - -function setup() { - const config = new Map(); - const telemetry = new AstroTelemetry({ astroVersion: '0.0.0-test.1', viteVersion: '0.0.0' }); - const logs = []; - // Stub isCI to false so we can test user-facing behavior - telemetry.isCI = false; - // Stub process.env to properly test in Astro's own CI - telemetry.env = {}; - // Override config so we can inspect it - telemetry.config = config; - - // Mock the global debug function to capture logs - const originalDebug = globalThis._astroGlobalDebug; - globalThis._astroGlobalDebug = (type, ...args) => { - if (type === 'telemetry') { - logs.push(args); - } - // Call original if it exists (for other namespaces) - if (originalDebug) { - originalDebug(type, ...args); - } - }; - - // Enable debug for telemetry - const oldDebug = process.env.DEBUG; - process.env.DEBUG = 'astro:telemetry'; - - return { - telemetry, - config, - logs, - cleanup: () => { - globalThis._astroGlobalDebug = originalDebug; - process.env.DEBUG = oldDebug; - }, - }; -} -describe('AstroTelemetry', () => { - let oldCI; - before(() => { - oldCI = process.env.CI; - // Stub process.env.CI to `false` - process.env.CI = 'false'; - }); - after(() => { - process.env.CI = oldCI; - }); - it('initializes when expected arguments are given', () => { - const { telemetry, cleanup } = setup(); - assert(telemetry instanceof AstroTelemetry); - cleanup(); - }); - it('does not record event if disabled', async () => { - const { telemetry, config, logs, cleanup } = setup(); - telemetry.setEnabled(false); - const [key] = Array.from(config.keys()); - assert.notEqual(key, undefined); - assert.equal(config.get(key), false); - assert.equal(telemetry.enabled, false); - assert.equal(telemetry.isDisabled, true); - const result = await telemetry.record(['TEST']); - assert.equal(result, undefined); - const [log] = logs; - assert.notEqual(log, undefined); - assert.match(logs.join(''), /disabled/); - cleanup(); - }); - it('records event if enabled', async () => { - const { telemetry, config, logs, cleanup } = setup(); - telemetry.setEnabled(true); - const [key] = Array.from(config.keys()); - assert.notEqual(key, undefined); - assert.equal(config.get(key), true); - assert.equal(telemetry.enabled, true); - assert.equal(telemetry.isDisabled, false); - await telemetry.record(['TEST']); - assert.equal(logs.length, 2); - cleanup(); - }); - it('respects disable from notify', async () => { - const { telemetry, config, logs, cleanup } = setup(); - await telemetry.notify(() => false); - const [key] = Array.from(config.keys()); - assert.notEqual(key, undefined); - assert.equal(config.get(key), false); - assert.equal(telemetry.enabled, false); - assert.equal(telemetry.isDisabled, true); - const [log] = logs; - assert.notEqual(log, undefined); - assert.match(logs.join(''), /disabled/); - cleanup(); - }); - it('respects enable from notify', async () => { - const { telemetry, config, logs, cleanup } = setup(); - await telemetry.notify(() => true); - const [key] = Array.from(config.keys()); - assert.notEqual(key, undefined); - assert.equal(config.get(key), true); - assert.equal(telemetry.enabled, true); - assert.equal(telemetry.isDisabled, false); - const [log] = logs; - assert.notEqual(log, undefined); - assert.match(logs.join(''), /enabled/); - cleanup(); - }); -}); diff --git a/packages/telemetry/test/index.test.ts b/packages/telemetry/test/index.test.ts new file mode 100644 index 000000000000..d2c3f5c95dd3 --- /dev/null +++ b/packages/telemetry/test/index.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { AstroTelemetry } from '../dist/index.js'; + +function setup() { + const config = new Map(); + const telemetry = new AstroTelemetry({ + astroVersion: '0.0.0-test.1', + viteVersion: '0.0.0', + }); + const logs: unknown[][] = []; + // Stub isCI to false so we can test user-facing behavior + telemetry['isCI'] = false; + // Stub process.env to properly test in Astro's own CI + telemetry['env'] = {}; + // Override config so we can inspect it + telemetry['config'] = config; + + // Mock the global debug function to capture logs + const originalDebug = (globalThis as any)._astroGlobalDebug; + (globalThis as any)._astroGlobalDebug = (type: string, ...args: unknown[]) => { + if (type === 'telemetry') { + logs.push(args); + } + // Call original if it exists (for other namespaces) + if (originalDebug) { + originalDebug(type, ...args); + } + }; + + // Enable debug for telemetry + const oldDebug = process.env.DEBUG; + process.env.DEBUG = 'astro:telemetry'; + + return { + telemetry, + config, + logs, + cleanup: () => { + (globalThis as any)._astroGlobalDebug = originalDebug; + process.env.DEBUG = oldDebug; + }, + }; +} +describe('AstroTelemetry', () => { + let oldCI: string | undefined; + before(() => { + oldCI = process.env.CI; + // Stub process.env.CI to `false` + process.env.CI = 'false'; + }); + after(() => { + process.env.CI = oldCI; + }); + it('initializes when expected arguments are given', () => { + const { telemetry, cleanup } = setup(); + assert(telemetry instanceof AstroTelemetry); + cleanup(); + }); + it('does not record event if disabled', async () => { + const { telemetry, config, logs, cleanup } = setup(); + telemetry.setEnabled(false); + const [key] = Array.from(config.keys()); + assert.notEqual(key, undefined); + assert.equal(config.get(key), false); + assert.equal(telemetry['enabled'], false); + assert.equal(telemetry['isDisabled'], true); + const result = await telemetry.record([{ eventName: 'TEST', payload: {} }]); + assert.equal(result, undefined); + const [log] = logs; + assert.notEqual(log, undefined); + assert.match(logs.join(''), /disabled/); + cleanup(); + }); + it('records event if enabled', async () => { + const { telemetry, config, logs, cleanup } = setup(); + telemetry.setEnabled(true); + const [key] = Array.from(config.keys()); + assert.notEqual(key, undefined); + assert.equal(config.get(key), true); + assert.equal(telemetry['enabled'], true); + assert.equal(telemetry['isDisabled'], false); + await telemetry.record([{ eventName: 'TEST', payload: {} }]); + assert.equal(logs.length, 2); + cleanup(); + }); + it('respects disable from notify', async () => { + const { telemetry, config, logs, cleanup } = setup(); + await telemetry.notify(() => false); + const [key] = Array.from(config.keys()); + assert.notEqual(key, undefined); + assert.equal(config.get(key), false); + assert.equal(telemetry['enabled'], false); + assert.equal(telemetry['isDisabled'], true); + const [log] = logs; + assert.notEqual(log, undefined); + assert.match(logs.join(''), /disabled/); + cleanup(); + }); + it('respects enable from notify', async () => { + const { telemetry, config, logs, cleanup } = setup(); + await telemetry.notify(() => true); + const [key] = Array.from(config.keys()); + assert.notEqual(key, undefined); + assert.equal(config.get(key), true); + assert.equal(telemetry['enabled'], true); + assert.equal(telemetry['isDisabled'], false); + const [log] = logs; + assert.notEqual(log, undefined); + assert.match(logs.join(''), /enabled/); + cleanup(); + }); +}); diff --git a/packages/telemetry/tsconfig.test.json b/packages/telemetry/tsconfig.test.json new file mode 100644 index 000000000000..7d6bc4428b35 --- /dev/null +++ b/packages/telemetry/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [{ "path": "../astro/tsconfig.test.json" }] +} diff --git a/packages/underscore-redirects/CHANGELOG.md b/packages/underscore-redirects/CHANGELOG.md index e04745eabdad..31a2bda5198f 100644 --- a/packages/underscore-redirects/CHANGELOG.md +++ b/packages/underscore-redirects/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/underscore-redirects +## 1.0.3 + +### Patch Changes + +- [#16034](https://github.com/withastro/astro/pull/16034) [`814406d`](https://github.com/withastro/astro/commit/814406de7dc3ea014b47d2d886d55c45e4e1c034) Thanks [@alexanderniebuhr](https://github.com/alexanderniebuhr)! - Fixes generated redirect files to respect Astro’s `trailingSlash` configuration, so redirect routes work with the expected URL format in built output instead of returning a 404 when accessed with a trailing slash. + ## 1.0.2 ### Patch Changes diff --git a/packages/underscore-redirects/package.json b/packages/underscore-redirects/package.json index 6ebb9c468fd3..06b8bedeb06c 100644 --- a/packages/underscore-redirects/package.json +++ b/packages/underscore-redirects/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/underscore-redirects", "description": "Utilities to generate _redirects files in Astro projects", - "version": "1.0.2", + "version": "1.0.3", "type": "module", "author": "withastro", "license": "MIT", @@ -24,7 +24,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc --build tsconfig.test.json" }, "devDependencies": { "astro": "workspace:*", diff --git a/packages/underscore-redirects/src/astro.ts b/packages/underscore-redirects/src/astro.ts index 30ee2ab16037..860a171eedf7 100644 --- a/packages/underscore-redirects/src/astro.ts +++ b/packages/underscore-redirects/src/astro.ts @@ -17,7 +17,7 @@ function getRedirectStatus(route: IntegrationResolvedRoute): ValidRedirectStatus } interface CreateRedirectsFromAstroRoutesParams { - config: Pick; + config: Pick; /** * Maps a `RouteData` to a dynamic target */ @@ -27,6 +27,35 @@ interface CreateRedirectsFromAstroRoutesParams { assets: HookParameters<'astro:build:done'>['assets']; } +/** + * Returns the path(s) to use for a redirect entry based on the trailingSlash config. + * - 'always': ensures the path ends with '/' + * - 'never': ensures the path does not end with '/' + * - 'ignore'(default): returns both with and without trailing slash variants + */ +export function getTrailingSlashPaths( + inputPath: string, + trailingSlash: 'always' | 'never' | 'ignore', +): string[] { + if (inputPath === '/') { + return ['/']; + } + + const hasTrailingSlash = inputPath.endsWith('/'); + const withoutSlash = hasTrailingSlash ? inputPath.slice(0, -1) : inputPath; + const withSlash = hasTrailingSlash ? inputPath : inputPath + '/'; + + switch (trailingSlash) { + case 'always': + return [withSlash]; + case 'never': + return [withoutSlash]; + case 'ignore': + default: + return [withoutSlash, withSlash]; + } +} + /** * Takes a set of routes and creates a Redirects object from them. */ @@ -57,13 +86,20 @@ export function createRedirectsFromAstroRoutes({ // Use `entrypoint` when available to keep trailing slashes in _redirects. const inputPath = route.type === 'redirect' && route.entrypoint ? route.entrypoint : route.pathname; - redirects.add({ - dynamic: false, - input: `${base}${inputPath}`, - target: typeof route.redirect === 'object' ? route.redirect.destination : route.redirect, - status: getRedirectStatus(route), - weight: 2, - }); + + // Generate redirect entries based on trailingSlash config. + const trailingSlash = config.trailingSlash ?? 'ignore'; + const paths = getTrailingSlashPaths(inputPath, trailingSlash); + for (const path of paths) { + redirects.add({ + dynamic: false, + input: `${base}${path}`, + target: + typeof route.redirect === 'object' ? route.redirect.destination : route.redirect, + status: getRedirectStatus(route), + weight: 2, + }); + } continue; } diff --git a/packages/underscore-redirects/src/index.ts b/packages/underscore-redirects/src/index.ts index 8411cc3cabd6..2c6477e7daf7 100644 --- a/packages/underscore-redirects/src/index.ts +++ b/packages/underscore-redirects/src/index.ts @@ -1,6 +1,7 @@ export { createHostedRouteDefinition, createRedirectsFromAstroRoutes, + getTrailingSlashPaths, } from './astro.js'; export { HostRoutes } from './host-route.js'; export { printAsRedirects } from './print.js'; diff --git a/packages/underscore-redirects/test/astro.test.js b/packages/underscore-redirects/test/astro.test.js deleted file mode 100644 index 6a4944dc907a..000000000000 --- a/packages/underscore-redirects/test/astro.test.js +++ /dev/null @@ -1,28 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { createRedirectsFromAstroRoutes } from '../dist/index.js'; - -describe('Astro', () => { - it('Creates a Redirects object from routes', () => { - const routeToDynamicTargetMap = new Map( - Array.from([ - [{ pattern: '/', pathname: '/', segments: [] }, './.adapter/dist/entry.mjs'], - [{ pattern: '/one', pathname: '/one', segments: [] }, './.adapter/dist/entry.mjs'], - ]), - ); - const _redirects = createRedirectsFromAstroRoutes({ - config: { - build: { format: 'directory' }, - }, - routeToDynamicTargetMap, - dir: new URL(import.meta.url), - buildOutput: 'server', - assets: new Map([ - ['/', new URL('./index.html', import.meta.url)], - ['/one', new URL('./one/index.html', import.meta.url)], - ]), - }); - - assert.equal(_redirects.definitions.length, 2); - }); -}); diff --git a/packages/underscore-redirects/test/astro.test.ts b/packages/underscore-redirects/test/astro.test.ts new file mode 100644 index 000000000000..623c7261f502 --- /dev/null +++ b/packages/underscore-redirects/test/astro.test.ts @@ -0,0 +1,60 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createRedirectsFromAstroRoutes, getTrailingSlashPaths } from '../dist/index.js'; +import type { AstroConfig, IntegrationResolvedRoute } from 'astro'; + +describe('Astro', () => { + it('Creates a Redirects object from routes', () => { + const routeToDynamicTargetMap = new Map( + Array.from([ + [createIntegrationRoute('/'), './.adapter/dist/entry.mjs'], + [createIntegrationRoute('/one'), './.adapter/dist/entry.mjs'], + ]), + ); + + const redirects = createRedirectsFromAstroRoutes({ + config: { + build: { format: 'directory' }, + } as AstroConfig, + routeToDynamicTargetMap, + dir: new URL(import.meta.url), + buildOutput: 'server', + assets: new Map([ + ['/', [new URL('./index.html', import.meta.url)]], + ['/one', [new URL('./one/index.html', import.meta.url)]], + ]), + }); + + assert.equal(redirects.definitions.length, 2); + }); + + it('Generates correct paths for root', () => { + assert.deepEqual(getTrailingSlashPaths('/', 'ignore'), ['/']); + assert.deepEqual(getTrailingSlashPaths('/', 'always'), ['/']); + assert.deepEqual(getTrailingSlashPaths('/', 'never'), ['/']); + }); + + it('Generates correct paths for trailingslash ignore', () => { + assert.deepEqual(getTrailingSlashPaths('/path', 'ignore'), ['/path', '/path/']); + assert.deepEqual(getTrailingSlashPaths('/path/', 'ignore'), ['/path', '/path/']); + }); + + it('Generates correct paths for trailingslash always', () => { + assert.deepEqual(getTrailingSlashPaths('/path', 'always'), ['/path/']); + assert.deepEqual(getTrailingSlashPaths('/path/', 'always'), ['/path/']); + }); + + it('Generates correct paths for trailingslash never', () => { + assert.deepEqual(getTrailingSlashPaths('/path', 'never'), ['/path']); + assert.deepEqual(getTrailingSlashPaths('/path/', 'never'), ['/path']); + }); +}); + +function createIntegrationRoute(pattern: string, pathname = pattern): IntegrationResolvedRoute { + const route: Partial = { + pattern, + pathname, + segments: [], + }; + return route as IntegrationResolvedRoute; +} diff --git a/packages/underscore-redirects/test/print.test.js b/packages/underscore-redirects/test/print.test.ts similarity index 100% rename from packages/underscore-redirects/test/print.test.js rename to packages/underscore-redirects/test/print.test.ts diff --git a/packages/underscore-redirects/test/weight.test.js b/packages/underscore-redirects/test/weight.test.ts similarity index 100% rename from packages/underscore-redirects/test/weight.test.js rename to packages/underscore-redirects/test/weight.test.ts diff --git a/packages/underscore-redirects/tsconfig.test.json b/packages/underscore-redirects/tsconfig.test.json new file mode 100644 index 000000000000..7d6bc4428b35 --- /dev/null +++ b/packages/underscore-redirects/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [{ "path": "../astro/tsconfig.test.json" }] +} diff --git a/packages/upgrade/package.json b/packages/upgrade/package.json index bf1758e4133f..2209c26100f6 100644 --- a/packages/upgrade/package.json +++ b/packages/upgrade/package.json @@ -20,7 +20,8 @@ "build": "astro-scripts build \"src/index.ts\" --bundle && tsc", "build:ci": "astro-scripts build \"src/index.ts\" --bundle", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "astro-scripts test \"test/**/*.test.js\"" + "test": "astro-scripts test \"test/**/*.test.ts\"", + "typecheck:tests": "tsc -p tsconfig.test.json" }, "files": [ "dist", diff --git a/packages/upgrade/test/context.test.js b/packages/upgrade/test/context.test.ts similarity index 100% rename from packages/upgrade/test/context.test.js rename to packages/upgrade/test/context.test.ts diff --git a/packages/upgrade/test/install.test.js b/packages/upgrade/test/install.test.js deleted file mode 100644 index 23b000758bfc..000000000000 --- a/packages/upgrade/test/install.test.js +++ /dev/null @@ -1,363 +0,0 @@ -import * as assert from 'node:assert/strict'; -import { tmpdir } from 'node:os'; -import { describe, it, mock } from 'node:test'; -import { pathToFileURL } from 'node:url'; -import { install } from '../dist/index.js'; -import { setup } from './utils.js'; - -const tmpUrl = pathToFileURL(tmpdir()); - -describe('install', () => { - const fixture = setup(); - const ctx = { - cwd: '', - version: 'latest', - packageManager: 'npm', - dryRun: true, - }; - - it('up to date', async () => { - const context = { - ...ctx, - packages: [ - { - name: 'astro', - currentVersion: '1.0.0', - targetVersion: '1.0.0', - }, - ], - }; - await install(context); - assert.equal(fixture.hasMessage('◼ astro is up to date on v1.0.0'), true); - }); - - it('patch', async () => { - const context = { - ...ctx, - packages: [ - { - name: 'astro', - currentVersion: '1.0.0', - targetVersion: '1.0.1', - }, - ], - }; - await install(context); - assert.equal(fixture.hasMessage('● astro can be updated from v1.0.0 to v1.0.1'), true); - }); - - it('minor', async () => { - const context = { - ...ctx, - packages: [ - { - name: 'astro', - currentVersion: '1.0.0', - targetVersion: '1.2.0', - }, - ], - }; - await install(context); - assert.equal(fixture.hasMessage('● astro can be updated from v1.0.0 to v1.2.0'), true); - }); - - it('major (reject)', async () => { - let prompted = false; - let exitCode; - const context = { - ...ctx, - prompt: () => { - prompted = true; - return { proceed: false }; - }, - exit: (code) => { - exitCode = code; - }, - packages: [ - { - name: 'astro', - currentVersion: '1.0.0', - targetVersion: '2.0.0', - isMajor: true, - changelogTitle: 'CHANGELOG', - changelogURL: 'https://example.com', - }, - ], - }; - await install(context); - assert.equal(fixture.hasMessage('▲ astro can be updated from v1.0.0 to v2.0.0'), true); - assert.equal(prompted, true); - assert.equal(exitCode, 0); - assert.equal(fixture.hasMessage('check Be sure to follow the CHANGELOG.'), false); - }); - - it('major (accept)', async () => { - let prompted = false; - let exitCode; - const context = { - ...ctx, - prompt: () => { - prompted = true; - return { proceed: true }; - }, - exit: (code) => { - exitCode = code; - }, - packages: [ - { - name: 'astro', - currentVersion: '1.0.0', - targetVersion: '2.0.0', - isMajor: true, - changelogTitle: 'CHANGELOG', - changelogURL: 'https://example.com', - }, - ], - }; - await install(context); - assert.equal(fixture.hasMessage('▲ astro can be updated from v1.0.0 to v2.0.0'), true); - assert.equal(prompted, true); - assert.equal(exitCode, undefined); - assert.equal(fixture.hasMessage('check Be sure to follow the CHANGELOG.'), true); - }); - - it('multiple major', async () => { - let prompted = false; - let exitCode; - const context = { - ...ctx, - prompt: () => { - prompted = true; - return { proceed: true }; - }, - exit: (code) => { - exitCode = code; - }, - packages: [ - { - name: 'a', - currentVersion: '1.0.0', - targetVersion: '2.0.0', - isMajor: true, - changelogTitle: 'CHANGELOG', - changelogURL: 'https://example.com', - }, - { - name: 'b', - currentVersion: '6.0.0', - targetVersion: '7.0.0', - isMajor: true, - changelogTitle: 'CHANGELOG', - changelogURL: 'https://example.com', - }, - ], - }; - await install(context); - assert.equal(fixture.hasMessage('▲ a can be updated from v1.0.0 to v2.0.0'), true); - assert.equal(fixture.hasMessage('▲ b can be updated from v6.0.0 to v7.0.0'), true); - assert.equal(prompted, true); - assert.equal(exitCode, undefined); - const [changelog, a, b] = fixture.messages().slice(-5); - assert.match(changelog, /^check/); - assert.match(a, /^a/); - assert.match(b, /^b/); - }); - - it('current patch minor major', async () => { - let prompted = false; - let exitCode; - const context = { - ...ctx, - prompt: () => { - prompted = true; - return { proceed: true }; - }, - exit: (code) => { - exitCode = code; - }, - packages: [ - { - name: 'current', - currentVersion: '1.0.0', - targetVersion: '1.0.0', - }, - { - name: 'patch', - currentVersion: '1.0.0', - targetVersion: '1.0.1', - }, - { - name: 'minor', - currentVersion: '1.0.0', - targetVersion: '1.2.0', - }, - { - name: 'major', - currentVersion: '1.0.0', - targetVersion: '3.0.0', - isMajor: true, - changelogTitle: 'CHANGELOG', - changelogURL: 'https://example.com', - }, - ], - }; - await install(context); - assert.equal(fixture.hasMessage('◼ current is up to date on v1.0.0'), true); - assert.equal(fixture.hasMessage('● patch can be updated from v1.0.0 to v1.0.1'), true); - assert.equal(fixture.hasMessage('● minor can be updated from v1.0.0 to v1.2.0'), true); - assert.equal(fixture.hasMessage('▲ major can be updated from v1.0.0 to v3.0.0'), true); - assert.equal(prompted, true); - assert.equal(exitCode, undefined); - assert.equal(fixture.hasMessage('check Be sure to follow the CHANGELOG.'), true); - const [changelog, major] = fixture.messages().slice(-4); - assert.match(changelog, /^check/); - assert.match(major, /^major/); - }); - - it('npm peer dependency error retry with legacy-peer-deps', async () => { - const mockShell = mock.fn(async () => { - if (mockShell.mock.callCount() === 0) { - // First call fails with peer dependency error - throw new Error('npm ERR! peer dependencies conflict'); - } - // Second call succeeds - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - let exitCode; - const context = { - ...ctx, - dryRun: false, - cwd: tmpUrl, - packageManager: { name: 'npm', agent: 'npm' }, - exit: (code) => { - exitCode = code; - }, - packages: [ - { - name: 'astro', - currentVersion: '1.0.0', - targetVersion: '1.1.0', - }, - ], - }; - - await install(context, mockShell); - - // Should have been called twice (initial failure, then retry with --legacy-peer-deps) - assert.equal(mockShell.mock.callCount(), 2); - - // Check that second call includes --legacy-peer-deps - const secondCallArgs = mockShell.mock.calls[1].arguments[1]; - assert.ok( - secondCallArgs.includes('--legacy-peer-deps'), - 'Second command should include --legacy-peer-deps', - ); - - assert.equal(exitCode, undefined, 'Should not exit with error after successful retry'); - assert.equal(fixture.hasMessage('Installed dependencies!'), true); - }); - - it('npm non-peer dependency error does not retry', async () => { - const mockShell = mock.fn(async () => { - throw new Error('npm ERR! some other error'); - }); - - let exitCode; - const context = { - ...ctx, - dryRun: false, - cwd: tmpUrl, - packageManager: { name: 'npm', agent: 'npm' }, - exit: (code) => { - exitCode = code; - }, - packages: [ - { - name: 'astro', - currentVersion: '1.0.0', - targetVersion: '1.1.0', - }, - ], - }; - - await install(context, mockShell); - - // Should only be called once (no retry for non-peer dependency errors) - assert.equal(mockShell.mock.callCount(), 1); - assert.equal(exitCode, 1); - assert.equal(fixture.hasMessage('Dependencies failed to install'), true); - }); - - it('npm peer dependency error retry fails on second attempt', async () => { - const mockShell = mock.fn(async () => { - // Both calls fail with peer dependency errors - throw new Error('npm ERR! peer dependencies conflict'); - }); - - let exitCode; - const context = { - ...ctx, - dryRun: false, - cwd: tmpUrl, - packageManager: { name: 'npm', agent: 'npm' }, - exit: (code) => { - exitCode = code; - }, - packages: [ - { - name: 'astro', - currentVersion: '1.0.0', - targetVersion: '1.1.0', - }, - ], - }; - - await install(context, mockShell); - - // Should have been called twice (initial failure, then retry with --legacy-peer-deps that also fails) - assert.equal(mockShell.mock.callCount(), 2); - - // Check that second call includes --legacy-peer-deps - const secondCallArgs = mockShell.mock.calls[1].arguments[1]; - assert.ok( - secondCallArgs.includes('--legacy-peer-deps'), - 'Second command should include --legacy-peer-deps', - ); - - // Should exit with error code 1 after both attempts fail - assert.equal(exitCode, 1); - assert.equal(fixture.hasMessage('Dependencies failed to install'), true); - }); - - it('pnpm peer dependency error does not retry', async () => { - const mockShell = mock.fn(async () => { - throw new Error('pnpm ERR! peer dependencies conflict'); - }); - - let exitCode; - const context = { - ...ctx, - dryRun: false, - cwd: tmpUrl, - packageManager: { name: 'pnpm', agent: 'pnpm' }, - exit: (code) => { - exitCode = code; - }, - packages: [ - { - name: 'astro', - currentVersion: '1.0.0', - targetVersion: '1.1.0', - }, - ], - }; - - await install(context, mockShell); - - // Should only be called once (no retry for pnpm, only npm gets retry) - assert.equal(mockShell.mock.callCount(), 1); - assert.equal(exitCode, 1); - assert.equal(fixture.hasMessage('Dependencies failed to install'), true); - }); -}); diff --git a/packages/upgrade/test/install.test.ts b/packages/upgrade/test/install.test.ts new file mode 100644 index 000000000000..5a5777b845ae --- /dev/null +++ b/packages/upgrade/test/install.test.ts @@ -0,0 +1,363 @@ +import * as assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { describe, it, mock } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { install } from '../dist/index.js'; +import { setup, type ShellFunction } from './utils.ts'; + +const tmpUrl = pathToFileURL(tmpdir()); + +describe('install', () => { + const fixture = setup(); + const ctx = { + cwd: '', + version: 'latest', + packageManager: 'npm', + dryRun: true, + }; + + it('up to date', async () => { + const context = { + ...ctx, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '1.0.0', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('◼ astro is up to date on v1.0.0'), true); + }); + + it('patch', async () => { + const context = { + ...ctx, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '1.0.1', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('● astro can be updated from v1.0.0 to v1.0.1'), true); + }); + + it('minor', async () => { + const context = { + ...ctx, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '1.2.0', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('● astro can be updated from v1.0.0 to v1.2.0'), true); + }); + + it('major (reject)', async () => { + let prompted = false; + let exitCode; + const context = { + ...ctx, + prompt: () => { + prompted = true; + return { proceed: false }; + }, + exit: (code: number) => { + exitCode = code; + }, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '2.0.0', + isMajor: true, + changelogTitle: 'CHANGELOG', + changelogURL: 'https://example.com', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('▲ astro can be updated from v1.0.0 to v2.0.0'), true); + assert.equal(prompted, true); + assert.equal(exitCode, 0); + assert.equal(fixture.hasMessage('check Be sure to follow the CHANGELOG.'), false); + }); + + it('major (accept)', async () => { + let prompted = false; + let exitCode; + const context = { + ...ctx, + prompt: () => { + prompted = true; + return { proceed: true }; + }, + exit: (code: number) => { + exitCode = code; + }, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '2.0.0', + isMajor: true, + changelogTitle: 'CHANGELOG', + changelogURL: 'https://example.com', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('▲ astro can be updated from v1.0.0 to v2.0.0'), true); + assert.equal(prompted, true); + assert.equal(exitCode, undefined); + assert.equal(fixture.hasMessage('check Be sure to follow the CHANGELOG.'), true); + }); + + it('multiple major', async () => { + let prompted = false; + let exitCode; + const context = { + ...ctx, + prompt: () => { + prompted = true; + return { proceed: true }; + }, + exit: (code: number) => { + exitCode = code; + }, + packages: [ + { + name: 'a', + currentVersion: '1.0.0', + targetVersion: '2.0.0', + isMajor: true, + changelogTitle: 'CHANGELOG', + changelogURL: 'https://example.com', + }, + { + name: 'b', + currentVersion: '6.0.0', + targetVersion: '7.0.0', + isMajor: true, + changelogTitle: 'CHANGELOG', + changelogURL: 'https://example.com', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('▲ a can be updated from v1.0.0 to v2.0.0'), true); + assert.equal(fixture.hasMessage('▲ b can be updated from v6.0.0 to v7.0.0'), true); + assert.equal(prompted, true); + assert.equal(exitCode, undefined); + const [changelog, a, b] = fixture.messages().slice(-5); + assert.match(changelog, /^check/); + assert.match(a, /^a/); + assert.match(b, /^b/); + }); + + it('current patch minor major', async () => { + let prompted = false; + let exitCode; + const context = { + ...ctx, + prompt: () => { + prompted = true; + return { proceed: true }; + }, + exit: (code: number) => { + exitCode = code; + }, + packages: [ + { + name: 'current', + currentVersion: '1.0.0', + targetVersion: '1.0.0', + }, + { + name: 'patch', + currentVersion: '1.0.0', + targetVersion: '1.0.1', + }, + { + name: 'minor', + currentVersion: '1.0.0', + targetVersion: '1.2.0', + }, + { + name: 'major', + currentVersion: '1.0.0', + targetVersion: '3.0.0', + isMajor: true, + changelogTitle: 'CHANGELOG', + changelogURL: 'https://example.com', + }, + ], + }; + await install(context); + assert.equal(fixture.hasMessage('◼ current is up to date on v1.0.0'), true); + assert.equal(fixture.hasMessage('● patch can be updated from v1.0.0 to v1.0.1'), true); + assert.equal(fixture.hasMessage('● minor can be updated from v1.0.0 to v1.2.0'), true); + assert.equal(fixture.hasMessage('▲ major can be updated from v1.0.0 to v3.0.0'), true); + assert.equal(prompted, true); + assert.equal(exitCode, undefined); + assert.equal(fixture.hasMessage('check Be sure to follow the CHANGELOG.'), true); + const [changelog, major] = fixture.messages().slice(-4); + assert.match(changelog, /^check/); + assert.match(major, /^major/); + }); + + it('npm peer dependency error retry with legacy-peer-deps', async () => { + const mockShell = mock.fn(async () => { + if (mockShell.mock.callCount() === 0) { + // First call fails with peer dependency error + throw new Error('npm ERR! peer dependencies conflict'); + } + // Second call succeeds + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + let exitCode; + const context = { + ...ctx, + dryRun: false, + cwd: tmpUrl, + packageManager: { name: 'npm', agent: 'npm' }, + exit: (code: number) => { + exitCode = code; + }, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + ], + }; + + await install(context, mockShell); + + // Should have been called twice (initial failure, then retry with --legacy-peer-deps) + assert.equal(mockShell.mock.callCount(), 2); + + // Check that second call includes --legacy-peer-deps + const secondCallArgs = mockShell.mock.calls[1].arguments[1]; + assert.ok( + secondCallArgs.includes('--legacy-peer-deps'), + 'Second command should include --legacy-peer-deps', + ); + + assert.equal(exitCode, undefined, 'Should not exit with error after successful retry'); + assert.equal(fixture.hasMessage('Installed dependencies!'), true); + }); + + it('npm non-peer dependency error does not retry', async () => { + const mockShell = mock.fn(async () => { + throw new Error('npm ERR! some other error'); + }); + + let exitCode; + const context = { + ...ctx, + dryRun: false, + cwd: tmpUrl, + packageManager: { name: 'npm', agent: 'npm' }, + exit: (code: number) => { + exitCode = code; + }, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + ], + }; + + await install(context, mockShell); + + // Should only be called once (no retry for non-peer dependency errors) + assert.equal(mockShell.mock.callCount(), 1); + assert.equal(exitCode, 1); + assert.equal(fixture.hasMessage('Dependencies failed to install'), true); + }); + + it('npm peer dependency error retry fails on second attempt', async () => { + const mockShell = mock.fn(async () => { + // Both calls fail with peer dependency errors + throw new Error('npm ERR! peer dependencies conflict'); + }); + + let exitCode; + const context = { + ...ctx, + dryRun: false, + cwd: tmpUrl, + packageManager: { name: 'npm', agent: 'npm' }, + exit: (code: number) => { + exitCode = code; + }, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + ], + }; + + await install(context, mockShell); + + // Should have been called twice (initial failure, then retry with --legacy-peer-deps that also fails) + assert.equal(mockShell.mock.callCount(), 2); + + // Check that second call includes --legacy-peer-deps + const secondCallArgs = mockShell.mock.calls[1].arguments[1]; + assert.ok( + secondCallArgs.includes('--legacy-peer-deps'), + 'Second command should include --legacy-peer-deps', + ); + + // Should exit with error code 1 after both attempts fail + assert.equal(exitCode, 1); + assert.equal(fixture.hasMessage('Dependencies failed to install'), true); + }); + + it('pnpm peer dependency error does not retry', async () => { + const mockShell = mock.fn(async () => { + throw new Error('pnpm ERR! peer dependencies conflict'); + }); + + let exitCode; + const context = { + ...ctx, + dryRun: false, + cwd: tmpUrl, + packageManager: { name: 'pnpm', agent: 'pnpm' }, + exit: (code: number) => { + exitCode = code; + }, + packages: [ + { + name: 'astro', + currentVersion: '1.0.0', + targetVersion: '1.1.0', + }, + ], + }; + + await install(context, mockShell); + + // Should only be called once (no retry for pnpm, only npm gets retry) + assert.equal(mockShell.mock.callCount(), 1); + assert.equal(exitCode, 1); + assert.equal(fixture.hasMessage('Dependencies failed to install'), true); + }); +}); diff --git a/packages/upgrade/test/utils.js b/packages/upgrade/test/utils.js deleted file mode 100644 index 20063ec53255..000000000000 --- a/packages/upgrade/test/utils.js +++ /dev/null @@ -1,32 +0,0 @@ -import { before, beforeEach } from 'node:test'; -import { stripVTControlCharacters } from 'node:util'; -import { setStdout } from '../dist/index.js'; - -export function setup() { - const ctx = { messages: [] }; - before(() => { - setStdout( - Object.assign({}, process.stdout, { - write(buf) { - ctx.messages.push(stripVTControlCharacters(String(buf)).trim()); - return true; - }, - }), - ); - }); - beforeEach(() => { - ctx.messages = []; - }); - - return { - messages() { - return ctx.messages; - }, - length() { - return ctx.messages.length; - }, - hasMessage(content) { - return !!ctx.messages.find((msg) => msg.includes(content)); - }, - }; -} diff --git a/packages/upgrade/test/utils.ts b/packages/upgrade/test/utils.ts new file mode 100644 index 000000000000..b5aa1c25aabc --- /dev/null +++ b/packages/upgrade/test/utils.ts @@ -0,0 +1,41 @@ +import { before, beforeEach } from 'node:test'; +import { stripVTControlCharacters } from 'node:util'; +import { setStdout } from '../dist/index.js'; + +export type ShellFunction = ( + command: string, + flags: string[], +) => Promise<{ + stdout: string; + stderr: string; + exitCode: number; +}>; + +export function setup() { + const ctx: { messages: string[] } = { messages: [] }; + before(() => { + setStdout( + Object.assign({}, process.stdout, { + write(buf: string | Uint8Array) { + ctx.messages.push(stripVTControlCharacters(String(buf)).trim()); + return true; + }, + }), + ); + }); + beforeEach(() => { + ctx.messages = []; + }); + + return { + messages() { + return ctx.messages; + }, + length() { + return ctx.messages.length; + }, + hasMessage(content: string) { + return !!ctx.messages.find((msg) => msg.includes(content)); + }, + }; +} diff --git a/packages/upgrade/test/verify.test.js b/packages/upgrade/test/verify.test.ts similarity index 100% rename from packages/upgrade/test/verify.test.js rename to packages/upgrade/test/verify.test.ts diff --git a/packages/upgrade/tsconfig.test.json b/packages/upgrade/tsconfig.test.json new file mode 100644 index 000000000000..7d6bc4428b35 --- /dev/null +++ b/packages/upgrade/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/**/*.ts"], + "exclude": ["test/fixtures/**"], + "compilerOptions": { + "allowJs": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "rewriteRelativeImportExtensions": true + }, + "references": [{ "path": "../astro/tsconfig.test.json" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 127f98557456..79f4ab078724 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,14 +21,14 @@ importers: specifier: ^0.9.5 version: link:packages/language-tools/astro-check '@biomejs/biome': - specifier: 2.4.2 - version: 2.4.2 + specifier: 2.4.10 + version: 2.4.10 '@changesets/changelog-github': specifier: ^0.5.2 version: 0.5.2 '@changesets/cli': specifier: ^2.29.8 - version: 2.29.8(@types/node@18.19.130) + version: 2.29.8(@types/node@22.19.11) '@flue/cli': specifier: ^0.0.47 version: 0.0.47(typescript@5.9.3) @@ -36,8 +36,8 @@ importers: specifier: ^0.0.29 version: 0.0.29(typescript@5.9.3) '@types/node': - specifier: ^18.19.115 - version: 18.19.130 + specifier: ^22.10.6 + version: 22.19.11 bgproc: specifier: ^0.2.0 version: 0.2.0 @@ -52,13 +52,13 @@ importers: version: 3.0.0(eslint@9.39.3(jiti@2.6.1)) knip: specifier: 5.82.1 - version: 5.82.1(@types/node@18.19.130)(typescript@5.9.3) + version: 5.82.1(@types/node@22.19.11)(typescript@5.9.3) only-allow: specifier: ^1.2.2 version: 1.2.2 prettier: specifier: ^3.8.1 - version: 3.8.1 + version: 3.8.2 prettier-plugin-astro: specifier: ^0.14.1 version: 0.14.1 @@ -67,7 +67,7 @@ importers: version: 0.3.17 tinyglobby: specifier: ^0.2.15 - version: 0.2.15 + version: 0.2.16 turbo: specifier: ^2.8.15 version: 2.8.15 @@ -189,7 +189,7 @@ importers: examples/basics: dependencies: astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro examples/blog: @@ -204,7 +204,7 @@ importers: specifier: ^3.7.2 version: link:../../packages/integrations/sitemap astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro sharp: specifier: ^0.34.3 @@ -213,16 +213,16 @@ importers: examples/component: devDependencies: astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro examples/container-with-vitest: dependencies: '@astrojs/react': - specifier: ^5.0.2 + specifier: ^5.0.3 version: link:../../packages/integrations/react astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro react: specifier: ^18.3.1 @@ -253,22 +253,22 @@ importers: specifier: ^3.15.8 version: 3.15.8 astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro examples/framework-multiple: dependencies: '@astrojs/preact': - specifier: ^5.1.0 + specifier: ^5.1.1 version: link:../../packages/integrations/preact '@astrojs/react': - specifier: ^5.0.2 + specifier: ^5.0.3 version: link:../../packages/integrations/react '@astrojs/solid-js': specifier: ^6.0.1 version: link:../../packages/integrations/solid '@astrojs/svelte': - specifier: ^8.0.4 + specifier: ^8.0.5 version: link:../../packages/integrations/svelte '@astrojs/vue': specifier: ^6.0.1 @@ -280,7 +280,7 @@ importers: specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.28) astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro preact: specifier: ^10.28.4 @@ -296,7 +296,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.53.5 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.29 version: 3.5.30(typescript@5.9.3) @@ -304,13 +304,13 @@ importers: examples/framework-preact: dependencies: '@astrojs/preact': - specifier: ^5.1.0 + specifier: ^5.1.1 version: link:../../packages/integrations/preact '@preact/signals': specifier: ^2.8.1 version: 2.8.2(preact@10.29.0) astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro preact: specifier: ^10.28.4 @@ -319,7 +319,7 @@ importers: examples/framework-react: dependencies: '@astrojs/react': - specifier: ^5.0.2 + specifier: ^5.0.3 version: link:../../packages/integrations/react '@types/react': specifier: ^18.3.28 @@ -328,7 +328,7 @@ importers: specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.28) astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro react: specifier: ^18.3.1 @@ -343,7 +343,7 @@ importers: specifier: ^6.0.1 version: link:../../packages/integrations/solid astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro solid-js: specifier: ^1.9.11 @@ -352,14 +352,14 @@ importers: examples/framework-svelte: dependencies: '@astrojs/svelte': - specifier: ^8.0.4 + specifier: ^8.0.5 version: link:../../packages/integrations/svelte astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro svelte: specifier: ^5.53.5 - version: 5.54.1 + version: 5.55.3 examples/framework-vue: dependencies: @@ -367,7 +367,7 @@ importers: specifier: ^6.0.1 version: link:../../packages/integrations/vue astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro vue: specifier: ^3.5.29 @@ -376,49 +376,49 @@ importers: examples/hackernews: dependencies: '@astrojs/node': - specifier: ^10.0.4 + specifier: ^10.0.5 version: link:../../packages/integrations/node astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro examples/integration: devDependencies: astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro examples/minimal: dependencies: astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro examples/portfolio: dependencies: astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro examples/ssr: dependencies: '@astrojs/node': - specifier: ^10.0.4 + specifier: ^10.0.5 version: link:../../packages/integrations/node '@astrojs/svelte': - specifier: ^8.0.4 + specifier: ^8.0.5 version: link:../../packages/integrations/svelte astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro svelte: specifier: ^5.53.5 - version: 5.54.1 + version: 5.55.3 examples/starlog: dependencies: astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro sass: specifier: ^1.97.3 @@ -430,10 +430,10 @@ importers: examples/toolbar-app: devDependencies: '@types/node': - specifier: ^18.17.8 - version: 18.19.130 + specifier: ^22.10.6 + version: 22.19.11 astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro examples/with-markdoc: @@ -442,7 +442,7 @@ importers: specifier: ^1.0.3 version: link:../../packages/integrations/markdoc astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro examples/with-mdx: @@ -451,10 +451,10 @@ importers: specifier: ^5.0.3 version: link:../../packages/integrations/mdx '@astrojs/preact': - specifier: ^5.1.0 + specifier: ^5.1.1 version: link:../../packages/integrations/preact astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro preact: specifier: ^10.28.4 @@ -463,13 +463,13 @@ importers: examples/with-nanostores: dependencies: '@astrojs/preact': - specifier: ^5.1.0 + specifier: ^5.1.1 version: link:../../packages/integrations/preact '@nanostores/preact': specifier: ^1.0.0 version: 1.0.0(nanostores@1.1.1)(preact@10.29.0) astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro nanostores: specifier: ^1.1.1 @@ -490,7 +490,7 @@ importers: specifier: ^1.9.0 version: 1.9.0 astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro canvas-confetti: specifier: ^1.9.4 @@ -502,7 +502,7 @@ importers: examples/with-vitest: dependencies: astro: - specifier: ^6.1.2 + specifier: ^6.1.8 version: link:../../packages/astro vitest: specifier: ^4.1.0 @@ -558,9 +558,6 @@ importers: diff: specifier: ^8.0.3 version: 8.0.3 - dlv: - specifier: ^1.1.3 - version: 1.1.3 dset: specifier: ^3.1.4 version: 3.1.4 @@ -641,7 +638,7 @@ importers: version: 1.0.4 tinyglobby: specifier: ^0.2.15 - version: 0.2.15 + version: 0.2.16 tsconfck: specifier: ^3.1.6 version: 3.1.6(typescript@5.9.3) @@ -688,9 +685,6 @@ importers: '@types/aria-query': specifier: ^5.0.4 version: 5.0.4 - '@types/dlv': - specifier: ^1.1.5 - version: 1.1.5 '@types/hast': specifier: ^3.0.4 version: 3.0.4 @@ -703,6 +697,9 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/parse-srcset': + specifier: ^1.0.0 + version: 1.0.0 '@types/picomatch': specifier: ^4.0.2 version: 4.0.2 @@ -725,8 +722,8 @@ importers: specifier: ^1.3.0 version: 1.3.0 fs-fixture: - specifier: ^2.11.0 - version: 2.11.0 + specifier: ^2.13.0 + version: 2.13.0 mdast-util-mdx: specifier: ^3.0.0 version: 3.0.0 @@ -735,7 +732,7 @@ importers: version: 3.2.0 node-mocks-http: specifier: ^1.17.2 - version: 1.17.2(@types/node@25.2.3) + version: 1.17.2(@types/express@5.0.6)(@types/node@25.2.3) parse-srcset: specifier: ^1.0.2 version: 1.0.2 @@ -940,7 +937,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -1015,6 +1012,15 @@ importers: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) + packages/astro/e2e/fixtures/cloudflare-node-prerender-hmr: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../../../integrations/cloudflare + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/e2e/fixtures/cloudflare/packages/my-lib: {} packages/astro/e2e/fixtures/content-collections: @@ -1138,12 +1144,19 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) packages/astro/e2e/fixtures/hmr: + dependencies: + '@astrojs/preact': + specifier: workspace:* + version: link:../../../../integrations/preact + preact: + specifier: ^10.28.2 + version: 10.29.0 devDependencies: astro: specifier: workspace:* @@ -1195,7 +1208,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -1251,7 +1264,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -1291,7 +1304,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -1331,7 +1344,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -1371,7 +1384,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -1411,7 +1424,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -1451,7 +1464,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -1669,7 +1682,7 @@ importers: version: link:../../.. svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 packages/astro/e2e/fixtures/tailwindcss: dependencies: @@ -1729,7 +1742,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -1873,7 +1886,7 @@ importers: version: 18.3.1(react@18.3.1) svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -1894,7 +1907,7 @@ importers: version: link:../../.. svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 packages/astro/test/fixtures/alias-tsconfig: dependencies: @@ -1909,29 +1922,23 @@ importers: version: link:../../.. svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 - packages/astro/test/fixtures/alias-tsconfig-baseurl-only: + packages/astro/test/fixtures/alias-tsconfig-no-baseurl: dependencies: - '@astrojs/svelte': - specifier: workspace:* - version: link:../../../../integrations/svelte astro: specifier: workspace:* version: link:../../.. - svelte: - specifier: ^5.54.0 - version: 5.54.1 - packages/astro/test/fixtures/alias-tsconfig-no-baseurl: + packages/astro/test/fixtures/alias-tsconfig/deps/namespace-package: {} + + packages/astro/test/fixtures/api-routes: dependencies: astro: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/alias-tsconfig/deps/namespace-package: {} - - packages/astro/test/fixtures/api-routes: + packages/astro/test/fixtures/asset-query-params-chunks: dependencies: astro: specifier: workspace:* @@ -2052,7 +2059,7 @@ importers: version: 10.29.0 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -2079,7 +2086,7 @@ importers: version: 18.3.1(react@18.3.1) svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 packages/astro/test/fixtures/astro-client-only/pkg: {} @@ -2104,12 +2111,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/astro-components: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/astro-cookies: dependencies: astro: @@ -2122,12 +2123,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/astro-css-bundling-nested-layouts: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/astro-dev-headers: dependencies: astro: @@ -2171,7 +2166,7 @@ importers: version: 18.3.1(react@18.3.1) svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 packages/astro/test/fixtures/astro-env: dependencies: @@ -2227,30 +2222,6 @@ importers: specifier: ^10.29.0 version: 10.29.0 - packages/astro/test/fixtures/astro-external-files: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/astro-fallback: - dependencies: - '@astrojs/preact': - specifier: workspace:* - version: link:../../../../integrations/preact - astro: - specifier: workspace:* - version: link:../../.. - preact: - specifier: ^10.29.0 - version: 10.29.0 - - packages/astro/test/fixtures/astro-generator: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/astro-get-static-paths: dependencies: astro: @@ -2443,24 +2414,6 @@ importers: specifier: ^4.2.2 version: 4.2.2 - packages/astro/test/fixtures/astro-sitemap-rss: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/astro-slot-with-client: - dependencies: - '@astrojs/preact': - specifier: workspace:* - version: link:../../../../integrations/preact - astro: - specifier: workspace:* - version: link:../../.. - preact: - specifier: ^10.29.0 - version: 10.29.0 - packages/astro/test/fixtures/astro-slots: dependencies: astro: @@ -2501,7 +2454,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -2618,7 +2571,7 @@ importers: version: 18.3.1(react@18.3.1) svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 packages/astro/test/fixtures/component-library-shared: dependencies: @@ -2633,18 +2586,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/config-host: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/config-path: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/config-vite: dependencies: astro: @@ -2696,6 +2637,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/content-collection-picture-render: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/content-collection-references: dependencies: astro: @@ -2726,12 +2673,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/content-collections-cache-invalidation: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/content-collections-empty-dir: dependencies: astro: @@ -2762,19 +2703,19 @@ importers: specifier: ^4.3.6 version: 4.3.6 - packages/astro/test/fixtures/content-collections-same-contents: + packages/astro/test/fixtures/content-collections-type-inference: dependencies: astro: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/content-collections-type-inference: + packages/astro/test/fixtures/content-collections-with-config-mjs: dependencies: astro: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/content-collections-with-config-mjs: + packages/astro/test/fixtures/content-frontmatter: dependencies: astro: specifier: workspace:* @@ -2840,12 +2781,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/core-image-base: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/core-image-data-url: dependencies: astro: @@ -2942,12 +2877,6 @@ importers: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) - packages/astro/test/fixtures/core-image-svg-optimized: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/core-image-unconventional-settings: dependencies: astro: @@ -3002,7 +2931,7 @@ importers: version: link:../../.. svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 packages/astro/test/fixtures/css-deduplication: dependencies: @@ -3049,18 +2978,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/css-inline-stylesheets-2: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/css-inline-stylesheets-3: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/css-no-code-split: dependencies: astro: @@ -3100,12 +3017,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/css-order-transparent: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/custom-404-html: dependencies: astro: @@ -3193,12 +3104,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/custom-500-middleware: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/custom-assets-name: dependencies: '@astrojs/node': @@ -3238,6 +3143,30 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/dev-container: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + + packages/astro/test/fixtures/dev-error-pages: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + + packages/astro/test/fixtures/dev-render: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + + packages/astro/test/fixtures/dev-request-url: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/dont-delete-me: dependencies: astro: @@ -3256,6 +3185,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/endpoint-routing: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/entry-file-names: dependencies: '@astrojs/preact': @@ -3292,12 +3227,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/feature-support-message-suppresion: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/fetch: dependencies: '@astrojs/preact': @@ -3317,7 +3246,7 @@ importers: version: 10.29.0 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -3352,18 +3281,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/head-injection: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/hmr-css: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/hmr-markdown: dependencies: astro: @@ -3424,52 +3341,7 @@ importers: specifier: ^10.29.0 version: 10.29.0 - packages/astro/test/fixtures/i18n-routing-base: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/i18n-routing-dynamic: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/i18n-routing-fallback-rewrite-hybrid: - dependencies: - '@astrojs/node': - specifier: workspace:* - version: link:../../../../integrations/node - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/i18n-routing-manual: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/i18n-routing-manual-with-default-middleware: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/i18n-routing-redirect-preferred-language: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/i18n-routing-subdomain: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/i18n-server-island: + packages/astro/test/fixtures/i18n-css-leak-basic: dependencies: astro: specifier: workspace:* @@ -3524,7 +3396,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -3620,24 +3492,12 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/middleware-sequence-request-clone: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/middleware-sequence-rewrite: dependencies: astro: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/middleware-ssg: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/middleware-tailwind: dependencies: '@tailwindcss/vite': @@ -3650,12 +3510,6 @@ importers: specifier: ^4.2.2 version: 4.2.2 - packages/astro/test/fixtures/middleware-virtual: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/minification-html: dependencies: astro: @@ -3754,7 +3608,7 @@ importers: version: 1.9.11 svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -3981,7 +3835,7 @@ importers: version: link:../../../.. svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 packages/astro/test/fixtures/server-islands/ssr: dependencies: @@ -3996,7 +3850,7 @@ importers: version: link:../../../.. svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 packages/astro/test/fixtures/sessions: dependencies: @@ -4004,12 +3858,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/set-html: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/slots-preact: dependencies: '@astrojs/mdx': @@ -4071,7 +3919,7 @@ importers: version: link:../../.. svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 packages/astro/test/fixtures/slots-vue: dependencies: @@ -4172,24 +4020,6 @@ importers: specifier: workspace:* version: link:../../.. - packages/astro/test/fixtures/ssr-env: - dependencies: - '@astrojs/preact': - specifier: workspace:* - version: link:../../../../integrations/preact - astro: - specifier: workspace:* - version: link:../../.. - preact: - specifier: ^10.29.0 - version: 10.29.0 - - packages/astro/test/fixtures/ssr-markdown: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/ssr-partytown: dependencies: '@astrojs/partytown': @@ -4253,21 +4083,6 @@ importers: specifier: ^10.29.0 version: 10.29.0 - packages/astro/test/fixtures/ssr-split-manifest: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/ssr-trailing-slash: - dependencies: - '@astrojs/node': - specifier: workspace:* - version: link:../../../../integrations/node - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/static-build: dependencies: '@astrojs/preact': @@ -4367,7 +4182,7 @@ importers: version: link:../../.. svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 packages/astro/test/fixtures/svg-deduplication: dependencies: @@ -4399,12 +4214,6 @@ importers: specifier: ^0.8.0 version: 0.8.0(astro@packages+astro) - packages/astro/test/fixtures/unused-slot: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - packages/astro/test/fixtures/url-import-suffix: dependencies: astro: @@ -4466,38 +4275,11 @@ importers: version: link:../../.. svelte: specifier: ^5.54.0 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) - packages/astro/test/fixtures/with-endpoint-routes: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/with-subpath-no-trailing-slash: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/fixtures/without-site-config: - dependencies: - astro: - specifier: workspace:* - version: link:../../.. - - packages/astro/test/units/_temp-fixtures: - dependencies: - '@astrojs/mdx': - specifier: workspace:* - version: link:../../../../integrations/mdx - astro: - specifier: workspace:* - version: link:../../.. - packages/create-astro: dependencies: '@astrojs/cli-kit': @@ -4785,7 +4567,7 @@ importers: version: 0.1.3 tinyglobby: specifier: ^0.2.15 - version: 0.2.15 + version: 0.2.16 vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) @@ -4920,6 +4702,26 @@ importers: version: link:../../../../../astro packages/integrations/cloudflare/test/fixtures/prerender-node-env: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../.. + '@astrojs/svelte': + specifier: workspace:* + version: link:../../../../svelte + astro: + specifier: workspace:* + version: link:../../../../../astro + fake-svelte-pkg: + specifier: file:./fake-svelte-pkg + version: file:packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg(svelte@5.55.3) + svelte: + specifier: ^5.0.0 + version: 5.55.3 + + packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg: {} + + packages/integrations/cloudflare/test/fixtures/prerender-queue-consumers: dependencies: '@astrojs/cloudflare': specifier: workspace:* @@ -5048,7 +4850,7 @@ importers: version: link:../../../../../astro svelte: specifier: ^5.53.5 - version: 5.54.1 + version: 5.55.3 packages/integrations/cloudflare/test/fixtures/top-level-return: dependencies: @@ -5099,7 +4901,7 @@ importers: version: 0.34.5 svelte: specifier: ^5.53.5 - version: 5.54.1 + version: 5.55.3 vue: specifier: ^3.5.29 version: 3.5.30(typescript@5.9.3) @@ -5159,7 +4961,7 @@ importers: version: link:../../../../../astro svelte: specifier: ^5.53.5 - version: 5.54.1 + version: 5.55.3 packages/integrations/cloudflare/test/fixtures/with-vue: dependencies: @@ -5550,6 +5352,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/mdx/test/fixtures/mdx-astro-container-escape: + dependencies: + '@astrojs/mdx': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection: dependencies: '@astrojs/mdx': @@ -5710,7 +5521,7 @@ importers: version: 0.27.3 tinyglobby: specifier: ^0.2.15 - version: 0.2.15 + version: 0.2.16 vite: specifier: ^7.3.1 version: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) @@ -5804,6 +5615,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/netlify/test/static/fixtures/image-missing-dimension: + dependencies: + '@astrojs/netlify': + specifier: workspace:* + version: link:../../../.. + astro: + specifier: workspace:* + version: link:../../../../../../astro + packages/integrations/netlify/test/static/fixtures/redirects: dependencies: '@astrojs/netlify': @@ -5834,6 +5654,9 @@ importers: '@fastify/static': specifier: ^9.0.0 version: 9.0.0 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 '@types/node': specifier: ^22.10.6 version: 22.19.11 @@ -5863,7 +5686,7 @@ importers: version: 5.8.3 node-mocks-http: specifier: ^1.17.2 - version: 1.17.2(@types/node@22.19.11) + version: 1.17.2(@types/express@5.0.6)(@types/node@22.19.11) packages/integrations/node/test/fixtures/api-route: dependencies: @@ -6042,8 +5865,8 @@ importers: packages/integrations/partytown: dependencies: '@qwik.dev/partytown': - specifier: ^0.11.2 - version: 0.11.2 + specifier: ^0.13.2 + version: 0.13.2 mrmime: specifier: ^2.0.1 version: 2.0.1 @@ -6126,6 +5949,21 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + packages/integrations/react/test/fixtures/react-19-preloads: + dependencies: + '@astrojs/react': + specifier: latest + version: link:../../.. + astro: + specifier: latest + version: link:../../../../../astro + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + packages/integrations/react/test/fixtures/react-component: dependencies: '@astrojs/react': @@ -6249,13 +6087,16 @@ importers: dependencies: '@sveltejs/vite-plugin-svelte': specifier: ^6.2.4 - version: 6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 6.2.4(svelte@5.55.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) svelte2tsx: specifier: ^0.7.52 - version: 0.7.52(svelte@5.54.1)(typescript@5.9.3) + version: 0.7.52(svelte@5.55.3)(typescript@5.9.3) vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) + vitefu: + specifier: ^1.1.2 + version: 1.1.2(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) devDependencies: astro: specifier: workspace:* @@ -6268,7 +6109,7 @@ importers: version: 1.2.0 svelte: specifier: ^5.54.1 - version: 5.54.1 + version: 5.55.3 packages/integrations/svelte/test/fixtures/async-rendering: dependencies: @@ -6280,7 +6121,7 @@ importers: version: link:../../../../../astro svelte: specifier: ^5.54.1 - version: 5.54.1 + version: 5.55.3 packages/integrations/svelte/test/fixtures/conditional-rendering: dependencies: @@ -6292,7 +6133,7 @@ importers: version: link:../../../../../astro svelte: specifier: ^5.54.1 - version: 5.54.1 + version: 5.55.3 packages/integrations/svelte/test/fixtures/empty-class: dependencies: @@ -6304,7 +6145,7 @@ importers: version: link:../../../../../astro svelte: specifier: ^5.17.1 - version: 5.54.1 + version: 5.55.3 packages/integrations/svelte/test/fixtures/prop-types: dependencies: @@ -6316,7 +6157,7 @@ importers: version: link:../../../../../astro svelte: specifier: ^5.54.1 - version: 5.54.1 + version: 5.55.3 packages/integrations/vercel: dependencies: @@ -6325,7 +6166,7 @@ importers: version: link:../../internal-helpers '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(react@19.2.4)(svelte@5.54.1)(vue@3.5.30(typescript@5.9.3)) + version: 1.6.1(react@19.2.4)(svelte@5.55.3)(vue@3.5.30(typescript@5.9.3)) '@vercel/functions': specifier: ^3.4.3 version: 3.4.3 @@ -6340,7 +6181,7 @@ importers: version: 0.27.3 tinyglobby: specifier: ^0.2.15 - version: 0.2.15 + version: 0.2.16 devDependencies: astro: specifier: workspace:* @@ -6703,8 +6544,8 @@ importers: specifier: workspace:* version: link:../../../scripts tinyglobby: - specifier: ^0.2.15 - version: 0.2.15 + specifier: ^0.2.16 + version: 0.2.16 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -6736,8 +6577,8 @@ importers: specifier: ^0.4.1 version: 0.4.1 tinyglobby: - specifier: ^0.2.15 - version: 0.2.15 + specifier: ^0.2.16 + version: 0.2.16 volar-service-css: specifier: 0.0.70 version: 0.0.70(@volar/language-service@2.4.28) @@ -6749,7 +6590,7 @@ importers: version: 0.0.70(@volar/language-service@2.4.28) volar-service-prettier: specifier: 0.0.70 - version: 0.0.70(@volar/language-service@2.4.28)(prettier@3.8.1) + version: 0.0.70(@volar/language-service@2.4.28)(prettier@3.8.2) volar-service-typescript: specifier: 0.0.70 version: 0.0.70(@volar/language-service@2.4.28) @@ -6803,12 +6644,12 @@ importers: specifier: workspace:* version: link:../../../astro svelte: - specifier: ^5.54.1 - version: 5.54.1 + specifier: ^5.55.3 + version: 5.55.3 devDependencies: tinyglobby: - specifier: ^0.2.15 - version: 0.2.15 + specifier: ^0.2.16 + version: 0.2.16 packages/language-tools/language-server/test/fixture: devDependencies: @@ -6871,8 +6712,8 @@ importers: specifier: ^2.13.1 version: 2.13.1 prettier: - specifier: ^3.8.1 - version: 3.8.1 + specifier: ^3.8.2 + version: 3.8.2 prettier-plugin-astro: specifier: ^0.14.1 version: 0.14.1 @@ -6923,8 +6764,8 @@ importers: specifier: ^11.7.5 version: 11.7.5 ovsx: - specifier: ^0.10.9 - version: 0.10.9 + specifier: ^0.10.10 + version: 0.10.10 vscode-languageclient: specifier: ^9.0.1 version: 9.0.1 @@ -7061,8 +6902,8 @@ importers: specifier: ^1.1.5 version: 1.1.5 '@types/node': - specifier: ^18.17.8 - version: 18.19.130 + specifier: ^22.10.6 + version: 22.19.11 '@types/which-pm-runs': specifier: ^1.0.2 version: 1.0.2 @@ -7117,7 +6958,7 @@ importers: version: 0.1.3 tinyglobby: specifier: ^0.2.15 - version: 0.2.15 + version: 0.2.16 tsconfck: specifier: ^3.1.6 version: 3.1.6(typescript@5.9.3) @@ -7475,59 +7316,59 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@biomejs/biome@2.4.2': - resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==} + '@biomejs/biome@2.4.10': + resolution: {integrity: sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.4.2': - resolution: {integrity: sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==} + '@biomejs/cli-darwin-arm64@2.4.10': + resolution: {integrity: sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.4.2': - resolution: {integrity: sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==} + '@biomejs/cli-darwin-x64@2.4.10': + resolution: {integrity: sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.4.2': - resolution: {integrity: sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==} + '@biomejs/cli-linux-arm64-musl@2.4.10': + resolution: {integrity: sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.4.2': - resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==} + '@biomejs/cli-linux-arm64@2.4.10': + resolution: {integrity: sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.4.2': - resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==} + '@biomejs/cli-linux-x64-musl@2.4.10': + resolution: {integrity: sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.4.2': - resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==} + '@biomejs/cli-linux-x64@2.4.10': + resolution: {integrity: sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.4.2': - resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==} + '@biomejs/cli-win32-arm64@2.4.10': + resolution: {integrity: sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.4.2': - resolution: {integrity: sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==} + '@biomejs/cli-win32-x64@2.4.10': + resolution: {integrity: sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -9607,8 +9448,8 @@ packages: resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=18'} - '@qwik.dev/partytown@0.11.2': - resolution: {integrity: sha512-795y49CqBiKiwKAD+QBZlzlqEK275hVcazZ7wBPSfgC23L+vWuA7PJmMpgxojOucZHzYi5rAAQ+IP1I3BKVZxw==} + '@qwik.dev/partytown@0.13.2': + resolution: {integrity: sha512-Umls4bSkuzqLVcGvf8OgwIn/OldproSAbaQ/iYGe8VPYBpl2CaOSxabWwkeC72LDFqxVL0b0q8XlI8MuChDyzg==} engines: {node: '>=18.0.0'} hasBin: true @@ -10040,12 +9881,18 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/canvas-confetti@1.9.0': resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -10061,6 +9908,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -10070,6 +9923,9 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -10118,9 +9974,6 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@18.19.130': - resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@20.19.33': resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} @@ -10136,6 +9989,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/parse-srcset@1.0.0': + resolution: {integrity: sha512-5ehc1s3aIQJ05ZVAmYoqDUN8zvlrIZvRUfYP+2bhGNzrY5RQBeJS9JO9jpkVm/vSOW/MkRYLArS/fhXY5cgXPg==} + '@types/picomatch@4.0.2': resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} @@ -10145,6 +10001,12 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -10168,6 +10030,9 @@ packages: '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/server-destroy@1.0.4': resolution: {integrity: sha512-+x8oAQ4Xp1wtDi2Hlmi7gUNXZNVhB5EoSQpi0qEmINdDN5Ab724WLGAalEdT1SudVY/NzMhbfZO7vU+klT0R+A==} @@ -10616,6 +10481,7 @@ packages: '@xmldom/xmldom@0.9.8': resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} @@ -11977,6 +11843,11 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fake-svelte-pkg@file:packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg: + resolution: {directory: packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg, type: directory} + peerDependencies: + svelte: ^5.0.0 + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -12178,8 +12049,8 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} - fs-fixture@2.11.0: - resolution: {integrity: sha512-elzOu5Ru04qPSBT344kngxx1bpq3RbpznEyjTcn+NHI2nvzwDcGt2zde/a6LBmF5SJtgSYBGHAPnel6S1IefeA==} + fs-fixture@2.13.0: + resolution: {integrity: sha512-bqL4EVFNgoA38OnztLfeHn4NZJ32zWnSNA3ALtessYO0WjpL//QuYl1YkYd7j+TY0cLO6cqgoHxPJpfSwKQAPA==} engines: {node: '>=18.0.0'} fs.realpath@1.0.0: @@ -13691,8 +13562,8 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} - ovsx@0.10.9: - resolution: {integrity: sha512-gY6912U50YzzNdAEFr9IxAqu59pKySXZzJUxzHRzi3/h/fWFdDDFCCXyjik6VL4TmiVKeor1Yv/cg7I3KfOUuQ==} + ovsx@0.10.10: + resolution: {integrity: sha512-/X5J4VLKPUGGaMynW9hgvsGg9jmwsK/3RhODeA2yzdeDbb8PUSNcg5GQ9aPDJW/znlqNvAwQcXAyE+Cq0RRvAQ==} engines: {node: '>= 20'} hasBin: true @@ -14153,8 +14024,8 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + prettier@3.8.2: + resolution: {integrity: sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==} engines: {node: '>=14'} hasBin: true @@ -14939,8 +14810,8 @@ packages: svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 typescript: ^4.9.4 || ^5.0.0 - svelte@5.54.1: - resolution: {integrity: sha512-ow8tncN097Ty8U1H+C3bM1xNlsCbnO2UZeN0lWBnv8f3jKho7QTTQ2LWbMXrPQDodLjH91n4kpNnLolyRhVE6A==} + svelte@5.55.3: + resolution: {integrity: sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ==} engines: {node: '>=18'} svgo@3.3.3: @@ -15034,8 +14905,8 @@ packages: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} tinyrainbow@3.0.3: @@ -15229,9 +15100,6 @@ packages: underscore@1.13.7: resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -16479,39 +16347,39 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@biomejs/biome@2.4.2': + '@biomejs/biome@2.4.10': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.4.2 - '@biomejs/cli-darwin-x64': 2.4.2 - '@biomejs/cli-linux-arm64': 2.4.2 - '@biomejs/cli-linux-arm64-musl': 2.4.2 - '@biomejs/cli-linux-x64': 2.4.2 - '@biomejs/cli-linux-x64-musl': 2.4.2 - '@biomejs/cli-win32-arm64': 2.4.2 - '@biomejs/cli-win32-x64': 2.4.2 + '@biomejs/cli-darwin-arm64': 2.4.10 + '@biomejs/cli-darwin-x64': 2.4.10 + '@biomejs/cli-linux-arm64': 2.4.10 + '@biomejs/cli-linux-arm64-musl': 2.4.10 + '@biomejs/cli-linux-x64': 2.4.10 + '@biomejs/cli-linux-x64-musl': 2.4.10 + '@biomejs/cli-win32-arm64': 2.4.10 + '@biomejs/cli-win32-x64': 2.4.10 - '@biomejs/cli-darwin-arm64@2.4.2': + '@biomejs/cli-darwin-arm64@2.4.10': optional: true - '@biomejs/cli-darwin-x64@2.4.2': + '@biomejs/cli-darwin-x64@2.4.10': optional: true - '@biomejs/cli-linux-arm64-musl@2.4.2': + '@biomejs/cli-linux-arm64-musl@2.4.10': optional: true - '@biomejs/cli-linux-arm64@2.4.2': + '@biomejs/cli-linux-arm64@2.4.10': optional: true - '@biomejs/cli-linux-x64-musl@2.4.2': + '@biomejs/cli-linux-x64-musl@2.4.10': optional: true - '@biomejs/cli-linux-x64@2.4.2': + '@biomejs/cli-linux-x64@2.4.10': optional: true - '@biomejs/cli-win32-arm64@2.4.2': + '@biomejs/cli-win32-arm64@2.4.10': optional: true - '@biomejs/cli-win32-x64@2.4.2': + '@biomejs/cli-win32-x64@2.4.10': optional: true '@bluwy/giget-core@0.1.6': @@ -16559,7 +16427,7 @@ snapshots: transitivePeerDependencies: - encoding - '@changesets/cli@2.29.8(@types/node@18.19.130)': + '@changesets/cli@2.29.8(@types/node@22.19.11)': dependencies: '@changesets/apply-release-plan': 7.0.14 '@changesets/assemble-release-plan': 6.0.9 @@ -16575,7 +16443,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@18.19.130) + '@inquirer/external-editor': 1.0.3(@types/node@22.19.11) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -17670,12 +17538,12 @@ snapshots: '@import-maps/resolve@2.0.0': {} - '@inquirer/external-editor@1.0.3(@types/node@18.19.130)': + '@inquirer/external-editor@1.0.3(@types/node@22.19.11)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.11 '@isaacs/cliui@8.0.2': dependencies: @@ -18546,7 +18414,7 @@ snapshots: '@publint/pack@0.1.4': {} - '@qwik.dev/partytown@0.11.2': + '@qwik.dev/partytown@0.13.2': dependencies: dotenv: 16.6.1 @@ -18801,20 +18669,20 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) obug: 2.1.1 - svelte: 5.54.1 + svelte: 5.55.3 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.54.1 + svelte: 5.55.3 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) vitefu: 1.1.2(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -18949,6 +18817,11 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.2.3 + '@types/canvas-confetti@1.9.0': {} '@types/chai@5.2.3': @@ -18956,6 +18829,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.2.3 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -18970,6 +18847,19 @@ snapshots: '@types/estree@1.0.8': {} + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 25.2.3 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -18978,6 +18868,8 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/js-yaml@4.0.9': {} @@ -19022,10 +18914,6 @@ snapshots: '@types/node@12.20.55': {} - '@types/node@18.19.130': - dependencies: - undici-types: 5.26.5 - '@types/node@20.19.33': dependencies: undici-types: 6.21.0 @@ -19044,12 +18932,18 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/parse-srcset@1.0.0': {} + '@types/picomatch@4.0.2': {} '@types/prismjs@1.26.6': {} '@types/prop-types@15.7.15': {} + '@types/qs@6.15.0': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@18.3.7(@types/react@18.3.28)': dependencies: '@types/react': 18.3.28 @@ -19065,17 +18959,22 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 18.19.130 + '@types/node': 25.2.3 '@types/semver@7.7.1': {} '@types/send@1.2.1': dependencies: - '@types/node': 18.19.130 + '@types/node': 25.2.3 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.2.3 '@types/server-destroy@1.0.4': dependencies: - '@types/node': 18.19.130 + '@types/node': 25.2.3 '@types/triple-beam@1.3.5': {} @@ -19093,11 +18992,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 18.19.130 + '@types/node': 25.2.3 '@types/xml2js@0.4.14': dependencies: - '@types/node': 18.19.130 + '@types/node': 25.2.3 '@types/yargs-parser@21.0.3': {} @@ -19107,7 +19006,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 18.19.130 + '@types/node': 25.2.3 optional: true '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': @@ -19181,7 +19080,7 @@ snapshots: debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.4 semver: 7.7.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -19224,10 +19123,10 @@ snapshots: dependencies: valibot: 1.2.0(typescript@5.9.3) - '@vercel/analytics@1.6.1(react@19.2.4)(svelte@5.54.1)(vue@3.5.30(typescript@5.9.3))': + '@vercel/analytics@1.6.1(react@19.2.4)(svelte@5.55.3)(vue@3.5.30(typescript@5.9.3))': optionalDependencies: react: 19.2.4 - svelte: 5.54.1 + svelte: 5.55.3 vue: 3.5.30(typescript@5.9.3) '@vercel/functions@3.4.3': @@ -21143,6 +21042,10 @@ snapshots: transitivePeerDependencies: - supports-color + fake-svelte-pkg@file:packages/integrations/cloudflare/test/fixtures/prerender-node-env/fake-svelte-pkg(svelte@5.55.3): + dependencies: + svelte: 5.55.3 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -21366,7 +21269,7 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 - fs-fixture@2.11.0: {} + fs-fixture@2.13.0: {} fs.realpath@1.0.0: {} @@ -22113,10 +22016,10 @@ snapshots: kleur@4.1.5: {} - knip@5.82.1(@types/node@18.19.130)(typescript@5.9.3): + knip@5.82.1(@types/node@22.19.11)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 - '@types/node': 18.19.130 + '@types/node': 22.19.11 fast-glob: 3.3.3 formatly: 0.3.0 jiti: 2.6.1 @@ -23110,7 +23013,7 @@ snapshots: node-mock-http@1.0.4: {} - node-mocks-http@1.17.2(@types/node@22.19.11): + node-mocks-http@1.17.2(@types/express@5.0.6)(@types/node@22.19.11): dependencies: accepts: 1.3.8 content-disposition: 0.5.4 @@ -23123,9 +23026,10 @@ snapshots: range-parser: 1.2.1 type-is: 1.6.18 optionalDependencies: + '@types/express': 5.0.6 '@types/node': 22.19.11 - node-mocks-http@1.17.2(@types/node@25.2.3): + node-mocks-http@1.17.2(@types/express@5.0.6)(@types/node@25.2.3): dependencies: accepts: 1.3.8 content-disposition: 0.5.4 @@ -23138,6 +23042,7 @@ snapshots: range-parser: 1.2.1 type-is: 1.6.18 optionalDependencies: + '@types/express': 5.0.6 '@types/node': 25.2.3 node-releases@2.0.27: {} @@ -23270,7 +23175,7 @@ snapshots: outdent@0.5.0: {} - ovsx@0.10.9: + ovsx@0.10.10: dependencies: '@vscode/vsce': 3.7.1 commander: 6.2.1 @@ -23826,12 +23731,12 @@ snapshots: prettier-plugin-astro@0.14.1: dependencies: '@astrojs/compiler': 2.13.1 - prettier: 3.8.1 + prettier: 3.8.2 sass-formatter: 0.7.9 prettier@2.8.8: {} - prettier@3.8.1: {} + prettier@3.8.2: {} pretty-bytes@5.6.0: {} @@ -24784,14 +24689,14 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte2tsx@0.7.52(svelte@5.54.1)(typescript@5.9.3): + svelte2tsx@0.7.52(svelte@5.55.3)(typescript@5.9.3): dependencies: dedent-js: 1.0.1 scule: 1.3.0 - svelte: 5.54.1 + svelte: 5.55.3 typescript: 5.9.3 - svelte@5.54.1: + svelte@5.55.3: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -24924,7 +24829,7 @@ snapshots: tinyexec@1.0.4: {} - tinyglobby@0.2.15: + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 @@ -25088,8 +24993,6 @@ snapshots: underscore@1.13.7: {} - undici-types@5.26.5: {} - undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -25441,7 +25344,7 @@ snapshots: picomatch: 4.0.4 postcss: 8.5.8 rollup: 4.59.1 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.2.3 fsevents: 2.3.3 @@ -25458,7 +25361,7 @@ snapshots: picomatch: 4.0.4 postcss: 8.5.8 rollup: 4.59.1 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 optionalDependencies: '@types/node': 22.19.11 fsevents: 2.3.3 @@ -25475,7 +25378,7 @@ snapshots: picomatch: 4.0.4 postcss: 8.5.8 rollup: 4.59.1 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.2.3 fsevents: 2.3.3 @@ -25511,7 +25414,7 @@ snapshots: std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tinyrainbow: 3.0.3 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 @@ -25556,12 +25459,12 @@ snapshots: optionalDependencies: '@volar/language-service': 2.4.28 - volar-service-prettier@0.0.70(@volar/language-service@2.4.28)(prettier@3.8.1): + volar-service-prettier@0.0.70(@volar/language-service@2.4.28)(prettier@3.8.2): dependencies: vscode-uri: 3.1.0 optionalDependencies: '@volar/language-service': 2.4.28 - prettier: 3.8.1 + prettier: 3.8.2 volar-service-typescript-twoslash-queries@0.0.70(@volar/language-service@2.4.28): dependencies: @@ -25808,7 +25711,7 @@ snapshots: '@vscode/l10n': 0.0.18 ajv: 8.18.0 ajv-draft-04: 1.0.0(ajv@8.18.0) - prettier: 3.8.1 + prettier: 3.8.2 request-light: 0.5.8 vscode-json-languageservice: 4.1.8 vscode-languageserver: 9.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b0d8f3026bdf..f4591e2626b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -48,6 +48,10 @@ minimumReleaseAgeExclude: - smol-toml@1.6.1 # Renovate security update: picomatch@4.0.4 - picomatch@4.0.4 + # Smoke test dependency (docs site) + - astro-og-canvas@0.11.0 + # @types/node@24.12.2 published <3 days ago + - '@types/node@24.12.2' peerDependencyRules: allowAny: - 'astro' diff --git a/scripts/jsconfig.json b/scripts/jsconfig.json deleted file mode 100644 index 0caa704a7a8e..000000000000 --- a/scripts/jsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "declaration": true, - "strict": true, - "module": "esnext", - "moduleResolution": "nodenext", - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "target": "esnext" - } -} diff --git a/scripts/testing/github-test-reporter.js b/scripts/testing/github-test-reporter.js index 38f1c813bca0..0351b6fd5d92 100644 --- a/scripts/testing/github-test-reporter.js +++ b/scripts/testing/github-test-reporter.js @@ -20,7 +20,7 @@ export default new Transform({ file: event.data.file, line: event.data.line, column: event.data.column, - isSuite: event.data.details.type !== 'test', + isSuite: event.data.details.type === 'suite', }); break; } diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 000000000000..00ed7dd5d654 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "allowJs": true + }, + "references": [ + { + "path": "../.github/scripts/tsconfig.json" + } + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 7ed082d83639..1600925b31ce 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,6 +13,8 @@ "stripInternal": true, "noUnusedLocals": true, "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "outDir": "${configDir}/node_modules/.cache/tsconfig/out", "types": ["node"] } }