/**
 * Unit + integration tests for SSR helpers introduced on the
 * light-dom-tailwind-v2 branch:
 *   - hoistHeadTags: leading <script>/<style> are lifted to <head>
 *   - data-layout wrapping: layout output is wrapped with a marker
 *   - cache-control default: no-store unless the page opts in
 */
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve, join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const HTML_MODULE_URL = pathToFileURL(
  resolve(__dirname, '../../packages/core/src/html.js')
).toString();
const WEBJS_MODULE_URL = pathToFileURL(
  resolve(__dirname, '../../packages/core/index.js')
).toString();

let _hoistHeadTags, _extractUserShell, _buildDocumentParts, ssrPage, ssrNotFound;
let withRequest;
let tmpDir;

before(async () => {
  ({
    _hoistHeadTags,
    _extractUserShell,
    _buildDocumentParts,
    ssrPage,
    ssrNotFound,
  } = await import('../../packages/server/src/ssr.js'));
  // The CSP nonce now flows through the per-request AsyncLocalStorage store
  // (issue #233): `cspNonce()` reads the minted nonce there, or falls back
  // to an inbound Content-Security-Policy request header. The legacy
  // inbound-header tests below exercise that fallback, so they must call
  // ssrPage inside a request scope, exactly as the real handler does.
  ({ withRequest } = await import('../../packages/server/src/context.js'));
  tmpDir = mkdtempSync(join(tmpdir(), 'webjs-ssr-test-'));
});

after(() => {
  if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
});

/* ------------ hoistHeadTags (pure function) ------------ */

test('hoistHeadTags: no hoisting when body has no leading script/style', () => {
  const { head, body } = _hoistHeadTags(
    '<head><title>x</title></head>',
    '<div>hello</div>'
  );
  assert.equal(head, '<head><title>x</title></head>');
  assert.equal(body, '<div>hello</div>');
});

test('hoistHeadTags: lifts leading <script> to head', () => {
  const bodyHtml = '<script>window.x = 1;</script><main>page</main>';
  const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
  assert.ok(head.includes('<script>window.x = 1;</script>'));
  assert.equal(body, '<main>page</main>');
});

test('hoistHeadTags: lifts leading <style> to head', () => {
  const bodyHtml = '<style>.a{color:red}</style><main>page</main>';
  const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
  assert.ok(head.includes('<style>.a{color:red}</style>'));
  assert.equal(body, '<main>page</main>');
});

test('hoistHeadTags: lifts multiple consecutive leading script/style tags', () => {
  const bodyHtml =
    '<script src="/a.js"></script>' +
    '<style>.x{}</style>' +
    '<script>window.y = 2;</script>' +
    '<main>rest</main>';
  const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
  assert.ok(head.includes('<script src="/a.js"></script>'));
  assert.ok(head.includes('<style>.x{}</style>'));
  assert.ok(head.includes('<script>window.y = 2;</script>'));
  assert.equal(body, '<main>rest</main>');
});

test('hoistHeadTags: does NOT lift script/style that appear after normal content', () => {
  const bodyHtml = '<main>page</main><script>alert(1)</script>';
  const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
  // The script isn't leading: stays in the body.
  assert.equal(head, '<head></head>');
  assert.equal(body, bodyHtml);
});

test('hoistHeadTags: tolerates whitespace before leading tags', () => {
  const bodyHtml = '  \n  <script>a=1</script><main>ok</main>';
  const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
  assert.ok(head.includes('<script>a=1</script>'));
  assert.equal(body, '<main>ok</main>');
});

test('hoistHeadTags: is case-insensitive for script/style tags', () => {
  const bodyHtml = '<SCRIPT>upper = 1;</SCRIPT><main>ok</main>';
  const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
  assert.ok(head.includes('<SCRIPT>upper = 1;</SCRIPT>'));
  assert.equal(body, '<main>ok</main>');
});

test('hoistHeadTags: lifts leading <link rel="icon"> to head', () => {
  // Browsers only honour favicons declared in <head>; layouts that emit
  // them in their template body must be hoisted, otherwise the tab icon
  // never appears.
  const bodyHtml =
    '<link rel="icon" href="/public/favicon.svg" type="image/svg+xml">' +
    '<link rel="apple-touch-icon" href="/public/favicon.png">' +
    '<main>page</main>';
  const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
  assert.ok(head.includes('<link rel="icon" href="/public/favicon.svg" type="image/svg+xml">'));
  assert.ok(head.includes('<link rel="apple-touch-icon" href="/public/favicon.png">'));
  assert.equal(body, '<main>page</main>');
});

test('hoistHeadTags: lifts a mixed run of leading link/script/style', () => {
  const bodyHtml =
    '<link rel="icon" href="/f.svg">' +
    '<script>var t = "dark";</script>' +
    '<link rel="stylesheet" href="/x.css">' +
    '<style>.a{}</style>' +
    '<main>rest</main>';
  const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
  assert.ok(head.includes('<link rel="icon" href="/f.svg">'));
  assert.ok(head.includes('<script>var t = "dark";</script>'));
  assert.ok(head.includes('<link rel="stylesheet" href="/x.css">'));
  assert.ok(head.includes('<style>.a{}</style>'));
  assert.equal(body, '<main>rest</main>');
});

test('hoistHeadTags: does NOT lift <link> after normal content', () => {
  const bodyHtml = '<main>page</main><link rel="icon" href="/late.svg">';
  const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
  assert.equal(head, '<head></head>');
  assert.equal(body, bodyHtml);
});

test('hoistHeadTags: lifts head-bound tags at the top of body (no wrapper now)', () => {
  // The SSR pipeline no longer wraps layout output in a wrapping div -
  // partial-nav uses inline comment markers instead. Head-bound tags
  // emitted at the top of a layout template lift directly into <head>.
  const bodyHtml =
    '<link rel="icon" href="/public/favicon.svg" type="image/svg+xml">' +
    '<script>var t=1;</script>' +
    '<main>page</main>';
  const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
  assert.ok(head.includes('<link rel="icon" href="/public/favicon.svg" type="image/svg+xml">'));
  assert.ok(head.includes('<script>var t=1;</script>'));
  assert.ok(body.startsWith('<main>page</main>'),
    `body starts with the first non-head content, got: ${body.slice(0, 80)}`);
  assert.ok(!body.includes('rel="icon"'), 'icon link removed from body');
});

/* ------------ extractUserShell (pure function) ------------ */

test('extractUserShell: returns null when body has no <html> shell', () => {
  assert.equal(_extractUserShell('<main>hello</main>'), null);
  assert.equal(_extractUserShell('<div>x</div><span>y</span>'), null);
});

test('extractUserShell: parses minimal <!doctype><html><head><body> shell', () => {
  const shell = _extractUserShell(
    '<!doctype html><html lang="es"><head><meta charset="utf-8"></head><body class="dark"><main>x</main></body></html>'
  );
  assert.ok(shell, 'shell must be detected');
  assert.equal(shell.htmlAttrs.trim(), 'lang="es"');
  assert.equal(shell.bodyAttrs.trim(), 'class="dark"');
  assert.match(shell.userHead, /<meta charset="utf-8">/);
  assert.match(shell.userBody, /<main>x<\/main>/);
});

test('extractUserShell: tolerates leading whitespace + multi-attr <html>', () => {
  const shell = _extractUserShell(
    '\n  <!doctype html>\n  <html lang="en" dir="rtl" data-theme="dark">\n  <head></head><body><p>p</p></body></html>'
  );
  assert.ok(shell);
  assert.match(shell.htmlAttrs, /lang="en"/);
  assert.match(shell.htmlAttrs, /dir="rtl"/);
  assert.match(shell.htmlAttrs, /data-theme="dark"/);
});

test('extractUserShell: works with <html> but no explicit <head>', () => {
  const shell = _extractUserShell('<html lang="en"><body><main>x</main></body></html>');
  assert.ok(shell);
  assert.equal(shell.htmlAttrs.trim(), 'lang="en"');
  assert.equal(shell.userHead, '');
  assert.match(shell.userBody, /<main>x<\/main>/);
});

test('extractUserShell: rejects body that only contains <html> as a literal text', () => {
  // Text containing the string "<html>" but not at the start shouldn't match.
  assert.equal(_extractUserShell('<div>some <html> in text</div>'), null);
});

/* ------------ buildDocumentParts: user-shell + framework-shell paths ---- */

test('buildDocumentParts: framework shell when no user shell present', () => {
  const { prefix, streamBody, closer } = _buildDocumentParts(
    '<main>page</main>',
    { metadata: { title: 'X' }, moduleUrls: [], dev: false, streaming: false }
  );
  assert.match(prefix, /^<!doctype html>/);
  assert.match(prefix, /<html lang="en">/);
  assert.match(prefix, /<title>X<\/title>/);
  assert.equal(streamBody, '<main>page</main>');
  assert.equal(closer, '\n</body>\n</html>');
});

test('buildDocumentParts: keeps user shell attrs; splices framework tags into user <head>', () => {
  const userShell =
    '<!doctype html><html lang="es" data-theme="dark"><head><link rel="preconnect" href="https://cdn.test"></head><body class="bg-dark"><main>page</main></body></html>';
  const { prefix, streamBody, closer } = _buildDocumentParts(userShell, {
    metadata: { title: 'X', description: 'd' },
    moduleUrls: [],
    dev: false,
    streaming: false,
  });
  // Open tag attributes from user.
  assert.match(prefix, /<html lang="es" data-theme="dark">/);
  assert.match(prefix, /<body class="bg-dark">/);
  // Framework tags injected into <head>.
  assert.match(prefix, /<title>X<\/title>/);
  assert.match(prefix, /<meta name="description" content="d">/);
  // User's own head tag preserved.
  assert.match(prefix, /<link rel="preconnect" href="https:\/\/cdn\.test">/);
  // No duplicate <html> or <head> wrapper.
  assert.equal(prefix.match(/<html\b/g)?.length, 1, 'exactly one <html> tag');
  assert.equal(prefix.match(/<head\b/g)?.length, 1, 'exactly one <head> tag');
  assert.equal(streamBody.trim(), '<main>page</main>');
  assert.equal(closer, '\n</body>\n</html>');
});

test('buildDocumentParts: auto-hoist of body-positioned <link> still works with user shell', () => {
  const userShell =
    '<!doctype html><html lang="en"><head></head><body><link rel="icon" href="/x.svg"><main>p</main></body></html>';
  const { prefix, streamBody } = _buildDocumentParts(userShell, {
    metadata: { title: 'X' },
    moduleUrls: [],
    dev: false,
    streaming: false,
  });
  // The body-positioned <link rel="icon"> should have been lifted into <head>.
  assert.match(prefix, /<link rel="icon" href="\/x\.svg">/);
  // …and removed from the body.
  assert.equal(streamBody.includes('rel="icon"'), false, 'icon link removed from body');
});

test('buildDocumentParts: passes through user shell with no <head> at all', () => {
  const { prefix } = _buildDocumentParts(
    '<html lang="en"><body><main>p</main></body></html>',
    { metadata: { title: 'X' }, moduleUrls: [], dev: false, streaming: false }
  );
  // Framework still injects its tags (we just open a fresh <head>).
  assert.match(prefix, /<head\b/);
  assert.match(prefix, /<title>X<\/title>/);
});

test('buildDocumentParts: user shell is detected directly (no wrapper to peek past)', () => {
  // The renderChain output goes directly into the shell extractor: partial
  // -nav uses inline comment markers, not a wrapping div. extractUserShell
  // sees the user's <!doctype><html> shell at the top of body.
  const userShellBody =
    `<!doctype html><html lang="es" data-theme="dark"><head></head><body class="bg-test"><main>page</main></body></html>`;
  const { prefix, streamBody } = _buildDocumentParts(userShellBody, {
    metadata: { title: 'X' },
    moduleUrls: [],
    dev: false,
    streaming: false,
  });
  // User shell attributes preserved.
  assert.match(prefix, /<html lang="es" data-theme="dark">/);
  assert.match(prefix, /<body class="bg-test">/);
  // User body content preserved.
  assert.match(streamBody, /<main>page<\/main>/);
});

/* ------------ Metadata parity: i18n + SEO essentials ------------ */

// Small helper that bypasses the full SSR boot and just exercises wrapHead
// indirectly via _buildDocumentParts (whose framework branch ends up calling
// wrapHead).
function render(metadata) {
  const { prefix } = _buildDocumentParts(
    '<main>p</main>',
    { metadata, moduleUrls: [], dev: false, streaming: false },
  );
  return prefix;
}

test('metadata.robots: object form maps to noindex/nofollow tokens', () => {
  const html = render({ robots: { index: false, follow: true, noarchive: true } });
  assert.match(html, /<meta name="robots" content="noindex, follow, noarchive">/);
});

test('metadata.robots: string form passes through unchanged', () => {
  const html = render({ robots: 'noindex, nofollow' });
  assert.match(html, /<meta name="robots" content="noindex, nofollow">/);
});

test('metadata.robots.googleBot emits a separate <meta name="googlebot">', () => {
  const html = render({ robots: { googleBot: 'index, max-snippet:-1' } });
  assert.match(html, /<meta name="googlebot" content="index, max-snippet:-1">/);
});

test('metadata.keywords: array joins with comma-space; string passes through', () => {
  const html = render({ keywords: ['ai', 'web components', 'no-build'] });
  assert.match(html, /<meta name="keywords" content="ai, web components, no-build">/);
  const html2 = render({ keywords: 'a, b' });
  assert.match(html2, /<meta name="keywords" content="a, b">/);
});

test('metadata.authors: single + array forms emit <meta name="author"> + optional <link rel="author">', () => {
  const html = render({
    authors: [
      { name: 'Vivek', url: 'https://vivek.dev' },
      { name: 'Alice' },
      'Bob (string form)',
    ],
  });
  assert.match(html, /<meta name="author" content="Vivek">/);
  assert.match(html, /<link rel="author" href="https:\/\/vivek\.dev">/);
  assert.match(html, /<meta name="author" content="Alice">/);
  assert.match(html, /<meta name="author" content="Bob \(string form\)">/);
});

test('metadata: creator / publisher / applicationName / generator / referrer', () => {
  const html = render({
    creator: 'C',
    publisher: 'P',
    applicationName: 'webjs',
    generator: 'webjs 0.5',
    referrer: 'origin-when-cross-origin',
  });
  assert.match(html, /<meta name="creator" content="C">/);
  assert.match(html, /<meta name="publisher" content="P">/);
  assert.match(html, /<meta name="application-name" content="webjs">/);
  assert.match(html, /<meta name="generator" content="webjs 0\.5">/);
  assert.match(html, /<meta name="referrer" content="origin-when-cross-origin">/);
});

test('metadata.alternates.canonical emits <link rel="canonical">', () => {
  const html = render({ alternates: { canonical: 'https://example.com/post' } });
  assert.match(html, /<link rel="canonical" href="https:\/\/example\.com\/post">/);
});

test('metadata.alternates.languages emits hreflang <link>s', () => {
  const html = render({
    alternates: {
      languages: { 'es-ES': 'https://example.com/es', 'fr-FR': 'https://example.com/fr' },
    },
  });
  assert.match(html, /<link rel="alternate" hreflang="es-ES" href="https:\/\/example\.com\/es">/);
  assert.match(html, /<link rel="alternate" hreflang="fr-FR" href="https:\/\/example\.com\/fr">/);
});

test('metadata.alternates.media + alternates.types emit media + type alternates', () => {
  const html = render({
    alternates: {
      media: { 'only screen and (max-width: 600px)': '/mobile' },
      types: { 'application/rss+xml': '/rss.xml' },
    },
  });
  assert.match(html, /<link rel="alternate" media="only screen and \(max-width: 600px\)" href="\/mobile">/);
  assert.match(html, /<link rel="alternate" type="application\/rss\+xml" href="\/rss\.xml">/);
});

test('metadata.metadataBase: relative og:image becomes absolute', () => {
  const html = render({
    metadataBase: 'https://example.com',
    openGraph: { image: '/og.png' },
  });
  assert.match(html, /<meta property="og:image" content="https:\/\/example\.com\/og\.png">/);
});

test('metadata.metadataBase: relative canonical + hreflang become absolute', () => {
  const html = render({
    metadataBase: 'https://example.com/',
    alternates: {
      canonical: '/post',
      languages: { 'es-ES': '/es' },
    },
  });
  assert.match(html, /<link rel="canonical" href="https:\/\/example\.com\/post">/);
  assert.match(html, /<link rel="alternate" hreflang="es-ES" href="https:\/\/example\.com\/es">/);
});

test('metadata.metadataBase: absolute URLs pass through untouched', () => {
  const html = render({
    metadataBase: 'https://example.com',
    openGraph: { image: 'https://cdn.test/og.png' },
    alternates: { canonical: 'https://other.test/post' },
  });
  assert.match(html, /<meta property="og:image" content="https:\/\/cdn\.test\/og\.png">/);
  assert.match(html, /<link rel="canonical" href="https:\/\/other\.test\/post">/);
});

/* ------------ title template propagation across nested metadata layers ------------ */

async function makeLayeredRoute(...metadataSources) {
  const sub = mkdtempSync(join(tmpDir, 'meta-route-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });
  const pageFile = join(appDir, 'page.js');
  writeFileSync(
    pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function P() { return html\`<main>p</main>\`; }\n`,
  );
  const metadataFiles = metadataSources.map((src, i) => {
    const f = join(appDir, `meta-${i}.js`);
    writeFileSync(f, src);
    return f;
  });
  return {
    route: { file: pageFile, layouts: [], errors: [], metadataFiles },
    appDir,
  };
}

test('title template: page string title is wrapped by root template', async () => {
  const { route, appDir } = await makeLayeredRoute(
    // Root (outer): template + default
    `export const metadata = { title: { template: '%s: webjs', default: 'webjs' } };`,
    // Page (inner): plain string title
    `export const metadata = { title: 'Hello' };`,
  );
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  const html = await resp.text();
  assert.match(html, /<title>Hello: webjs<\/title>/);
});

test('title template: page omits title; root default is used', async () => {
  const { route, appDir } = await makeLayeredRoute(
    `export const metadata = { title: { template: '%s: webjs', default: 'webjs' } };`,
  );
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  const html = await resp.text();
  assert.match(html, /<title>webjs<\/title>/);
});

test('title template: page absolute title escapes the template', async () => {
  const { route, appDir } = await makeLayeredRoute(
    `export const metadata = { title: { template: '%s: webjs', default: 'webjs' } };`,
    `export const metadata = { title: { absolute: 'A standalone title' } };`,
  );
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  const html = await resp.text();
  assert.match(html, /<title>A standalone title<\/title>/);
  assert.doesNotMatch(html, /: webjs/);
});

test('title template: deeper layout can override the inherited template', async () => {
  const { route, appDir } = await makeLayeredRoute(
    `export const metadata = { title: { template: '%s: Site', default: 'Site' } };`,
    `export const metadata = { title: { template: '%s: Blog' } };`, // intermediate layout overrides
    `export const metadata = { title: 'Post' };`,                    // page supplies plain string
  );
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  const html = await resp.text();
  assert.match(html, /<title>Post: Blog<\/title>/);
});

/* ------------ JSON-LD structured data (#260) ------------ */

test('metadata.jsonLd: single object emits one <script type="application/ld+json">', () => {
  const html = render({
    jsonLd: { '@context': 'https://schema.org', '@type': 'Article', headline: 'Hi' },
  });
  const matches = html.match(/<script type="application\/ld\+json">/g) || [];
  assert.equal(matches.length, 1, 'exactly one ld+json script');
  assert.match(
    html,
    /<script type="application\/ld\+json">\{"@context":"https:\/\/schema\.org","@type":"Article","headline":"Hi"\}<\/script>/,
  );
});

test('metadata.jsonLd: HTML-safe escaping prevents a </script> breakout AND stays valid JSON', () => {
  const obj = {
    '@type': 'Thing',
    name: '</script><img src=x onerror=alert(1)>',
    // A malicious payload in KEY position is escaped too (escapeJsonLd runs
    // over the whole stringified blob, keys included), so a key cannot close
    // the tag any more than a value can. A bare HTML comment opener is covered.
    '</script><script>alert(2)</script>': 'k',
    '<!-- c -->': 'c',
    desc: 'a & b',
    sep: 'x y z',
  };
  const html = render({ jsonLd: obj });

  // Pull the exact bytes between the opening and closing ld+json script tags.
  const m = html.match(
    /<script type="application\/ld\+json">([\s\S]*?)<\/script>/,
  );
  assert.ok(m, 'ld+json script present');
  const inner = m[1];

  // SECURITY: the literal `</script>` must NOT appear inside the body, so a
  // value carrying `</script><img ...>` can never close the tag and inject
  // markup. The `<` is emitted as the JSON unicode escape <.
  assert.ok(
    !inner.includes('</script>'),
    `escaped body must not contain a literal </script>: ${inner}`,
  );
  // The strongest invariant: NO raw `<` survives anywhere in the body (value,
  // key, or structural position), so `</script>`, `<script`, `<!--`, and any
  // other tag/comment opener are all impossible to form.
  assert.ok(!inner.includes('<'), `no raw < may survive in the body: ${inner}`);
  assert.ok(inner.includes('\\u003c'), '< is escaped to \\u003c');
  assert.ok(inner.includes('\\u003e'), '> is escaped to \\u003e');
  assert.ok(inner.includes('\\u0026'), '& is escaped to \\u0026');
  assert.ok(inner.includes('\\u2028'), 'U+2028 is escaped');
  assert.ok(inner.includes('\\u2029'), 'U+2029 is escaped');

  // VALIDITY: the escaped body still parses back to the author's exact object
  // (the unicode escapes decode to the original characters).
  assert.deepEqual(JSON.parse(inner), obj);

  // COUNTERFACTUAL: the raw, unescaped JSON.stringify output WOULD have broken
  // out of the script tag (it contains a literal </script>). This is the gap
  // the escaper closes.
  assert.ok(
    JSON.stringify(obj).includes('</script>'),
    'raw stringify contains a literal </script> (the breakout the escaper prevents)',
  );
});

test('metadata.jsonLd: array emits one script per element', () => {
  const article = { '@type': 'Article', headline: 'Post' };
  const crumbs = { '@type': 'BreadcrumbList', itemListElement: [] };
  const html = render({ jsonLd: [article, crumbs] });
  const matches = html.match(/<script type="application\/ld\+json">/g) || [];
  assert.equal(matches.length, 2, 'two ld+json scripts for a two-element array');
  assert.match(html, /"@type":"Article"/);
  assert.match(html, /"@type":"BreadcrumbList"/);
});

test('metadata.jsonLd: a non-object array element is skipped, valid ones still emit', () => {
  const html = render({ jsonLd: [{ '@type': 'Article' }, null, 'not-an-object', 42] });
  const matches = html.match(/<script type="application\/ld\+json">/g) || [];
  assert.equal(matches.length, 1, 'only the one plain object emits a script');
});

test('metadata.jsonLd: a circular reference fails safe (no script, no throw)', () => {
  const obj = { '@type': 'Thing' };
  obj.self = obj; // circular: JSON.stringify throws
  // Must not throw; the whole render still succeeds.
  const html = render({ jsonLd: obj });
  assert.doesNotMatch(html, /application\/ld\+json/);
  assert.match(html, /<title>/, 'the rest of the head still renders');
});

test('metadata.jsonLd via generateMetadata round-trips through the merge', async () => {
  const { route, appDir } = await makeLayeredRoute(
    `export async function generateMetadata(ctx) {\n` +
      `  return { jsonLd: { '@context': 'https://schema.org', '@type': 'Article', headline: 'From-' + ctx.params.slug } };\n` +
      `}`,
  );
  const resp = await ssrPage(
    route,
    { slug: 'gen' },
    new URL('http://localhost/'),
    { dev: false, appDir },
  );
  const html = await resp.text();
  assert.match(html, /<script type="application\/ld\+json">/);
  assert.match(html, /"headline":"From-gen"/);
});

test('metadata.jsonLd: absent emits NO ld+json script (additive no-op)', () => {
  const withLd = render({ title: 'X', jsonLd: { '@type': 'Article' } });
  const withoutLd = render({ title: 'X' });
  assert.match(withLd, /application\/ld\+json/);
  assert.doesNotMatch(withoutLd, /application\/ld\+json/);
  // The head sans the script line is otherwise unchanged by the feature.
  assert.equal(
    withLd.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>\n?/g, ''),
    withoutLd,
    'head is byte-identical once the ld+json script is removed',
  );
});

test('metadata.jsonLd: renders fine under CSP and carries NO nonce (data block, not script)', async () => {
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export const metadata = { jsonLd: { '@type': 'Article', headline: 'csp' } };\n` +
      `export default function Page() { return html\`<p>ok</p>\`; }\n`,
    metadata:
      `export const metadata = { jsonLd: { '@type': 'Article', headline: 'csp' } };\n`,
  });
  const req = new Request('http://localhost/', {
    headers: { 'content-security-policy': "script-src 'nonce-cspLdNonce1' 'self'" },
  });
  const resp = await withRequest(req, () =>
    ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir, req }));
  const body = await resp.text();
  // The ld+json block is present and NOT broken by CSP.
  assert.match(body, /<script type="application\/ld\+json">\{"@type":"Article","headline":"csp"\}<\/script>/);
  // It is a non-executable data island, so it must NOT carry the nonce.
  assert.doesNotMatch(
    body,
    /<script type="application\/ld\+json" nonce=/,
    'ld+json data block must not carry a CSP nonce',
  );
});

/* ------------ Metadata parity: icons + manifest ------------ */

test('metadata.icons: string shorthand sets <link rel="icon">', () => {
  const html = render({ icons: '/favicon.svg' });
  assert.match(html, /<link rel="icon" href="\/favicon\.svg">/);
});

test('metadata.icons: object form with icon/apple/shortcut', () => {
  const html = render({
    icons: {
      icon: '/favicon.svg',
      apple: '/apple-touch-icon.png',
      shortcut: '/favicon.ico',
    },
  });
  assert.match(html, /<link rel="icon" href="\/favicon\.svg">/);
  assert.match(html, /<link rel="apple-touch-icon" href="\/apple-touch-icon\.png">/);
  assert.match(html, /<link rel="shortcut icon" href="\/favicon\.ico">/);
});

test('metadata.icons: array form with {url, sizes, type}', () => {
  const html = render({
    icons: {
      icon: [
        { url: '/icon-16.png', sizes: '16x16', type: 'image/png' },
        { url: '/icon-32.png', sizes: '32x32', type: 'image/png' },
      ],
    },
  });
  assert.match(html, /<link rel="icon" href="\/icon-16\.png" sizes="16x16" type="image\/png">/);
  assert.match(html, /<link rel="icon" href="\/icon-32\.png" sizes="32x32" type="image\/png">/);
});

test('metadata.icons.other: arbitrary rel allowed', () => {
  const html = render({
    icons: {
      other: [
        { rel: 'mask-icon', url: '/mask.svg', type: 'image/svg+xml' },
      ],
    },
  });
  assert.match(html, /<link rel="mask-icon" href="\/mask\.svg" type="image\/svg\+xml">/);
});

test('metadata.icons + metadataBase: relative URLs are absolutified', () => {
  const html = render({
    metadataBase: 'https://example.com',
    icons: { icon: '/favicon.svg', apple: '/apple.png' },
  });
  assert.match(html, /<link rel="icon" href="https:\/\/example\.com\/favicon\.svg">/);
  assert.match(html, /<link rel="apple-touch-icon" href="https:\/\/example\.com\/apple\.png">/);
});

test('metadata.manifest: emits <link rel="manifest">', () => {
  const html = render({ manifest: '/manifest.webmanifest' });
  assert.match(html, /<link rel="manifest" href="\/manifest\.webmanifest">/);
});

/* ------------ Metadata parity: verification ------------ */

test('metadata.verification: google/yandex/yahoo/me emit canonical meta names', () => {
  const html = render({
    verification: {
      google: 'g-token',
      yandex: 'y-token',
      yahoo: 'yahoo-token',
      me: 'https://me.example',
    },
  });
  assert.match(html, /<meta name="google-site-verification" content="g-token">/);
  assert.match(html, /<meta name="yandex-verification" content="y-token">/);
  assert.match(html, /<meta name="y_key" content="yahoo-token">/);
  assert.match(html, /<meta name="me" content="https:\/\/me\.example">/);
});

test('metadata.verification: array form emits multiple <meta>s with the same name', () => {
  const html = render({ verification: { google: ['token-a', 'token-b'] } });
  assert.match(html, /<meta name="google-site-verification" content="token-a">/);
  assert.match(html, /<meta name="google-site-verification" content="token-b">/);
});

test('metadata.verification.other: arbitrary <meta name="…"> entries', () => {
  const html = render({
    verification: { other: { 'facebook-domain-verification': 'fb-token' } },
  });
  assert.match(html, /<meta name="facebook-domain-verification" content="fb-token">/);
});

/* ------------ Metadata parity: viewport object + split-export ------------ */

test('metadata.viewport: object form serializes to comma-separated content', () => {
  const html = render({
    viewport: { width: 'device-width', initialScale: 1, maximumScale: 5, userScalable: true },
  });
  assert.match(
    html,
    /<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=5,user-scalable=yes">/,
  );
});

test('metadata.viewport: user-scalable=false emits user-scalable=no', () => {
  const html = render({ viewport: { width: 'device-width', userScalable: false } });
  assert.match(html, /user-scalable=no/);
});

test('metadata.viewport: string form still works (legacy)', () => {
  const html = render({ viewport: 'width=device-width,initial-scale=1.0' });
  assert.match(html, /<meta name="viewport" content="width=device-width,initial-scale=1\.0">/);
});

test('metadata.colorScheme: emits <meta name="color-scheme">', () => {
  const html = render({ colorScheme: 'light dark' });
  assert.match(html, /<meta name="color-scheme" content="light dark">/);
});

test('split `viewport` export: collectMetadata picks it up alongside metadata', async () => {
  const { route, appDir } = await makeLayeredRoute(
    `export const viewport = { width: 'device-width', initialScale: 1, themeColor: '#000' };
     export const metadata = { title: 'X' };`,
  );
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  const html = await resp.text();
  assert.match(html, /<meta name="viewport" content="width=device-width,initial-scale=1">/);
  // themeColor on the viewport export bubbles up.
  assert.match(html, /<meta name="theme-color" content="#000">/);
});

/* ------------ Metadata parity: long-tail + `other` passthrough ------------ */

test('metadata.appleWebApp: object form emits apple-mobile-web-app meta tags', () => {
  const html = render({
    appleWebApp: {
      capable: true,
      title: 'My App',
      statusBarStyle: 'black-translucent',
    },
  });
  assert.match(html, /<meta name="apple-mobile-web-app-capable" content="yes">/);
  assert.match(html, /<meta name="apple-mobile-web-app-title" content="My App">/);
  assert.match(html, /<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">/);
});

test('metadata.appleWebApp.startupImage: emits <link rel="apple-touch-startup-image">', () => {
  const html = render({
    appleWebApp: {
      startupImage: [
        { url: '/splash-1.png', media: '(device-width: 320px)' },
        '/splash-2.png',
      ],
    },
  });
  assert.match(html, /<link rel="apple-touch-startup-image" href="\/splash-1\.png" media="\(device-width: 320px\)">/);
  assert.match(html, /<link rel="apple-touch-startup-image" href="\/splash-2\.png">/);
});

test('metadata.appleWebApp: true shorthand emits just the `capable` tag', () => {
  const html = render({ appleWebApp: true });
  assert.match(html, /<meta name="apple-mobile-web-app-capable" content="yes">/);
});

test('metadata.formatDetection: combines bool fields into a content string', () => {
  const html = render({
    formatDetection: { telephone: false, address: false, email: true },
  });
  assert.match(
    html,
    /<meta name="format-detection" content="telephone=no, address=no, email=yes">/,
  );
});

test('metadata.itunes: appId + appArgument emit apple-itunes-app meta', () => {
  const html = render({ itunes: { appId: '12345', appArgument: 'myapp://open' } });
  assert.match(html, /<meta name="apple-itunes-app" content="app-id=12345, app-argument=myapp:\/\/open">/);
});

test('metadata: category / classification / abstract emit <meta name="…">', () => {
  const html = render({
    category: 'tech',
    classification: 'documentation',
    abstract: 'A short summary',
  });
  assert.match(html, /<meta name="category" content="tech">/);
  assert.match(html, /<meta name="classification" content="documentation">/);
  assert.match(html, /<meta name="abstract" content="A short summary">/);
});

test('metadata: archives / assets / bookmarks emit <link rel="…">', () => {
  const html = render({
    archives: ['/archive-2024', '/archive-2023'],
    assets: '/assets-cdn',
    bookmarks: ['/bm-1', '/bm-2'],
  });
  assert.match(html, /<link rel="archives" href="\/archive-2024">/);
  assert.match(html, /<link rel="archives" href="\/archive-2023">/);
  assert.match(html, /<link rel="assets" href="\/assets-cdn">/);
  assert.match(html, /<link rel="bookmark" href="\/bm-1">/);
  assert.match(html, /<link rel="bookmark" href="\/bm-2">/);
});

test('metadata.other: arbitrary meta key passthrough; supports string + array values', () => {
  const html = render({
    other: {
      'facebook-domain-verification': 'fb-token',
      'msvalidate.01': ['bing-token-a', 'bing-token-b'],
      'custom-key': 'custom-value',
    },
  });
  assert.match(html, /<meta name="facebook-domain-verification" content="fb-token">/);
  assert.match(html, /<meta name="msvalidate\.01" content="bing-token-a">/);
  assert.match(html, /<meta name="msvalidate\.01" content="bing-token-b">/);
  assert.match(html, /<meta name="custom-key" content="custom-value">/);
});

/* ------------ ssrPage integration: cache-control + data-layout wrapping ------------ */

async function makeRoute({ pageSrc, layoutSrc, metadata = null }) {
  const sub = mkdtempSync(join(tmpDir, 'route-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });
  const pageFile = join(appDir, 'page.js');
  writeFileSync(pageFile, pageSrc);
  const files = { file: pageFile, layouts: [] };
  if (layoutSrc) {
    const layoutFile = join(appDir, 'layout.js');
    writeFileSync(layoutFile, layoutSrc);
    files.layouts = [layoutFile];
  }
  if (metadata) {
    const metaFile = join(appDir, 'metadata.js');
    writeFileSync(metaFile, metadata);
    files.metadataFiles = [metaFile];
  }
  return {
    route: {
      file: files.file,
      layouts: files.layouts,
      errors: [],
      metadataFiles: files.metadataFiles || [],
    },
    appDir,
  };
}

test('ssrPage: default cache-control is no-store (opt-in caching)', async () => {
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function Page() { return html\`<p>plain page</p>\`; }\n`,
  });
  const url = new URL('http://localhost/');
  const resp = await ssrPage(route, {}, url, { dev: false, appDir });
  assert.equal(resp.headers.get('cache-control'), 'no-store');
});

test('ssrPage: page metadata.cacheControl is honoured', async () => {
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export const metadata = { cacheControl: 'public, max-age=60' };\n` +
      `export default function Page() { return html\`<p>cached</p>\`; }\n`,
    metadata:
      `export const metadata = { cacheControl: 'public, max-age=60' };\n`,
  });
  const url = new URL('http://localhost/');
  const resp = await ssrPage(route, {}, url, { dev: false, appDir });
  assert.equal(resp.headers.get('cache-control'), 'public, max-age=60');
});

test('ssrPage: emits wj:children comment marker around the page slot for each layout', async () => {
  // Each layout's ${children} interpolation is wrapped in a
  // <!--wj:children:<segment-path>--> ... <!--/wj:children--> comment pair
  // by renderChain. The client router walks both old + new DOM for these
  // markers and swaps only the deepest shared layout's children slot.
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function Page() { return html\`<p>page content</p>\`; }\n`,
    layoutSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function Layout({ children }) {\n` +
      `  return html\`<div class="shell">\${children}</div>\`;\n` +
      `}\n`,
  });
  const url = new URL('http://localhost/');
  const resp = await ssrPage(route, {}, url, { dev: false, appDir });
  const body = await resp.text();
  // Marker for the root layout, segment path '/'.
  assert.ok(body.includes('<!--wj:children:/-->'),
    `expected open marker for root layout, got: ${body.slice(0, 600)}`);
  assert.ok(body.includes('<!--/wj:children-->'),
    `expected close marker, got: ${body.slice(0, 600)}`);
  // The shell wraps the marker, not the other way around: the layout
  // markup is OUTSIDE its own children-slot marker.
  const idxShell = body.indexOf('class="shell"');
  const idxOpen = body.indexOf('<!--wj:children:/-->');
  const idxPage = body.indexOf('page content');
  const idxClose = body.indexOf('<!--/wj:children-->');
  assert.ok(idxShell < idxOpen, 'layout markup precedes marker');
  assert.ok(idxOpen < idxPage, 'open marker precedes page content');
  assert.ok(idxPage < idxClose, 'close marker follows page content');
});

test('ssrPage: X-Webjs-Have skips rendering layouts above the deepest match', async () => {
  // The client tells the server "I already have layouts at / and /docs"
  // via the X-Webjs-Have header. Server must short-circuit at /docs -
  // emit only the page content wrapped in the /docs marker pair, never
  // re-render the docs layout's outer markup (header/sidenav/etc.).
  const { route, appDir, tmpDir } = await makeRoute({
    pageSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function Page() { return html\`<p>page body</p>\`; }\n`,
    layoutSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function Layout({ children }) {\n` +
      `  return html\`<div class="HEAVY-OUTER-LAYOUT">\${children}</div>\`;\n` +
      `}\n`,
  });

  const url = new URL('http://localhost/');
  const req = new Request(url.toString(), {
    headers: { 'x-webjs-have': '/' },
  });
  const resp = await ssrPage(route, {}, url, { dev: false, appDir, req });
  const body = await resp.text();

  // The outer layout's distinctive markup must NOT appear: it was skipped.
  assert.ok(!body.includes('HEAVY-OUTER-LAYOUT'),
    `outer layout should be skipped, but body contains it. got: ${body.slice(0, 500)}`);
  // The page content is still present, wrapped in the matched marker.
  assert.ok(body.includes('<!--wj:children:/-->'), 'matched marker present');
  assert.ok(body.includes('page body'), 'page content present');
});

test('ssrPage: X-Webjs-Have picks deepest match (not just any match)', async () => {
  // Two-level layout chain: root and docs. Client has both.
  // Server should match at /docs (deepest), not / (shallower).
  const sub = mkdtempSync(join(tmpDir, 'have-deepest-'));
  const appDir = join(sub, 'app');
  mkdirSync(join(appDir, 'docs'), { recursive: true });
  const rootLayout = join(appDir, 'layout.js');
  const docsLayout = join(appDir, 'docs', 'layout.js');
  const pageFile = join(appDir, 'docs', 'page.js');
  writeFileSync(rootLayout,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function Root({ children }) { return html\`<div class="ROOT">\${children}</div>\`; }\n`);
  writeFileSync(docsLayout,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function Docs({ children }) { return html\`<div class="DOCS">\${children}</div>\`; }\n`);
  writeFileSync(pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function Page() { return html\`<p>sub page</p>\`; }\n`);

  const route = {
    file: pageFile,
    // layouts[0] = outermost (root), layouts[N-1] = innermost
    layouts: [rootLayout, docsLayout],
    errors: [],
    metadataFiles: [],
  };

  const url = new URL('http://localhost/docs');
  const req = new Request(url.toString(), {
    headers: { 'x-webjs-have': '/,/docs' },
  });
  const resp = await ssrPage(route, {}, url, { dev: false, appDir, req });
  const body = await resp.text();

  // Both outer layouts must be skipped: body has neither's distinctive markup.
  assert.ok(!body.includes('ROOT'), `root layout skipped; got: ${body.slice(0, 600)}`);
  assert.ok(!body.includes('DOCS'), `docs layout skipped; got: ${body.slice(0, 600)}`);
  // Marker for /docs is present (deepest match).
  assert.ok(body.includes('<!--wj:children:/docs-->'),
    `deepest matched marker /docs present, got: ${body.slice(0, 600)}`);
  // Page content is there.
  assert.ok(body.includes('sub page'), 'page content present');
});

test('ssrPage: emits <template id="wj-loading:<path>"> for each loading.ts in the chain', async () => {
  // Two-level loading chain: app/loading.ts and app/docs/loading.ts.
  // Both should emit hidden <template> elements at the end of body
  // keyed by their segment path. The client router clones the
  // deepest matching template on nav-start for an instant per-segment
  // skeleton.
  const sub = mkdtempSync(join(tmpDir, 'loading-templates-'));
  const appDir = join(sub, 'app');
  mkdirSync(join(appDir, 'docs'), { recursive: true });
  const rootLayout = join(appDir, 'layout.js');
  const docsLayout = join(appDir, 'docs', 'layout.js');
  const rootLoading = join(appDir, 'loading.js');
  const docsLoading = join(appDir, 'docs', 'loading.js');
  const pageFile = join(appDir, 'docs', 'page.js');
  writeFileSync(rootLayout,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function R({ children }) { return html\`<div>\${children}</div>\`; }\n`);
  writeFileSync(docsLayout,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function D({ children }) { return html\`<div>\${children}</div>\`; }\n`);
  writeFileSync(rootLoading,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function L() { return html\`<div class="ROOT-SKELETON">root skeleton</div>\`; }\n`);
  writeFileSync(docsLoading,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function L() { return html\`<div class="DOCS-SKELETON">docs skeleton</div>\`; }\n`);
  writeFileSync(pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function P() { return html\`<p>page</p>\`; }\n`);

  const route = {
    file: pageFile,
    layouts: [rootLayout, docsLayout],
    loadings: [rootLoading, docsLoading],
    errors: [],
    metadataFiles: [],
  };

  const url = new URL('http://localhost/docs');
  const resp = await ssrPage(route, {}, url, { dev: false, appDir });
  const body = await resp.text();

  assert.ok(body.includes('<template id="wj-loading:/"'),
    `expected root loading template, got: ${body.slice(-500)}`);
  assert.ok(body.includes('<template id="wj-loading:/docs"'),
    `expected docs loading template, got: ${body.slice(-500)}`);
  assert.ok(body.includes('ROOT-SKELETON'), 'root loading content present');
  assert.ok(body.includes('DOCS-SKELETON'), 'docs loading content present');
});

test('ssrPage: no children-slot markers when route has no layouts', async () => {
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function Page() { return html\`<p>no layout</p>\`; }\n`,
  });
  const url = new URL('http://localhost/');
  const resp = await ssrPage(route, {}, url, { dev: false, appDir });
  const body = await resp.text();
  assert.ok(!body.includes('wj:children:'),
    `no layouts → no markers, got: ${body.slice(0, 400)}`);
});

test('ssrPage: modulepreload never points at server-only files', async () => {
  // Set up a page that imports a .server.ts AND a 'use server' plain .ts.
  // Both files should be excluded from the <link rel="modulepreload"> set:
  // they're server-imports, and the client only ever sees a safe RPC stub
  // served lazily on first import, never a preload.
  const sub = mkdtempSync(join(tmpDir, 'route-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });

  const serverSuffix = join(appDir, 'query.server.ts');
  writeFileSync(serverSuffix,
    `export async function list() { return []; }\n`);

  const useServerPlain = join(appDir, 'db.ts');
  writeFileSync(useServerPlain,
    `'use server';\nexport async function q() { return null; }\n`);

  const pageFile = join(appDir, 'page.ts');
  writeFileSync(pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `import { list } from './query.server.ts';\n` +
    `import { q } from './db.ts';\n` +
    `export default async function Page() {\n` +
    `  await list(); await q();\n` +
    `  return html\`<p>hi</p>\`;\n` +
    `}\n`);

  // Build a minimal module graph mirroring the imports above.
  const moduleGraph = new Map([
    [pageFile, new Set([serverSuffix, useServerPlain])],
    [serverSuffix, new Set()],
    [useServerPlain, new Set()],
  ]);

  // serverFiles mimics the action index (abs-path keyed).
  const serverFiles = new Map([
    [serverSuffix, 'hashA'],
    [useServerPlain, 'hashB'],
  ]);

  const route = { file: pageFile, layouts: [], errors: [], metadataFiles: [] };
  const url = new URL('http://localhost/');
  const resp = await ssrPage(route, {}, url, {
    dev: false,
    appDir,
    moduleGraph,
    serverFiles,
  });
  const body = await resp.text();

  const preloads = (body.match(/modulepreload[^>]*href="[^"]*"/g) || []).join('\n');
  assert.ok(!/\.server\.ts"/.test(preloads),
    `.server.ts should not be preloaded; got preloads:\n${preloads}`);
  assert.ok(!/\bdb\.ts"/.test(preloads),
    `'use server' plain file should not be preloaded; got preloads:\n${preloads}`);
});

test('ssrPage: modulepreload never points at a server-only dep reached THROUGH a .server file', async () => {
  // Regression for #158: a page imports a server action, and the action
  // imports a plain server-only util (the slugify.ts / types.ts shape on the
  // blog). The util is reachable ONLY through the .server file, so the client
  // never fetches it (the action becomes an RPC stub). The preload walk must
  // stop at the .server boundary, exactly like the auth gate; otherwise it
  // emits a <link rel="modulepreload"> for the util, which then 404s.
  // Before the fix, `formatPost.ts` below leaks into the preload set.
  const sub = mkdtempSync(join(tmpDir, 'route-serverdep-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });

  const action = join(appDir, 'list.server.ts');
  const serverOnlyUtil = join(appDir, 'formatPost.ts');   // reached only via the action
  const clientComp = join(appDir, 'card.ts');             // a real client edge, kept

  writeFileSync(serverOnlyUtil, `export const fmt = (p) => p;\n`);
  writeFileSync(action,
    `import { fmt } from './formatPost.ts';\n` +
    `export async function list() { return [fmt(1)]; }\n`);
  writeFileSync(clientComp, `export const card = 1;\n`);

  const pageFile = join(appDir, 'page.ts');
  writeFileSync(pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `import { list } from './list.server.ts';\n` +
    `import './card.ts';\n` +
    `export default async function Page() { await list(); return html\`<my-card></my-card>\`; }\n`);

  // Graph mirrors the imports: page -> {action, card}; action -> {serverOnlyUtil}.
  const moduleGraph = new Map([
    [pageFile, new Set([action, clientComp])],
    [action, new Set([serverOnlyUtil])],
    [serverOnlyUtil, new Set()],
    [clientComp, new Set()],
  ]);
  const serverFiles = new Map([[action, 'hashA']]);

  const route = { file: pageFile, layouts: [], errors: [], metadataFiles: [] };
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), {
    dev: false, appDir, moduleGraph, serverFiles,
  });
  const preloads = ((await resp.text()).match(/modulepreload[^>]*href="[^"]*"/g) || []).join('\n');

  assert.ok(!/formatPost\.ts"/.test(preloads),
    `server-only dep reached through a .server file must not be preloaded; got:\n${preloads}`);
  assert.ok(!/list\.server\.ts"/.test(preloads),
    `the .server file itself is not preloaded; got:\n${preloads}`);
  // The real client edge is still preloaded (the boundary only prunes the
  // server path, it does not drop legitimate client modules).
  assert.ok(/card\.ts"/.test(preloads),
    `a real client dep must still be preloaded; got:\n${preloads}`);
});

test('preloadCrossOriginAttr: adds crossorigin=anonymous for cross-origin URLs only', async () => {
  // Browsers require crossorigin on cross-origin modulepreload, else
  // the preload is ignored or double-fetched (defeating the
  // optimization). Same-origin preloads must NOT have crossorigin
  // (browser would double-fetch in the reverse direction).
  const { preloadCrossOriginAttr } = await import(
    new URL('../../packages/server/src/ssr.js', import.meta.url).href
  );

  // Cross-origin (vendor packages from jspm.io etc.)
  assert.equal(
    preloadCrossOriginAttr('https://ga.jspm.io/npm:dayjs@1.11.20/dayjs.min.js'),
    ' crossorigin="anonymous"',
  );
  assert.equal(
    preloadCrossOriginAttr('http://cdn.example.com/x.js'),
    ' crossorigin="anonymous"',
  );

  // Same-origin (framework + user code)
  assert.equal(preloadCrossOriginAttr('/__webjs/core/index.js'), '');
  assert.equal(preloadCrossOriginAttr('/components/foo.ts'), '');
  assert.equal(preloadCrossOriginAttr('/__webjs/vendor/dayjs@1.11.20.js'), '');
});

/* ------------ ssrNotFound + not-found.js rendering ------------ */

test('ssrNotFound: no notFound file → plain 404 fallback', async () => {
  const resp = await ssrNotFound(null, { dev: false, appDir: tmpDir });
  assert.equal(resp.status, 404);
  const body = await resp.text();
  assert.ok(body.includes('404: Not found'));
});

test('ssrNotFound: renders the user-supplied not-found.js module', async () => {
  const sub = mkdtempSync(join(tmpDir, 'nf-'));
  const notFoundFile = join(sub, 'not-found.js');
  writeFileSync(notFoundFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function NotFound() { return html\`<p>custom missing</p>\`; }\n`);
  const resp = await ssrNotFound(notFoundFile, { dev: false, appDir: sub });
  assert.equal(resp.status, 404);
  const body = await resp.text();
  assert.ok(body.includes('<p>custom missing</p>'));
});

test('ssrNotFound: not-found.js that throws falls back to an inline error body', async () => {
  const sub = mkdtempSync(join(tmpDir, 'nf-err-'));
  const notFoundFile = join(sub, 'not-found.js');
  writeFileSync(notFoundFile,
    `export default function NotFound() { throw new Error('boom'); }\n`);
  const resp = await ssrNotFound(notFoundFile, { dev: false, appDir: sub });
  assert.equal(resp.status, 404);
  const body = await resp.text();
  assert.ok(body.includes('404: Not found'));
  assert.ok(body.includes('boom'));
});

/* ------------ ssrPage: redirect / notFound / error boundaries ------------ */

test('ssrPage: redirect() thrown during a GET render → 302 Found by default', async () => {
  // A gating redirect during a GET render (auth bounce) is GET-to-GET, so the
  // default is 302, the conventional code there (not the method-preserving 307
  // an action gets). #452.
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { redirect } from ${JSON.stringify(WEBJS_MODULE_URL)};\n` +
      `export default function Page() { redirect('/login'); }\n`,
  });
  const url = new URL('http://localhost/old');
  const resp = await ssrPage(route, {}, url, { dev: false, appDir });
  assert.equal(resp.status, 302, `got status ${resp.status}`);
  assert.equal(resp.headers.get('location'), '/login');
});

test('ssrPage: an explicit redirect() status overrides the 302 GET default', async () => {
  // `redirect(url, 308)` (and the `{ status }` options form) must win over the
  // GET-gate convention.
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { redirect } from ${JSON.stringify(WEBJS_MODULE_URL)};\n` +
      `export default function Page() { redirect('/perm', 308); }\n`,
  });
  const resp = await ssrPage(route, {}, new URL('http://localhost/old'), { dev: false, appDir });
  assert.equal(resp.status, 308);
  assert.equal(resp.headers.get('location'), '/perm');
});

test('ssrPage: the redirect() { status } options form overrides the 302 GET default', async () => {
  // The end-to-end override path for the options form through the GET catch
  // site, not just the sentinel unit test. #452.
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { redirect } from ${JSON.stringify(WEBJS_MODULE_URL)};\n` +
      `export default function Page() { redirect('/perm', { status: 301 }); }\n`,
  });
  const resp = await ssrPage(route, {}, new URL('http://localhost/old'), { dev: false, appDir });
  assert.equal(resp.status, 301);
  assert.equal(resp.headers.get('location'), '/perm');
});

test('ssrPage: notFound() thrown during render → 404 Response', async () => {
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { notFound } from ${JSON.stringify(WEBJS_MODULE_URL)};\n` +
      `export default function Page() { notFound(); }\n`,
  });
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  assert.equal(resp.status, 404);
});

test('ssrPage: error.js boundary catches a render throw and returns 500', async () => {
  const sub = mkdtempSync(join(tmpDir, 'err-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });

  const pageFile = join(appDir, 'page.js');
  writeFileSync(pageFile,
    `export default function Page() { throw new Error('kaboom'); }\n`);

  const errorFile = join(appDir, 'error.js');
  writeFileSync(errorFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function Err({ error }) {\n` +
    `  return html\`<p>Handled: \${error.message}</p>\`;\n` +
    `}\n`);

  const route = { file: pageFile, layouts: [], errors: [errorFile], metadataFiles: [] };
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  assert.equal(resp.status, 500);
  const body = await resp.text();
  assert.ok(body.includes('Handled: kaboom'));
});

test('ssrPage: error.js that itself throws falls through to the default 500', async () => {
  const sub = mkdtempSync(join(tmpDir, 'errfb-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });

  const pageFile = join(appDir, 'page.js');
  writeFileSync(pageFile,
    `export default function Page() { throw new Error('outer'); }\n`);

  const errorFile = join(appDir, 'error.js');
  writeFileSync(errorFile,
    `export default function Err() { throw new Error('boundary-broke'); }\n`);

  const route = { file: pageFile, layouts: [], errors: [errorFile], metadataFiles: [] };
  // Silence the intentional console.error from the unhandled-render path
  const prev = console.error;
  console.error = () => {};
  try {
    const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
    assert.equal(resp.status, 500);
    const body = await resp.text();
    // Prod default: terse message, no stack.
    assert.ok(body.includes('Something went wrong'));
    assert.ok(!body.includes('boundary-broke'));
  } finally { console.error = prev; }
});

test('ssrPage: page throws + NO error.js boundary → default 500', async () => {
  // The user-incident scenario: a route has no error.js at all
  // (route.errors is empty) and the page throws. Framework should
  // produce its terse built-in 500 page rather than crashing the
  // whole request. Verifies the for-loop at ssr.js:98 is safe over
  // an empty errors[] array and falls through to the default body.
  const { route, appDir } = await makeRoute({
    pageSrc: `export default function Page() { throw new Error('no-boundary'); }\n`,
  });
  const prev = console.error;
  console.error = () => {};
  try {
    const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
    assert.equal(resp.status, 500);
    const body = await resp.text();
    assert.ok(body.includes('Something went wrong'));
    // The thrown error.message is NOT leaked in prod when no boundary
    // handles it: only the framework's terse default body shows.
    assert.ok(!body.includes('no-boundary'));
  } finally { console.error = prev; }
});

test('ssrPage: error.js fails to LOAD (syntax error) → falls through to default 500', async () => {
  // Distinct from "error.js renders then throws" (already covered
  // above). This exercises the loadModule() throw path inside the
  // for-loop at ssr.js:98: when the boundary file itself can't be
  // imported (bad syntax, missing dep, broken template literal,
  // etc.), the inner catch should swallow the load failure and
  // continue to the next boundary, eventually reaching the default
  // 500 body. This is the exact failure mode that bit the
  // ui.webjs.dev deploy when a stray backtick closed the html
  // tagged template literal at parse time.
  const sub = mkdtempSync(join(tmpDir, 'errload-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });

  const pageFile = join(appDir, 'page.js');
  writeFileSync(pageFile,
    `export default function Page() { throw new Error('SENTINEL_PAGE_ERR_zq7'); }\n`);

  // Intentionally malformed module: JS parse failure on import.
  const errorFile = join(appDir, 'error.js');
  writeFileSync(errorFile, `export default function Err({ error } { return; }\n`);

  const route = { file: pageFile, layouts: [], errors: [errorFile], metadataFiles: [] };
  const prev = console.error;
  console.error = () => {};
  try {
    const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
    assert.equal(resp.status, 500);
    const body = await resp.text();
    assert.ok(body.includes('Something went wrong'));
    // Prod default body shouldn't leak the original error message.
    // The sentinel string is unique to the thrown error so a substring
    // match would never come from incidental shell content.
    assert.ok(!body.includes('SENTINEL_PAGE_ERR_zq7'));
  } finally { console.error = prev; }
});

test('ssrPage: dev=true exposes the error stack, prod hides it', async () => {
  const { route, appDir } = await makeRoute({
    pageSrc:
      `export default function Page() { throw new Error('stacky'); }\n`,
  });
  const prev = console.error;
  console.error = () => {};
  try {
    const dev = await ssrPage(route, {}, new URL('http://localhost/'), { dev: true, appDir });
    const devBody = await dev.text();
    assert.ok(devBody.includes('stacky'));

    const prod = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
    const prodBody = await prod.text();
    assert.ok(!prodBody.includes('stacky'));
    assert.ok(prodBody.includes('Something went wrong'));
  } finally { console.error = prev; }
});

/* ------------ metadata: generateMetadata fn, openGraph, preload links ------------ */

test('ssrPage: metadata.generateMetadata(ctx) is called and merged', async () => {
  const sub = mkdtempSync(join(tmpDir, 'metagen-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });

  const pageFile = join(appDir, 'page.js');
  writeFileSync(pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export async function generateMetadata(ctx) {\n` +
    `  return { title: 'Dyn ' + (ctx.params.id || 'x') };\n` +
    `}\n` +
    `export default function Page() { return html\`<p>ok</p>\`; }\n`);

  const route = { file: pageFile, layouts: [], errors: [], metadataFiles: [pageFile] };
  const resp = await ssrPage(route, { id: '42' }, new URL('http://localhost/'), { dev: false, appDir });
  const body = await resp.text();
  assert.ok(body.includes('<title>Dyn 42</title>'));
});

test('ssrPage: metadata.openGraph emits og:* meta tags', async () => {
  const sub = mkdtempSync(join(tmpDir, 'og-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });

  const pageFile = join(appDir, 'page.js');
  writeFileSync(pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export const metadata = {\n` +
    `  title: 'Blog',\n` +
    `  description: 'A blog',\n` +
    `  themeColor: '#ff0000',\n` +
    `  viewport: 'width=device-width, initial-scale=2',\n` +
    `  openGraph: { title: 'OG Blog', image: '/cover.png' },\n` +
    `  preload: [ { href: '/font.woff2', as: 'font', type: 'font/woff2', crossorigin: 'anonymous' } ],\n` +
    `};\n` +
    `export default function Page() { return html\`<p>ok</p>\`; }\n`);

  const route = { file: pageFile, layouts: [], errors: [], metadataFiles: [pageFile] };
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  const body = await resp.text();
  assert.ok(body.includes('<meta name="description" content="A blog">'));
  assert.ok(body.includes('<meta name="theme-color" content="#ff0000">'));
  assert.ok(body.includes('<meta property="og:title" content="OG Blog">'));
  assert.ok(body.includes('<meta property="og:image" content="/cover.png">'));
  assert.ok(/<meta name="viewport"[^>]*initial-scale=2/.test(body));
  assert.ok(/<link rel="preload"[^>]*href="\/font\.woff2"/.test(body));
  assert.ok(/<link rel="preload"[^>]*as="font"/.test(body));
});

test('ssrPage: metadata.twitter emits twitter:* meta tags', async () => {
  const sub = mkdtempSync(join(tmpDir, 'tw-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });

  const pageFile = join(appDir, 'page.js');
  writeFileSync(pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export const metadata = {\n` +
    `  twitter: { card: 'summary_large_image', title: 'Tw', image: '/og.png' },\n` +
    `};\n` +
    `export default function Page() { return html\`<p>ok</p>\`; }\n`);

  const route = { file: pageFile, layouts: [], errors: [], metadataFiles: [pageFile] };
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  const body = await resp.text();
  assert.ok(body.includes('<meta name="twitter:card" content="summary_large_image">'));
  assert.ok(body.includes('<meta name="twitter:title" content="Tw">'));
  assert.ok(body.includes('<meta name="twitter:image" content="/og.png">'));
});

test('ssrPage: a metadata file that throws is silently skipped', async () => {
  const sub = mkdtempSync(join(tmpDir, 'metaerr-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });

  const pageFile = join(appDir, 'page.js');
  writeFileSync(pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function Page() { return html\`<p>ok</p>\`; }\n`);

  const brokenMeta = join(appDir, 'broken.js');
  writeFileSync(brokenMeta,
    `export function generateMetadata() { throw new Error('meta boom'); }\n`);

  const route = { file: pageFile, layouts: [], errors: [], metadataFiles: [brokenMeta, pageFile] };
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  assert.equal(resp.status, 200);
  const body = await resp.text();
  assert.ok(body.includes('<p>ok</p>'));
});

/* ------------ loading.ts → automatic Suspense wrap ------------ */

test('ssrPage: loading.js wraps the page in Suspense (fallback in initial HTML)', async () => {
  const sub = mkdtempSync(join(tmpDir, 'loading-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });

  const pageFile = join(appDir, 'page.js');
  writeFileSync(pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default async function Page() {\n` +
    `  await new Promise(r => setTimeout(r, 10));\n` +
    `  return html\`<p>ready</p>\`;\n` +
    `}\n`);

  const loadingFile = join(appDir, 'loading.js');
  writeFileSync(loadingFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function Loading() { return html\`<p>loading…</p>\`; }\n`);

  const route = { file: pageFile, layouts: [], errors: [], metadataFiles: [], loadings: [loadingFile] };
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  assert.equal(resp.status, 200);
  const body = await resp.text();
  assert.ok(body.includes('loading…'), 'fallback should appear in initial HTML');
  assert.ok(body.includes('ready'), 'resolved content streamed in');
  // Streaming flush inserts a <template data-webjs-resolve="..."> chunk.
  assert.ok(/data-webjs-resolve/.test(body));
});

test('ssrPage: Suspense resolution fallback <script> carries the CSP nonce', async () => {
  // The fallback script `<script>window.__webjsResolve&&...</script>`
  // streams inline for each settled Suspense boundary. Under strict
  // CSP it was being blocked by the browser because the nonce wasn't
  // threaded into the streaming response. Regression test.
  const sub = mkdtempSync(join(tmpDir, 'suspense-csp-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });
  const pageFile = join(appDir, 'page.js');
  writeFileSync(pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default async function Page() {\n` +
    `  await new Promise(r => setTimeout(r, 10));\n` +
    `  return html\`<p>ready</p>\`;\n` +
    `}\n`);
  const loadingFile = join(appDir, 'loading.js');
  writeFileSync(loadingFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function Loading() { return html\`<p>loading…</p>\`; }\n`);

  const route = { file: pageFile, layouts: [], errors: [], metadataFiles: [], loadings: [loadingFile] };
  const req = new Request('http://localhost/', {
    headers: { 'content-security-policy': "script-src 'nonce-suspNonce99' 'self'" },
  });
  const resp = await withRequest(req, () =>
    ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir, req }));
  const body = await resp.text();
  // Locate every script that contains __webjsResolve and assert each one
  // carries nonce="suspNonce99".
  const resolveScripts = body.match(/<script[^>]*>[^<]*__webjsResolve[^<]*<\/script>/g) || [];
  assert.ok(resolveScripts.length >= 1, 'expected at least one Suspense resolve script');
  for (const s of resolveScripts) {
    assert.match(s, /nonce="suspNonce99"/,
      `Suspense resolve script missing nonce: ${s}`);
  }
});

test('ssrPage: loading.js that fails to load → page renders without Suspense', async () => {
  const sub = mkdtempSync(join(tmpDir, 'loading-err-'));
  const appDir = join(sub, 'app');
  mkdirSync(appDir, { recursive: true });

  const pageFile = join(appDir, 'page.js');
  writeFileSync(pageFile,
    `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
    `export default function Page() { return html\`<p>ok</p>\`; }\n`);

  const loadingFile = join(appDir, 'loading.js');
  writeFileSync(loadingFile, `throw new Error('cannot load');\n`);

  const route = { file: pageFile, layouts: [], errors: [], metadataFiles: [], loadings: [loadingFile] };
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
  assert.equal(resp.status, 200);
  const body = await resp.text();
  assert.ok(body.includes('<p>ok</p>'));
});

/* ------------ CSP nonce + CSRF cookie ------------ */

test('ssrPage: CSP nonce on request → nonce attribute on injected scripts', async () => {
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function Page() { return html\`<p>ok</p>\`; }\n`,
  });
  const req = new Request('http://localhost/', {
    headers: { 'content-security-policy': "script-src 'nonce-abc123XYZ' 'self'" },
  });
  const resp = await withRequest(req, () =>
    ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir, req }));
  const body = await resp.text();
  assert.ok(body.includes('nonce="abc123XYZ"'));
});

test('ssrPage: CSP nonce → meta csp-nonce tag emitted for client-router pickup', async () => {
  // Turbo's convention: server emits <meta name="csp-nonce" content="..."> so
  // the client router (router-client.js) can apply the original page-load
  // nonce to dynamically-created scripts (head merge, script reactivation).
  // Without this, strict-CSP apps break on every client-side nav.
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function Page() { return html\`<p>ok</p>\`; }\n`,
  });
  const req = new Request('http://localhost/', {
    headers: { 'content-security-policy': "script-src 'nonce-xyz789' 'self'" },
  });
  const resp = await withRequest(req, () =>
    ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir, req }));
  const body = await resp.text();
  assert.match(body, /<meta name="csp-nonce" content="xyz789">/);
});

test('ssrPage: no nonce in CSP → no meta csp-nonce tag', async () => {
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function Page() { return html\`<p>ok</p>\`; }\n`,
  });
  const req = new Request('http://localhost/');
  const resp = await withRequest(req, () =>
    ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir, req }));
  const body = await resp.text();
  assert.ok(!body.includes('csp-nonce'), 'no meta tag when no nonce in request CSP');
});

test('ssrPage: CSP nonce propagates to error-page response (boot scripts on error page need it)', async () => {
  // When the page render throws, the error response goes through a
  // different path (wrapInDocument with route.errors / fallback) but
  // still emits inline scripts because moduleUrls includes the
  // page + layouts. Strict-CSP would block those scripts if the
  // nonce isn't threaded through the error path.
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function Page() { throw new Error('boom'); }\n`,
  });
  const req = new Request('http://localhost/', {
    headers: { 'content-security-policy': "script-src 'nonce-errnonceXYZ' 'self'" },
  });
  const resp = await withRequest(req, () =>
    ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir, req }));
  assert.equal(resp.status, 500);
  const body = await resp.text();
  assert.match(body, /<meta name="csp-nonce" content="errnonceXYZ">/,
    'error response must carry the meta csp-nonce tag');
});

test('ssrPage: response attaches a csrf set-cookie when request has no token', async () => {
  const { route, appDir } = await makeRoute({
    pageSrc:
      `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
      `export default function Page() { return html\`<p>ok</p>\`; }\n`,
  });
  const req = new Request('http://localhost/');
  const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir, req });
  const setCookie = resp.headers.get('set-cookie');
  assert.ok(setCookie && /csrf/i.test(setCookie), `expected csrf cookie, got ${setCookie}`);
});

test('ssrPage: WEBJS_PUBLIC_* env vars are injected into window.process.env', async () => {
  const prevApi = process.env.WEBJS_PUBLIC_API_URL;
  const prevSecret = process.env.NOT_PUBLIC_SECRET;
  process.env.WEBJS_PUBLIC_API_URL = 'https://api.example.test';
  process.env.NOT_PUBLIC_SECRET = 'must-not-leak';
  try {
    const { route, appDir } = await makeRoute({
      pageSrc:
        `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` +
        `export default function Page() { return html\`<p>ok</p>\`; }\n`,
    });
    const resp = await ssrPage(route, {}, new URL('http://localhost/'), { dev: false, appDir });
    const body = await resp.text();
    assert.ok(body.includes('window.process.env'), 'shim assignment should appear in head');
    assert.ok(body.includes('"WEBJS_PUBLIC_API_URL":"https://api.example.test"'));
    assert.ok(body.includes('"NODE_ENV":"production"'), 'NODE_ENV must reflect dev:false');
    assert.equal(
      body.includes('must-not-leak'), false,
      'unprefixed env values must not appear in the SSR output',
    );
  } finally {
    if (prevApi === undefined) delete process.env.WEBJS_PUBLIC_API_URL;
    else process.env.WEBJS_PUBLIC_API_URL = prevApi;
    if (prevSecret === undefined) delete process.env.NOT_PUBLIC_SECRET;
    else process.env.NOT_PUBLIC_SECRET = prevSecret;
  }
});

/* ------------ bundle mode skips per-file preloads ------------ */


test('vendor: pin file changes update served importmap (fs.watch drives clearVendorCache)', async () => {
  // The pin file is at .webjs/vendor/importmap.json under the app
  // directory. When the dev-server file watcher fires for that path
  // it calls clearVendorCache so the next SSR rereads the new
  // bindings. This integration test verifies the seam: changing
  // the in-memory vendor entries (the same hook clearVendorCache
  // resets to) and re-rendering produces an importmap reflecting
  // the new state.
  const { setVendorEntries, buildImportMap } = await import(
    new URL('../../packages/server/src/importmap.js', import.meta.url).href
  );
  await setVendorEntries({ 'a': 'https://cdn.example/a.js' });
  let map = buildImportMap();
  assert.equal(map.imports.a, 'https://cdn.example/a.js');
  // Hand-edit equivalent: a new pin file would update the in-memory
  // entries on the next fs.watch fire. Simulate by re-setting.
  await setVendorEntries({ 'a': 'https://cdn.example/a-v2.js', 'b': 'https://cdn.example/b.js' });
  map = buildImportMap();
  assert.equal(map.imports.a, 'https://cdn.example/a-v2.js', 'updated URL replaces old');
  assert.equal(map.imports.b, 'https://cdn.example/b.js', 'new entry appears');
  await setVendorEntries({});
});

test('integrityAttr: emits integrity attribute for vendor URLs with known SRI hash', async () => {
  // Companion to preloadCrossOriginAttr coverage. Tests that the
  // integrityAttr helper used by the modulepreload emission loop
  // returns the matching integrity attribute when the URL has a
  // pinned hash, and nothing when it doesn't.
  const { setVendorEntries } = await import(
    new URL('../../packages/server/src/importmap.js', import.meta.url).href
  );
  const { integrityAttr } = await import(
    new URL('../../packages/server/src/ssr.js', import.meta.url).href
  );
  await setVendorEntries(
    { 'fake-vendor': '/__webjs/vendor/fake-vendor@1.0.0.js' },
    { '/__webjs/vendor/fake-vendor@1.0.0.js': 'sha384-validHashValueHere==' },
  );
  try {
    assert.equal(
      integrityAttr('/__webjs/vendor/fake-vendor@1.0.0.js'),
      ' integrity="sha384-validHashValueHere=="',
    );
    // URL not in the integrity map: no attribute.
    assert.equal(integrityAttr('/__webjs/vendor/unpinned.js'), '');
    // Non-vendor URLs always return empty.
    assert.equal(integrityAttr('/components/foo.ts'), '');
    assert.equal(integrityAttr('/__webjs/core/index.js'), '');
  } finally {
    await setVendorEntries({});
  }
});
