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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

</div>

Joltrin (formerly SOP) is an open-source Go library that checks an AI agent's action against the recorded trace before the action commits. The agent proposes. A separate verifier decides, using fixed preconditions and the steps the server has actually recorded. It does not use the agent's claims, its prompt context, its self-evaluation, or its confidence.
Joltrin blocks an AI agent's risky step, like dropping a database, until the steps before it are recorded, and tells the agent what to do first. It is an open-source Go library that checks the action against the recorded trace before it commits. The agent proposes. A separate verifier decides, using fixed preconditions and the steps the server has actually recorded. It does not use the agent's claims, its prompt context, its self-evaluation, or its confidence.

**The problem.** An agent can propose a destructive action and report success from its own context. If the same agent also judges the action safe, both judgments can fail for the same reason. That correlated failure is the production risk, and asking the agent to be careful does not remove it.

Expand Down
332 changes: 185 additions & 147 deletions demo/index.html

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion tests/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test';
import { createRequire } from 'node:module';
import { waitForWasmReady } from './helpers/wasm-lifecycle';

/**
* Every page passes the automated WCAG 2 A and AA checks, in the dark and the
Expand All @@ -17,7 +18,23 @@ for (const scheme of ['dark', 'light'] as const) {
for (const path of PAGES) {
test(`${path} has no WCAG A or AA violations`, async ({ page }) => {
await page.goto(path, { waitUntil: 'load' });
await page.waitForTimeout(1200);
await waitForWasmReady(page);
if (path === '/private-intent/') {
await expect(page.locator('#b-attempt')).toBeEnabled({ timeout: 30_000 });
}
// Scan the settled text, rather than a partially transparent entrance frame.
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
// Poll playState: WebKit can leave animation.finished unresolved for animations that have already finished.
const settled = () => document.getAnimations()
.filter(animation => Number.isFinite(animation.effect?.getComputedTiming().endTime))
.every(animation => animation.playState !== 'running');
const deadline = performance.now() + 5_000;
while (!settled() && performance.now() < deadline) {
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
}
});
await page.addScriptTag({ path: axePath });
const result = await page.evaluate(async () =>
// @ts-expect-error axe is injected above
Expand Down
29 changes: 29 additions & 0 deletions tests/helpers/wasm-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { Page, expect } from '@playwright/test';
* and WebAssembly execution boundary listeners.
*/
export async function waitForWasmReady(page: Page): Promise<void> {
// The homepage downloads its engine on the first interaction. Tests that wait for it start it here.
await page.evaluate(() => (window as any).startWasmEngine?.());

// Wait for the loader overlay to hide (opacity-0 class added upon WASM instantiation)
const overlay = page.locator('#wasm-loader-overlay');
if (await overlay.count() > 0) {
Expand Down Expand Up @@ -52,3 +55,29 @@ export async function waitForCanvasRendered(page: Page, canvasSelector = 'canvas
});
});
}

/**
* Lets the page run a few animation frames, so scroll handlers and timers that react to the
* last action have run. Use it before asserting that something did NOT happen.
*/
export async function nextFrames(page: Page, frames = 5): Promise<void> {
await page.evaluate(
(n) =>
new Promise<void>((resolve) => {
const step = (left: number) => (left <= 0 ? resolve() : requestAnimationFrame(() => step(left - 1)));
step(n);
}),
frames,
);
}

/** Waits until the page's form-velocity guard (render timestamp older than 1.2 s) lets a submit through. */
export async function waitForFormReady(page: Page, tsId: string): Promise<void> {
await page.waitForFunction(
(id) => {
const el = document.getElementById(id) as HTMLInputElement | null;
return !!el && el.value !== '' && Date.now() - Number(el.value) > 1300;
},
tsId,
);
}
91 changes: 72 additions & 19 deletions tests/homepage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForWasmReady } from './helpers/wasm-lifecycle';
import { waitForWasmReady, waitForFormReady } from './helpers/wasm-lifecycle';

/**
* Homepage positioning, pricing honesty, and metadata.
Expand All @@ -22,15 +22,33 @@ test.describe('Homepage', () => {
expect(fits, 'the headline fits the viewport').toBe(true);
});

test('headline and the one primary action are in the first screen, and the engine is not preloaded', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const vh = page.viewportSize()!.height;
const h1 = await page.getByRole('heading', { level: 1 }).boundingBox();
expect(h1!.y + h1!.height, 'the headline ends inside the first screen').toBeLessThan(vh);
const cta = await page.getByRole('link', { name: /try to make an agent drop a database/i }).boundingBox();
expect(cta!.y + cta!.height, 'the primary action ends inside the first screen').toBeLessThan(vh);
await expect(page.locator('link[rel="preload"][href$="sop.wasm"]')).toHaveCount(0);
});

test('the 6 MB engine downloads only after the visitor interacts', async ({ page }) => {
let fetched = 0;
await page.route('**/sop.wasm', (route) => { fetched++; route.continue(); });
await page.goto('/', { waitUntil: 'load' });
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
expect(fetched, 'no engine download before any interaction').toBe(0);
await page.getByRole('link', { name: /try to make an agent drop a database/i }).hover();
await expect.poll(() => fetched).toBeGreaterThan(0);
});

test('nothing covers the hero when the icon CDN is unreachable', async ({ page }) => {
// The boot overlay once stayed up for good after the icon script failed to load,
// and the homepage was a black screen that still answered 200.
await page.route(/^https:\/\/unpkg\.com\//, (route) => route.abort());
await page.goto('/', { waitUntil: 'load' });
const h1 = page.getByRole('heading', { level: 1 });
await expect(h1).toBeAttached();
// The scroll story sits above the hero, so bring the headline into view first.
await h1.scrollIntoViewIfNeeded();
await expect(h1).toBeVisible();
await expect
.poll(
Expand Down Expand Up @@ -81,29 +99,34 @@ test.describe('Homepage', () => {

test('the page does not lead with database or infrastructure claims', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const hero = (await page.locator('main section').first().innerText()).toLowerCase();
const hero = (await page.locator('#start').innerText()).toLowerCase();
for (const phrase of ['future of databases', 'ai-native', 'autonomous agent platform']) {
expect(hero).not.toContain(phrase);
}
});

test('intro video sits below the hero and autoplays muted', async ({ page }) => {
test('intro video is a click-to-play thumbnail that loads YouTube only on click', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const play = page.locator('#demo-video-play');
await expect(page.locator('#demo-video iframe')).toHaveCount(0);
await play.scrollIntoViewIfNeeded();
await expect(page.locator('#demo-video iframe')).toHaveCount(0);
const playY = (await play.boundingBox())!.y + (await page.evaluate(() => window.scrollY));
const heroY = (await page.getByRole('heading', { level: 1 }).boundingBox())!.y + (await page.evaluate(() => window.scrollY));
expect(playY).toBeGreaterThan(heroY);
await page.route('**/youtube-nocookie.com/**', (route) => route.fulfill({ status: 200, contentType: 'text/html', body: '' }));
await play.click();
const frame = page.locator('iframe[title="Joltrin intro video"]');
await expect(frame).not.toHaveAttribute('src', /./);
await frame.scrollIntoViewIfNeeded();
await expect(frame).toHaveAttribute('src', /youtube-nocookie\.com\/embed\/WZbz868nTi4\?.*autoplay=1.*mute=1/);
const frameY = (await frame.boundingBox())!.y;
const heroY = (await page.getByRole('heading', { level: 1 }).boundingBox())!.y;
expect(frameY).toBeGreaterThan(heroY);
await expect(frame).toHaveAttribute('src', /youtube-nocookie\.com\/embed\/WZbz868nTi4\?.*autoplay=1/);
expect(await frame.getAttribute('src')).not.toMatch(/loop=1/);
});

test('header has a Watch demo button that jumps to the video', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const btn = page.locator('#watch-demo-btn');
await expect(btn).toBeVisible();
await expect(btn).toHaveAttribute('href', '#demo-video');
await expect(page.locator('#demo-video iframe')).toHaveCount(1);
await expect(page.locator('#demo-video-play')).toHaveCount(1);
});

test('explains how to test the barrier with your own AI agent', async ({ page }) => {
Expand Down Expand Up @@ -261,7 +284,7 @@ test.describe('Homepage', () => {
test('share preview text matches the hero on the homepage and the agents page', async ({ request }) => {
const home = await (await request.get('/')).text();
expect(home).toContain('property="og:title" content="Your AI agent wants to delete production.');
expect(home).toContain('property="twitter:title" content="Your AI agent wants to delete production.');
expect(home).toContain('name="twitter:title" content="Your AI agent wants to delete production.');
expect(home).toContain('It checks the recorded trace, not the world.');
const agents = await (await request.get('/agents/')).text();
const og = agents.match(/property="og:title" content="([^"]+)"/)?.[1];
Expand All @@ -276,6 +299,31 @@ test.describe('Homepage', () => {
expect(overflow).toBeLessThanOrEqual(0);
});

for (const outcome of ['accepted', 'server error', 'lost response'] as const) {
test(`an inquiry is posted once and reports ${outcome} accurately`, async ({ page }) => {
let posts = 0;
await page.route('**/api/billing/enterprise-contact', route => {
posts++;
return outcome === 'lost response' ? route.abort('failed')
: route.fulfill({ status: outcome === 'accepted' ? 200 : 503, body: '{}' });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await waitForFormReady(page, 'interest-ts');
await page.fill('#inq-name', 'Test Person');
await page.fill('#inq-email', 'test@example.test');
await page.fill('#inq-company', 'Test Company');
await page.click('#inq-submit-btn');
const status = page.locator('#form-status-alert');
await expect(status).toContainText(outcome === 'accepted' ? 'inquiry has been received' : 'We could not confirm this submission');
expect(posts).toBe(1);
await expect(page.locator('#inq-name')).toHaveValue(outcome === 'accepted' ? '' : 'Test Person');
if (outcome !== 'accepted') {
await expect(page.locator('#inq-submit-btn')).toContainText('Submission Unconfirmed');
await expect(status).not.toContainText('Nothing was sent');
}
});
}

test('enterprise inquiry status is announced to screen readers', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.locator('#form-status-alert')).toHaveAttribute('role', 'status');
Expand All @@ -288,8 +336,12 @@ test.describe('Homepage', () => {
await expect(page.locator('html')).toHaveClass(/js-story/);

const total = await page.locator('.story-words .w').count();
const introHeight = await page.locator('.story-intro').evaluate((el) => (el as HTMLElement).offsetHeight);
await page.evaluate((y) => window.scrollTo(0, y), introHeight * 0.9);
// The story sits below the hero, so scroll relative to where it starts.
const [storyTop, introHeight] = await page.locator('.story-intro').evaluate((el) => [
el.getBoundingClientRect().top + window.scrollY,
(el as HTMLElement).offsetHeight,
]);
await page.evaluate((y) => window.scrollTo(0, y), storyTop + introHeight * 0.9);
await expect.poll(() => page.locator('.story-words .w.lit').count()).toBe(total);

const pin = page.locator('.story-pin');
Expand All @@ -316,9 +368,10 @@ test.describe('Homepage', () => {
const hint = page.locator('#story-hint');
await expect(hint).toBeVisible();
await expect(hint).toContainText('Scroll down to continue');
await page.evaluate(() => window.scrollTo(0, 200));
const storyTop = await page.locator('.story-intro').evaluate((el) => el.getBoundingClientRect().top + window.scrollY);
await page.evaluate((y) => window.scrollTo(0, y), storyTop + 200);
await expect(hint).toHaveClass(/gone/);
await page.evaluate(() => window.scrollTo(0, 0));
await page.evaluate((y) => window.scrollTo(0, y), storyTop);
await expect(hint).not.toHaveClass(/gone/);
});

Expand All @@ -336,9 +389,9 @@ test.describe('Homepage', () => {
});
});

test('hero is fully opaque when it first comes into view below the scroll story', async ({ page }) => {
test('the first section after the scroll story is fully opaque when it comes into view', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('h1').scrollIntoViewIfNeeded();
await page.locator('main > section').first().scrollIntoViewIfNeeded();
await page.evaluate(() => window.scrollBy(0, -100));
await expect
.poll(() => page.locator('main > section').first().evaluate((el) => Number(getComputedStyle(el).opacity)))
Expand Down
6 changes: 3 additions & 3 deletions tests/page-sound.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, Page } from '@playwright/test';
import { waitForWasmReady } from './helpers/wasm-lifecycle';
import { waitForWasmReady, nextFrames } from './helpers/wasm-lifecycle';

/**
* The Private Intent and Agents pages share one sound layer (assets/site-sound.js).
Expand Down Expand Up @@ -38,7 +38,7 @@ test.describe('Sound on the Private Intent page', () => {
test('stays locked until a press, and the first press does not switch it off', async ({ page }) => {
const toggle = page.locator('.site-sound');
await expect(toggle).toHaveText('Tap for sound');
await page.waitForTimeout(800);
await nextFrames(page);
expect(await oscillators(page)).toBe(0);
await toggle.click();
await expect(toggle).toHaveText('Sound on');
Expand All @@ -56,7 +56,7 @@ test.describe('Sound on the Private Intent page', () => {
const muted = await oscillators(page);
await page.locator('#b-reset').click();
await page.locator('#b-attempt').click();
await page.waitForTimeout(500);
await nextFrames(page);
expect(await oscillators(page)).toBe(muted);
});
});
Expand Down
7 changes: 4 additions & 3 deletions tests/playful.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForFormReady } from './helpers/wasm-lifecycle';

/**
* The playful parts: the homepage "Be the agent" panel, the agent barrier
Expand Down Expand Up @@ -126,7 +127,7 @@ test.describe('Agents page inquiry form', () => {
(window as any).plausible = (name: string, opts?: unknown) => (window as any).__events.push([name, opts]);
});
await page.goto('/agents/', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
await waitForFormReady(page, 'interest-ts-agents');
await page.fill('#interest-form-agents [name="name"]', 'Test Person');
await page.fill('#interest-form-agents [name="email"]', 'test@example.com');
await page.fill('#interest-form-agents [name="company"]', 'Test Co');
Expand All @@ -142,7 +143,7 @@ test.describe('Agents page inquiry form', () => {
await page.route('**/plausible.io/**', (route) => route.abort());
await page.route('**/api/billing/enterprise-contact', (route) => route.fulfill({ status: 500, body: '{}' }));
await page.goto('/agents/', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
await waitForFormReady(page, 'interest-ts-agents');
await page.fill('#interest-form-agents [name="name"]', 'Test Person');
await page.fill('#interest-form-agents [name="email"]', 'test@example.com');
await page.fill('#interest-form-agents [name="company"]', 'Test Co');
Expand All @@ -157,7 +158,7 @@ test.describe('Agents page inquiry form', () => {
await page.route('**/plausible.io/**', (route) => route.abort());
await page.route('**/api/billing/enterprise-contact', (route) => route.fulfill({ status: 200, body: '{}' }));
await page.goto('/agents/', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
await waitForFormReady(page, 'interest-ts-agents');
const name = '<img src=x onerror="window.__xss=1">';
await page.fill('#interest-form-agents [name="name"]', name);
await page.fill('#interest-form-agents [name="email"]', 'test@example.com');
Expand Down
3 changes: 2 additions & 1 deletion tests/private-intent.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { nextFrames } from './helpers/wasm-lifecycle';

/**
* The Private Intent demo runs the real intent package in WebAssembly.
Expand Down Expand Up @@ -432,7 +433,7 @@ test.describe('Share a run', () => {
for (const [label, hash] of [['unknown letters', '#run=QQQ'], ['markup', '#run=A%3Cscript%3E'], ['lower case', '#run=aba'], ['too many steps', '#run=' + 'A'.repeat(41)]]) {
test(`a link with ${label} is ignored`, async ({ page }) => {
await ready(page, hash);
await page.waitForTimeout(1500);
await nextFrames(page, 30);
await expect(page.locator('#replay-note')).toBeHidden();
await expect(page.locator('#log li')).toHaveCount(0);
await expect(page.locator('#banner-title')).toHaveText('READY');
Expand Down
Loading
Loading