mirror of
https://github.com/different-ai/openwork
synced 2026-04-26 01:25:10 +02:00
* refactor(repo): move OpenWork apps into apps and ee layout Rebase the monorepo layout migration onto the latest dev changes so the moved app, desktop, share, and cloud surfaces keep working from their new paths. Carry the latest deeplink, token persistence, build, Vercel, and docs updates forward to avoid stale references and broken deploy tooling. * chore(repo): drop generated desktop artifacts Ignore the moved Tauri target and sidecar paths so local cargo checks do not pollute the branch. Remove the accidentally committed outputs from the repo while keeping the layout migration intact. * fix(release): drop built server cli artifact Stop tracking the locally built apps/server/cli binary so generated server outputs do not leak into commits. Also update the release workflow to check the published scoped package name for @openwork/server before deciding whether npm publish is needed. * fix(workspace): add stable CLI bin wrappers Point the server and router package bins at committed wrapper scripts so workspace installs can create shims before dist outputs exist. Keep the wrappers compatible with built binaries and source checkouts to avoid Vercel install warnings without changing runtime behavior.
341 lines
8.9 KiB
JavaScript
341 lines
8.9 KiB
JavaScript
import { randomUUID } from "node:crypto";
|
|
import { once } from "node:events";
|
|
import net from "node:net";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { spawn } from "node:child_process";
|
|
import mysql from "mysql2/promise";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const serviceDir = resolve(__dirname, "..");
|
|
|
|
function log(message) {
|
|
process.stdout.write(`${message}\n`);
|
|
}
|
|
|
|
function fail(message, detail) {
|
|
if (detail !== undefined) {
|
|
console.error(message, detail);
|
|
} else {
|
|
console.error(message);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
async function getFreePort() {
|
|
return await new Promise((resolvePort, reject) => {
|
|
const server = net.createServer();
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
reject(new Error("failed_to_resolve_free_port"));
|
|
return;
|
|
}
|
|
const { port } = address;
|
|
server.close((error) => (error ? reject(error) : resolvePort(port)));
|
|
});
|
|
server.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function spawnCommand(command, args, options = {}) {
|
|
return spawn(command, args, {
|
|
cwd: serviceDir,
|
|
env: process.env,
|
|
stdio: "pipe",
|
|
...options,
|
|
});
|
|
}
|
|
|
|
async function runCommand(command, args, options = {}) {
|
|
const child = spawnCommand(command, args, options);
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout?.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr?.on("data", (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
const [code] = await once(child, "exit");
|
|
if (code !== 0) {
|
|
throw new Error(`${command} ${args.join(" ")} failed\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`);
|
|
}
|
|
return { stdout, stderr };
|
|
}
|
|
|
|
async function waitForMysqlConnection(databaseUrl, attempts = 60) {
|
|
for (let index = 0; index < attempts; index += 1) {
|
|
try {
|
|
const connection = await mysql.createConnection(databaseUrl);
|
|
await connection.query("SELECT 1");
|
|
await connection.end();
|
|
return;
|
|
} catch {
|
|
await delay(1000);
|
|
}
|
|
}
|
|
|
|
throw new Error("mysql_not_ready");
|
|
}
|
|
|
|
async function waitForHttp(url, attempts = 60) {
|
|
for (let index = 0; index < attempts; index += 1) {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// ignore until retries are exhausted
|
|
}
|
|
await delay(500);
|
|
}
|
|
|
|
throw new Error(`http_not_ready:${url}`);
|
|
}
|
|
|
|
function extractAuthToken(payload) {
|
|
if (!payload || typeof payload !== "object") {
|
|
return null;
|
|
}
|
|
|
|
if (typeof payload.token === "string" && payload.token.trim()) {
|
|
return payload.token;
|
|
}
|
|
|
|
if (payload.session && typeof payload.session === "object" && typeof payload.session.token === "string") {
|
|
return payload.session.token;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function requestJson(baseUrl, path, { method = "GET", body, token, cookie } = {}) {
|
|
const headers = new Headers();
|
|
const origin = new URL(baseUrl).origin;
|
|
headers.set("Accept", "application/json");
|
|
headers.set("Origin", origin);
|
|
headers.set("Referer", `${origin}/`);
|
|
if (body !== undefined) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
if (token) {
|
|
headers.set("Authorization", `Bearer ${token}`);
|
|
}
|
|
if (cookie) {
|
|
headers.set("Cookie", cookie);
|
|
}
|
|
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method,
|
|
headers,
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
|
|
const text = await response.text();
|
|
let payload = null;
|
|
if (text) {
|
|
try {
|
|
payload = JSON.parse(text);
|
|
} catch {
|
|
payload = text;
|
|
}
|
|
}
|
|
|
|
return {
|
|
response,
|
|
payload,
|
|
cookie: response.headers.get("set-cookie"),
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const mysqlPort = await getFreePort();
|
|
const appPort = await getFreePort();
|
|
const containerName = `openwork-den-e2e-${randomUUID().slice(0, 8)}`;
|
|
const dbName = "openwork_den_e2e";
|
|
const dbPassword = "openwork-root";
|
|
const baseUrl = `http://127.0.0.1:${appPort}`;
|
|
const databaseUrl = `mysql://root:${dbPassword}@127.0.0.1:${mysqlPort}/${dbName}`;
|
|
const env = {
|
|
...process.env,
|
|
DATABASE_URL: databaseUrl,
|
|
BETTER_AUTH_SECRET: "openwork-den-e2e-secret-000000000000",
|
|
BETTER_AUTH_URL: baseUrl,
|
|
PORT: String(appPort),
|
|
OPENWORK_DEV_MODE: "1",
|
|
CORS_ORIGINS: baseUrl,
|
|
PROVISIONER_MODE: "stub",
|
|
WORKER_URL_TEMPLATE: "https://workers.example.com/{workerId}",
|
|
POLAR_FEATURE_GATE_ENABLED: "false",
|
|
};
|
|
|
|
let serviceProcess = null;
|
|
|
|
const cleanup = async () => {
|
|
if (serviceProcess && !serviceProcess.killed) {
|
|
serviceProcess.kill("SIGINT");
|
|
await once(serviceProcess, "exit").catch(() => {});
|
|
}
|
|
|
|
await runCommand("docker", ["rm", "-f", containerName], { cwd: serviceDir }).catch(() => {});
|
|
};
|
|
|
|
process.on("SIGINT", async () => {
|
|
await cleanup();
|
|
process.exit(130);
|
|
});
|
|
|
|
try {
|
|
log("Starting disposable MySQL container...");
|
|
await runCommand("docker", [
|
|
"run",
|
|
"-d",
|
|
"--rm",
|
|
"--name",
|
|
containerName,
|
|
"-e",
|
|
`MYSQL_ROOT_PASSWORD=${dbPassword}`,
|
|
"-e",
|
|
`MYSQL_DATABASE=${dbName}`,
|
|
"-p",
|
|
`${mysqlPort}:3306`,
|
|
"mysql:8.4",
|
|
]);
|
|
|
|
log("Waiting for MySQL...");
|
|
await waitForMysqlConnection(databaseUrl);
|
|
|
|
log("Running Den migrations...");
|
|
await runCommand("pnpm", ["db:migrate"], { cwd: serviceDir, env });
|
|
|
|
log("Starting Den service...");
|
|
serviceProcess = spawn("pnpm", ["exec", "tsx", "src/index.ts"], {
|
|
cwd: serviceDir,
|
|
env,
|
|
stdio: "pipe",
|
|
});
|
|
|
|
let serviceOutput = "";
|
|
serviceProcess.stdout?.on("data", (chunk) => {
|
|
serviceOutput += chunk.toString();
|
|
});
|
|
serviceProcess.stderr?.on("data", (chunk) => {
|
|
serviceOutput += chunk.toString();
|
|
});
|
|
|
|
serviceProcess.on("exit", (code) => {
|
|
if (code !== 0) {
|
|
console.error(serviceOutput);
|
|
}
|
|
});
|
|
|
|
await waitForHttp(`${baseUrl}/health`);
|
|
|
|
const email = `den-e2e-${Date.now()}@example.com`;
|
|
const password = "TestPass123!";
|
|
|
|
log("Creating account...");
|
|
const signup = await requestJson(baseUrl, "/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
body: {
|
|
name: "Den E2E",
|
|
email,
|
|
password,
|
|
},
|
|
});
|
|
|
|
if (!signup.response.ok) {
|
|
fail("Signup failed", signup.payload);
|
|
}
|
|
|
|
const token = extractAuthToken(signup.payload);
|
|
const cookie = signup.cookie;
|
|
if (!token && !cookie) {
|
|
fail("Signup did not return a bearer token or session cookie", signup.payload);
|
|
}
|
|
|
|
log("Validating authenticated session...");
|
|
const me = await requestJson(baseUrl, "/v1/me", { token, cookie });
|
|
if (!me.response.ok) {
|
|
fail("Session lookup failed", me.payload);
|
|
}
|
|
|
|
log("Checking billing summary is disabled...");
|
|
const billing = await requestJson(baseUrl, "/v1/workers/billing", { token, cookie });
|
|
if (!billing.response.ok) {
|
|
fail("Billing summary request failed", billing.payload);
|
|
}
|
|
|
|
if (
|
|
!billing.payload?.billing ||
|
|
billing.payload.billing.featureGateEnabled !== false ||
|
|
billing.payload.billing.checkoutRequired !== false ||
|
|
billing.payload.billing.checkoutUrl !== null
|
|
) {
|
|
fail("Billing summary should be disabled for the experiment", billing.payload);
|
|
}
|
|
|
|
log("Creating first cloud worker...");
|
|
const firstWorker = await requestJson(baseUrl, "/v1/workers", {
|
|
method: "POST",
|
|
token,
|
|
cookie,
|
|
body: {
|
|
name: "first-worker",
|
|
destination: "cloud",
|
|
},
|
|
});
|
|
|
|
if (firstWorker.response.status !== 202) {
|
|
fail("First worker did not launch successfully", {
|
|
status: firstWorker.response.status,
|
|
payload: firstWorker.payload,
|
|
});
|
|
}
|
|
|
|
log("Attempting second cloud worker...");
|
|
const secondWorker = await requestJson(baseUrl, "/v1/workers", {
|
|
method: "POST",
|
|
token,
|
|
cookie,
|
|
body: {
|
|
name: "second-worker",
|
|
destination: "cloud",
|
|
},
|
|
});
|
|
|
|
if (secondWorker.response.status !== 202) {
|
|
fail("Second worker should be allowed in dev mode", {
|
|
status: secondWorker.response.status,
|
|
payload: secondWorker.payload,
|
|
});
|
|
}
|
|
|
|
if (!secondWorker.payload?.worker?.id) {
|
|
fail("Second worker did not return a worker payload", secondWorker.payload);
|
|
}
|
|
|
|
log("Listing workers...");
|
|
const workers = await requestJson(baseUrl, "/v1/workers?limit=20", { token, cookie });
|
|
if (!workers.response.ok) {
|
|
fail("Worker list request failed", workers.payload);
|
|
}
|
|
|
|
const items = Array.isArray(workers.payload?.workers) ? workers.payload.workers : null;
|
|
if (!items || items.length !== 2) {
|
|
fail("Expected two cloud workers in dev mode", workers.payload);
|
|
}
|
|
|
|
log("E2E dev worker limit check passed.");
|
|
} finally {
|
|
await cleanup();
|
|
}
|
|
}
|
|
|
|
await main().catch((error) => {
|
|
fail(error instanceof Error ? error.message : String(error));
|
|
});
|