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
18 changes: 17 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ jobs:
run: npm ci

- name: Build application
run: npm run build
run: VERSION="v$(node -p "require('./package.json').version.split('.').slice(0, 2).join('.')")" npm run build

# Both read dist/ and src/ only; neither needs a GPU or a browser.
- name: Validate bundle budget
Expand All @@ -104,6 +104,22 @@ jobs:
- name: Validate module reachability
run: npm run validate:reachability

- name: Install browser for Jev UI acceptance
run: npx playwright install --with-deps chromium

- name: Validate Jev personal-key flow in desktop and mobile layouts
timeout-minutes: 8
run: node scripts/validate-jev-browser.mjs

- name: Upload Jev browser evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: jev-browser
path: test-results/jev-browser/
if-no-files-found: ignore
retention-days: 7

- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
Expand Down
3 changes: 2 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
CSP connect-src third-party domains and their runtime consumers:
- https://0.peerjs.com PeerJS multiplayer signalling
- https://generativelanguage.googleapis.com Gemini cloud AI (GeminiSettingsModal)
- https://openrouter.ai opt-in Jev advisory (jevClient.ts)
- https://cdn.jsdelivr.net asset/CDN fetches
- https://huggingface.co + https://*.hf.co @mlc-ai/web-llm model weights
(Qwen3-4B-q4f16_1-MLC); HF LFS blobs
Expand All @@ -24,7 +25,7 @@
style-src 'self' 'unsafe-inline';
font-src 'self';
img-src 'self' data: blob: https://www.millos.net;
connect-src 'self' blob: ws: wss: https://0.peerjs.com https://generativelanguage.googleapis.com https://cdn.jsdelivr.net https://huggingface.co https://*.huggingface.co https://*.hf.co https://raw.githubusercontent.com;
connect-src 'self' blob: ws: wss: https://openrouter.ai https://0.peerjs.com https://generativelanguage.googleapis.com https://cdn.jsdelivr.net https://huggingface.co https://*.huggingface.co https://*.hf.co https://raw.githubusercontent.com;
worker-src 'self' blob:;
object-src 'none';
base-uri 'self';
Expand Down
186 changes: 186 additions & 0 deletions scripts/validate-jev-browser.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Built-assembly UI gate. Only synthetic keys/text and intercepted provider responses.
// Working if desktop/mobile can submit manually, forget credentials, and make no unsolicited calls.
import assert from 'node:assert/strict';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { chromium } from 'playwright';
import { preview } from 'vite';
import { acquireCaptureLock } from './lib/capture-lock.mjs';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const output = path.join(root, 'test-results/jev-browser');
const endpoint = 'https://openrouter.ai/api/alpha/decisions';
const key = `sk-or-v1-${'test-only-'.repeat(5)}`;
const note = 'Synthetic incident: a drive belt has snapped and awaits replacement.';
const report = {
passed: false,
requests: 0,
layouts: [],
pageErrors: [],
consoleErrors: [],
failedRequests: [],
csp: [],
};
const lock = await acquireCaptureLock('jev-browser-acceptance', { root });
let browser, server, page;
try {
await mkdir(output, { recursive: true });
const html = await readFile(path.join(root, 'dist/index.html'), 'utf8');
const base = html.match(/src="([^"]*\/)assets\/main-[^"]+\.js"/)?.[1];
assert.ok(base, 'Built application entry must identify its deployment base');
server = await preview({
root,
base,
preview: { host: '127.0.0.1', port: 4398, strictPort: true },
});
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ serviceWorkers: 'block', reducedMotion: 'reduce' });
await context.addInitScript(() => {
localStorage.setItem(
'millos-ui',
JSON.stringify({ state: { hasSeenIntro: true }, version: 1 })
);
window.jevCspViolations = [];
document.addEventListener('securitypolicyviolation', (event) => {
window.jevCspViolations.push(event.violatedDirective);
});
});
await context.route(endpoint, async (route) => {
const request = route.request();
const body = request.postDataJSON();
report.requests++;
assert.equal(request.headers().authorization, `Bearer ${key}`);
assert.equal(body.state, note);
assert.equal(body.model, 'typesafe/jev-1.13');
assert.deepEqual(Object.keys(body).sort(), ['model', 'provider', 'questions', 'state']);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
model: 'typesafe/jev-1.13',
answers: {
decision: {
type: 'choice',
choice: 'Equipment maintenance',
probabilities: {
'Equipment maintenance': 1,
'Product quality': 0,
'Logistics coordination': 0,
'No current incident': 0,
'Insufficient evidence': 0,
},
},
},
}),
});
});
page = await context.newPage();
page.setDefaultTimeout(30_000);
page.on('pageerror', (error) => report.pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') report.consoleErrors.push(message.text());
});
page.on('requestfailed', (request) => report.failedRequests.push(request.url()));
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto(
`http://127.0.0.1:4398${base}?benchmark=overview&quality=low&operations=on&pa=off`,
{
waitUntil: 'domcontentloaded',
}
);
await page.waitForFunction(
() =>
window.__MILLOS_RUNTIME__?.ready &&
document.documentElement.dataset.millosWorldReady === 'true' &&
!document.querySelector('[aria-label="Loading MillOS"]'),
null,
{ timeout: 240_000 }
);
// CompleteWorldMarker fires on mount, before incremental static batching.
// Read the batcher's readiness counter directly. Runtime snapshot() performs
// geometry raycasts as well as counting objects, so it is unsuitable for polling.
report.phase = 'scene-batching';
await writeFile(path.join(output, 'result.json'), `${JSON.stringify(report, null, 2)}\n`);
const settlingStarted = Date.now();
await page.waitForFunction(
() => {
const now = performance.now();
if (Number(document.documentElement.dataset.millosStaticBatchesPending ?? 0) > 0) {
window.jevSceneStableSince = now;
return false;
}
window.jevSceneStableSince ??= now;
return now - window.jevSceneStableSince >= 2000;
},
null,
{ polling: 400, timeout: 240_000 }
);
report.settlingMs = Date.now() - settlingStarted;

for (const width of [1440, 390]) {
report.phase = `layout-${width}`;
await writeFile(path.join(output, 'result.json'), `${JSON.stringify(report, null, 2)}\n`);
await page.setViewportSize({ width, height: width === 390 ? 844 : 1000 });
await page.getByRole('button', { name: 'AI Partner', exact: true }).click();
await page.getByRole('tab', { name: 'Advisory', exact: true }).click();
const panel = page.getByRole('region', { name: 'Jev advisory' });
const credential = panel.getByLabel('Your OpenRouter API key');
const notes = panel.getByLabel('Incident text');
const consent = panel.getByRole('checkbox');
const send = panel.getByRole('button', { name: 'Send to Jev', exact: true });
const before = report.requests;
assert.equal(await credential.inputValue(), '');
assert.equal(await consent.isChecked(), false);
await credential.fill(key);
await notes.fill(note);
assert.equal(await send.isEnabled(), false);
await consent.check();
assert.equal(report.requests, before);
await send.click();
await panel.getByText('Equipment maintenance', { exact: true }).waitFor();
assert.equal(report.requests, before + 1);
assert.equal(
await panel.getByRole('button', { name: /accept|apply|clear incident/i }).count(),
0
);
assert.equal(
await page.evaluate(
(secret) =>
[...Object.values(localStorage), ...Object.values(sessionStorage)].some((v) =>
v.includes(secret)
),
key
),
false
);
await panel.getByText('Equipment maintenance', { exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(output, `advisory-${width}.png`) });
const bounds = await panel.evaluate((element) => ({
client: element.clientWidth,
scroll: element.scrollWidth,
right: element.getBoundingClientRect().right,
}));
assert.ok(bounds.scroll <= bounds.client + 1 && bounds.right <= width + 1);
await panel.getByRole('button', { name: 'Forget key', exact: true }).click();
assert.equal(await credential.inputValue(), '');
assert.equal(await consent.isChecked(), false);
report.layouts.push({ width, passed: true });
}
report.csp = await page.evaluate(() => window.jevCspViolations);
assert.equal(report.csp.length, 0);
assert.equal(report.pageErrors.length, 0);
assert.equal(report.consoleErrors.length, 0);
assert.equal(report.failedRequests.length, 0);
report.passed = true;
} catch (error) {
report.error = error.message;
await page?.screenshot({ path: path.join(output, 'failure.png'), timeout: 5000 }).catch(() => {});
process.exitCode = 1;
} finally {
await writeFile(path.join(output, 'result.json'), `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify(report, null, 2));
await browser?.close();
if (server) await new Promise((resolve) => server.httpServer.close(resolve));
await lock.release();
}
25 changes: 22 additions & 3 deletions src/components/AICommandCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useAIConfigStore } from '../stores/aiConfigStore';
import { useShallow } from 'zustand/react/shallow';
import { applyDecisionEffects, reactToAlert } from '../utils/aiEngine';
import { GeminiSettingsModal } from './GeminiSettingsModal';
import { JevAdvisoryPanel } from './ui/JevAdvisoryPanel';
import { ActionPlanTimeline } from './ui/ActionPlanTimeline';
import { DecisionHistoryPanel } from './ui/DecisionHistoryPanel';
import { StrategicPriorityCards } from './ui/StrategicPriorityCards';
Expand Down Expand Up @@ -51,7 +52,7 @@ export const AICommandCenter: React.FC<AICommandCenterProps> = ({
embedded = false,
}) => {
const [isThinking, setIsThinking] = useState(false);
const [activeTab, setActiveTab] = useState<'decisions' | 'strategic'>('decisions');
const [activeTab, setActiveTab] = useState<'decisions' | 'strategic' | 'advisory'>('decisions');
const [selectedDecision, setSelectedDecision] = useState<AIDecision | null>(null);

const [systemStatus, setSystemStatus] = useState({
Expand Down Expand Up @@ -315,6 +316,20 @@ export const AICommandCenter: React.FC<AICommandCenterProps> = ({
<Target className="w-3 h-3 inline mr-1" aria-hidden="true" />
Strategic
</button>
<button
role="tab"
id="ai-advisory-tab"
aria-selected={activeTab === 'advisory'}
aria-controls="ai-command-tabpanel"
onClick={() => setActiveTab('advisory')}
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition-all ${
activeTab === 'advisory'
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30'
: 'bg-slate-800/50 text-slate-400 hover:bg-slate-800'
}`}
>
Advisory
</button>
</div>

{/* Screen-reader-only live region announcing the newest AI decision */}
Expand All @@ -326,10 +341,14 @@ export const AICommandCenter: React.FC<AICommandCenterProps> = ({
<div
id="ai-command-tabpanel"
role="tabpanel"
aria-labelledby={activeTab === 'decisions' ? 'ai-decisions-tab' : 'ai-strategic-tab'}
aria-labelledby={`ai-${activeTab}-tab`}
className="flex-1 overflow-y-auto p-3 space-y-2"
>
{activeTab === 'decisions' ? (
{activeTab === 'advisory' ? (
<JevAdvisoryPanel
latestAlert={alerts[0] ? `${alerts[0].title}\n${alerts[0].message}` : undefined}
/>
) : activeTab === 'decisions' ? (
<>
{aiDecisions.slice(0, 15).map((decision: AIDecision) => (
<div
Expand Down
16 changes: 16 additions & 0 deletions src/components/__tests__/AICommandCenter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,22 @@ describe('AICommandCenter', () => {
});

describe('Rendering', () => {
it('opens the advisory tab and forgets its key when switching away', () => {
render(<AICommandCenter isOpen={true} onClose={vi.fn()} embedded />);
fireEvent.click(screen.getByRole('tab', { name: 'Advisory' }));
expect(screen.getByRole('tabpanel', { name: 'Advisory' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Jev advisory' })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Your OpenRouter API key'), {
target: { value: `sk-or-v1-${'test-only-'.repeat(5)}` },
});
fireEvent.click(screen.getByRole('tab', { name: 'Strategic' }));
expect(screen.queryByRole('region', { name: 'Jev advisory' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Advisory' }));
expect(screen.getByLabelText('Your OpenRouter API key')).toHaveValue('');
expect(screen.getByRole('checkbox')).not.toBeChecked();
expect(aiEngineMock.applyDecisionEffects).not.toHaveBeenCalled();
});

it('should not render when isOpen is false', () => {
const { container } = render(<AICommandCenter isOpen={false} onClose={vi.fn()} embedded />);

Expand Down
73 changes: 73 additions & 0 deletions src/components/mobile/MobilePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MobilePanel } from './MobilePanel';
import { requestJevAdvice } from '../../utils/jevClient';

vi.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
motion: {
div: 'div',
aside: 'aside',
},
}));
vi.mock('../../stores/productionStore', () => ({
useProductionStore: (selector: (state: unknown) => unknown) => selector({ aiDecisions: [] }),
}));
vi.mock('../../stores/uiStore', () => ({
useUIStore: (selector: (state: unknown) => unknown) =>
selector({ alerts: [{ title: 'Bearing fault', message: 'Needs inspection.' }] }),
}));
vi.mock('../../stores/gameSimulationStore', () => ({ useGameSimulationStore: vi.fn() }));
vi.mock('../../stores/safetyStore', () => ({ useSafetyStore: vi.fn() }));
vi.mock('../../stores/operationsCampaignStore', () => ({ useOperationsCampaignStore: vi.fn() }));
vi.mock('../ui/EmergencyStopButton', () => ({ EmergencyStopButton: () => null }));
vi.mock('../../utils/jevClient', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/jevClient')>()),
requestJevAdvice: vi.fn(),
}));
const requestMock = vi.mocked(requestJevAdvice);
const key = `sk-or-v1-${'test-only-'.repeat(5)}`;
afterEach(() => {
cleanup();
vi.clearAllMocks();
});

describe('Mobile AI advisory wiring', () => {
it('keeps Decisions as the default and exposes the same reviewed-text advisory', () => {
render(<MobilePanel isVisible content="ai" onClose={vi.fn()} />);
expect(screen.getByText('No AI decisions yet')).toBeInTheDocument();
expect(screen.queryByRole('region', { name: 'Jev advisory' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Advisory' }));
expect(screen.getByRole('tabpanel', { name: 'Advisory' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Review latest alert' }));
expect(screen.getByLabelText('Incident text')).toHaveValue('Bearing fault\nNeeds inspection.');
expect(requestMock).not.toHaveBeenCalled();
});

it('forgets the credential and consent on tab changes', () => {
render(<MobilePanel isVisible content="ai" onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('tab', { name: 'Advisory' }));
fireEvent.change(screen.getByLabelText('Your OpenRouter API key'), { target: { value: key } });
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.click(screen.getByRole('tab', { name: 'Decisions' }));
fireEvent.click(screen.getByRole('tab', { name: 'Advisory' }));
expect(screen.getByLabelText('Your OpenRouter API key')).toHaveValue('');
expect(screen.getByRole('checkbox')).not.toBeChecked();
});

it('aborts advisory work when the mobile panel closes', () => {
requestMock.mockImplementation(() => new Promise(() => {}));
const onClose = vi.fn();
const { rerender } = render(<MobilePanel isVisible content="ai" onClose={onClose} />);
fireEvent.click(screen.getByRole('tab', { name: 'Advisory' }));
fireEvent.change(screen.getByLabelText('Your OpenRouter API key'), { target: { value: key } });
fireEvent.change(screen.getByLabelText('Incident text'), {
target: { value: 'Bearing fault.' },
});
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.click(screen.getByRole('button', { name: 'Send to Jev' }));
const signal = requestMock.mock.calls[0][0].signal;
rerender(<MobilePanel isVisible={false} content="ai" onClose={onClose} />);
expect(signal.aborted).toBe(true);
});
});
Loading
Loading