mirror of
https://github.com/hanzoai/desktop.git
synced 2026-11-02 07:39:42 +00:00
253 lines
8.1 KiB
TypeScript
253 lines
8.1 KiB
TypeScript
// Deep E2E fixture for the @hanzo/ai desktop SDK as embedded by a brand app.
|
|
//
|
|
// The apps gate on (1) onboarding completion and (2) node health. This fixture
|
|
// lifts BOTH gates against a built `dist/` with zero native backend:
|
|
// - serves dist over http with SPA fallback (worker-scoped server)
|
|
// - mocks the Tauri host (window.__TAURI_INTERNALS__) so invoke() resolves
|
|
// benign defaults instead of throwing
|
|
// - pre-seeds the persisted Zustand `auth` + `settings` localStorage so the
|
|
// app boots PAST onboarding to Home (gotoDeep), or omits it to land on the
|
|
// onboarding gate (gotoFresh)
|
|
// - Playwright-routes the node HTTP API: health_check -> healthy, list
|
|
// endpoints -> [], object endpoints -> {}
|
|
//
|
|
// One factory, parameterized by brand + dist path, so every brand app reuses
|
|
// the identical flow specs and only the brand metadata varies.
|
|
import { test as base, expect, type Page } from '@playwright/test';
|
|
import http from 'node:http';
|
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
import { extname, join } from 'node:path';
|
|
|
|
const MIME: Record<string, string> = {
|
|
'.js': 'text/javascript',
|
|
'.mjs': 'text/javascript',
|
|
'.css': 'text/css',
|
|
'.html': 'text/html',
|
|
'.json': 'application/json',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.webp': 'image/webp',
|
|
'.gif': 'image/gif',
|
|
'.ico': 'image/x-icon',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
'.ttf': 'font/ttf',
|
|
'.wasm': 'application/wasm',
|
|
'.map': 'application/json',
|
|
};
|
|
|
|
export type AppOptions = {
|
|
/** Absolute path to the built Vite dist/ for this brand app. */
|
|
distPath: string;
|
|
/** Brand display name as rendered in-app, e.g. "Zoo" / "Lux" / "Hanzo". */
|
|
brand: string;
|
|
/** Native local-engine option label in Add AI, e.g. "Zoo Engine". */
|
|
engineLabel: string;
|
|
/** Document <title> matcher, e.g. /Zoo/i. */
|
|
titleRe: RegExp;
|
|
/** Brand names that MUST NOT leak into this app's visible UI text. */
|
|
foreign: string[];
|
|
};
|
|
|
|
export type App = AppOptions & {
|
|
baseURL: string;
|
|
page: Page;
|
|
/** Seed completed-onboarding + valid auth, then navigate -> authed UI. */
|
|
gotoDeep: (route: string) => Promise<void>;
|
|
/** Navigate with NO auth seed -> onboarding gate. */
|
|
gotoFresh: (route: string) => Promise<void>;
|
|
/** Concatenated visible body text, whitespace-collapsed. */
|
|
bodyText: () => Promise<string>;
|
|
};
|
|
|
|
type ServerHandle = { baseURL: string; close: () => Promise<void> };
|
|
|
|
function startServer(distPath: string): Promise<ServerHandle> {
|
|
if (!existsSync(join(distPath, 'index.html'))) {
|
|
throw new Error(
|
|
`dist not built: ${join(distPath, 'index.html')} is missing — build the app first`,
|
|
);
|
|
}
|
|
const server = http.createServer((req, res) => {
|
|
const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
|
|
let file = join(distPath, urlPath);
|
|
if (!existsSync(file) || statSync(file).isDirectory()) {
|
|
file = join(distPath, 'index.html'); // SPA fallback for client routes
|
|
}
|
|
try {
|
|
res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' });
|
|
res.end(readFileSync(file));
|
|
} catch {
|
|
res.writeHead(404);
|
|
res.end();
|
|
}
|
|
});
|
|
return new Promise((resolve) => {
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const addr = server.address();
|
|
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
resolve({
|
|
baseURL: `http://127.0.0.1:${port}`,
|
|
close: () => new Promise((r) => server.close(() => r())),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Persisted-store seed: completed onboarding + valid-looking auth. Keys/shape
|
|
// are SDK-level (shared across brands), so this works for every brand app.
|
|
function seedScript(baseURL: string) {
|
|
return {
|
|
auth: {
|
|
state: {
|
|
auth: {
|
|
profile: 'main',
|
|
node_address: baseURL,
|
|
hanzo_identity: '@@e2e.localhost',
|
|
encryption_pk: '00'.repeat(32),
|
|
identity_pk: '00'.repeat(32),
|
|
api_v2_key: 'e2e-key',
|
|
},
|
|
},
|
|
version: 0,
|
|
},
|
|
settings: {
|
|
state: {
|
|
onboarding: {
|
|
steps: {
|
|
'terms-conditions': { completed: true, choice: true },
|
|
analytics: { completed: true, choice: false },
|
|
},
|
|
},
|
|
},
|
|
version: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Tauri host shim — resolve benign defaults so node-manager/invoke code paths
|
|
// execute instead of throwing in a non-Tauri (browser) context.
|
|
async function installTauriMock(page: Page) {
|
|
await page.addInitScript(() => {
|
|
const invoke = async (cmd: string) => {
|
|
if (/is_running|health/.test(cmd)) return false;
|
|
return null;
|
|
};
|
|
// @ts-expect-error injected host
|
|
window.__TAURI_INTERNALS__ = {
|
|
invoke,
|
|
transformCallback: (c: unknown) => c,
|
|
metadata: {
|
|
currentWindow: { label: 'main' },
|
|
currentWebview: { label: 'main' },
|
|
},
|
|
};
|
|
// @ts-expect-error injected host
|
|
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: async () => {} };
|
|
});
|
|
}
|
|
|
|
// Mock the node HTTP API so the node-health gate passes and queries resolve.
|
|
async function installNodeRoute(page: Page, baseURL: string) {
|
|
await page.route('**/*', (route) => {
|
|
const url = route.request().url();
|
|
if (!url.startsWith(baseURL)) return route.continue(); // external -> untouched
|
|
const path = new URL(url).pathname;
|
|
if (/health_check/.test(path)) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
status: 'ok',
|
|
node_name: '@@e2e.localhost',
|
|
is_pristine: false,
|
|
version: '1.0.0',
|
|
update_requires_reset: false,
|
|
}),
|
|
});
|
|
}
|
|
if (/\/(v1|v2)\//.test(path)) {
|
|
// object-shaped endpoints -> {}; list/iterable endpoints -> []
|
|
const isObject =
|
|
/(node_name|public_key|encryption|preferences|settings|default_|storage_location|wallet_balance|node_info)/.test(
|
|
path,
|
|
);
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: isObject ? '{}' : '[]',
|
|
});
|
|
}
|
|
return route.continue(); // same-origin static assets -> served from dist
|
|
});
|
|
}
|
|
|
|
async function waitReady(page: Page) {
|
|
await page.waitForLoadState('domcontentloaded');
|
|
// SPA mounted when #root has rendered children.
|
|
await page.locator('#root *').first().waitFor({ state: 'attached', timeout: 30_000 });
|
|
}
|
|
|
|
export type AppTest = {
|
|
test: ReturnType<typeof base.extend<{ app: App }, { server: ServerHandle }>>;
|
|
expect: typeof expect;
|
|
};
|
|
|
|
export function makeApp(opts: AppOptions): AppTest {
|
|
const test = base.extend<{ app: App }, { server: ServerHandle }>({
|
|
server: [
|
|
// eslint-disable-next-line no-empty-pattern
|
|
async ({}, use) => {
|
|
const handle = await startServer(opts.distPath);
|
|
await use(handle);
|
|
await handle.close();
|
|
},
|
|
{ scope: 'worker' },
|
|
],
|
|
|
|
baseURL: async ({ server }, use) => {
|
|
await use(server.baseURL);
|
|
},
|
|
|
|
app: async ({ page, server }, use) => {
|
|
const baseURL = server.baseURL;
|
|
await installTauriMock(page);
|
|
await installNodeRoute(page, baseURL);
|
|
|
|
let seeded = false;
|
|
const seed = async () => {
|
|
if (seeded) return;
|
|
await page.addInitScript((s) => {
|
|
for (const [k, v] of Object.entries(s)) localStorage.setItem(k, JSON.stringify(v));
|
|
}, seedScript(baseURL));
|
|
seeded = true;
|
|
};
|
|
|
|
const gotoDeep = async (route: string) => {
|
|
await seed();
|
|
await page.goto(baseURL + route, { waitUntil: 'domcontentloaded' });
|
|
await waitReady(page);
|
|
};
|
|
const gotoFresh = async (route: string) => {
|
|
await page.goto(baseURL + route, { waitUntil: 'domcontentloaded' });
|
|
await waitReady(page);
|
|
};
|
|
const bodyText = async () =>
|
|
(await page.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' ').trim();
|
|
|
|
await use({
|
|
...opts,
|
|
baseURL,
|
|
page,
|
|
gotoDeep,
|
|
gotoFresh,
|
|
bodyText,
|
|
});
|
|
},
|
|
});
|
|
|
|
return { test, expect };
|
|
}
|