Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import { execFile, execFileSync } from "node:child_process";
|
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
import { createServer, type Server } from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { serve } from "@hono/node-server";
|
|
import {
|
|
ComposioEmulator,
|
|
EmailEmulator,
|
|
PipedreamConnector,
|
|
ThirdPartyConnectorEmulator,
|
|
} from "@rakazo/adapters";
|
|
import { createThreadMessage, type PrismaClient } from "@rakazo/db";
|
|
import { sessionCookieHeader } from "../index.js";
|
|
import { runProcess } from "./process.js";
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
|
|
const REPORT_DIR = path.join(ROOT, "test-report", "mobile-screenshots");
|
|
const DATA_DIR = path.join(ROOT, ".tmp", "mobile-screenshots-data");
|
|
const FLOW_DIR = path.join(ROOT, "apps", "mobile", ".maestro");
|
|
const EMAIL = "mobile-screenshots@example.test";
|
|
const PASSWORD = "test-password-123";
|
|
const API_PORT = 3110;
|
|
const DEVICE_CONTROL_PORT = 3111;
|
|
const HOST_API_URL = `http://127.0.0.1:${API_PORT}`;
|
|
const DEVICE_CONTROL_URL = `http://127.0.0.1:${DEVICE_CONTROL_PORT}`;
|
|
const EXPAND_NOTIFICATIONS_URL = `${DEVICE_CONTROL_URL}/expand-notifications`;
|
|
const WEB_ORIGIN = "http://127.0.0.1:5180";
|
|
|
|
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
|
|
|
|
/** Capture app screenshots and the notification demo against an isolated, disposable backend. */
|
|
async function main() {
|
|
process.chdir(ROOT);
|
|
configureEnvironment();
|
|
await rm(REPORT_DIR, { recursive: true, force: true });
|
|
await rm(DATA_DIR, { recursive: true, force: true });
|
|
await mkdir(REPORT_DIR, { recursive: true });
|
|
await mkdir(DATA_DIR, { recursive: true });
|
|
|
|
execFileSync("pnpm", ["--filter", "@rakazo/db", "exec", "prisma", "migrate", "deploy"], {
|
|
cwd: path.join(ROOT, "packages", "db"),
|
|
env: process.env,
|
|
stdio: "inherit",
|
|
});
|
|
const thirdParties = new ThirdPartyConnectorEmulator();
|
|
const pipedream = new PipedreamConnector(
|
|
{
|
|
clientId: "fake-client-id",
|
|
clientSecret: "fake-client-secret",
|
|
projectId: "fake-project-id",
|
|
environment: "development",
|
|
identitySecret: process.env.ENCRYPTION_KEY!,
|
|
},
|
|
{ fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname },
|
|
);
|
|
const { createApp } = await import("../../../../apps/api/src/app.ts");
|
|
const handles = await createApp({
|
|
databaseUrl: process.env.DATABASE_URL!,
|
|
dataDir: DATA_DIR,
|
|
apiHost: "0.0.0.0",
|
|
apiUrl: HOST_API_URL,
|
|
composio: new ComposioEmulator(),
|
|
pipedream,
|
|
email: new EmailEmulator(),
|
|
remoteConnectors: {
|
|
fetch: thirdParties.fetch,
|
|
resolveHostname: thirdParties.resolveHostname,
|
|
},
|
|
});
|
|
|
|
const fixture = await seedFixture(handles.app, handles.prisma);
|
|
const server = serve({
|
|
fetch: handles.app.fetch,
|
|
hostname: "0.0.0.0",
|
|
port: API_PORT,
|
|
});
|
|
const deviceControl = startDeviceControlServer();
|
|
|
|
try {
|
|
await waitForHealth(`${HOST_API_URL}/health`, 15_000);
|
|
for (const flow of ["screenshots", "notification-demo"]) {
|
|
await runProcess(
|
|
"maestro",
|
|
[
|
|
"test",
|
|
"--no-ansi",
|
|
"--flatten-debug-output",
|
|
"--debug-output",
|
|
path.join(REPORT_DIR, "debug", flow),
|
|
"--test-output-dir",
|
|
REPORT_DIR,
|
|
"--format",
|
|
"HTML",
|
|
"--output",
|
|
path.join(REPORT_DIR, `${flow}.html`),
|
|
"-e",
|
|
`RAKAZO_SCREENSHOT_EMAIL=${EMAIL}`,
|
|
"-e",
|
|
`RAKAZO_SCREENSHOT_PASSWORD=${PASSWORD}`,
|
|
"-e",
|
|
`RAKAZO_SCREENSHOT_BOT_ID=${fixture.botId}`,
|
|
"-e",
|
|
`RAKAZO_SCREENSHOT_GROUP_ID=${fixture.groupId}`,
|
|
"-e",
|
|
`RAKAZO_SCREENSHOT_ROUTINE_ID=${fixture.routineId}`,
|
|
"-e",
|
|
`RAKAZO_NOTIFICATION_VIDEO=${path.join(REPORT_DIR, "notification-demo")}`,
|
|
"-e",
|
|
`RAKAZO_EXPAND_NOTIFICATIONS_URL=${EXPAND_NOTIFICATIONS_URL}`,
|
|
path.join(FLOW_DIR, `${flow}.yaml`),
|
|
],
|
|
process.env,
|
|
);
|
|
}
|
|
await writeFile(
|
|
path.join(REPORT_DIR, "summary.json"),
|
|
`${JSON.stringify({ ok: true }, null, 2)}\n`,
|
|
);
|
|
} finally {
|
|
await new Promise<void>((resolve) => {
|
|
server.close(() => resolve());
|
|
server.closeAllConnections();
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
deviceControl.close((error) => (error ? reject(error) : resolve()));
|
|
}).catch(() => undefined);
|
|
await handles.stop().catch(() => undefined);
|
|
await rm(DATA_DIR, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** Maestro host-side helper: expand the Android shade without flaky status-bar swipes. */
|
|
function startDeviceControlServer(): Server {
|
|
const server = createServer((req, res) => {
|
|
if (req.method !== "GET" || req.url !== "/expand-notifications") {
|
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
res.end("not found");
|
|
return;
|
|
}
|
|
execFile(
|
|
"adb",
|
|
["shell", "cmd", "statusbar", "expand-notifications"],
|
|
{ timeout: 5_000 },
|
|
(error) => {
|
|
res.writeHead(error ? 500 : 200, { "content-type": "text/plain; charset=utf-8" });
|
|
res.end(error ? "Could not expand notifications" : "ok");
|
|
},
|
|
);
|
|
});
|
|
server.listen(DEVICE_CONTROL_PORT, "127.0.0.1");
|
|
return server;
|
|
}
|
|
|
|
function configureEnvironment() {
|
|
if (!process.env.DATABASE_URL) {
|
|
throw new Error("DATABASE_URL is required for the isolated mobile screenshot fixture");
|
|
}
|
|
Object.assign(process.env, {
|
|
NODE_ENV: "test",
|
|
WAKEUP_DRIVER: "memory",
|
|
SANDBOX_PROVIDER: "fake",
|
|
AGENT_RUNTIME: "scripted",
|
|
COMPOSIO_API_KEY: "",
|
|
BETTER_AUTH_SECRET: "mobile-screenshot-auth-secret-32chars",
|
|
ENCRYPTION_KEY: "mobile-screenshot-encryption-key-32chars",
|
|
SCREEN_PROXY_SECRET: "mobile-screenshot-screen-secret-32chars",
|
|
BETTER_AUTH_URL: HOST_API_URL,
|
|
WEB_ORIGIN,
|
|
API_HOST: "0.0.0.0",
|
|
API_PORT: String(API_PORT),
|
|
API_URL: HOST_API_URL,
|
|
DATA_DIR,
|
|
SIGNUPS_ENABLED: "true",
|
|
SIGNUP_ALLOWLIST: "",
|
|
CI: "1",
|
|
});
|
|
}
|
|
|
|
async function seedFixture(app: App, prisma: PrismaClient) {
|
|
const cookie = await signup(app);
|
|
const researcher = await rpc<{ id: string }>(app, cookie, "bots/create", {
|
|
name: "Researcher",
|
|
title: "Product research",
|
|
description: "Finds evidence and keeps fixture notes organized.",
|
|
instructions: "Find evidence and summarize it clearly.",
|
|
notifyOnFinish: true,
|
|
});
|
|
const writer = await rpc<{ id: string }>(app, cookie, "bots/create", {
|
|
name: "Writer",
|
|
title: "Clear writing",
|
|
description: "Turns research into concise drafts.",
|
|
instructions: "Write concise drafts from the available evidence.",
|
|
notifyOnFinish: true,
|
|
});
|
|
const support = await rpc<{ id: string }>(app, cookie, "bots/create", {
|
|
name: "Support",
|
|
title: "Customer support",
|
|
description: "Prepares friendly customer replies.",
|
|
instructions: "Prepare accurate and friendly customer replies.",
|
|
notifyOnFinish: false,
|
|
});
|
|
const archived = await rpc<{ id: string }>(app, cookie, "bots/create", {
|
|
name: "Archived fixture",
|
|
title: "Archived",
|
|
description: "A recoverable archived bot for account screenshots.",
|
|
instructions: "Remain archived.",
|
|
notifyOnFinish: true,
|
|
});
|
|
await rpc(app, cookie, "bots/archive", { botId: archived.id });
|
|
|
|
const group = await rpc<{ id: string }>(app, cookie, "groups/create", {
|
|
name: "Launch team",
|
|
botIds: [researcher.id, writer.id, support.id],
|
|
});
|
|
const routine = await rpc<{ id: string }>(app, cookie, "routines/create", {
|
|
botId: researcher.id,
|
|
name: "Weekly fixture review",
|
|
prompt: "Review the fixture launch notes and summarize the open questions.",
|
|
crons: ["0 9 * * 1"],
|
|
timezone: "UTC",
|
|
active: true,
|
|
notify: true,
|
|
});
|
|
|
|
const botThread = await prisma.thread.findUniqueOrThrow({ where: { botId: researcher.id } });
|
|
const groupThread = await prisma.thread.findUniqueOrThrow({ where: { groupId: group.id } });
|
|
const question = await createThreadMessage(prisma, {
|
|
threadId: botThread.id,
|
|
role: "user",
|
|
blocks: [{ kind: "text", text: "Summarize the fixture launch checklist." }],
|
|
});
|
|
await createThreadMessage(prisma, {
|
|
threadId: botThread.id,
|
|
role: "bot",
|
|
botId: researcher.id,
|
|
blocks: [{ kind: "text", text: "The fixture workspace is ready." }],
|
|
});
|
|
await createThreadMessage(prisma, {
|
|
threadId: groupThread.id,
|
|
role: "user",
|
|
blocks: [{ kind: "text", text: "Coordinate the fixture launch plan." }],
|
|
});
|
|
await createThreadMessage(prisma, {
|
|
threadId: groupThread.id,
|
|
role: "bot",
|
|
botId: writer.id,
|
|
blocks: [{ kind: "text", text: "The fixture launch plan is ready." }],
|
|
});
|
|
|
|
const task = await prisma.task.create({
|
|
data: {
|
|
spaceId: botThread.spaceId,
|
|
userId: botThread.userId,
|
|
botId: researcher.id,
|
|
threadId: botThread.id,
|
|
prompt: "Prepare the fixture workspace",
|
|
status: "completed",
|
|
},
|
|
});
|
|
await prisma.run.create({
|
|
data: {
|
|
spaceId: botThread.spaceId,
|
|
userId: botThread.userId,
|
|
botId: researcher.id,
|
|
threadId: botThread.id,
|
|
taskId: task.id,
|
|
status: "completed",
|
|
trigger: "user",
|
|
modelProvider: "scripted",
|
|
modelId: "scripted",
|
|
sourceMessageId: question.id,
|
|
startedAt: new Date(Date.now() - 2_000),
|
|
completedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return { botId: researcher.id, groupId: group.id, routineId: routine.id };
|
|
}
|
|
|
|
async function signup(app: App) {
|
|
const response = await app.request("/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", origin: WEB_ORIGIN },
|
|
body: JSON.stringify({ email: EMAIL, password: PASSWORD, name: "Screenshot User" }),
|
|
});
|
|
if (!response.ok) throw new Error(`Fixture signup failed with ${response.status}`);
|
|
const cookie = sessionCookieHeader(response);
|
|
if (!cookie) throw new Error("Fixture signup did not return a session cookie");
|
|
return cookie;
|
|
}
|
|
|
|
async function rpc<T>(app: App, cookie: string, procedure: string, body: unknown = {}): Promise<T> {
|
|
const response = await app.request(`/rpc/${procedure}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie,
|
|
origin: WEB_ORIGIN,
|
|
},
|
|
body: JSON.stringify({ json: body }),
|
|
});
|
|
const text = await response.text();
|
|
const parsed = JSON.parse(text) as { json?: T; error?: { message?: string } };
|
|
if (!response.ok || parsed.error) {
|
|
throw new Error(`${procedure} ${response.status}: ${parsed.error?.message ?? text}`);
|
|
}
|
|
return parsed.json as T;
|
|
}
|
|
|
|
async function waitForHealth(url: string, timeoutMs: number) {
|
|
const startedAt = Date.now();
|
|
let lastError = "no response";
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.ok) return;
|
|
lastError = `HTTP ${response.status}`;
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
}
|
|
throw new Error(`Mobile screenshot API did not become ready: ${lastError}`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|