Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions site/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { defineConfig } from '@playwright/test';

const baseURL = 'http://127.0.0.1:4329/Ziggurat/';
const port = process.env.PORT ?? '4329';
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
throw new Error('PORT must be a decimal integer between 1 and 65535.');
}
const baseURL = `http://127.0.0.1:${Number(port)}/Ziggurat/`;
const sizes = [
{ name: 'desktop', viewport: { width: 1280, height: 900 } },
{ name: 'tablet', viewport: { width: 834, height: 1112 } },
Expand Down Expand Up @@ -30,7 +34,7 @@ export default defineConfig({
webServer: {
command: 'npm run build && node scripts/serve-dist.mjs',
url: baseURL,
reuseExistingServer: !process.env.CI,
reuseExistingServer: false,
timeout: 120_000,
},
});
2 changes: 1 addition & 1 deletion site/src/components/AdmissionFigure.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
const stages = [
{ actor: 'Model', title: 'Propose a draft', detail: 'Content and source ranges, without admission authority.' },
{ actor: 'Host', title: 'Validate and stage Silver', detail: 'Resolve citations and check the live evidence.' },
{ actor: 'Reviewer', title: 'Author the page and sign externally', detail: 'The private key stays outside the model and host.', external: true },
{ actor: 'Reviewer', title: 'Author the page and sign externally', detail: 'Keep the private key separate from the model and host.', external: true },
{ actor: 'Host', title: 'Admit eligible Gold', detail: 'Verify the receipt and every other eligibility condition.' },
];
---
Expand Down
9 changes: 5 additions & 4 deletions site/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ const notGuaranteed = [
A model can read authorized content and draft a complete, evidence-backed
candidate. It cannot admit that candidate to durable shared memory. Only the
holder of an external Ed25519 private key that operator policy assigns to a reviewer
can, and that key is never reachable from any shipped code path.
can. Ziggurat ships no signer. Keeping the private key outside the vault and
inaccessible to the model and Ziggurat process is an operator responsibility.
The receipt proves key control and exact-content authorization, not humanity or attention.
</p>
<div class="actions">
Expand Down Expand Up @@ -373,7 +374,7 @@ retrieval_eligible: true
<h3>Watch the boundary hold</h3>
<p>
The garden walkthrough runs the poisoned-memory scenario against fixture data and
stops at the human signing boundary by design. Clone the
stops at the human signing boundary by design. Clone the{' '}
<a href={repo}>repository</a>, then from the checkout root:
</p>
</div>
Expand All @@ -392,8 +393,8 @@ node scripts/run-garden-walkthrough.mjs</code></pre>
</div>
<div class="prose">
<p>
<a href={url('security/threat-model/')}>Threat model overview</a>, then the
<a href={url('security/attack-controls/')}>attack-control mapping</a> and the
<a href={url('security/threat-model/')}>Threat model overview</a>, then the{' '}
<a href={url('security/attack-controls/')}>attack-control mapping</a> and the{' '}
<a href={url('security/guarantees/')}>guarantees and residual risks</a>.
</p>
</div>
Expand Down
66 changes: 60 additions & 6 deletions site/tests/visual.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,68 @@
import { mkdir } from 'node:fs/promises';
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';

const artifacts = fileURLToPath(new URL('../.artifacts/', import.meta.url));
const docsDirectory = fileURLToPath(new URL('../src/content/docs/', import.meta.url));
const docRoutes = readdirSync(docsDirectory, { recursive: true, encoding: 'utf8' })
.filter(path => /\.(md|mdx)$/.test(path))
.map(path => path.replaceAll('\\', '/').replace(/\.(md|mdx)$/, '').replace(/(^|\/)index$/, '$1'))
.map(path => `${path.replace(/\/$/, '')}/`);
const routes = [...new Set(['/', ...docRoutes])].sort();

test('homepage inline prose link boundaries', async ({ page }) => {
await page.goto('./');
const boundaries = await page.locator('main p a, main li a, main dd a, main figcaption a, .site-footer p a')
.evaluateAll(links => links.map(link => {
const prose = link.closest('p, li, dd, figcaption')!;
const before = document.createRange();
before.selectNodeContents(prose);
before.setEndBefore(link);
const after = document.createRange();
after.selectNodeContents(prose);
after.setStartAfter(link);
// DOM ranges retain missing spaces that layout gaps and accessibility checks cannot detect.
return {
text: link.textContent,
before: before.toString().replace(/\s+/g, ' '),
after: after.toString().replace(/\s+/g, ' '),
};
}));

expect(boundaries).toEqual([
{
text: 'provenance and authority',
before: expect.stringMatching(/; see $/),
after: '.',
},
{
text: 'repository',
before: expect.stringMatching(/Clone the $/),
after: expect.stringMatching(/^, then from the checkout root:/),
},
{
text: 'Threat model overview',
before: expect.stringMatching(/^\s*$/),
after: expect.stringMatching(/^, then the attack-control mapping and the guarantees and residual risks\.\s*$/),
},
{
text: 'attack-control mapping',
before: expect.stringMatching(/Threat model overview, then the $/),
after: expect.stringMatching(/^ and the guarantees and residual risks\.\s*$/),
},
{
text: 'guarantees and residual risks',
before: expect.stringMatching(/attack-control mapping and the $/),
after: expect.stringMatching(/^\.\s*$/),
},
{
text: 'project status',
before: expect.stringMatching(/documented in $/),
after: expect.stringMatching(/^, alongside failures and remaining gaps\./),
},
]);
});

for (const route of routes) {
test(`${route} visual and accessibility`, async ({ page }, testInfo) => {
// A leading slash would discard the /Ziggurat/ base path.
Expand All @@ -27,6 +77,12 @@ for (const route of routes) {
await expect(page.locator('h1')).toHaveCount(1);

if (route === '/') {
await expect(page.locator('.thesis .prose')).toContainText(
'Ziggurat ships no signer. Keeping the private key outside the vault and inaccessible to the model and Ziggurat process is an operator responsibility.',
);
await expect(page.locator('.admission__external')).toContainText(
'Keep the private key separate from the model and host.',
);
await expect(page.locator('.admission__flow > li')).toHaveCount(4);
await expect(page.locator('#ascent [data-ascent-figure]')).toBeVisible();
await expect(page.locator('.walkthrough__steps > li')).toHaveCount(7);
Expand Down Expand Up @@ -116,10 +172,8 @@ for (const route of routes) {
expect(violations, `Axe violations on ${route} (${testInfo.project.name})`).toEqual([]);

const slug = route === '/' ? 'home' : route.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
const [project, scheme] = testInfo.project.name.split('-');
await mkdir(artifacts, { recursive: true });
await page.screenshot({
path: join(artifacts, `${slug}-${project}-${scheme}.png`),
path: testInfo.outputPath(`${slug}-${testInfo.project.name}.png`),
fullPage: true,
animations: 'disabled',
});
Expand Down