diff --git a/.changeset/few-apples-double.md b/.changeset/few-apples-double.md new file mode 100644 index 000000000000..fbafeedc5843 --- /dev/null +++ b/.changeset/few-apples-double.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fix a false positive in the dev toolbar accessibility audit for anchors with text inside closed `
` elements. diff --git a/.changeset/fix-advanced-routing-404-fallback.md b/.changeset/fix-advanced-routing-404-fallback.md new file mode 100644 index 000000000000..4ec812e69c1b --- /dev/null +++ b/.changeset/fix-advanced-routing-404-fallback.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a bug where `experimental.advancedRouting` with `astro/hono` handlers threw `TypeError: Cannot read properties of undefined (reading 'route')` for unmatched routes instead of rendering the custom 404 page. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f9fcdbc0d38b..8fa63fa238f5 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -36,7 +36,7 @@ jobs: with: persist-credentials: false - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49e1659a4d3f..4899c85a4e56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup node@${{ matrix.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -125,7 +125,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -184,7 +184,7 @@ jobs: fetch-depth: 0 - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup node@${{ matrix.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -240,7 +240,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup node@${{ matrix.node_version }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -295,7 +295,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup node@${{ matrix.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -333,7 +333,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup node@${{ matrix.NODE_VERSION }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/continuous_benchmark.yml b/.github/workflows/continuous_benchmark.yml index 85b9fd412726..1645f1ed89b4 100644 --- a/.github/workflows/continuous_benchmark.yml +++ b/.github/workflows/continuous_benchmark.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/fix-verification.yml b/.github/workflows/fix-verification.yml index 1bde51d82ee7..054e72d990e7 100644 --- a/.github/workflows/fix-verification.yml +++ b/.github/workflows/fix-verification.yml @@ -32,7 +32,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 9ea30aca6554..c1d2095d94e5 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -65,7 +65,7 @@ jobs: git config user.email "fred+astrobot@astro.build" - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/merge-fix.yml b/.github/workflows/merge-fix.yml index 409596ef678a..07d1eb2565e9 100644 --- a/.github/workflows/merge-fix.yml +++ b/.github/workflows/merge-fix.yml @@ -94,7 +94,7 @@ jobs: - name: Setup PNPM if: steps.pr.outputs.number != '' && steps.attempts.outputs.skip != 'true' - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node if: steps.pr.outputs.number != '' && steps.attempts.outputs.skip != 'true' diff --git a/.github/workflows/merge-main-to-next.yml b/.github/workflows/merge-main-to-next.yml index 490a6fe207e6..ef8efbfd18c1 100644 --- a/.github/workflows/merge-main-to-next.yml +++ b/.github/workflows/merge-main-to-next.yml @@ -47,7 +47,7 @@ jobs: - name: Setup PNPM if: steps.check-next.outputs.exists == 'true' - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 with: package_json_file: runtime/package.json diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml index 3395c2251446..32863546fbda 100644 --- a/.github/workflows/preview-release.yml +++ b/.github/workflows/preview-release.yml @@ -47,7 +47,7 @@ jobs: fetch-depth: 0 - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7ac3bdf7c57..6b48862cfe7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/scripts.yml b/.github/workflows/scripts.yml index 0711d8648c91..8bfa427534be 100644 --- a/.github/workflows/scripts.yml +++ b/.github/workflows/scripts.yml @@ -38,7 +38,7 @@ jobs: path: main - name: Setup PNPM - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.prettierignore b/.prettierignore index 5cc64099eca0..cca3a0e0fe66 100644 --- a/.prettierignore +++ b/.prettierignore @@ -32,5 +32,6 @@ pnpm-lock.yaml **/*.mjs **/*.cjs **/*.css +**/*.code-snippets CHANGELOG.md diff --git a/package.json b/package.json index 362290c26545..4ff3724c1985 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "engines": { "node": ">=22.12.0" }, - "packageManager": "pnpm@11.0.9", + "packageManager": "pnpm@11.5.0", "dependencies": { "astro-benchmark": "workspace:*" }, diff --git a/packages/astro/e2e/dev-toolbar-audits.test.ts b/packages/astro/e2e/dev-toolbar-audits.test.ts index b9069be314bb..b8690f0e9ed1 100644 --- a/packages/astro/e2e/dev-toolbar-audits.test.ts +++ b/packages/astro/e2e/dev-toolbar-audits.test.ts @@ -259,4 +259,22 @@ test.describe('Dev Toolbar - Audits', () => { const count = await auditHighlights.count(); expect(count).toEqual(0); }); + + test('does not warn about anchor text inside closed details but still warns for hidden text', async ({ + page, + astro, + }) => { + await page.goto(astro.resolveUrl('/a11y-hidden-anchor')); + + const toolbar = page.locator('astro-dev-toolbar'); + const appButton = toolbar.locator('button[data-app-id="astro:audit"]'); + await appButton.click(); + + const auditCanvas = toolbar.locator('astro-dev-toolbar-app-canvas[data-app-id="astro:audit"]'); + const missingContentHighlights = auditCanvas.locator( + 'astro-dev-toolbar-highlight[data-audit-code="a11y-missing-content"]', + ); + + await expect(missingContentHighlights).toHaveCount(1); + }); }); diff --git a/packages/astro/e2e/fixtures/dev-toolbar/src/pages/a11y-hidden-anchor.astro b/packages/astro/e2e/fixtures/dev-toolbar/src/pages/a11y-hidden-anchor.astro new file mode 100644 index 000000000000..f0a1474a19c0 --- /dev/null +++ b/packages/astro/e2e/fixtures/dev-toolbar/src/pages/a11y-hidden-anchor.astro @@ -0,0 +1,12 @@ +--- +--- + +
+ More links + Read the documentation +
+ + + + + diff --git a/packages/astro/src/core/fetch/fetch-state.ts b/packages/astro/src/core/fetch/fetch-state.ts index afbbfc5eb552..f888f79a404c 100644 --- a/packages/astro/src/core/fetch/fetch-state.ts +++ b/packages/astro/src/core/fetch/fetch-state.ts @@ -15,7 +15,6 @@ import { AstroCookies } from '../cookies/index.js'; import { type Pipeline, Slots } from '../render/index.js'; import { ASTRO_GENERATOR, - DEFAULT_404_COMPONENT, fetchStateSymbol, originPathnameSymbol, pipelineSymbol, @@ -36,7 +35,7 @@ import { Rewrites } from '../rewrites/handler.js'; import { isRoute404or500, isRouteServerIsland } from '../routing/match.js'; import { normalizeUrl } from '../util/normalized-url.js'; import { getOriginPathname, setOriginPathname } from '../routing/rewrite.js'; -import { routeHasHtmlExtension } from '../routing/helpers.js'; +import { getCustom404Route, routeHasHtmlExtension } from '../routing/helpers.js'; import type { ResolvedRenderOptions } from '../app/base.js'; import { getRenderOptions } from '../app/render-options.js'; import { getFirstForwardedValue, validateForwardedHeaders } from '../app/validate-headers.js'; @@ -579,10 +578,8 @@ export class FetchState implements AstroFetchState { } return { insertDirective(payload) { - if (state?.result?.directives) { + if (state.result) { state.result.directives = pushDirective(state.result.directives, payload); - } else { - state?.result?.directives.push(payload); } }, insertScriptResource(resource) { @@ -820,9 +817,14 @@ export class FetchState implements AstroFetchState { // Fall back to a 404 route so middleware can still run. if (!this.routeData) { - this.routeData = pipeline.manifestData.routes.find( - (route) => route.component === '404.astro' || route.component === DEFAULT_404_COMPONENT, - ); + const custom404 = getCustom404Route(pipeline.manifestData); + // Only use SSR 404 routes here. Prerendered 404 pages are already + // built to static HTML, so the pipeline can't render them at + // runtime. Leaving routeData unset lets the error handler serve + // the pre-built page from disk instead. + if (custom404 && !custom404.prerender) { + this.routeData = custom404; + } } if (!this.routeData) { pipeline.logger.debug('router', "Astro hasn't found routes that match " + this.request.url); diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/a11y.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/a11y.ts index ba0a3d725d79..46a73290ed5a 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/a11y.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/rules/a11y.ts @@ -31,6 +31,17 @@ import type { AuditRuleWithSelector } from './index.js'; const WHITESPACE_REGEX = /\s+/; +function isHiddenByClosedDetails(element: HTMLElement): boolean { + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + if (parent.localName !== 'details' || (parent as HTMLDetailsElement).open) continue; + + const summary = Array.from(parent.children).find((child) => child.localName === 'summary'); + if (!summary?.contains(element)) return true; + } + + return false; +} + const a11y_required_attributes = { a: ['href'], area: ['alt', 'aria-label', 'aria-labelledby'], @@ -367,6 +378,8 @@ export const a11y: AuditRuleWithSelector[] = [ 'Headings and anchors must have an accessible name, which can come from: inner text, aria-label, aria-labelledby, an img with alt property, or an svg with a tag .', selector: a11y_required_content.join(','), match(element: HTMLElement) { + if (isHiddenByClosedDetails(element)) return false; + // innerText is used to ignore hidden text const innerText = element.innerText?.trim(); if (innerText && innerText !== '') return false; diff --git a/packages/astro/test/astro-directives.test.ts b/packages/astro/test/astro-directives.test.ts deleted file mode 100644 index 9303be517c0c..000000000000 --- a/packages/astro/test/astro-directives.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import 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('Directives', async () => { - let fixture: Fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/astro-directives/', - // test suite was authored when inlineStylesheets defaulted to never - build: { inlineStylesheets: 'never' }, - outDir: './dist/astro-directives-directives/', - }); - await fixture.build(); - }); - - it('Passes define:vars to script elements', async () => { - const html = await fixture.readFile('/define-vars/index.html'); - const $ = cheerio.load(html); - - assert.equal($('script').length, 5); - - let i = 0; - for (const script of $('script').toArray()) { - // Wrap script in scope ({}) to avoid redeclaration errors - assert.equal($(script).text().startsWith('(function(){'), true); - assert.equal($(script).text().endsWith('})();'), true); - if (i < 2) { - // Inline defined variables - assert.equal($(script).toString().includes('const foo = "bar"'), true); - } else if (i < 3) { - // Convert invalid keys to valid identifiers - assert.equal($(script).toString().includes('const dashCase = "bar"'), true); - } else if (i < 4) { - // Closing script tags in strings are escaped - assert.equal( - $(script).toString().includes('const bar = "\\u003cscript>bar\\u003c/script>"'), - true, - ); - } else { - // Vars with undefined values are handled - assert.equal($(script).toString().includes('const undef = undefined'), true); - } - i++; - } - }); - - it('Passes define:vars to style elements', async () => { - const html = await fixture.readFile('/define-vars/index.html'); - const $ = cheerio.load(html); - - // All styles should be bundled - assert.equal($('style').length, 0); - - // Inject style attribute on top-level element in page - assert.equal($('html').attr('style')!.toString().includes('--bg: white;'), true); - assert.equal($('html').attr('style')!.toString().includes('--fg: black;'), true); - - // Inject style attribute on top-level elements in component - assert.equal($('h1').attr('style')!.toString().includes('--textColor: red;'), true); - }); - - it('Properly handles define:vars on style elements with style object', async () => { - const html = await fixture.readFile('/define-vars/index.html'); - const $ = cheerio.load(html); - - // All styles should be bundled - assert.equal($('style').length, 0); - - // Inject style attribute on top-level element in page - assert.equal( - $('#compound-style') - .attr('style')! - .toString() - .includes('color:var(--fg);--bg: white;--fg: black;'), - true, - ); - }); - - it('set:html', async () => { - const html = await fixture.readFile('/set-html/index.html'); - const $ = cheerio.load(html); - - assert.equal($('#text').length, 1); - assert.equal($('#text').text(), 'a'); - - assert.equal($('#zero').length, 1); - assert.equal($('#zero').text(), '0'); - - assert.equal($('#number').length, 1); - assert.equal($('#number').text(), '1'); - - assert.equal($('#undefined').length, 1); - assert.equal($('#undefined').text(), ''); - - assert.equal($('#null').length, 1); - assert.equal($('#null').text(), ''); - - assert.equal($('#false').length, 1); - assert.equal($('#false').text(), ''); - - assert.equal($('#true').length, 1); - assert.equal($('#true').text(), 'true'); - }); - - it('ignores client directives on Astro components', async () => { - const html = await fixture.readFile('/client/index.html'); - const $ = cheerio.load(html); - - const hello = $('h1.hello'); - assert.equal(hello.text(), 'Hello'); - - // Astro components should not have client directives - assert.equal(hello.attr('client:load'), undefined); - - // Should not create Astro islands - assert.equal($('astro-island').length, 0); - }); - - it('set:html Fragment as slot (children)', async () => { - let res = await fixture.readFile('/set-html-children/index.html'); - assert.equal(res.includes('Test'), true); - }); -}); - -describe('set:html dev', () => { - let fixture: Fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/astro-directives/', - outDir: './dist/astro-directives-set-html-dev/', - }); - }); - - describe('Development', () => { - let devServer: DevServer; - - before(async () => { - devServer = await fixture.startDevServer(); - (globalThis as any).TEST_FETCH = ( - fetch: typeof globalThis.fetch, - url: string, - init?: RequestInit, - ) => { - return fetch(fixture.resolveUrl(url), init); - }; - }); - - after(async () => { - await devServer.stop(); - }); - - it('set:html can take a fetch()', async () => { - let res = await fixture.fetch('/set-html-fetch'); - assert.equal(res.status, 200); - let html = await res.text(); - const $ = cheerio.load(html); - assert.equal($('#fetched-html').length, 1); - assert.equal($('#fetched-html').text(), 'works'); - }); - - it('set:html Fragment as slot (children) in dev', async () => { - let res = await fixture.fetch('/set-html-children'); - assert.equal(res.status, 200); - let html = await res.text(); - assert.equal(html.includes('Test'), true); - }); - }); -}); diff --git a/packages/astro/test/cache-route.test.ts b/packages/astro/test/cache-route.test.ts deleted file mode 100644 index 93d60b87e7a5..000000000000 --- a/packages/astro/test/cache-route.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import testAdapter from './test-adapter.ts'; -import { type App, type Fixture, loadFixture } from './test-utils.ts'; - -describe('context.cache', () => { - it('build fails for invalid cache option values', async () => { - await assert.rejects( - () => - loadFixture({ - root: './fixtures/cache-route/', - output: 'server', - adapter: testAdapter(), - experimental: { - cache: { - provider: { - entrypoint: fileURLToPath( - new URL('./fixtures/cache-route/mock-cache-provider.mjs', import.meta.url), - ), - }, - }, - routeRules: { - '/api': { maxAge: -1 }, - }, - }, - outDir: './dist/cache-route-context-cache/', - }), - (err: Error) => { - assert.ok(err.message.includes('maxAge')); - return true; - }, - ); - }); - - it('build fails with a clear error for an invalid cache provider', async () => { - const fixture = await loadFixture({ - root: './fixtures/cache-route/', - output: 'server', - adapter: testAdapter(), - experimental: { - cache: { - provider: { entrypoint: 'nonexistent-cache-provider-package' }, - }, - }, - outDir: './dist/cache-route-context-cache/', - }); - await assert.rejects( - () => fixture.build({}), - (err: Error) => { - assert.ok( - err.message.includes('nonexistent-cache-provider-package'), - `Expected provider name in error, got: ${err.message}`, - ); - return true; - }, - ); - }); - - describe('Production (CDN-style provider)', () => { - let fixture: Fixture; - let app: App; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/cache-route/', - output: 'server', - adapter: testAdapter(), - experimental: { - cache: { - provider: { - entrypoint: fileURLToPath( - new URL('./fixtures/cache-route/mock-cache-provider.mjs', import.meta.url), - ), - }, - }, - routeRules: { - '/config-route': { maxAge: 600, tags: ['config'] }, - }, - }, - outDir: './dist/cache-route-production-cdn-style-provider/', - }); - await fixture.build({}); - app = await fixture.loadTestAdapterApp(); - }); - - async function fetchResponse(path: string) { - const request = new Request('http://example.com' + path); - const response = await app.render(request); - return response; - } - - it('sets CDN-Cache-Control and Cache-Tag headers from context.cache.set()', async () => { - const response = await fetchResponse('/api'); - assert.equal(response.status, 200); - const cacheControl = response.headers.get('CDN-Cache-Control')!; - assert.ok(cacheControl, 'CDN-Cache-Control header should be present'); - assert.ok(cacheControl.includes('max-age=300'), `Expected max-age=300, got: ${cacheControl}`); - assert.ok( - cacheControl.includes('stale-while-revalidate=60'), - `Expected stale-while-revalidate=60, got: ${cacheControl}`, - ); - const cacheTag = response.headers.get('Cache-Tag')!; - assert.ok(cacheTag, 'Cache-Tag header should be present'); - assert.ok(cacheTag.includes('api'), `Expected 'api' in Cache-Tag, got: ${cacheTag}`); - assert.ok(cacheTag.includes('data'), `Expected 'data' in Cache-Tag, got: ${cacheTag}`); - }); - - it('produces no cache headers when cache.set(false)', async () => { - const response = await fetchResponse('/no-cache'); - assert.equal(response.status, 200); - assert.equal(response.headers.get('CDN-Cache-Control'), null); - assert.equal(response.headers.get('Cache-Tag'), null); - }); - - it('produces Cache-Tag but no CDN-Cache-Control for tags-only', async () => { - const response = await fetchResponse('/tags-only'); - assert.equal(response.status, 200); - assert.equal( - response.headers.get('CDN-Cache-Control'), - null, - 'CDN-Cache-Control should not be set for tags-only', - ); - const cacheTag = response.headers.get('Cache-Tag')!; - assert.ok(cacheTag, 'Cache-Tag header should be present'); - assert.ok(cacheTag.includes('product'), `Expected 'product' tag, got: ${cacheTag}`); - assert.ok(cacheTag.includes('sku-123'), `Expected 'sku-123' tag, got: ${cacheTag}`); - }); - - it('applies config-level route cache options automatically', async () => { - const response = await fetchResponse('/config-route'); - assert.equal(response.status, 200); - const cacheControl = response.headers.get('CDN-Cache-Control')!; - assert.ok(cacheControl, 'CDN-Cache-Control header should be present from config'); - assert.ok(cacheControl.includes('max-age=600'), `Expected max-age=600, got: ${cacheControl}`); - const cacheTag = response.headers.get('Cache-Tag')!; - assert.ok(cacheTag, 'Cache-Tag header should be present from config'); - assert.ok(cacheTag.includes('config'), `Expected 'config' tag, got: ${cacheTag}`); - }); - - it('sets cache headers on .astro pages via Astro.cache', async () => { - const response = await fetchResponse('/'); - assert.equal(response.status, 200); - const cacheControl = response.headers.get('CDN-Cache-Control')!; - assert.ok(cacheControl, 'CDN-Cache-Control should be set on .astro page'); - assert.ok(cacheControl.includes('max-age=120'), `Expected max-age=120, got: ${cacheControl}`); - const cacheTag = response.headers.get('Cache-Tag')!; - assert.ok(cacheTag, 'Cache-Tag should be set on .astro page'); - assert.ok(cacheTag.includes('home'), `Expected 'home' tag, got: ${cacheTag}`); - }); - - it('response body is correct JSON from API route', async () => { - const response = await fetchResponse('/api'); - const body = await response.json(); - assert.deepEqual(body, { ok: true }); - }); - }); - - describe('Disabled (no cache provider configured)', () => { - let fixture: Fixture; - let app: App; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/cache-route/', - output: 'server', - adapter: testAdapter(), - outDir: './dist/cache-route-disabled/', - }); - await fixture.build({}); - app = await fixture.loadTestAdapterApp(); - }); - - async function fetchResponse(path: string) { - const request = new Request('http://example.com' + path); - return app.render(request); - } - - // Regression: Astro.cache must always be defined as a no-op shim - // even when experimental.cache is not configured, so that - // `Astro.cache.set(...)` calls do not crash. - it('Astro.cache.set() is a no-op on .astro pages', async () => { - const response = await fetchResponse('/'); - assert.equal(response.status, 200); - assert.equal(response.headers.get('CDN-Cache-Control'), null); - assert.equal(response.headers.get('Cache-Tag'), null); - }); - - it('context.cache.set() is a no-op in API routes', async () => { - const response = await fetchResponse('/api'); - assert.equal(response.status, 200); - assert.equal(response.headers.get('CDN-Cache-Control'), null); - assert.equal(response.headers.get('Cache-Tag'), null); - const body = await response.json(); - assert.deepEqual(body, { ok: true }); - }); - }); -}); diff --git a/packages/astro/test/fixtures/astro-directives/package.json b/packages/astro/test/fixtures/astro-directives/package.json deleted file mode 100644 index fc233a5a60d3..000000000000 --- a/packages/astro/test/fixtures/astro-directives/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@test/astro-directives", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/astro-directives/public/test.html b/packages/astro/test/fixtures/astro-directives/public/test.html deleted file mode 100644 index 227e8db08e06..000000000000 --- a/packages/astro/test/fixtures/astro-directives/public/test.html +++ /dev/null @@ -1 +0,0 @@ -
works
diff --git a/packages/astro/test/fixtures/astro-directives/src/components/Hello.astro b/packages/astro/test/fixtures/astro-directives/src/components/Hello.astro deleted file mode 100644 index 7bb87d1814e6..000000000000 --- a/packages/astro/test/fixtures/astro-directives/src/components/Hello.astro +++ /dev/null @@ -1 +0,0 @@ -

Hello

diff --git a/packages/astro/test/fixtures/astro-directives/src/components/Slot.astro b/packages/astro/test/fixtures/astro-directives/src/components/Slot.astro deleted file mode 100644 index a5b6f5a17543..000000000000 --- a/packages/astro/test/fixtures/astro-directives/src/components/Slot.astro +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/packages/astro/test/fixtures/astro-directives/src/components/Title.astro b/packages/astro/test/fixtures/astro-directives/src/components/Title.astro deleted file mode 100644 index 113d5b1fef6b..000000000000 --- a/packages/astro/test/fixtures/astro-directives/src/components/Title.astro +++ /dev/null @@ -1,11 +0,0 @@ ---- -const textColor = 'red' ---- - -

hello there

- - diff --git a/packages/astro/test/fixtures/astro-directives/src/pages/client.astro b/packages/astro/test/fixtures/astro-directives/src/pages/client.astro deleted file mode 100644 index 4bf59fc2b4f0..000000000000 --- a/packages/astro/test/fixtures/astro-directives/src/pages/client.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -import Hello from '../components/Hello.astro'; ---- - - - - - - Document - - - - - diff --git a/packages/astro/test/fixtures/astro-directives/src/pages/define-vars.astro b/packages/astro/test/fixtures/astro-directives/src/pages/define-vars.astro deleted file mode 100644 index 108a120355d4..000000000000 --- a/packages/astro/test/fixtures/astro-directives/src/pages/define-vars.astro +++ /dev/null @@ -1,42 +0,0 @@ ---- -import Title from "../components/Title.astro" -let foo = 'bar' -let bg = 'white' -let fg = 'black' -let bar = '' -let undef: undefined; ---- - - - - - - - - - - - - -
- - </body> -</html> diff --git a/packages/astro/test/fixtures/astro-directives/src/pages/set-html-children.astro b/packages/astro/test/fixtures/astro-directives/src/pages/set-html-children.astro deleted file mode 100644 index c539bc3f6f97..000000000000 --- a/packages/astro/test/fixtures/astro-directives/src/pages/set-html-children.astro +++ /dev/null @@ -1,22 +0,0 @@ ---- -import Slot from '../components/Slot.astro'; ---- - -<html> - <body> - <h3>Bug: Astro.slots.render() with arguments does not work with <Fragment> slot</h3> - <p>Comment out working example and uncomment non working examples</p> - <hr> - - <Slot> - <Fragment slot="name"> - Test - </Fragment> - </Slot> - <Slot> - <!-- <Fragment slot="name"> - {arg => <p>{arg}</p>} - </Fragment> --> - </Slot> - </body> -</html> diff --git a/packages/astro/test/fixtures/astro-directives/src/pages/set-html-fetch.astro b/packages/astro/test/fixtures/astro-directives/src/pages/set-html-fetch.astro deleted file mode 100644 index d03f45f7f586..000000000000 --- a/packages/astro/test/fixtures/astro-directives/src/pages/set-html-fetch.astro +++ /dev/null @@ -1,18 +0,0 @@ ---- -// This is a dev only test -const mode = import.meta.env.MODE; ---- -<html> - <head> - <title>Testing - - -

Testing

-
- - diff --git a/packages/astro/test/fixtures/astro-directives/src/pages/set-html-types.astro b/packages/astro/test/fixtures/astro-directives/src/pages/set-html-types.astro deleted file mode 100644 index d70061169c00..000000000000 --- a/packages/astro/test/fixtures/astro-directives/src/pages/set-html-types.astro +++ /dev/null @@ -1,36 +0,0 @@ ---- -function * iterator(id = 'iterator') { - for(const num of [1, 2, 3, 4, 5]) { - yield `${num}`; - } -} - -async function * asynciterator() { - for(const num of iterator('asynciterator')) { - yield Promise.resolve(num); - } -} ---- - - - Testing - - -

Testing

-
works`}>
-
works`)}>
-
`, { - headers: { - 'content-type': 'text/html' - } - })}>
-
-
-
read me`); - controller.close(); - }, - })}>
- - diff --git a/packages/astro/test/fixtures/astro-directives/src/pages/set-html.astro b/packages/astro/test/fixtures/astro-directives/src/pages/set-html.astro deleted file mode 100644 index c51b2da3da84..000000000000 --- a/packages/astro/test/fixtures/astro-directives/src/pages/set-html.astro +++ /dev/null @@ -1,12 +0,0 @@ - - - -
-
-
-
-
-
-
- - diff --git a/packages/astro/test/fixtures/cache-route/astro.config.mjs b/packages/astro/test/fixtures/cache-route/astro.config.mjs deleted file mode 100644 index 23000852f939..000000000000 --- a/packages/astro/test/fixtures/cache-route/astro.config.mjs +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-check -import { defineConfig } from 'astro/config'; -import node from '@astrojs/node'; - -export default defineConfig({ - adapter: node({ - mode: 'standalone' - }) -}); diff --git a/packages/astro/test/fixtures/cache-route/mock-cache-provider.mjs b/packages/astro/test/fixtures/cache-route/mock-cache-provider.mjs deleted file mode 100644 index 26d55bacef1e..000000000000 --- a/packages/astro/test/fixtures/cache-route/mock-cache-provider.mjs +++ /dev/null @@ -1,12 +0,0 @@ -/** - * A CDN-style cache provider for testing. - * Does NOT implement onRequest — just relies on CDN-Cache-Control / Cache-Tag headers. - */ -export default function createCacheProvider(_config) { - return { - name: 'mock-cdn-cache', - async invalidate(_options) { - // no-op for testing - }, - }; -} diff --git a/packages/astro/test/fixtures/cache-route/package.json b/packages/astro/test/fixtures/cache-route/package.json deleted file mode 100644 index 0a893b9a79ed..000000000000 --- a/packages/astro/test/fixtures/cache-route/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@test/cache-route", - "version": "0.0.0", - "private": true, - "dependencies": { - "@astrojs/node": "workspace:*", - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/cache-route/src/pages/api.ts b/packages/astro/test/fixtures/cache-route/src/pages/api.ts deleted file mode 100644 index 9a59ea7d8640..000000000000 --- a/packages/astro/test/fixtures/cache-route/src/pages/api.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const prerender = false; - -export const GET = async (context) => { - // Set cache options via the API - context.cache.set({ maxAge: 300, swr: 60, tags: ['api', 'data'] }); - return Response.json({ ok: true }); -}; diff --git a/packages/astro/test/fixtures/cache-route/src/pages/config-route.ts b/packages/astro/test/fixtures/cache-route/src/pages/config-route.ts deleted file mode 100644 index 96ca16969b20..000000000000 --- a/packages/astro/test/fixtures/cache-route/src/pages/config-route.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const prerender = false; - -export const GET = async (context) => { - // This route is configured via config-level cache routes. - // Don't call cache.set() — the config match should apply automatically. - return Response.json({ fromConfig: true }); -}; diff --git a/packages/astro/test/fixtures/cache-route/src/pages/index.astro b/packages/astro/test/fixtures/cache-route/src/pages/index.astro deleted file mode 100644 index a410e1c1cc64..000000000000 --- a/packages/astro/test/fixtures/cache-route/src/pages/index.astro +++ /dev/null @@ -1,9 +0,0 @@ ---- -export const prerender = false; -Astro.cache.set({ maxAge: 120, tags: ['home'] }); ---- - - -

Cache Test Home

- - diff --git a/packages/astro/test/fixtures/cache-route/src/pages/no-cache.ts b/packages/astro/test/fixtures/cache-route/src/pages/no-cache.ts deleted file mode 100644 index 1293893178c1..000000000000 --- a/packages/astro/test/fixtures/cache-route/src/pages/no-cache.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const prerender = false; - -export const GET = async (context) => { - // Explicitly disable caching - context.cache.set(false); - return Response.json({ cached: false }); -}; diff --git a/packages/astro/test/fixtures/cache-route/src/pages/slow.astro b/packages/astro/test/fixtures/cache-route/src/pages/slow.astro deleted file mode 100644 index b2e2ffe77936..000000000000 --- a/packages/astro/test/fixtures/cache-route/src/pages/slow.astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -export const prerender = false; -Astro.cache.set({ maxAge: 30 }); - -await new Promise((resolve) => setTimeout(resolve, 3000)); - ---- - - -

Cache Test Home

-{new Date().toLocaleString()} - - diff --git a/packages/astro/test/fixtures/cache-route/src/pages/tags-only.ts b/packages/astro/test/fixtures/cache-route/src/pages/tags-only.ts deleted file mode 100644 index 52ccc2ad0c6c..000000000000 --- a/packages/astro/test/fixtures/cache-route/src/pages/tags-only.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const prerender = false; - -export const GET = async (context) => { - // Set only tags, no maxAge - context.cache.set({ tags: ['product', 'sku-123'] }); - return Response.json({ tagged: true }); -}; diff --git a/packages/astro/test/fixtures/queue-rendering/astro.config.mjs b/packages/astro/test/fixtures/queue-rendering/astro.config.mjs deleted file mode 100644 index f86e8a140cae..000000000000 --- a/packages/astro/test/fixtures/queue-rendering/astro.config.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'astro/config'; -import react from '@astrojs/react'; - -export default defineConfig({ - integrations: [react()], - experimental: { - queuedRendering: { - enabled: true, - }, - }, -}); diff --git a/packages/astro/test/fixtures/queue-rendering/package.json b/packages/astro/test/fixtures/queue-rendering/package.json deleted file mode 100644 index 557362d1c348..000000000000 --- a/packages/astro/test/fixtures/queue-rendering/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@test/queue-rendering", - "version": "0.0.0", - "private": true, - "dependencies": { - "astro": "workspace:*", - "@astrojs/react": "workspace:*", - "react": "^18.3.1", - "react-dom": "^18.3.1" - } -} diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/Counter.jsx b/packages/astro/test/fixtures/queue-rendering/src/components/Counter.jsx deleted file mode 100644 index 27e86047c16c..000000000000 --- a/packages/astro/test/fixtures/queue-rendering/src/components/Counter.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useState } from 'react'; - -export default function Counter({ initialCount = 0 }) { - const [count, setCount] = useState(initialCount); - - return ( -
-

Count: {count}

- -
- ); -} diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/Nested.astro b/packages/astro/test/fixtures/queue-rendering/src/components/Nested.astro deleted file mode 100644 index 2ef06af9e559..000000000000 --- a/packages/astro/test/fixtures/queue-rendering/src/components/Nested.astro +++ /dev/null @@ -1,6 +0,0 @@ ---- -const { level = 0 } = Astro.props; ---- -
- Level {level} -
diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/Static.jsx b/packages/astro/test/fixtures/queue-rendering/src/components/Static.jsx deleted file mode 100644 index c4e5c176c6ec..000000000000 --- a/packages/astro/test/fixtures/queue-rendering/src/components/Static.jsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function Static({ message }) { - return ( -
-

Message: {message}

-
- ); -} diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/WithHead.astro b/packages/astro/test/fixtures/queue-rendering/src/components/WithHead.astro deleted file mode 100644 index 2309abcb3d7b..000000000000 --- a/packages/astro/test/fixtures/queue-rendering/src/components/WithHead.astro +++ /dev/null @@ -1,17 +0,0 @@ ---- -const { title } = Astro.props; ---- -
-

{title}

-

This component adds content to the head

-
- - - - diff --git a/packages/astro/test/fixtures/queue-rendering/src/components/WithSlot.astro b/packages/astro/test/fixtures/queue-rendering/src/components/WithSlot.astro deleted file mode 100644 index 9a22e7c043db..000000000000 --- a/packages/astro/test/fixtures/queue-rendering/src/components/WithSlot.astro +++ /dev/null @@ -1,9 +0,0 @@ ---- -const { title } = Astro.props; ---- -
-

{title}

-
- -
-
diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/client-components.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/client-components.astro deleted file mode 100644 index 5ca7ba363c46..000000000000 --- a/packages/astro/test/fixtures/queue-rendering/src/pages/client-components.astro +++ /dev/null @@ -1,42 +0,0 @@ ---- -import Counter from '../components/Counter.jsx'; -import Static from '../components/Static.jsx'; ---- - - - Client Components Test - - -

Client Components Test

- -
-

client:load

- -
- -
-

client:idle

- -
- -
-

client:visible

- -
- -
-

client:media

- -
- -
-

client:only

- -
- -
-

No client directive (SSR only)

- -
- - diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/directives.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/directives.astro deleted file mode 100644 index b68aaa292c29..000000000000 --- a/packages/astro/test/fixtures/queue-rendering/src/pages/directives.astro +++ /dev/null @@ -1,33 +0,0 @@ ---- -const htmlContent = 'Bold text from set:html'; -const textContent = 'This should be escaped'; -const inlineStyle = { color: 'red', fontSize: '20px' }; ---- - - - Astro Directives Test - - -

Directives Test

- -
-

set:html

-
-
- -
-

set:text

-
-
- -
-

class:list

-
Class List Test
-
- -
-

Inline Style Object

-
Styled Text
-
- - diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/head-content.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/head-content.astro deleted file mode 100644 index ec7ae11a8dce..000000000000 --- a/packages/astro/test/fixtures/queue-rendering/src/pages/head-content.astro +++ /dev/null @@ -1,31 +0,0 @@ ---- -import WithHead from '../components/WithHead.astro'; ---- - - - Head Content Test - - - -

Head Content Test

- -
- -

Inline styles test

-
- -
- -
- -
- -
- - diff --git a/packages/astro/test/fixtures/queue-rendering/src/pages/index.astro b/packages/astro/test/fixtures/queue-rendering/src/pages/index.astro deleted file mode 100644 index 8200422133ab..000000000000 --- a/packages/astro/test/fixtures/queue-rendering/src/pages/index.astro +++ /dev/null @@ -1,39 +0,0 @@ ---- -import Nested from '../components/Nested.astro'; -import WithSlot from '../components/WithSlot.astro'; - -const items = ['First', 'Second', 'Third']; ---- - - - Queue Rendering Test - - -

Queue Rendering Test

- -
-

Simple text rendering

-

Number: {42}

-

Boolean: {true}

-
- -
-
    - {items.map(item =>
  • {item}
  • )} -
-
- -
- - - -
- -
- -

Slot content here

-

Multiple paragraphs

-
-
- - diff --git a/packages/astro/test/fixtures/ssr-api-route/astro.config.mjs b/packages/astro/test/fixtures/ssr-api-route/astro.config.mjs deleted file mode 100644 index 1ec4aa7af466..000000000000 --- a/packages/astro/test/fixtures/ssr-api-route/astro.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import node from '@astrojs/node'; -import { defineConfig } from 'astro/config'; - -// https://astro.build/config -export default defineConfig({ - output: 'server', - adapter: node({ mode: 'standalone' }), -}); diff --git a/packages/astro/test/fixtures/ssr-api-route/package.json b/packages/astro/test/fixtures/ssr-api-route/package.json deleted file mode 100644 index ab761dd6b1e6..000000000000 --- a/packages/astro/test/fixtures/ssr-api-route/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@test/ssr-api-route", - "version": "0.0.0", - "private": true, - "dependencies": { - "@astrojs/node": "workspace:*", - "astro": "workspace:*" - } -} diff --git a/packages/astro/test/fixtures/ssr-api-route/src/images/penguin.jpg b/packages/astro/test/fixtures/ssr-api-route/src/images/penguin.jpg deleted file mode 100644 index d474ea60c907..000000000000 Binary files a/packages/astro/test/fixtures/ssr-api-route/src/images/penguin.jpg and /dev/null differ diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/500.js b/packages/astro/test/fixtures/ssr-api-route/src/pages/500.js deleted file mode 100644 index 2e6025945871..000000000000 --- a/packages/astro/test/fixtures/ssr-api-route/src/pages/500.js +++ /dev/null @@ -1,3 +0,0 @@ -export async function GET() { - return new Response("500 Internal Server Error", { status: 500 }); -} diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/binary.js b/packages/astro/test/fixtures/ssr-api-route/src/pages/binary.js deleted file mode 100644 index b5f1b013617e..000000000000 --- a/packages/astro/test/fixtures/ssr-api-route/src/pages/binary.js +++ /dev/null @@ -1,30 +0,0 @@ -import fs from 'node:fs'; - -export function GET() { - return new Response('ok') -} - -export async function POST({ request }) { - const data = await request.formData(); - const file = data.get('file'); - - if (file) { - const buffer = await file.arrayBuffer(); - const realBuffer = await fs.promises.readFile(new URL('../images/penguin.jpg', import.meta.url)); - - if(buffersEqual(buffer, realBuffer)) { - return new Response('ok', { status: 200 }); - } - } - return new Response(null, { status: 400 }); -} - -function buffersEqual(buf1, buf2) { - if (buf1.byteLength != buf2.byteLength) return false; - const dv1 = new Uint8Array(buf1); - const dv2 = new Uint8Array(buf2); - for (let i = 0; i !== buf1.byteLength; i++) { - if (dv1[i] != dv2[i]) return false; - } - return true; -} diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/context/[param].js b/packages/astro/test/fixtures/ssr-api-route/src/pages/context/[param].js deleted file mode 100644 index d5c9f4033d67..000000000000 --- a/packages/astro/test/fixtures/ssr-api-route/src/pages/context/[param].js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @param {import('astro').APIContext} api - */ -export function GET(ctx) { - return Response.json({ - cookiesExist: !!ctx.cookies, - requestExist: !!ctx.request, - redirectExist: !!ctx.redirect, - propsExist: !!ctx.props, - params: ctx.params, - site: ctx.site?.toString(), - generator: ctx.generator, - url: ctx.url.toString(), - clientAddress: ctx.clientAddress, - }); -} diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/custom-status.ts b/packages/astro/test/fixtures/ssr-api-route/src/pages/custom-status.ts deleted file mode 100644 index a8cbab27019b..000000000000 --- a/packages/astro/test/fixtures/ssr-api-route/src/pages/custom-status.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { APIRoute } from 'astro'; - -export const GET: APIRoute = async function () { - return new Response("hello world", { status: 403, headers: { "x-hello": 'world' } }); -}; diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/fail.js b/packages/astro/test/fixtures/ssr-api-route/src/pages/fail.js deleted file mode 100644 index b91ea7507e7d..000000000000 --- a/packages/astro/test/fixtures/ssr-api-route/src/pages/fail.js +++ /dev/null @@ -1,3 +0,0 @@ -export async function GET({ request }) { - return fetch(new URL("/500", request.url), request); -} diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/food.json.js b/packages/astro/test/fixtures/ssr-api-route/src/pages/food.json.js deleted file mode 100644 index e145757b1342..000000000000 --- a/packages/astro/test/fixtures/ssr-api-route/src/pages/food.json.js +++ /dev/null @@ -1,25 +0,0 @@ - -export function GET() { - return new Response( - JSON.stringify([ - { name: 'lettuce' }, - { name: 'broccoli' }, - { name: 'pizza' } - ]), { - status: 200, - statusText: `tasty`, - } - ) -} - -export async function POST({ params, request }) { - const body = await request.text(); - const ok = body === `some data` - return new Response( ok ? `ok` : `not ok`, { - status: ok ? 200 : 400, - statusText: ok ? `ok` : `not ok`, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - }); -} diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/index.astro b/packages/astro/test/fixtures/ssr-api-route/src/pages/index.astro deleted file mode 100644 index 53e029f04983..000000000000 --- a/packages/astro/test/fixtures/ssr-api-route/src/pages/index.astro +++ /dev/null @@ -1,6 +0,0 @@ - -Testing - -

Testing

- - diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/login.js b/packages/astro/test/fixtures/ssr-api-route/src/pages/login.js deleted file mode 100644 index 0e851df749bc..000000000000 --- a/packages/astro/test/fixtures/ssr-api-route/src/pages/login.js +++ /dev/null @@ -1,12 +0,0 @@ -/** @type {import('astro').APIRoute} */ -export function POST({ cookies }) { - cookies.set('foo', 'foo', { - httpOnly: true - }); - cookies.set('bar', 'bar', { - httpOnly: true - }); - return new Response('', { - status: 201, - }); -} diff --git a/packages/astro/test/queue-rendering.test.ts b/packages/astro/test/queue-rendering.test.ts deleted file mode 100644 index b5cc7b080137..000000000000 --- a/packages/astro/test/queue-rendering.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import { type App, type Fixture, loadFixture } from './test-utils.ts'; - -describe('Queue-based rendering - Static', () => { - let fixture: Fixture; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/queue-rendering/', - output: 'static', - outDir: './dist/queue-rendering-static/', - }); - await fixture.build(); - }); - - describe('Basic rendering', () => { - it('should render index page successfully', async () => { - const html = await fixture.readFile('/index.html'); - - // Verify basic structure - assert.ok(html.includes('Queue Rendering Test')); - assert.ok(html.includes('

Queue Rendering Test

')); - }); - - it('should render simple text and primitives correctly', async () => { - const html = await fixture.readFile('/index.html'); - - assert.ok(html.includes('

Simple text rendering

')); - assert.ok(html.includes('

Number: 42

')); - assert.ok(html.includes('

Boolean: true

')); - }); - - it('should render arrays correctly', async () => { - const html = await fixture.readFile('/index.html'); - - assert.ok(html.includes('
  • First
  • ')); - assert.ok(html.includes('
  • Second
  • ')); - assert.ok(html.includes('
  • Third
  • ')); - - // Verify order - const firstPos = html.indexOf('
  • First
  • '); - const secondPos = html.indexOf('
  • Second
  • '); - const thirdPos = html.indexOf('
  • Third
  • '); - - assert.ok(firstPos < secondPos); - assert.ok(secondPos < thirdPos); - }); - - it('should render multiple component instances correctly', async () => { - const html = await fixture.readFile('/index.html'); - - assert.ok(html.includes('data-level="0"')); - assert.ok(html.includes('data-level="1"')); - assert.ok(html.includes('data-level="2"')); - - assert.ok(html.includes('Level 0')); - assert.ok(html.includes('Level 1')); - assert.ok(html.includes('Level 2')); - }); - - it('should render components with slots correctly', async () => { - const html = await fixture.readFile('/index.html'); - - assert.ok(html.includes('class="with-slot"')); - assert.ok(html.includes('

    Test Title

    ')); - assert.ok(html.includes('class="slot-content"')); - assert.ok(html.includes('

    Slot content here

    ')); - assert.ok(html.includes('

    Multiple paragraphs

    ')); - }); - }); - - describe('Astro directives', () => { - it('should handle set:html directive', async () => { - const html = await fixture.readFile('/directives/index.html'); - - // set:html should render raw HTML - assert.ok(html.includes('Bold text from set:html')); - }); - - it('should handle set:text directive', async () => { - const html = await fixture.readFile('/directives/index.html'); - - // set:text should escape HTML - assert.ok(html.includes('<em>This should be escaped</em>')); - }); - - it('should handle class:list directive', async () => { - const html = await fixture.readFile('/directives/index.html'); - - // class:list should merge classes correctly - assert.ok(html.includes('class="foo bar baz"')); - }); - - it('should handle inline style objects', async () => { - const html = await fixture.readFile('/directives/index.html'); - - // Style object should be converted to inline CSS - assert.ok(html.includes('color:red') || html.includes('color: red')); - assert.ok(html.includes('font-size:20px') || html.includes('font-size: 20px')); - }); - }); - - describe('Client components', () => { - it('should render client:load components', async () => { - const html = await fixture.readFile('/client-components/index.html'); - - // Should include the component HTML - assert.ok(html.includes('class="counter"')); - // React adds HTML comments, so check for the number separately - assert.ok(html.includes('>5<') || html.includes('5')); - - // Should include hydration script - assert.ok(html.includes('astro-island')); - assert.ok(html.includes('client:load')); - }); - - it('should render client:idle components', async () => { - const html = await fixture.readFile('/client-components/index.html'); - - assert.ok(html.includes('>10<') || html.includes('10')); - assert.ok(html.includes('client:idle')); - }); - - it('should render client:visible components', async () => { - const html = await fixture.readFile('/client-components/index.html'); - - assert.ok(html.includes('>15<') || html.includes('15')); - assert.ok(html.includes('client:visible')); - }); - - it('should render client:media components', async () => { - const html = await fixture.readFile('/client-components/index.html'); - - assert.ok(html.includes('>20<') || html.includes('20')); - assert.ok(html.includes('client:media')); - }); - - it('should render client:only components', async () => { - const html = await fixture.readFile('/client-components/index.html'); - - // client:only should not render on server - // The component placeholder should exist but not the SSR content - assert.ok(html.includes('client:only')); - }); - - it('should render static components without hydration', async () => { - const html = await fixture.readFile('/client-components/index.html'); - - // Static component should render but not have hydration - assert.ok(html.includes('Server-side only')); - assert.ok(html.includes('class="static-component"')); - }); - }); - - describe('Head content', () => { - it('should include inline styles in head', async () => { - const html = await fixture.readFile('/head-content/index.html'); - - // Inline styles should be hoisted to head or remain inline - assert.ok(html.includes('.inline-test')); - assert.ok(html.includes('color: green') || html.includes('color:green')); - }); - - it('should include component styles in head', async () => { - const html = await fixture.readFile('/head-content/index.html'); - - // Component styles should be in head - assert.ok(html.includes('.with-head')); - assert.ok(html.includes('border: 1px solid blue') || html.includes('border:1px solid blue')); - }); - - it('should include component scripts', async () => { - const html = await fixture.readFile('/head-content/index.html'); - - // Component scripts should be included - assert.ok(html.includes('WithHead script loaded')); - }); - - it('should include inline scripts', async () => { - const html = await fixture.readFile('/head-content/index.html'); - - // Inline scripts with is:inline should be included - assert.ok(html.includes('Inline script executed')); - }); - }); -}); - -describe('Queue-based rendering - SSR', () => { - let fixture: Fixture; - let app: App; - - before(async () => { - // Note: In SSR mode (output: 'server'), pooling is automatically disabled - // because AppPipeline sets disablePooling: true in the render context. - // This is correct behavior since pooling provides no benefit in SSR - // where each request is independent. - fixture = await loadFixture({ - root: './fixtures/queue-rendering/', - output: 'server', - adapter: await import('./test-adapter.ts').then((mod) => mod.default()), - outDir: './dist/queue-rendering-ssr/', - }); - await fixture.build(); - app = await fixture.loadTestAdapterApp(); - }); - - it('should render SSR page with queue rendering', async () => { - const request = new Request('http://example.com/'); - const response = await app.render(request); - const html = await response.text(); - - assert.ok(html.includes('Queue Rendering Test')); - assert.ok(html.includes('

    Queue Rendering Test

    ')); - }); - - it('should render directives page in SSR', async () => { - const request = new Request('http://example.com/directives'); - const response = await app.render(request); - const html = await response.text(); - - // set:html should render raw HTML - assert.ok(html.includes('Bold text from set:html')); - - // set:text should escape HTML - assert.ok(html.includes('<em>This should be escaped</em>')); - - // class:list should merge classes - assert.ok(html.includes('class="foo bar baz"')); - }); - - it('should render client components in SSR', async () => { - const request = new Request('http://example.com/client-components'); - const response = await app.render(request); - const html = await response.text(); - - // Should include the component HTML with SSR content - assert.ok(html.includes('class="counter"')); - // React adds HTML comments, so check for the number separately - assert.ok(html.includes('>5<') || html.includes('5')); - - // Should include hydration islands - assert.ok(html.includes('astro-island')); - assert.ok(html.includes('client:load')); - }); - - it('should render head content in SSR', async () => { - const request = new Request('http://example.com/head-content'); - const response = await app.render(request); - const html = await response.text(); - - // Component styles should be in head - assert.ok(html.includes('.with-head')); - - // Inline scripts should be included - assert.ok(html.includes('Inline script executed')); - }); -}); - -describe('Queue-based rendering - Configuration', () => { - it('should support custom pool size configuration', async () => { - const fixture = await loadFixture({ - root: './fixtures/queue-rendering/', - output: 'static', - experimental: { - queuedRendering: { - enabled: true, - poolSize: 500, - }, - }, - outDir: './dist/queue-rendering-custom-pool/', - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - - // Verify basic rendering still works with custom pool size - assert.ok(html.includes('

    Queue Rendering Test

    ')); - assert.ok(html.includes('

    Simple text rendering

    ')); - }); - - it('should support object configuration', async () => { - const fixture = await loadFixture({ - root: './fixtures/queue-rendering/', - output: 'static', - experimental: { - queuedRendering: { - enabled: true, - }, - }, - outDir: './dist/queue-rendering-object-config/', - }); - await fixture.build(); - - const html = await fixture.readFile('/index.html'); - - // Verify rendering works with boolean config - assert.ok(html.includes('

    Queue Rendering Test

    ')); - assert.ok(html.includes('

    Simple text rendering

    ')); - }); -}); diff --git a/packages/astro/test/ssr-api-route.test.ts b/packages/astro/test/ssr-api-route.test.ts deleted file mode 100644 index 92b0866bd2ef..000000000000 --- a/packages/astro/test/ssr-api-route.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import net from 'node:net'; -import { after, before, describe, it } from 'node:test'; -import testAdapter from './test-adapter.ts'; -import { - type App, - type AstroInlineConfig, - type DevServer, - type Fixture, - loadFixture, -} from './test-utils.ts'; - -describe('API routes in SSR', () => { - const config: AstroInlineConfig = { - root: './fixtures/ssr-api-route/', - output: 'server', - site: 'https://mysite.dev/subsite/', - base: '/blog', - adapter: testAdapter(), - // Disable CSRF origin check for tests. These tests are for API route functionality, - // not for CSRF protection. The test client doesn't send proper origin headers, - // so the CSRF middleware would block requests before they reach the API handlers. - security: { - checkOrigin: false, - }, - outDir: './dist/ssr-api-route/', - }; - - describe('Build', () => { - let app: App; - before(async () => { - const fixture = await loadFixture(config); - await fixture.build(); - app = await fixture.loadTestAdapterApp(); - }); - - it('Basic pages work', async () => { - const request = new Request('http://example.com/'); - const response = await app.render(request); - const html = await response.text(); - assert.notEqual(html, ''); - }); - - it('Can load the API route too', async () => { - const request = new Request('http://example.com/food.json'); - const response = await app.render(request); - assert.equal(response.status, 200); - assert.equal(response.statusText, 'tasty'); - const body = await response.json(); - assert.equal(body.length, 3); - }); - - it('Has valid api context', async () => { - const request = new Request('http://example.com/context/any'); - const response = await app.render(request); - assert.equal(response.status, 200); - const data = await response.json(); - assert.equal(data.cookiesExist, true); - assert.equal(data.requestExist, true); - assert.equal(data.redirectExist, true); - assert.equal(data.propsExist, true); - assert.deepEqual(data.params, { param: 'any' }); - assert.match(data.generator, /^Astro v/); - assert.equal(data.url, 'http://example.com/context/any'); - assert.equal(data.clientAddress, '0.0.0.0'); - assert.equal(data.site, 'https://mysite.dev/subsite/'); - }); - - describe('custom status', () => { - it('should return a custom status code and empty body for HEAD', async () => { - const request = new Request('http://example.com/custom-status', { method: 'HEAD' }); - const response = await app.render(request); - const text = await response.text(); - assert.equal(response.status, 403); - assert.equal(text, ''); - }); - - it('should return a 403 status code with the correct body for GET', async () => { - const request = new Request('http://example.com/custom-status'); - const response = await app.render(request); - const text = await response.text(); - assert.equal(response.status, 403); - assert.equal(text, 'hello world'); - }); - - it('should return the correct headers for GET', async () => { - const request = new Request('http://example.com/custom-status'); - const response = await app.render(request); - const headers = response.headers.get('x-hello'); - assert.equal(headers, 'world'); - }); - - it('should return the correct headers for HEAD', async () => { - const request = new Request('http://example.com/custom-status', { method: 'HEAD' }); - const response = await app.render(request); - const headers = response.headers.get('x-hello'); - assert.equal(headers, 'world'); - }); - }); - }); - - describe('Dev', () => { - let devServer: DevServer; - let fixture: Fixture; - before(async () => { - fixture = await loadFixture(config); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('Can POST to API routes', async () => { - const response = await fixture.fetch('/food.json', { - method: 'POST', - body: `some data`, - }); - assert.equal(response.status, 200); - const text = await response.text(); - assert.equal(text, 'ok'); - }); - - it('Can read custom status text from API routes', async () => { - const response = await fixture.fetch('/food.json', { - method: 'POST', - body: `not some data`, - }); - assert.equal(response.status, 400); - assert.equal(response.statusText, 'not ok'); - const text = await response.text(); - assert.equal(text, 'not ok'); - }); - - it('Can be passed binary data from multipart formdata', async () => { - const formData = new FormData(); - const raw = await fs.promises.readFile( - new URL('./fixtures/ssr-api-route/src/images/penguin.jpg', import.meta.url), - ); - const file = new File([raw], 'penguin.jpg', { type: 'text/jpg' }); - formData.set('file', file, 'penguin.jpg'); - - const res = await fixture.fetch('/binary', { - method: 'POST', - body: formData, - }); - - assert.equal(res.status, 200); - }); - - it('Can set multiple headers of the same type', async () => { - const response = await new Promise((resolve) => { - let { port } = devServer.address; - let host = 'localhost'; - let socket = new net.Socket(); - socket.connect(port, host); - socket.on('connect', () => { - let rawRequest = `POST /login HTTP/1.1\r\nHost: ${host}\r\n\r\n`; - socket.write(rawRequest); - }); - - let rawResponse = ''; - socket.setEncoding('utf-8'); - socket.on('data', (chunk) => { - rawResponse += chunk.toString(); - socket.destroy(); - }); - socket.on('close', () => { - resolve(rawResponse); - }); - }); - - let count = 0; - let exp = /set-cookie:/g; - while (exp.test(response)) { - count++; - } - - assert.equal(count, 2, 'Found two separate set-cookie response headers'); - }); - - it('can return an immutable response object', async () => { - const response = await fixture.fetch('/fail'); - const text = await response.text(); - assert.equal(response.status, 500); - assert.equal(text, '500 Internal Server Error'); - }); - }); -}); diff --git a/packages/astro/test/units/app/ssr-api-route.test.ts b/packages/astro/test/units/app/ssr-api-route.test.ts new file mode 100644 index 000000000000..276d0e5cf4e1 --- /dev/null +++ b/packages/astro/test/units/app/ssr-api-route.test.ts @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createEndpoint, createTestApp, createPage, createRouteData } from '../mocks.ts'; +import { createComponent, render } from '../../../dist/runtime/server/index.js'; +import { dynamicPart, staticPart } from '../routing/test-helpers.ts'; + +import type { APIContext } from '../../../dist/types/public/context.js'; + +/** + * Tests for SSR API route behavior through the App pipeline. + * + * Covers: GET/POST endpoints, JSON responses, custom status codes, + * HEAD requests, API context shape, binary form data, and status text. + * + * Migrated from the integration test in test/ssr-api-route.test.ts. + */ + +// #region Route factories + +const noopPage = createComponent( + () => render`Testing

    Testing

    `, +); + +function foodEndpoint() { + return createEndpoint( + { + GET: () => + new Response( + JSON.stringify([{ name: 'lettuce' }, { name: 'broccoli' }, { name: 'pizza' }]), + { status: 200, statusText: 'tasty' }, + ), + POST: async (ctx: APIContext) => { + const body = await ctx.request.text(); + const ok = body === 'some data'; + return new Response(ok ? 'ok' : 'not ok', { + status: ok ? 200 : 400, + statusText: ok ? 'ok' : 'not ok', + }); + }, + }, + { route: '/food.json' }, + ); +} + +function contextEndpoint() { + const routeData = createRouteData({ + route: '/context/[param]', + type: 'endpoint', + segments: [[staticPart('context')], [dynamicPart('param')]], + pathname: undefined, + }); + return { + routeData, + module: async () => ({ + page: async () => ({ + GET: (ctx: APIContext) => + Response.json({ + cookiesExist: !!ctx.cookies, + requestExist: !!ctx.request, + redirectExist: !!ctx.redirect, + propsExist: !!ctx.props, + params: ctx.params, + site: ctx.site?.toString(), + generator: ctx.generator, + url: ctx.url.toString(), + clientAddress: ctx.clientAddress, + }), + }), + }), + }; +} + +function customStatusEndpoint() { + return createEndpoint( + { + GET: () => + new Response('hello world', { + status: 403, + headers: { 'x-hello': 'world' }, + }), + }, + { route: '/custom-status' }, + ); +} + +function binaryEndpoint() { + return createEndpoint( + { + POST: async (ctx: APIContext) => { + const data = await ctx.request.formData(); + const file = data.get('file') as File | null; + if (file) { + const buffer = await file.arrayBuffer(); + if (buffer.byteLength > 0) { + return new Response('ok', { status: 200 }); + } + } + return new Response(null, { status: 400 }); + }, + }, + { route: '/binary' }, + ); +} + +// #endregion + +// #region Tests + +describe('SSR API routes', () => { + const app = createTestApp( + [ + createPage(noopPage, { route: '/' }), + foodEndpoint(), + contextEndpoint() as any, + customStatusEndpoint(), + binaryEndpoint(), + ], + { site: 'https://mysite.dev/subsite/' }, + ); + + it('basic pages work', async () => { + const response = await app.render(new Request('http://example.com/')); + const html = await response.text(); + assert.notEqual(html, ''); + }); + + it('can load a JSON API route', async () => { + const response = await app.render(new Request('http://example.com/food.json')); + assert.equal(response.status, 200); + assert.equal(response.statusText, 'tasty'); + const body = await response.json(); + assert.equal(body.length, 3); + }); + + it('has valid API context', async () => { + const response = await app.render(new Request('http://example.com/context/any'), { + clientAddress: '0.0.0.0', + }); + assert.equal(response.status, 200); + const data = await response.json(); + assert.equal(data.cookiesExist, true); + assert.equal(data.requestExist, true); + assert.equal(data.redirectExist, true); + assert.equal(data.propsExist, true); + assert.deepEqual(data.params, { param: 'any' }); + assert.match(data.generator, /^Astro v/); + assert.equal(data.url, 'http://example.com/context/any'); + assert.equal(data.clientAddress, '0.0.0.0'); + assert.equal(data.site, 'https://mysite.dev/subsite/'); + }); + + it('can POST to API routes', async () => { + const response = await app.render( + new Request('http://example.com/food.json', { + method: 'POST', + body: 'some data', + }), + ); + assert.equal(response.status, 200); + const text = await response.text(); + assert.equal(text, 'ok'); + }); + + it('can read custom status text from API routes', async () => { + const response = await app.render( + new Request('http://example.com/food.json', { + method: 'POST', + body: 'not some data', + }), + ); + assert.equal(response.status, 400); + assert.equal(response.statusText, 'not ok'); + const text = await response.text(); + assert.equal(text, 'not ok'); + }); + + it('can be passed binary data from multipart formdata', async () => { + const formData = new FormData(); + const file = new File([new Uint8Array([1, 2, 3, 4])], 'test.bin', { + type: 'application/octet-stream', + }); + formData.set('file', file, 'test.bin'); + const response = await app.render( + new Request('http://example.com/binary', { + method: 'POST', + body: formData, + }), + ); + assert.equal(response.status, 200); + }); + + describe('custom status', () => { + it('returns a custom status code and empty body for HEAD', async () => { + const response = await app.render( + new Request('http://example.com/custom-status', { method: 'HEAD' }), + ); + const text = await response.text(); + assert.equal(response.status, 403); + assert.equal(text, ''); + }); + + it('returns a 403 status code with the correct body for GET', async () => { + const response = await app.render(new Request('http://example.com/custom-status')); + const text = await response.text(); + assert.equal(response.status, 403); + assert.equal(text, 'hello world'); + }); + + it('returns the correct headers for GET', async () => { + const response = await app.render(new Request('http://example.com/custom-status')); + assert.equal(response.headers.get('x-hello'), 'world'); + }); + + it('returns the correct headers for HEAD', async () => { + const response = await app.render( + new Request('http://example.com/custom-status', { method: 'HEAD' }), + ); + assert.equal(response.headers.get('x-hello'), 'world'); + }); + }); +}); + +// #endregion diff --git a/packages/astro/test/units/cache/app-cache.test.ts b/packages/astro/test/units/cache/app-cache.test.ts index 20adedb1730f..eeac905f950b 100644 --- a/packages/astro/test/units/cache/app-cache.test.ts +++ b/packages/astro/test/units/cache/app-cache.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import memoryProvider from '../../../dist/core/cache/memory-provider.js'; -import { createEndpoint, createTestApp } from '../mocks.ts'; +import { createComponent, render, renderHead } from '../../../dist/runtime/server/index.js'; +import { createEndpoint, createPage, createTestApp } from '../mocks.ts'; import type { APIContext } from '../../../dist/types/public/context.js'; function createCacheManifestOverrides() { @@ -329,3 +330,189 @@ describe('context.cache through App pipeline', () => { assert.equal(frSecond.headers.get('X-Astro-Cache'), 'HIT'); }); }); + +// #region CDN-style provider (no onRequest, headers only) + +describe('context.cache with CDN-style provider', () => { + function createCdnProvider() { + return { + name: 'mock-cdn-cache', + async invalidate() {}, + }; + } + + function createCdnApp( + pages: Parameters[0], + extraOverrides: Record = {}, + ) { + return createTestApp(pages, { + cacheProvider: async () => ({ default: () => createCdnProvider() }), + cacheConfig: { provider: 'mock-cdn' }, + ...extraOverrides, + }); + } + + it('sets CDN-Cache-Control and Cache-Tag headers from context.cache.set()', async () => { + const app = createCdnApp([ + createEndpoint( + { + GET: (ctx: APIContext) => { + ctx.cache.set({ maxAge: 300, swr: 60, tags: ['api', 'data'] }); + return Response.json({ ok: true }); + }, + }, + { route: '/api' }, + ), + ]); + const response = await app.render(new Request('http://example.com/api')); + assert.equal(response.status, 200); + const cc = response.headers.get('CDN-Cache-Control')!; + assert.ok(cc, 'CDN-Cache-Control header should be present'); + assert.ok(cc.includes('max-age=300')); + assert.ok(cc.includes('stale-while-revalidate=60')); + const ct = response.headers.get('Cache-Tag')!; + assert.ok(ct); + assert.ok(ct.includes('api')); + assert.ok(ct.includes('data')); + }); + + it('produces no cache headers when cache.set(false)', async () => { + const app = createCdnApp([ + createEndpoint( + { + GET: (ctx: APIContext) => { + ctx.cache.set(false); + return Response.json({ cached: false }); + }, + }, + { route: '/no-cache' }, + ), + ]); + const response = await app.render(new Request('http://example.com/no-cache')); + assert.equal(response.status, 200); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + assert.equal(response.headers.get('Cache-Tag'), null); + }); + + it('produces Cache-Tag but no CDN-Cache-Control for tags-only', async () => { + const app = createCdnApp([ + createEndpoint( + { + GET: (ctx: APIContext) => { + ctx.cache.set({ tags: ['product', 'sku-123'] }); + return Response.json({ tagged: true }); + }, + }, + { route: '/tags-only' }, + ), + ]); + const response = await app.render(new Request('http://example.com/tags-only')); + assert.equal(response.status, 200); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + const ct = response.headers.get('Cache-Tag')!; + assert.ok(ct); + assert.ok(ct.includes('product')); + assert.ok(ct.includes('sku-123')); + }); + + it('applies config-level route cache options automatically', async () => { + const app = createCdnApp( + [ + createEndpoint( + { GET: () => Response.json({ fromConfig: true }) }, + { route: '/config-route' }, + ), + ], + { + cacheConfig: { + provider: 'mock-cdn', + routes: { '/config-route': { maxAge: 600, tags: ['config'] } }, + }, + }, + ); + const response = await app.render(new Request('http://example.com/config-route')); + assert.equal(response.status, 200); + const cc = response.headers.get('CDN-Cache-Control')!; + assert.ok(cc); + assert.ok(cc.includes('max-age=600')); + const ct = response.headers.get('Cache-Tag')!; + assert.ok(ct); + assert.ok(ct.includes('config')); + }); + + it('sets cache headers on pages via Astro.cache', async () => { + const cachePage = createComponent((result, props, slots) => { + const Astro = result.createAstro(props, slots); + Astro.cache.set({ maxAge: 120, tags: ['home'] }); + return render`${renderHead()}

    Cache Test

    `; + }); + const app = createCdnApp([createPage(cachePage, { route: '/' })]); + const response = await app.render(new Request('http://example.com/')); + assert.equal(response.status, 200); + const cc = response.headers.get('CDN-Cache-Control')!; + assert.ok(cc); + assert.ok(cc.includes('max-age=120')); + const ct = response.headers.get('Cache-Tag')!; + assert.ok(ct); + assert.ok(ct.includes('home')); + }); + + it('response body is correct JSON from API route', async () => { + const app = createCdnApp([ + createEndpoint( + { + GET: (ctx: APIContext) => { + ctx.cache.set({ maxAge: 300, tags: ['api'] }); + return Response.json({ ok: true }); + }, + }, + { route: '/api' }, + ), + ]); + const response = await app.render(new Request('http://example.com/api')); + const body = await response.json(); + assert.deepEqual(body, { ok: true }); + }); +}); + +// #endregion + +// #region Disabled mode (no cache provider) + +describe('context.cache disabled (no provider configured)', () => { + it('Astro.cache.set() is a no-op on pages', async () => { + const cachePage = createComponent((result, props, slots) => { + const Astro = result.createAstro(props, slots); + Astro.cache.set({ maxAge: 120, tags: ['home'] }); + return render`${renderHead()}

    Cache Test

    `; + }); + // No cacheProvider or cacheConfig + const app = createTestApp([createPage(cachePage, { route: '/' })]); + const response = await app.render(new Request('http://example.com/')); + assert.equal(response.status, 200); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + assert.equal(response.headers.get('Cache-Tag'), null); + }); + + it('context.cache.set() is a no-op in API routes', async () => { + const app = createTestApp([ + createEndpoint( + { + GET: (ctx: APIContext) => { + ctx.cache.set({ maxAge: 300, tags: ['api'] }); + return Response.json({ ok: true }); + }, + }, + { route: '/api' }, + ), + ]); + const response = await app.render(new Request('http://example.com/api')); + assert.equal(response.status, 200); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + assert.equal(response.headers.get('Cache-Tag'), null); + const body = await response.json(); + assert.deepEqual(body, { ok: true }); + }); +}); + +// #endregion diff --git a/packages/astro/test/units/config/config-validate.test.ts b/packages/astro/test/units/config/config-validate.test.ts index 1477de903fb1..fb5b9b16191c 100644 --- a/packages/astro/test/units/config/config-validate.test.ts +++ b/packages/astro/test/units/config/config-validate.test.ts @@ -693,4 +693,17 @@ describe('Config Validation', () => { assert.equal(configError instanceof z.ZodError, true); }); }); + + describe('experimental.routeRules', () => { + it('rejects negative maxAge', async () => { + const configError = await validateConfig({ + experimental: { routeRules: { '/api': { maxAge: -1 } } }, + }).catch((err) => err); + assert.equal(configError instanceof z.ZodError, true); + assert.ok( + JSON.stringify(configError.issues).includes('maxAge'), + 'Error should reference maxAge', + ); + }); + }); }); diff --git a/packages/astro/test/units/fetch/index.test.ts b/packages/astro/test/units/fetch/index.test.ts index b895c8ccdb16..1bf745f7b6e0 100644 --- a/packages/astro/test/units/fetch/index.test.ts +++ b/packages/astro/test/units/fetch/index.test.ts @@ -80,6 +80,16 @@ describe('FetchState (astro/fetch)', () => { assert.equal(state.routeData!.route, '/[b_ssr]'); assert.equal(state.routeData!.prerender, false); }); + + it('falls back to the 404 route when no route matches', () => { + const notFoundPage = createPage(simplePage, { route: '/404' }); + const app = createTestApp([createPage(simplePage, { route: '/' }), notFoundPage]); + const request = stampApp(new Request('http://example.com/does-not-exist'), app); + const state = new FetchState(request); + + assert.ok(state.routeData, 'routeData should fall back to the 404 route'); + assert.equal(state.routeData!.route, '/404'); + }); }); // #endregion @@ -239,6 +249,24 @@ describe('pages()', () => { assert.match(text, /

    Hello<\/h1>/); }); + it('renders the 404 page for unmatched routes instead of throwing', async () => { + const notFoundPage = createComponent((_result: any, _props: any, _slots: any) => { + return render`

    Not Found

    `; + }); + const app = createTestApp([ + createPage(simplePage, { route: '/' }), + createPage(notFoundPage, { route: '/404' }), + ]); + const request = stampApp(new Request('http://example.com/does-not-exist'), app); + const state = new FetchState(request); + + const response = await pages(state); + + assert.equal(response.status, 404); + const text = await response.text(); + assert.match(text, /

    Not Found<\/h1>/); + }); + it('renders an endpoint', async () => { const app = createTestApp([ createEndpoint( diff --git a/packages/astro/test/units/render/directives.test.ts b/packages/astro/test/units/render/directives.test.ts new file mode 100644 index 000000000000..36a2413d74e6 --- /dev/null +++ b/packages/astro/test/units/render/directives.test.ts @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { + addAttribute, + createComponent, + defineScriptVars, + defineStyleVars, + render, + renderComponent, + renderHead, + unescapeHTML, +} from '../../../dist/runtime/server/index.js'; +import { createPage, createTestApp } from '../mocks.ts'; + +// #region Component factories (equivalent to compiled .astro output) + +// Equivalent to:

    {title}

    +// with +const TitleComponent = createComponent((_result, props) => { + const $$definedVars = defineStyleVars([{ textColor: 'red' }]); + return render`${props.title ?? 'Default'}`; +}); + +// Mirrors the compiled output of define-vars.astro +const DefineVarsPage = createComponent((result) => { + const foo = 'bar'; + const bg = 'white'; + const fg = 'black'; + const bar = ''; + const undef: undefined = undefined; + + const $$definedVars = defineStyleVars([{ bg }, { fg }]); + return render` +${renderHead()} + + + + + + +
    +${renderComponent(result, 'Title', TitleComponent, {})} +`; +}); + +// Mirrors the compiled output of set-html.astro +const SetHtmlPage = createComponent((_result) => { + return render`${renderHead()} +
    ${unescapeHTML('a')}
    +
    ${unescapeHTML(0)}
    +
    ${unescapeHTML(1)}
    +
    ${unescapeHTML(false)}
    +
    ${unescapeHTML(true)}
    +
    ${unescapeHTML(undefined)}
    +
    ${unescapeHTML(null)}
    +`; +}); + +// Mirrors a page with set:html Fragment as slot children +const SetHtmlChildrenPage = createComponent((_result) => { + return render`${unescapeHTML('

    Test

    ')}`; +}); + +// Mirrors set:html={fetch('/api')} — unescapeHTML handles Promises and Response objects +const SetHtmlFetchPage = createComponent((_result) => { + const fakeResponse = new Response('

    works

    ', { + headers: { 'Content-Type': 'text/html' }, + }); + return render`${unescapeHTML(fakeResponse)}`; +}); + +// #endregion + +// #region Tests + +describe('Directives', () => { + it('passes define:vars to script elements', async () => { + const app = createTestApp([createPage(DefineVarsPage, { route: '/define-vars' })]); + const response = await app.render(new Request('http://example.com/define-vars')); + const html = await response.text(); + const $ = cheerio.load(html); + + assert.equal($('script').length, 5); + + let i = 0; + for (const script of $('script').toArray()) { + assert.equal($(script).text().startsWith('(function(){'), true); + assert.equal($(script).text().endsWith('})();'), true); + if (i < 2) { + assert.equal($(script).toString().includes('const foo = "bar"'), true); + } else if (i < 3) { + assert.equal($(script).toString().includes('const dashCase = "bar"'), true); + } else if (i < 4) { + assert.equal( + $(script).toString().includes('const bar = "\\u003cscript>bar\\u003c/script>"'), + true, + ); + } else { + assert.equal($(script).toString().includes('const undef = undefined'), true); + } + i++; + } + }); + + it('passes define:vars to style elements (via inline style attribute)', async () => { + const app = createTestApp([createPage(DefineVarsPage, { route: '/define-vars' })]); + const response = await app.render(new Request('http://example.com/define-vars')); + const html = await response.text(); + const $ = cheerio.load(html); + + assert.ok($('html').attr('style')!.includes('--bg: white;')); + assert.ok($('html').attr('style')!.includes('--fg: black;')); + + // Title component injects --textColor + assert.ok($('h1').attr('style')!.includes('--textColor: red;')); + }); + + it('properly handles define:vars on style elements with style object', async () => { + const app = createTestApp([createPage(DefineVarsPage, { route: '/define-vars' })]); + const response = await app.render(new Request('http://example.com/define-vars')); + const html = await response.text(); + const $ = cheerio.load(html); + + assert.ok( + $('#compound-style').attr('style')!.includes('color:var(--fg);--bg: white;--fg: black;'), + ); + }); + + it('set:html', async () => { + const app = createTestApp([createPage(SetHtmlPage, { route: '/set-html' })]); + const response = await app.render(new Request('http://example.com/set-html')); + const html = await response.text(); + const $ = cheerio.load(html); + + assert.equal($('#text').length, 1); + assert.equal($('#text').text(), 'a'); + + assert.equal($('#zero').length, 1); + assert.equal($('#zero').text(), '0'); + + assert.equal($('#number').length, 1); + assert.equal($('#number').text(), '1'); + + assert.equal($('#undefined').length, 1); + assert.equal($('#undefined').text(), ''); + + assert.equal($('#null').length, 1); + assert.equal($('#null').text(), ''); + + assert.equal($('#false').length, 1); + assert.equal($('#false').text(), ''); + + assert.equal($('#true').length, 1); + assert.equal($('#true').text(), 'true'); + }); + + it('set:html Fragment as slot (children)', async () => { + const app = createTestApp([createPage(SetHtmlChildrenPage, { route: '/set-html-children' })]); + const response = await app.render(new Request('http://example.com/set-html-children')); + const html = await response.text(); + assert.ok(html.includes('Test')); + }); + + it('set:html can take a Response object (fetch result)', async () => { + const app = createTestApp([createPage(SetHtmlFetchPage, { route: '/set-html-fetch' })]); + const response = await app.render(new Request('http://example.com/set-html-fetch')); + assert.equal(response.status, 200); + const html = await response.text(); + const $ = cheerio.load(html); + assert.equal($('#fetched-html').length, 1); + assert.equal($('#fetched-html').text(), 'works'); + }); +}); + +// #endregion diff --git a/packages/astro/test/units/render/queue-rendering.test.ts b/packages/astro/test/units/render/queue-rendering.test.ts index f4d21b64ee59..01b76f5bed47 100644 --- a/packages/astro/test/units/render/queue-rendering.test.ts +++ b/packages/astro/test/units/render/queue-rendering.test.ts @@ -1,354 +1,230 @@ -import * as assert from 'node:assert/strict'; +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 { renderPage } from '../../../dist/runtime/server/render/page.js'; -import type { RenderDestination } from '../../../dist/runtime/server/render/common.js'; -import type { QueueNode, TextNode } from '../../../dist/runtime/server/render/queue/types.js'; - -/** Type-safe accessor for text node content */ -function textContent(node: QueueNode): string { - assert.equal(node.type, 'text'); - return (node as TextNode).content; +import { + addAttribute, + createComponent, + render, + renderComponent, + renderHead, + unescapeHTML, +} from '../../../dist/runtime/server/index.js'; +import { createPage, createTestApp } from '../mocks.ts'; + +import type { AstroComponentFactory } from '../../../dist/runtime/server/render/index.js'; + +// #region Helpers + +function createQueueApp(pages: Array<{ component: AstroComponentFactory; route: string }>) { + return createTestApp( + pages.map(({ component, route }) => createPage(component, { route })), + { experimentalQueuedRendering: { enabled: true } }, + ); } -/** - * 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: [] as string[], - extraStyleHashes: [] as string[], - extraScriptHashes: [] as string[], - 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(): NodePool { - return new NodePool(1000); - } +// #endregion - describe('buildRenderQueue()', () => { - it('should handle simple text nodes', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue('Hello, World!', result as any, pool); +// #region Component factories (equivalent to compiled .astro components) - assert.ok(queue.nodes.length > 0); - assert.equal(queue.nodes[0].type, 'text'); - assert.equal(textContent(queue.nodes[0]), 'Hello, World!'); - }); +const NestedComponent = createComponent((_result, props) => { + const level = props.level ?? 0; + return render`
    Level ${level}
    `; +}); - it('should handle numbers', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue(42, result as any, pool); +const WithSlotComponent = createComponent(async (result, props, slots) => { + const Astro = result.createAstro(props, slots); + const slotContent = await Astro.slots.render('default'); + return render`

    ${props.title}

    ${unescapeHTML(slotContent)}
    `; +}); - assert.ok(queue.nodes.length > 0); - assert.equal(queue.nodes[0].type, 'text'); - assert.equal(textContent(queue.nodes[0]), '42'); - }); +const IndexPage = createComponent((result) => { + const items = ['First', 'Second', 'Third']; + return render`Queue Rendering Test +

    Queue Rendering Test

    +
    +

    Simple text rendering

    +

    Number: ${42}

    +

    Boolean: ${true}

    +
    +
      +${items.map((item) => render`
    • ${item}
    • `)} +
    +
    +${renderComponent(result, 'Nested', NestedComponent, { level: 0 })} +${renderComponent(result, 'Nested', NestedComponent, { level: 1 })} +${renderComponent(result, 'Nested', NestedComponent, { level: 2 })} +
    +
    +${renderComponent( + result, + 'WithSlot', + WithSlotComponent, + { title: 'Test Title' }, + { + default: () => render`

    Slot content here

    Multiple paragraphs

    `, + }, +)} +
    +`; +}); - it('should handle booleans', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue(true, result as any, pool); +const DirectivesPage = createComponent((_result) => { + const htmlContent = 'Bold text from set:html'; + const textContent = 'This should be escaped'; + return render`Astro Directives Test +

    Directives Test

    +
    ${unescapeHTML(htmlContent)}
    +
    ${textContent}
    +
    Class List Test
    +
    Styled Text
    +`; +}); - assert.ok(queue.nodes.length > 0); - assert.equal(queue.nodes[0].type, 'text'); - assert.equal(textContent(queue.nodes[0]), 'true'); - }); +const HeadContentPage = createComponent((_result) => { + return render`Head Content Test${renderHead()} +

    Head Content Test

    +
    + +

    Inline styles test

    +
    +
    + +
    +
    +

    Component with Head Content

    This component adds content to the head

    + + +
    +`; +}); - it('should handle arrays', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue(['Hello', ' ', 'World'], result as any, pool); +// #endregion - assert.equal(queue.nodes.length, 3); - assert.equal(textContent(queue.nodes[0]), 'Hello'); - assert.equal(textContent(queue.nodes[1]), ' '); - assert.equal(textContent(queue.nodes[2]), 'World'); - }); +// #region Tests - it('should handle null and undefined (skip them)', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const nullQueue = await buildRenderQueue(null, result as any, pool); - const undefinedQueue = await buildRenderQueue(undefined, result as any, pool); +describe('Queue-based rendering', () => { + describe('Basic rendering', () => { + const app = createQueueApp([{ component: IndexPage, route: '/' }]); - assert.equal(nullQueue.nodes.length, 0); - assert.equal(undefinedQueue.nodes.length, 0); + it('should render index page successfully', async () => { + const response = await app.render(new Request('http://example.com/')); + const html = await response.text(); + assert.ok(html.includes('Queue Rendering Test')); + assert.ok(html.includes('

    Queue Rendering Test

    ')); }); - it('should skip false but render 0', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const falseQueue = await buildRenderQueue(false, result as any, pool); - const zeroQueue = await buildRenderQueue(0, result as any, pool); - - assert.equal(falseQueue.nodes.length, 0); - assert.equal(zeroQueue.nodes.length, 1); - assert.equal(textContent(zeroQueue.nodes[0]), '0'); + it('should render simple text and primitives correctly', async () => { + const response = await app.render(new Request('http://example.com/')); + const html = await response.text(); + assert.ok(html.includes('

    Simple text rendering

    ')); + assert.ok(html.includes('

    Number: 42

    ')); + assert.ok(html.includes('

    Boolean: true

    ')); }); - it('should handle promises', async () => { - const result = createMockResult(); - const promise = Promise.resolve('Resolved value'); - const pool = createMockPool(); - const queue = await buildRenderQueue(promise, result as any, pool); + it('should render arrays correctly', async () => { + const response = await app.render(new Request('http://example.com/')); + const html = await response.text(); + assert.ok(html.includes('
  • First
  • ')); + assert.ok(html.includes('
  • Second
  • ')); + assert.ok(html.includes('
  • Third
  • ')); - assert.equal(queue.nodes.length, 1); - assert.equal(textContent(queue.nodes[0]), 'Resolved value'); + const firstPos = html.indexOf('
  • First
  • '); + const secondPos = html.indexOf('
  • Second
  • '); + const thirdPos = html.indexOf('
  • Third
  • '); + assert.ok(firstPos < secondPos); + assert.ok(secondPos < thirdPos); }); - it('should handle nested arrays', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue([['Nested', ' '], 'Array'], result as any, pool); - - assert.equal(queue.nodes.length, 3); - assert.equal(textContent(queue.nodes[0]), 'Nested'); - assert.equal(textContent(queue.nodes[1]), ' '); - assert.equal(textContent(queue.nodes[2]), 'Array'); + it('should render multiple component instances correctly', async () => { + const response = await app.render(new Request('http://example.com/')); + const html = await response.text(); + assert.ok(html.includes('data-level="0"')); + assert.ok(html.includes('data-level="1"')); + assert.ok(html.includes('data-level="2"')); + assert.ok(html.includes('Level 0')); + assert.ok(html.includes('Level 1')); + assert.ok(html.includes('Level 2')); }); - 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 as any, pool); - - assert.equal(queue.nodes.length, 3); - assert.equal(textContent(queue.nodes[0]), 'First'); - assert.equal(textContent(queue.nodes[1]), 'Second'); - assert.equal(textContent(queue.nodes[2]), 'Third'); + it('should render components with slots correctly', async () => { + const response = await app.render(new Request('http://example.com/')); + const html = await response.text(); + assert.ok(html.includes('class="with-slot"')); + assert.ok(html.includes('

    Test Title

    ')); + assert.ok(html.includes('class="slot-content"')); + assert.ok(html.includes('

    Slot content here

    ')); + assert.ok(html.includes('

    Multiple paragraphs

    ')); }); + }); - it('should track parent relationships', async () => { - const result = createMockResult(); - const nestedArray = [['child1', 'child2'], 'sibling']; - const pool = createMockPool(); - const queue = await buildRenderQueue(nestedArray, result as any, pool); + describe('Astro directives', () => { + const app = createQueueApp([{ component: DirectivesPage, route: '/directives' }]); - // Verify correct node structure - assert.equal(queue.nodes.length, 3); - assert.equal(textContent(queue.nodes[0]), 'child1'); - assert.equal(textContent(queue.nodes[1]), 'child2'); - assert.equal(textContent(queue.nodes[2]), 'sibling'); + it('should handle set:html directive', async () => { + const response = await app.render(new Request('http://example.com/directives')); + const html = await response.text(); + assert.ok(html.includes('Bold text from set:html')); }); - it('should maintain correct rendering order', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue(['A', 'B', 'C'], result as any, pool); - - assert.equal(textContent(queue.nodes[0]), 'A'); - assert.equal(textContent(queue.nodes[1]), 'B'); - assert.equal(textContent(queue.nodes[2]), 'C'); + it('should handle set:text directive', async () => { + const response = await app.render(new Request('http://example.com/directives')); + const html = await response.text(); + assert.ok(html.includes('<em>This should be escaped</em>')); }); - 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 as any, pool); + it('should handle class:list directive', async () => { + const response = await app.render(new Request('http://example.com/directives')); + const html = await response.text(); + assert.ok(html.includes('class="foo bar baz"')); + }); - assert.equal(queue.nodes.length, 3); - // Set iteration order is insertion order - const contents = queue.nodes.map((n) => textContent(n)); - assert.ok(contents.includes('One')); - assert.ok(contents.includes('Two')); - assert.ok(contents.includes('Three')); + it('should handle inline style objects', async () => { + const response = await app.render(new Request('http://example.com/directives')); + const html = await response.text(); + assert.ok(html.includes('color:red') || html.includes('color: red')); + assert.ok(html.includes('font-size:20px') || html.includes('font-size: 20px')); }); }); - describe('renderQueue()', () => { - it('should render simple text to string', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue('Test content', result as any, pool); - - let output = ''; - const destination: RenderDestination = { - write(chunk) { - output += String(chunk); - }, - }; + describe('Head content', () => { + const app = createQueueApp([{ component: HeadContentPage, route: '/head-content' }]); - await renderQueue(queue, destination); - assert.ok(output.includes('Test content')); + it('should include inline styles', async () => { + const response = await app.render(new Request('http://example.com/head-content')); + const html = await response.text(); + assert.ok(html.includes('.inline-test')); + assert.ok(html.includes('color:green') || html.includes('color: green')); }); - it('should render array to concatenated string', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue(['Hello', ' ', 'World'], result as any, pool); - - let output = ''; - const destination: RenderDestination = { - write(chunk) { - output += String(chunk); - }, - }; - - await renderQueue(queue, destination); - assert.equal(output, 'Hello World'); + it('should include component styles', async () => { + const response = await app.render(new Request('http://example.com/head-content')); + const html = await response.text(); + assert.ok(html.includes('.with-head')); }); - it('should escape HTML in text nodes', async () => { - const result = createMockResult(); - const pool = createMockPool(); - const queue = await buildRenderQueue('', 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('