feat(onboarding): ask before discovering projects and let the user choose
The onboarding scan started on its own, discarded what it found, claimed to read email and calendars, simulated progress for everyone who could not scan, and its project creation always failed (wrong Space fields and a space lookup by title across tenants). Discovery now asks first and creates nothing without a choice: - The scan step appears only for the deployment owner when ONBOARDING_SCAN_HOME is set; everyone else skips it. The simulated scan is gone. - It shows the folder and asks. Only directory listings are read: git projects up to four levels deep, their languages and a few tools (Docker, GitHub Actions, Terraform). Symlinks are never followed and each chosen folder is resolved again before use, so nothing outside the folder is reached. Results stay with the account that scanned. - Found projects are a checklist (none preselected); found languages and tools are facts (preselected). "Add" creates one project space per chosen project with createSpaceForMember and wireProjectWorkspace in the user's own organization, reusing the user's own space already linked to that folder, and saves the chosen facts as onboarding facts under the profile lock and fact limit. - When a connection becomes connected (direct connect, authorization round trip, or catalog reconciliation), "Uses <App>" is remembered as a tools fact from the connector. Revoking keeps it. The E2E harness now points ONBOARDING_SCAN_HOME at a fixture folder and starts without a deployment owner, so owner-only onboarding steps appear only in specs that take the seat through /__e2e/deployment-owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
4191a4b6e6
commit
2c91a80f69
@ -25,9 +25,9 @@ PUBLIC_POSTHOG_KEY=
|
||||
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
SIGNUPS_ENABLED=true
|
||||
SIGNUP_ALLOWLIST=
|
||||
# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem.
|
||||
# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the
|
||||
# deployment owner's own onboarding discover local projects. Other accounts never scan the host.
|
||||
# First-run discovery. Leave unset and onboarding never looks at the host filesystem.
|
||||
# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the
|
||||
# git projects in it, reading file and folder names only. Other accounts never see the step.
|
||||
# ONBOARDING_SCAN_HOME=
|
||||
ENCRYPTION_KEY=replace-with-64-random-hex-characters
|
||||
DATA_DIR=./data
|
||||
|
||||
50
apps/api/src/connected-app-facts.ts
Normal file
50
apps/api/src/connected-app-facts.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { FEATURED_CONNECTOR_LABELS, matchFeaturedConnectorId } from "@rakazo/core";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { updateUserProfile } from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
|
||||
/**
|
||||
* The app's own name: a featured app's label, otherwise the label of the user's first account for
|
||||
* it (later accounts may be named "Gmail 2" or renamed).
|
||||
*/
|
||||
async function connectedAppName(
|
||||
prisma: PrismaClient,
|
||||
connection: { userId: string; connectorId: string; provider: string },
|
||||
): Promise<string | null> {
|
||||
const featured = matchFeaturedConnectorId(connection.provider);
|
||||
if (featured) return FEATURED_CONNECTOR_LABELS[featured];
|
||||
const first = await prisma.connection.findFirst({
|
||||
where: {
|
||||
userId: connection.userId,
|
||||
connectorId: connection.connectorId,
|
||||
provider: connection.provider,
|
||||
},
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
select: { displayName: true },
|
||||
});
|
||||
const name = first?.displayName.trim().slice(0, 80);
|
||||
return name || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers "Uses <App>" in the user's profile when one of their connections becomes connected.
|
||||
* Revoking the connection keeps the fact; the user removes it in About you. Best effort: the
|
||||
* connection itself has already succeeded, so a full profile or a database error only logs.
|
||||
*/
|
||||
export async function recordConnectedApp(
|
||||
prisma: PrismaClient,
|
||||
connection: { userId: string; connectorId: string; provider: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const name = await connectedAppName(prisma, connection);
|
||||
if (!name) return;
|
||||
await updateUserProfile(prisma, connection.userId, {
|
||||
addFacts: [{ category: "tools", value: `Uses ${name}`, source: "connector" }],
|
||||
});
|
||||
} catch (error) {
|
||||
getLogger().error("connected app fact was not recorded", error, {
|
||||
connectorId: connection.connectorId,
|
||||
provider: connection.provider,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,64 +1,192 @@
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||
import path from "node:path";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyOnboardingScan,
|
||||
discoverOnboardingScan,
|
||||
onboardingScanFolder,
|
||||
onboardingScanStatus,
|
||||
startOnboardingScan,
|
||||
} from "./onboarding-scan.js";
|
||||
|
||||
const owner = { userId: "user-owner", isDeploymentOwner: true };
|
||||
const member = { userId: "user-member", isDeploymentOwner: false };
|
||||
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
|
||||
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
|
||||
const roots: string[] = [];
|
||||
|
||||
async function scanHomeWithOneFile(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
||||
await mkdir(join(home, "Projects"));
|
||||
await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n");
|
||||
return home;
|
||||
/** Fails the test if the scan writes anything before its checks pass. */
|
||||
const untouchedPrisma = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("the database must not be touched");
|
||||
},
|
||||
},
|
||||
) as PrismaClient;
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-")));
|
||||
roots.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function files(root: string, entries: string[]): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
const file = path.join(root, entry);
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, "x\n");
|
||||
}
|
||||
}
|
||||
|
||||
async function project(root: string, relative: string, entries: string[]): Promise<void> {
|
||||
await mkdir(path.join(root, relative, ".git"), { recursive: true });
|
||||
await files(path.join(root, relative), entries);
|
||||
}
|
||||
|
||||
async function completed(scanId: string, userId: string) {
|
||||
return await vi.waitFor(
|
||||
async () => {
|
||||
const status = await getScanStatus(scanId, userId);
|
||||
if (status.status !== "complete" && status.status !== "failed") throw new Error("pending");
|
||||
() => {
|
||||
const status = onboardingScanStatus(scanId, userId);
|
||||
if (status.status === "scanning") throw new Error("pending");
|
||||
return status;
|
||||
},
|
||||
{ timeout: 10_000, interval: 50 },
|
||||
{ timeout: 10_000, interval: 20 },
|
||||
);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("onboarding scan", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
it("lists git projects with their languages and tools and never follows a symlink", async () => {
|
||||
const home = await tempDir();
|
||||
const outside = await tempDir();
|
||||
// A home folder tracked by git is not itself a project.
|
||||
await mkdir(path.join(home, ".git"));
|
||||
await project(home, "code/atlas", [
|
||||
"src/a.ts",
|
||||
"src/b.ts",
|
||||
"src/c.ts",
|
||||
"src/d.tsx",
|
||||
"scripts/build.js",
|
||||
"Dockerfile",
|
||||
".github/workflows/ci.yml",
|
||||
"infra/main.tf",
|
||||
"src/odd.constructor",
|
||||
"node_modules/dep/index.js",
|
||||
"node_modules/dep/more.js",
|
||||
]);
|
||||
await project(home, "code/garden", ["main.py"]);
|
||||
await project(home, "deep/a/b/edge", ["tool.py"]);
|
||||
await project(home, "deep/a/b/c/too-deep", ["main.go"]);
|
||||
await project(home, ".hidden/secret", ["main.go"]);
|
||||
await project(home, "notes/node_modules/vendored", ["main.go"]);
|
||||
await files(home, ["notes/todo.md"]);
|
||||
await project(outside, "escaped", ["main.rs"]);
|
||||
await symlink(path.join(outside, "escaped"), path.join(home, "linked"));
|
||||
await symlink(outside, path.join(home, "code/atlas/src/outside"));
|
||||
|
||||
const findings = await discoverOnboardingScan(home);
|
||||
|
||||
expect(findings.projects).toEqual([
|
||||
{
|
||||
id: "project-0",
|
||||
name: "atlas",
|
||||
path: "code/atlas",
|
||||
languages: ["TypeScript", "JavaScript"],
|
||||
tools: ["Docker", "GitHub Actions", "Terraform"],
|
||||
},
|
||||
{ id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] },
|
||||
{ id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] },
|
||||
]);
|
||||
expect(findings.facts).toEqual([
|
||||
{ id: "language:Python", label: "Python", category: "work", value: "Codes in Python" },
|
||||
{
|
||||
id: "language:JavaScript",
|
||||
label: "JavaScript",
|
||||
category: "work",
|
||||
value: "Codes in JavaScript",
|
||||
},
|
||||
{
|
||||
id: "language:TypeScript",
|
||||
label: "TypeScript",
|
||||
category: "work",
|
||||
value: "Codes in TypeScript",
|
||||
},
|
||||
{ id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" },
|
||||
{
|
||||
id: "tool:GitHub Actions",
|
||||
label: "GitHub Actions",
|
||||
category: "tools",
|
||||
value: "Uses GitHub Actions",
|
||||
},
|
||||
{ id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides a scan from every account except the one that started it", async () => {
|
||||
const { scanId } = await startOnboardingScan({ actor: member }, {});
|
||||
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
||||
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
||||
status: expect.any(String),
|
||||
});
|
||||
await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/);
|
||||
it("offers a scan folder only to the deployment owner and only when it exists", async () => {
|
||||
const home = await tempDir();
|
||||
await files(home, ["file.txt"]);
|
||||
await expect(onboardingScanFolder(member, home)).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull();
|
||||
await expect(onboardingScanFolder(owner, home)).resolves.toBe(home);
|
||||
await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("never reads the host without a configured scan home", async () => {
|
||||
const { scanId } = await startOnboardingScan({ actor: owner }, {});
|
||||
it("keeps a scan with the account that started it and returns only relative paths", async () => {
|
||||
const home = await tempDir();
|
||||
await project(home, "code/atlas", ["index.ts"]);
|
||||
const { scanId } = await startOnboardingScan(owner, home);
|
||||
|
||||
const status = await completed(scanId, owner.userId);
|
||||
expect(status.status).toBe("complete");
|
||||
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
||||
expect(status).toEqual({
|
||||
status: "complete",
|
||||
projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }],
|
||||
facts: [{ id: "language:TypeScript", label: "TypeScript" }],
|
||||
});
|
||||
expect(JSON.stringify(status)).not.toContain(home);
|
||||
expect(() => onboardingScanStatus(scanId, member.userId)).toThrow(
|
||||
expect.objectContaining({ code: "NOT_FOUND" }),
|
||||
);
|
||||
expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow(
|
||||
expect.objectContaining({ code: "NOT_FOUND" }),
|
||||
);
|
||||
await expect(
|
||||
applyOnboardingScan(untouchedPrisma, member, home, {
|
||||
scanId,
|
||||
projectIds: ["project-0"],
|
||||
factIds: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("lets only the deployment owner discover the configured scan home", async () => {
|
||||
// The shared test setup pins the emulated runtime, which also keeps the scan simulated.
|
||||
vi.stubEnv("AGENT_RUNTIME", "");
|
||||
vi.stubEnv("SANDBOX_PROVIDER", "");
|
||||
const scanHome = await scanHomeWithOneFile();
|
||||
it("checks ownership, the folder and every chosen project again before writing", async () => {
|
||||
const home = await tempDir();
|
||||
const outside = await tempDir();
|
||||
await project(home, "code/atlas", ["index.ts"]);
|
||||
await project(home, "code/garden", ["main.py"]);
|
||||
await project(outside, "elsewhere", ["main.rs"]);
|
||||
const { scanId } = await startOnboardingScan(owner, home);
|
||||
await completed(scanId, owner.userId);
|
||||
const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] };
|
||||
|
||||
const asMember = await startOnboardingScan({ actor: member, scanHome }, {});
|
||||
const memberStatus = await completed(asMember.scanId, member.userId);
|
||||
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
||||
await expect(
|
||||
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(
|
||||
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
|
||||
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {});
|
||||
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
||||
expect(ownerStatus.status).toBe("complete");
|
||||
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
||||
// The chosen project became a symlink out of the scan folder after the scan.
|
||||
await rm(path.join(home, "code/garden"), { recursive: true });
|
||||
await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
|
||||
await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "That folder does not exist or is not readable.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -157,6 +157,7 @@ import {
|
||||
resolveBusyBotName,
|
||||
toComputerStatus,
|
||||
} from "./computer-status.js";
|
||||
import { recordConnectedApp } from "./connected-app-facts.js";
|
||||
import { listInbox } from "./inbox.js";
|
||||
import { searchIntegrationCatalog } from "./integration-catalog.js";
|
||||
import {
|
||||
@ -180,7 +181,12 @@ import {
|
||||
promptFocus,
|
||||
startOnboarding,
|
||||
} from "./onboarding.js";
|
||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||
import {
|
||||
applyOnboardingScan,
|
||||
onboardingScanFolder,
|
||||
onboardingScanStatus,
|
||||
startOnboardingScan,
|
||||
} from "./onboarding-scan.js";
|
||||
import { listSpaceRuns } from "./runs.js";
|
||||
import { addScreenProxyCapability } from "./screen-proxy.js";
|
||||
import { querySpaceSearch } from "./search.js";
|
||||
@ -285,6 +291,11 @@ async function reconcilePendingConnections(
|
||||
: []),
|
||||
];
|
||||
if (updates.length > 0) await prisma.$transaction(updates);
|
||||
const connected = new Set(sync.connectIds);
|
||||
for (const row of rows) {
|
||||
if (row.status !== "pending" || !connected.has(row.id)) continue;
|
||||
await recordConnectedApp(prisma, { userId: owner.userId, connectorId, provider: row.provider });
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */
|
||||
@ -3434,16 +3445,18 @@ export function createRouter(deps: RouterDeps) {
|
||||
);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
scan: authed.onboarding.scan.handler(async ({ context, input }) => {
|
||||
const { scanId } = await startOnboardingScan(
|
||||
{ actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome },
|
||||
deps.prisma,
|
||||
);
|
||||
return { scanId };
|
||||
}),
|
||||
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => {
|
||||
return await getScanStatus(input.scanId, context.actor.userId);
|
||||
}),
|
||||
scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({
|
||||
folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome),
|
||||
})),
|
||||
scan: authed.onboarding.scan.handler(({ context }) =>
|
||||
startOnboardingScan(context.actor, deps.env.onboardingScanHome),
|
||||
),
|
||||
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) =>
|
||||
onboardingScanStatus(input.scanId, context.actor.userId),
|
||||
),
|
||||
applyScan: authed.onboarding.applyScan.handler(({ context, input }) =>
|
||||
applyOnboardingScan(deps.prisma, context.actor, deps.env.onboardingScanHome, input),
|
||||
),
|
||||
},
|
||||
integrationSetup: {
|
||||
get: authed.integrationSetup.get.handler(async ({ context }) => {
|
||||
@ -3692,6 +3705,13 @@ export function createRouter(deps: RouterDeps) {
|
||||
}
|
||||
throw new IsolationError();
|
||||
}
|
||||
if (!auth.authorizationUrl) {
|
||||
await recordConnectedApp(deps.prisma, {
|
||||
userId: context.actor.userId,
|
||||
connectorId: input.connectorId,
|
||||
provider: input.provider,
|
||||
});
|
||||
}
|
||||
return { connectionId: row.id, authorizationUrl: auth.authorizationUrl };
|
||||
} catch (error) {
|
||||
if (error instanceof IsolationError) throw error;
|
||||
@ -3997,6 +4017,13 @@ export function createRouter(deps: RouterDeps) {
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
if (row.status === "connected") {
|
||||
await recordConnectedApp(deps.prisma, {
|
||||
userId: context.actor.userId,
|
||||
connectorId: row.connectorId,
|
||||
provider: row.provider,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
76
apps/web/e2e/onboarding-discovery.spec.ts
Normal file
76
apps/web/e2e/onboarding-discovery.spec.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* E2E: the deployment owner's onboarding asks before scanning the fixture home folder the
|
||||
* harness configures, then adds only the projects and facts the user picks.
|
||||
*/
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import type { SpaceNavigation, UserProfileState } from "@rakazo/contracts";
|
||||
import { captureScreenshot, rpc, signup } from "./helpers";
|
||||
|
||||
const api = process.env.API_URL ?? "http://127.0.0.1:3110";
|
||||
|
||||
/** Advances whichever wizard step is showing until the scan step is reached. */
|
||||
async function walkToScanStep(page: Page) {
|
||||
const surface = page.locator('[data-rakazo-surface="onboarding"]');
|
||||
await expect(async () => {
|
||||
if ((await surface.getAttribute("data-onboarding-step")) === "scan") return;
|
||||
const skip = page.getByRole("button", { name: /^Skip/ }).first();
|
||||
const next = (await skip.isVisible())
|
||||
? skip
|
||||
: page.getByRole("button", { name: "Continue", exact: true });
|
||||
await next.click({ timeout: 2_000 });
|
||||
await expect(surface).toHaveAttribute("data-onboarding-step", "scan", { timeout: 1_000 });
|
||||
}).toPass({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
test("asks before looking for projects and adds only what the user picks", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const email = `discovery-${Date.now()}@rakazo.test`;
|
||||
await signup(page, email, "password12", "Discovery");
|
||||
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
|
||||
// The harness starts without a deployment owner; this spec takes the seat for its run.
|
||||
const owner = await page.request.post(`${api}/__e2e/deployment-owner`, { data: { email } });
|
||||
expect(owner.ok()).toBe(true);
|
||||
const { previousOwnerUserId } = (await owner.json()) as { previousOwnerUserId: string | null };
|
||||
try {
|
||||
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
||||
await walkToScanStep(page);
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Look for projects here?" })).toBeVisible();
|
||||
await expect(page.getByText(/rakazo-e2e-scan-home-/)).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "onboarding-discovery-consent");
|
||||
await page.getByRole("button", { name: "Look", exact: true }).click();
|
||||
|
||||
const projects = page.getByRole("group", { name: "Add as projects" });
|
||||
await expect(projects.getByRole("checkbox")).toHaveCount(2);
|
||||
const atlas = projects.getByRole("checkbox", { name: /atlas/ });
|
||||
await expect(atlas).not.toBeChecked();
|
||||
await expect(projects.getByRole("checkbox", { name: /garden/ })).not.toBeChecked();
|
||||
await atlas.check();
|
||||
const facts = page.getByRole("group", { name: "Add to About you" });
|
||||
await expect(facts.getByRole("checkbox", { name: "TypeScript" })).toBeChecked();
|
||||
await facts.getByRole("checkbox", { name: "Python" }).uncheck();
|
||||
await captureScreenshot(page, testInfo, "onboarding-discovery-results");
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
|
||||
|
||||
const navigation = await rpc<SpaceNavigation>(page, "spaces/list", {});
|
||||
const names = navigation.spaces.map((space) => space.name);
|
||||
expect(names).toContain("atlas");
|
||||
expect(names).not.toContain("garden");
|
||||
expect(navigation.spaces.find((space) => space.name === "atlas")?.workspace?.path).toMatch(
|
||||
/code\/atlas$/,
|
||||
);
|
||||
const values = (await rpc<UserProfileState>(page, "profile/get", {})).facts.map(
|
||||
(fact) => fact.value,
|
||||
);
|
||||
expect(values).toEqual(
|
||||
expect.arrayContaining(["Codes in TypeScript", "Uses Docker", "Uses GitHub Actions"]),
|
||||
);
|
||||
expect(values).not.toContain("Codes in Python");
|
||||
} finally {
|
||||
await page.request.post(`${api}/__e2e/deployment-owner`, {
|
||||
data: { userId: previousOwnerUserId },
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -1,186 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
|
||||
interface ScanStatus {
|
||||
status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed";
|
||||
progress: number;
|
||||
message: string;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
const PHASES = [
|
||||
{
|
||||
key: "scanning" as const,
|
||||
label: "Scanning your computer...",
|
||||
sublabel: "Files, folders, git repositories",
|
||||
},
|
||||
{
|
||||
key: "connecting" as const,
|
||||
label: "Connecting to your services...",
|
||||
sublabel: "GitHub, email, calendar",
|
||||
},
|
||||
{
|
||||
key: "building" as const,
|
||||
label: "Building your profile...",
|
||||
sublabel: "Interests, skills, preferences",
|
||||
},
|
||||
{
|
||||
key: "briefing" as const,
|
||||
label: "Setting up your agent team...",
|
||||
sublabel: "Project folders, role assignments",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function OnboardingScanProgress({
|
||||
onComplete,
|
||||
botId,
|
||||
}: {
|
||||
onComplete: () => void;
|
||||
botId?: string;
|
||||
}) {
|
||||
const [status, setStatus] = useState<ScanStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, setScanId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const scanResult = await rpc.onboarding.scan(botId ? { botId } : {});
|
||||
if (cancelled) return;
|
||||
setScanId(scanResult.scanId);
|
||||
|
||||
// Poll for status
|
||||
const poll = async () => {
|
||||
try {
|
||||
const s = await rpc.onboarding.scanStatus({ scanId: scanResult.scanId });
|
||||
if (cancelled) return;
|
||||
setStatus(s);
|
||||
if (s.status === "complete") {
|
||||
onComplete();
|
||||
} else if (s.status === "failed") {
|
||||
setError(s.message);
|
||||
}
|
||||
} catch {
|
||||
// poll error — keep trying
|
||||
}
|
||||
};
|
||||
|
||||
// First poll immediately, then every 2s
|
||||
await poll();
|
||||
pollingInterval = setInterval(poll, 2000);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Scan failed");
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollingInterval) clearInterval(pollingInterval);
|
||||
};
|
||||
}, [botId, onComplete]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl text-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button type="button" className="mt-4 rounded-lg border px-4 py-2" onClick={onComplete}>
|
||||
Continue anyway
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl text-center">
|
||||
<div className="mt-6 animate-spin rounded-full border-4 border-border border-t-primary h-8 w-8" />
|
||||
<p className="mt-4 text-muted-foreground">Preparing your profile...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPhaseIndex = PHASES.findIndex((p) => p.key === status.status);
|
||||
const isComplete = status.status === "complete";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground text-center">
|
||||
{isComplete ? "All set!" : "Getting to know you..."}
|
||||
</h2>
|
||||
<p className="mt-1 text-center text-sm text-muted-foreground">
|
||||
{isComplete
|
||||
? "Your profile and agent team are ready."
|
||||
: "We're scanning your computer and services to personalize your experience."}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{PHASES.map((phase, idx) => {
|
||||
const isActive = idx === currentPhaseIndex && !isComplete;
|
||||
const isDone = idx < currentPhaseIndex || isComplete;
|
||||
return (
|
||||
<div
|
||||
key={phase.key}
|
||||
className={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all ${
|
||||
isActive
|
||||
? "border-primary bg-primary/5"
|
||||
: isDone
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{isDone ? (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500 text-sm">
|
||||
✓
|
||||
</span>
|
||||
) : isActive ? (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-primary/20 animate-pulse text-primary text-sm">
|
||||
●
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid size-6 items-center justify-center rounded-full bg-muted/30 text-muted-foreground text-sm">
|
||||
○
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${isActive ? "text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{phase.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">{phase.sublabel}</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-1 w-16 overflow-hidden rounded-full bg-muted/30">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${status.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isComplete && (
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-white"
|
||||
onClick={onComplete}
|
||||
>
|
||||
Continue to your workspace →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
apps/web/src/components/onboarding/OnboardingScanStep.tsx
Normal file
250
apps/web/src/components/onboarding/OnboardingScanStep.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { OnboardingScanFact, OnboardingScanProject } from "@rakazo/contracts";
|
||||
import { Button, Checkbox } from "@rakazo/ui-web";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { LoadingState } from "../ai/primitives";
|
||||
|
||||
const POLL_MS = 1_000;
|
||||
|
||||
interface Found {
|
||||
projects: OnboardingScanProject[];
|
||||
facts: OnboardingScanFact[];
|
||||
}
|
||||
|
||||
function toggled(ids: string[], id: string, on: boolean): string[] {
|
||||
return on ? [...ids, id] : ids.filter((entry) => entry !== id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks before looking at the server's scan folder, then lets the user choose which found projects
|
||||
* become project spaces and which languages and tools go to About you. Nothing is created without
|
||||
* a choice. Skips itself when this account cannot scan.
|
||||
*/
|
||||
export function OnboardingScanStep({ onDone }: { onDone: () => void }) {
|
||||
const { t } = useLingui();
|
||||
const ids = useId();
|
||||
const done = useRef(onDone);
|
||||
done.current = onDone;
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
const [looking, setLooking] = useState(false);
|
||||
const [scanId, setScanId] = useState<string | null>(null);
|
||||
const [found, setFound] = useState<Found | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [projectIds, setProjectIds] = useState<string[]>([]);
|
||||
const [factIds, setFactIds] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
rpc.onboarding
|
||||
.scanTarget()
|
||||
.then((target) => {
|
||||
if (cancelled) return;
|
||||
if (target.folder) setFolder(target.folder);
|
||||
else done.current();
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) done.current();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scanId) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const poll = () => {
|
||||
rpc.onboarding
|
||||
.scanStatus({ scanId })
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
if (status.status === "scanning") {
|
||||
timer = setTimeout(poll, POLL_MS);
|
||||
} else if (status.status === "failed") {
|
||||
setFailed(true);
|
||||
} else {
|
||||
setFound({ projects: status.projects, facts: status.facts });
|
||||
setFactIds(status.facts.map((fact) => fact.id));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [scanId]);
|
||||
|
||||
async function look() {
|
||||
setLooking(true);
|
||||
try {
|
||||
setScanId((await rpc.onboarding.scan()).scanId);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!scanId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await rpc.onboarding.applyScan({ scanId, projectIds, factIds });
|
||||
done.current();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const continueButton = (
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Button onClick={() => done.current()}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<p className="text-sm text-destructive">
|
||||
<Trans>Could not look in this folder.</Trans>
|
||||
</p>
|
||||
{continueButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (found && found.projects.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>No projects found</Trans>
|
||||
</h2>
|
||||
{continueButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (found) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<fieldset className="border-0 p-0">
|
||||
<legend>
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>Add as projects</Trans>
|
||||
</h2>
|
||||
</legend>
|
||||
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
|
||||
{found.projects.map((project) => (
|
||||
<label
|
||||
key={project.id}
|
||||
htmlFor={`${ids}-${project.id}`}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-lg px-2 py-2 hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${ids}-${project.id}`}
|
||||
checked={projectIds.includes(project.id)}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) =>
|
||||
setProjectIds((current) => toggled(current, project.id, checked))
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">{project.name}</span>
|
||||
{project.path !== project.name ? (
|
||||
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{project.path}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{found.facts.length > 0 ? (
|
||||
<fieldset className="mt-6 border-0 p-0">
|
||||
<legend className="text-sm font-medium text-foreground">
|
||||
<Trans>Add to About you</Trans>
|
||||
</legend>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{found.facts.map((fact) => (
|
||||
<label
|
||||
key={fact.id}
|
||||
htmlFor={`${ids}-${fact.id}`}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${ids}-${fact.id}`}
|
||||
checked={factIds.includes(fact.id)}
|
||||
disabled={saving}
|
||||
onCheckedChange={(checked) =>
|
||||
setFactIds((current) => toggled(current, fact.id, checked))
|
||||
}
|
||||
/>
|
||||
{fact.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="mt-4 text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" disabled={saving} onClick={() => done.current()}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving || (projectIds.length === 0 && factIds.length === 0)}
|
||||
onClick={() => void add()}
|
||||
>
|
||||
<Trans>Add</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (looking) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<LoadingState label={t`Looking`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
<Trans>Look for projects here?</Trans>
|
||||
</h2>
|
||||
<p className="mt-3 break-all rounded-lg bg-muted px-3 py-2 font-mono text-sm text-foreground">
|
||||
{folder}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<Trans>Only file and folder names are read.</Trans>
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => done.current()}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button onClick={() => void look()}>
|
||||
<Trans>Look</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,7 +9,7 @@ import {
|
||||
type OnboardingEmojiConfig,
|
||||
OnboardingOoDesigner,
|
||||
} from "../components/onboarding/OnboardingOoDesigner";
|
||||
import { OnboardingScanProgress } from "../components/onboarding/OnboardingScanProgress";
|
||||
import { OnboardingScanStep } from "../components/onboarding/OnboardingScanStep";
|
||||
import {
|
||||
KOKORO_LOCAL_TOKEN,
|
||||
OnboardingVoiceStep,
|
||||
@ -115,7 +115,6 @@ export function OnboardingPage() {
|
||||
const [profilePersisted, setProfilePersisted] = useState(false);
|
||||
const [crewReady, setCrewReady] = useState(false);
|
||||
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
||||
const [scanBotId, setScanBotId] = useState<string | null>(null);
|
||||
|
||||
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
||||
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
|
||||
@ -141,14 +140,6 @@ export function OnboardingPage() {
|
||||
if (cancelled) return;
|
||||
setBootstrapMe(me);
|
||||
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
|
||||
// Get bot ID for scan phase
|
||||
try {
|
||||
const botList = await rpc.bots.list();
|
||||
const mainBot = botList.find((b) => b.kind === "main");
|
||||
if (mainBot && !cancelled) setScanBotId(mainBot.id);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
setIntegrationSetup(integrations);
|
||||
// The first step renders before this resolves; never pull a user back who moved on.
|
||||
if (!me.needsModel && integrations?.needsSetup) {
|
||||
@ -513,12 +504,7 @@ export function OnboardingPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === "scan" ? (
|
||||
<OnboardingScanProgress
|
||||
onComplete={() => setStep("bot")}
|
||||
botId={scanBotId ?? undefined}
|
||||
/>
|
||||
) : null}
|
||||
{step === "scan" ? <OnboardingScanStep onDone={() => setStep("bot")} /> : null}
|
||||
|
||||
{step === "bot" && error ? (
|
||||
<div className="text-center">
|
||||
|
||||
@ -10,6 +10,7 @@ export * from "./integration-settings.js";
|
||||
export * from "./local-settings.js";
|
||||
export * from "./mcp.js";
|
||||
export * from "./model-tiers.js";
|
||||
export * from "./onboarding-scan.js";
|
||||
export * from "./openai-compatible-ui.js";
|
||||
export * from "./project-goals.js";
|
||||
export * from "./project-sessions.js";
|
||||
|
||||
49
packages/contracts/src/onboarding-scan.ts
Normal file
49
packages/contracts/src/onboarding-scan.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import * as z from "zod";
|
||||
import { Id } from "./ids.js";
|
||||
|
||||
/** Upper bounds on one scan's choices; the server lists at most this many projects and facts. */
|
||||
export const ONBOARDING_SCAN_MAX_PROJECTS = 30;
|
||||
export const ONBOARDING_SCAN_MAX_FACTS = 20;
|
||||
|
||||
/** The folder a consented scan would read, or null when this account cannot scan. */
|
||||
export const OnboardingScanTargetSchema = z.object({ folder: z.string().nullable() });
|
||||
export type OnboardingScanTarget = z.infer<typeof OnboardingScanTargetSchema>;
|
||||
|
||||
export const OnboardingScanProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
/** Relative to the scanned folder. */
|
||||
path: z.string(),
|
||||
});
|
||||
export type OnboardingScanProject = z.infer<typeof OnboardingScanProjectSchema>;
|
||||
|
||||
export const OnboardingScanFactSchema = z.object({
|
||||
id: z.string(),
|
||||
/** A language or tool name, e.g. "TypeScript". */
|
||||
label: z.string(),
|
||||
});
|
||||
export type OnboardingScanFact = z.infer<typeof OnboardingScanFactSchema>;
|
||||
|
||||
export const OnboardingScanStatusSchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("scanning") }),
|
||||
z.object({ status: z.literal("failed") }),
|
||||
z.object({
|
||||
status: z.literal("complete"),
|
||||
projects: z.array(OnboardingScanProjectSchema),
|
||||
facts: z.array(OnboardingScanFactSchema),
|
||||
}),
|
||||
]);
|
||||
export type OnboardingScanStatus = z.infer<typeof OnboardingScanStatusSchema>;
|
||||
|
||||
export const OnboardingScanApplyInputSchema = z.object({
|
||||
scanId: z.string(),
|
||||
projectIds: z.array(z.string()).max(ONBOARDING_SCAN_MAX_PROJECTS),
|
||||
factIds: z.array(z.string()).max(ONBOARDING_SCAN_MAX_FACTS),
|
||||
});
|
||||
export type OnboardingScanApplyInput = z.infer<typeof OnboardingScanApplyInputSchema>;
|
||||
|
||||
export const OnboardingScanApplyResultSchema = z.object({
|
||||
spaces: z.array(z.object({ id: Id, name: z.string() })),
|
||||
factsAdded: z.number().int().nonnegative(),
|
||||
});
|
||||
export type OnboardingScanApplyResult = z.infer<typeof OnboardingScanApplyResultSchema>;
|
||||
@ -83,6 +83,12 @@ import {
|
||||
IntegrationSetupStateSchema,
|
||||
} from "./integration-settings.js";
|
||||
import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js";
|
||||
import {
|
||||
OnboardingScanApplyInputSchema,
|
||||
OnboardingScanApplyResultSchema,
|
||||
OnboardingScanStatusSchema,
|
||||
OnboardingScanTargetSchema,
|
||||
} from "./onboarding-scan.js";
|
||||
import {
|
||||
CreateProjectGoalInput,
|
||||
ProjectGoalSchema,
|
||||
@ -676,15 +682,13 @@ export const appContract = {
|
||||
z.object({ botId: Id, provider: z.string(), connectorId: z.string().default("composio") }),
|
||||
)
|
||||
.output(z.object({ ok: z.literal(true) })),
|
||||
scan: oc.input(z.object({ botId: Id.optional() })).output(z.object({ scanId: z.string() })),
|
||||
scanStatus: oc.input(z.object({ scanId: z.string() })).output(
|
||||
z.object({
|
||||
status: z.enum(["scanning", "connecting", "building", "briefing", "complete", "failed"]),
|
||||
progress: z.number(),
|
||||
message: z.string(),
|
||||
result: z.any().optional(),
|
||||
}),
|
||||
),
|
||||
/** The folder a scan would read; null when this account cannot scan, so the step is skipped. */
|
||||
scanTarget: oc.output(OnboardingScanTargetSchema),
|
||||
/** Start scanning that folder. Only called after the user agreed. */
|
||||
scan: oc.output(z.object({ scanId: z.string() })),
|
||||
scanStatus: oc.input(z.object({ scanId: z.string() })).output(OnboardingScanStatusSchema),
|
||||
/** Create a project space per chosen project and save the chosen facts. */
|
||||
applyScan: oc.input(OnboardingScanApplyInputSchema).output(OnboardingScanApplyResultSchema),
|
||||
},
|
||||
integrationSetup: {
|
||||
get: oc.output(IntegrationSetupStateSchema),
|
||||
|
||||
@ -556,19 +556,18 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
||||
).toMatchObject({ content: "owner-only-memory" });
|
||||
|
||||
// The user profile is per user: another user's fact ids and spaces are out of reach.
|
||||
const ownerProfile = await rpc<{ facts: Array<{ id: string; status: string }> }>(
|
||||
app,
|
||||
owner,
|
||||
"profile/update",
|
||||
{
|
||||
const ownerProfile = await rpc<{
|
||||
facts: Array<{ id: string; status: string; source: string }>;
|
||||
}>(app, owner, "profile/update", {
|
||||
addFacts: [
|
||||
{ category: "work", value: "Owner works nights" },
|
||||
{ category: "health", value: "Owner health note", source: "onboarding" },
|
||||
],
|
||||
},
|
||||
);
|
||||
const ownerFactIds = ownerProfile.facts.map((fact) => fact.id);
|
||||
expect(ownerProfile.facts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]);
|
||||
});
|
||||
// Connecting an app above already remembered it; this check is about the facts added here.
|
||||
const ownerFacts = ownerProfile.facts.filter((fact) => fact.source !== "connector");
|
||||
const ownerFactIds = ownerFacts.map((fact) => fact.id);
|
||||
expect(ownerFacts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]);
|
||||
const profileCalls = [
|
||||
["profile/update", { confirmFactIds: ownerFactIds }],
|
||||
["profile/update", { editFacts: [{ id: ownerFactIds[0], value: "Stolen" }] }],
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { loadRootEnv } from "@rakazo/core/node/load-root-env";
|
||||
import { PostgreSqlContainer } from "@testcontainers/postgresql";
|
||||
import type { createApp } from "../../../../apps/api/src/app.ts";
|
||||
import { writeOnboardingScanFixture } from "../onboarding-scan-fixture.js";
|
||||
import { runProcess } from "./process.js";
|
||||
|
||||
loadRootEnv();
|
||||
@ -106,6 +108,7 @@ async function main() {
|
||||
"packages/testkit/src/bot-secrets.test.ts",
|
||||
"packages/testkit/src/delegation.postgres.test.ts",
|
||||
"packages/testkit/src/user-profile.postgres.test.ts",
|
||||
"packages/testkit/src/onboarding-discovery.postgres.test.ts",
|
||||
"packages/db/src/space-membership.postgres.test.ts",
|
||||
"packages/db/src/main-agent.postgres.test.ts",
|
||||
"packages/db/src/user-profile.postgres.test.ts",
|
||||
@ -180,6 +183,10 @@ async function main() {
|
||||
{ fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname },
|
||||
);
|
||||
const email = new EmailEmulator();
|
||||
// A fixture home folder, so the deployment owner's onboarding offers the real project scan.
|
||||
const scanHome = await mkdtemp(path.join(tmpdir(), "rakazo-e2e-scan-home-"));
|
||||
await writeOnboardingScanFixture(scanHome);
|
||||
process.env.ONBOARDING_SCAN_HOME = scanHome;
|
||||
const handles = await createApp({
|
||||
databaseUrl,
|
||||
prisma: undefined,
|
||||
@ -192,13 +199,30 @@ async function main() {
|
||||
},
|
||||
integrationsCatalogUrl: "https://catalog.example.test/",
|
||||
});
|
||||
// No spec user owns the deployment unless it asks (POST /__e2e/deployment-owner), so
|
||||
// owner-only onboarding steps appear only in the specs that test them, whatever runs first.
|
||||
await handles.prisma.deploymentSettings.upsert({
|
||||
where: { id: "default" },
|
||||
create: {
|
||||
id: "default",
|
||||
ownerUserId: E2E_NO_OWNER,
|
||||
signupsEnabled: true,
|
||||
signupAllowlist: "",
|
||||
signupPolicyInitialized: true,
|
||||
},
|
||||
update: { ownerUserId: E2E_NO_OWNER },
|
||||
});
|
||||
let activeRequests = 0;
|
||||
const requestWaiters = new Set<() => void>();
|
||||
const server = serve({
|
||||
fetch: async (request) => {
|
||||
if (new URL(request.url).pathname === "/__e2e/emails") {
|
||||
const { pathname } = new URL(request.url);
|
||||
if (pathname === "/__e2e/emails") {
|
||||
return Response.json(email.sent, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
if (pathname === "/__e2e/deployment-owner" && request.method === "POST") {
|
||||
return Response.json(await setDeploymentOwner(handles, await request.json()));
|
||||
}
|
||||
activeRequests += 1;
|
||||
try {
|
||||
return await handles.app.fetch(request);
|
||||
@ -266,6 +290,7 @@ async function main() {
|
||||
await new Promise<void>((resolve) => requestWaiters.add(resolve));
|
||||
}
|
||||
await handles.stop().catch(() => undefined);
|
||||
await rm(scanHome, { recursive: true, force: true }).catch(() => undefined);
|
||||
for (let index = 0; index < computers.length; index += 4) {
|
||||
const results = await Promise.allSettled(
|
||||
computers.slice(index, index + 4).map((computer) =>
|
||||
@ -304,6 +329,9 @@ async function main() {
|
||||
|
||||
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
||||
|
||||
/** Matches no user, so the first signup does not claim the deployment. */
|
||||
const E2E_NO_OWNER = "e2e-no-owner";
|
||||
|
||||
async function managedComputers(handles: AppHandles) {
|
||||
if (!["e2b", "daytona", "box"].includes(sandboxProvider)) return [];
|
||||
return handles.prisma.computer.findMany({
|
||||
@ -312,6 +340,33 @@ async function managedComputers(handles: AppHandles) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets a spec act as the deployment owner (by email) for owner-only steps, then restore the
|
||||
* previous owner (by id). Returns the owner it replaced.
|
||||
*/
|
||||
async function setDeploymentOwner(
|
||||
handles: AppHandles,
|
||||
input: { email?: string; userId?: string | null },
|
||||
): Promise<{ previousOwnerUserId: string | null }> {
|
||||
const previous = await handles.prisma.deploymentSettings.findUnique({
|
||||
where: { id: "default" },
|
||||
select: { ownerUserId: true },
|
||||
});
|
||||
const ownerUserId = input.email
|
||||
? (
|
||||
await handles.prisma.user.findUniqueOrThrow({
|
||||
where: { email: input.email },
|
||||
select: { id: true },
|
||||
})
|
||||
).id
|
||||
: (input.userId ?? E2E_NO_OWNER);
|
||||
await handles.prisma.deploymentSettings.update({
|
||||
where: { id: "default" },
|
||||
data: { ownerUserId },
|
||||
});
|
||||
return { previousOwnerUserId: previous?.ownerUserId ?? null };
|
||||
}
|
||||
|
||||
async function writeSummary(reportDir: string, summary: Record<string, unknown>) {
|
||||
await writeFile(
|
||||
path.join(reportDir, "summary.json"),
|
||||
|
||||
363
packages/testkit/src/onboarding-discovery.postgres.test.ts
Normal file
363
packages/testkit/src/onboarding-discovery.postgres.test.ts
Normal file
@ -0,0 +1,363 @@
|
||||
import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ComposioEmulator,
|
||||
PipedreamConnector,
|
||||
ThirdPartyConnectorEmulator,
|
||||
} from "@rakazo/adapters";
|
||||
import type {
|
||||
OnboardingScanApplyResult,
|
||||
OnboardingScanStatus,
|
||||
SpaceNavigation,
|
||||
UserProfileState,
|
||||
} from "@rakazo/contracts";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { createApp } from "../../../apps/api/src/app.ts";
|
||||
import { sessionCookieHeader } from "./index.js";
|
||||
import { writeOnboardingScanFixture } from "./onboarding-scan-fixture.js";
|
||||
|
||||
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
|
||||
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
||||
type Actor = { userId: string; spaceId: string };
|
||||
|
||||
process.env.WAKEUP_DRIVER = "memory";
|
||||
process.env.SANDBOX_PROVIDER = "fake";
|
||||
process.env.AGENT_RUNTIME = "scripted";
|
||||
|
||||
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
|
||||
const describeWithDatabase = hasDb ? describe : describe.skip;
|
||||
const TEST_ENCRYPTION_KEY = "offline-discovery-test-encryption-key";
|
||||
|
||||
describeWithDatabase("onboarding discovery through the API", () => {
|
||||
let handles: AppHandles;
|
||||
let app: App;
|
||||
let composio: ComposioEmulator;
|
||||
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-discovery-"));
|
||||
const home = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-home-")));
|
||||
const outside = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-outside-")));
|
||||
|
||||
beforeAll(async () => {
|
||||
await writeOnboardingScanFixture(home);
|
||||
const { createApp } = await import("../../../apps/api/src/app.ts");
|
||||
composio = new ComposioEmulator();
|
||||
const thirdParties = new ThirdPartyConnectorEmulator();
|
||||
handles = await createApp({
|
||||
databaseUrl: process.env.DATABASE_URL!,
|
||||
dataDir,
|
||||
sandboxProvider: "fake",
|
||||
agentRuntime: "scripted",
|
||||
wakeupDriver: "memory",
|
||||
signupsEnabled: "true",
|
||||
encryptionKey: TEST_ENCRYPTION_KEY,
|
||||
onboardingScanHome: home,
|
||||
composio,
|
||||
pipedream: new PipedreamConnector(
|
||||
{
|
||||
clientId: "fake-client-id",
|
||||
clientSecret: "fake-client-secret",
|
||||
projectId: "fake-project-id",
|
||||
environment: "development",
|
||||
identitySecret: TEST_ENCRYPTION_KEY,
|
||||
},
|
||||
{ fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname },
|
||||
),
|
||||
remoteConnectors: {
|
||||
fetch: thirdParties.fetch,
|
||||
resolveHostname: thirdParties.resolveHostname,
|
||||
},
|
||||
});
|
||||
app = handles.app;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handles?.stop();
|
||||
for (const dir of [dataDir, home, outside]) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function account(label: string): Promise<{ cookie: string; actor: Actor }> {
|
||||
const cookie = await signup(app, `${label}-${stamp}@rakazo.test`, label);
|
||||
return { cookie, actor: await rpc<Actor>(app, cookie, "me") };
|
||||
}
|
||||
|
||||
async function makeDeploymentOwner(actor: Actor): Promise<void> {
|
||||
await handles.prisma.deploymentSettings.update({
|
||||
where: { id: "default" },
|
||||
data: { ownerUserId: actor.userId },
|
||||
});
|
||||
}
|
||||
|
||||
async function finishedScan(cookie: string) {
|
||||
const { scanId } = await rpc<{ scanId: string }>(app, cookie, "onboarding/scan");
|
||||
const status = await vi.waitFor(
|
||||
async () => {
|
||||
const current = await rpc<OnboardingScanStatus>(app, cookie, "onboarding/scanStatus", {
|
||||
scanId,
|
||||
});
|
||||
if (current.status === "scanning") throw new Error("still scanning");
|
||||
return current;
|
||||
},
|
||||
{ timeout: 10_000, interval: 50 },
|
||||
);
|
||||
if (status.status !== "complete") throw new Error("scan failed");
|
||||
return { scanId, ...status };
|
||||
}
|
||||
|
||||
it("offers the scan only to the deployment owner and keeps it with them", async () => {
|
||||
const owner = await account("discovery-owner");
|
||||
const other = await account("discovery-other");
|
||||
await makeDeploymentOwner(owner.actor);
|
||||
|
||||
await expect(rpc(app, other.cookie, "onboarding/scanTarget")).resolves.toEqual({
|
||||
folder: null,
|
||||
});
|
||||
expect((await raw(app, other.cookie, "onboarding/scan")).status).toBe(403);
|
||||
await expect(rpc(app, owner.cookie, "onboarding/scanTarget")).resolves.toEqual({
|
||||
folder: home,
|
||||
});
|
||||
|
||||
const scan = await finishedScan(owner.cookie);
|
||||
expect(scan.projects).toEqual([
|
||||
{ id: "project-0", name: "atlas", path: "code/atlas" },
|
||||
{ id: "project-1", name: "garden", path: "code/garden" },
|
||||
]);
|
||||
expect(scan.facts.map((fact) => fact.label)).toEqual([
|
||||
"Python",
|
||||
"TypeScript",
|
||||
"Docker",
|
||||
"GitHub Actions",
|
||||
]);
|
||||
expect(
|
||||
(await raw(app, other.cookie, "onboarding/scanStatus", { scanId: scan.scanId })).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await raw(app, other.cookie, "onboarding/applyScan", {
|
||||
scanId: scan.scanId,
|
||||
projectIds: ["project-0"],
|
||||
factIds: ["language:Python"],
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
await expect(rpc<UserProfileState>(app, other.cookie, "profile/get")).resolves.toMatchObject({
|
||||
facts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("creates the chosen projects in the owner's own spaces and saves only the chosen facts", async () => {
|
||||
const owner = await account("discovery-creator");
|
||||
const intruder = await account("discovery-intruder");
|
||||
await makeDeploymentOwner(owner.actor);
|
||||
const atlasFolder = path.join(home, "code/atlas");
|
||||
// Another tenant already linked a space with the same name to the same folder.
|
||||
const foreign = await rpc<{ id: string }>(app, intruder.cookie, "spaces/create", {
|
||||
name: "atlas",
|
||||
workspacePath: atlasFolder,
|
||||
});
|
||||
|
||||
const scan = await finishedScan(owner.cookie);
|
||||
const choice = {
|
||||
scanId: scan.scanId,
|
||||
projectIds: ["project-0"],
|
||||
factIds: ["language:TypeScript", "tool:Docker"],
|
||||
};
|
||||
const applied = await rpc<OnboardingScanApplyResult>(
|
||||
app,
|
||||
owner.cookie,
|
||||
"onboarding/applyScan",
|
||||
choice,
|
||||
);
|
||||
|
||||
expect(applied.factsAdded).toBe(2);
|
||||
expect(applied.spaces).toEqual([{ id: expect.any(String), name: "atlas" }]);
|
||||
const created = applied.spaces[0]!.id;
|
||||
expect(created).not.toBe(foreign.id);
|
||||
const ownerOrg = await handles.prisma.space.findUniqueOrThrow({
|
||||
where: { id: owner.actor.spaceId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
await expect(
|
||||
handles.prisma.space.findUniqueOrThrow({
|
||||
where: { id: created },
|
||||
select: { organizationId: true, workspacePath: true },
|
||||
}),
|
||||
).resolves.toEqual({ organizationId: ownerOrg.organizationId, workspacePath: atlasFolder });
|
||||
await expect(
|
||||
handles.prisma.spaceMember.findMany({
|
||||
where: { spaceId: { in: [created, foreign.id] } },
|
||||
select: { spaceId: true, userId: true, role: true },
|
||||
orderBy: { spaceId: "asc" },
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ spaceId: created, userId: owner.actor.userId, role: "owner" },
|
||||
{ spaceId: foreign.id, userId: intruder.actor.userId, role: "owner" },
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
await handles.prisma.spaceMember.count({ where: { spaceId: { in: [created, foreign.id] } } }),
|
||||
).toBe(2);
|
||||
await expect(
|
||||
handles.prisma.memoryDocument.count({
|
||||
where: { spaceId: created, userId: owner.actor.userId, scope: "user" },
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
const ownerSpaces = await rpc<SpaceNavigation>(app, owner.cookie, "spaces/list");
|
||||
expect(ownerSpaces.spaces).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: created,
|
||||
name: "atlas",
|
||||
workspace: expect.objectContaining({ path: atlasFolder }),
|
||||
}),
|
||||
);
|
||||
expect(ownerSpaces.spaces.map((space) => space.id)).not.toContain(foreign.id);
|
||||
const intruderSpaces = await rpc<SpaceNavigation>(app, intruder.cookie, "spaces/list");
|
||||
expect(intruderSpaces.spaces.map((space) => space.id)).not.toContain(created);
|
||||
|
||||
const profile = await rpc<UserProfileState>(app, owner.cookie, "profile/get");
|
||||
expect(
|
||||
profile.facts.map(({ category, value, source }) => ({ category, value, source })),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ category: "work", value: "Codes in TypeScript", source: "onboarding" },
|
||||
{ category: "tools", value: "Uses Docker", source: "onboarding" },
|
||||
]),
|
||||
);
|
||||
expect(profile.facts).toHaveLength(2);
|
||||
await expect(rpc<UserProfileState>(app, intruder.cookie, "profile/get")).resolves.toMatchObject(
|
||||
{ facts: [] },
|
||||
);
|
||||
|
||||
// Applying again reuses the space and the facts.
|
||||
await expect(
|
||||
rpc<OnboardingScanApplyResult>(app, owner.cookie, "onboarding/applyScan", choice),
|
||||
).resolves.toEqual({ spaces: [{ id: created, name: "atlas" }], factsAdded: 0 });
|
||||
expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a chosen project that left the scan folder and writes nothing", async () => {
|
||||
const owner = await account("discovery-escape");
|
||||
await makeDeploymentOwner(owner.actor);
|
||||
const scan = await finishedScan(owner.cookie);
|
||||
await mkdir(path.join(outside, "elsewhere", ".git"), { recursive: true });
|
||||
await rm(path.join(home, "code/garden"), { recursive: true, force: true });
|
||||
symlinkSync(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
|
||||
|
||||
const response = await raw(app, owner.cookie, "onboarding/applyScan", {
|
||||
scanId: scan.scanId,
|
||||
projectIds: ["project-1"],
|
||||
factIds: ["language:Python"],
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe(
|
||||
1,
|
||||
);
|
||||
await expect(rpc<UserProfileState>(app, owner.cookie, "profile/get")).resolves.toMatchObject({
|
||||
facts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("remembers each connected app once as a tools fact and keeps it after revoking", async () => {
|
||||
const user = await account("discovery-apps");
|
||||
const other = await account("discovery-apps-other");
|
||||
// A featured app is named by its label, whatever the account is called.
|
||||
const gmail = await rpc<{ connectionId: string }>(app, user.cookie, "connections/begin", {
|
||||
connectorId: "composio",
|
||||
provider: "GMAIL",
|
||||
displayName: "Personal",
|
||||
});
|
||||
// Other apps take the first account's name; a second account adds nothing.
|
||||
for (const displayName of ["GitHub", "GitHub 2"]) {
|
||||
await rpc(app, user.cookie, "connections/begin", {
|
||||
connectorId: "composio",
|
||||
provider: "GITHUB",
|
||||
displayName,
|
||||
});
|
||||
}
|
||||
// Connected through the authorization round trip.
|
||||
const linear = await rpc<{ connectionId: string; authorizationUrl: string | null }>(
|
||||
app,
|
||||
user.cookie,
|
||||
"connections/begin",
|
||||
{ connectorId: "pipedream", provider: "linear", displayName: "Linear" },
|
||||
);
|
||||
expect(linear.authorizationUrl).not.toBeNull();
|
||||
await expect(
|
||||
rpc<{ status: string }>(app, user.cookie, "connections/complete", {
|
||||
connectionId: linear.connectionId,
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "connected" });
|
||||
// Connected remotely, then noticed when the catalog reconciles a pending row.
|
||||
await handles.prisma.connection.create({
|
||||
data: {
|
||||
spaceId: user.actor.spaceId,
|
||||
userId: user.actor.userId,
|
||||
provider: "SLACK",
|
||||
displayName: "SLACK",
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
await composio.begin(
|
||||
{ provider: "SLACK", redirectUrl: "http://127.0.0.1.invalid/callback" },
|
||||
{
|
||||
operationId: "discovery-test",
|
||||
traceId: "discovery-test",
|
||||
spaceId: user.actor.spaceId,
|
||||
userId: user.actor.userId,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
);
|
||||
await rpc(app, user.cookie, "connections/catalog", { connectorId: "composio" });
|
||||
await rpc(app, user.cookie, "connections/revoke", { connectionId: gmail.connectionId });
|
||||
|
||||
const profile = await rpc<UserProfileState>(app, user.cookie, "profile/get");
|
||||
expect(
|
||||
profile.facts
|
||||
.map(({ category, value, source }) => ({ category, value, source }))
|
||||
.sort((left, right) => left.value.localeCompare(right.value)),
|
||||
).toEqual([
|
||||
{ category: "tools", value: "Uses GitHub", source: "connector" },
|
||||
{ category: "tools", value: "Uses Gmail", source: "connector" },
|
||||
{ category: "tools", value: "Uses Linear", source: "connector" },
|
||||
{ category: "tools", value: "Uses Slack", source: "connector" },
|
||||
]);
|
||||
await expect(rpc<UserProfileState>(app, other.cookie, "profile/get")).resolves.toMatchObject({
|
||||
facts: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function signup(app: App, email: string, name: string) {
|
||||
const response = await app.request("/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
|
||||
body: JSON.stringify({ email, password: "password12", name }),
|
||||
});
|
||||
if (response.status >= 400) {
|
||||
throw new Error(`signup failed ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return sessionCookieHeader(response);
|
||||
}
|
||||
|
||||
async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) {
|
||||
return app.request(`/rpc/${procedure}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173" },
|
||||
body: JSON.stringify({ json: body }),
|
||||
});
|
||||
}
|
||||
|
||||
async function rpc<T>(app: App, cookie: string, procedure: string, body: unknown = {}) {
|
||||
const response = await raw(app, cookie, procedure, body);
|
||||
const text = await response.text();
|
||||
const payload = JSON.parse(text) as { json?: T; error?: { message?: string } };
|
||||
if (response.status >= 400 || payload.error) {
|
||||
throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`);
|
||||
}
|
||||
return payload.json as T;
|
||||
}
|
||||
35
packages/testkit/src/onboarding-scan-fixture.ts
Normal file
35
packages/testkit/src/onboarding-scan-fixture.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/** An empty but valid git directory, so git commands stop here instead of searching upwards. */
|
||||
async function writeGitDirectory(project: string): Promise<void> {
|
||||
await mkdir(path.join(project, ".git", "objects"), { recursive: true });
|
||||
await mkdir(path.join(project, ".git", "refs"), { recursive: true });
|
||||
await writeFile(path.join(project, ".git", "HEAD"), "ref: refs/heads/main\n");
|
||||
}
|
||||
|
||||
async function writeFiles(root: string, files: Record<string, string>): Promise<void> {
|
||||
for (const [relative, content] of Object.entries(files)) {
|
||||
const file = path.join(root, relative);
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A home folder for the onboarding scan: two projects ("code/atlas" in TypeScript with Docker and
|
||||
* GitHub Actions, "code/garden" in Python) and a notes folder that is not a project.
|
||||
*/
|
||||
export async function writeOnboardingScanFixture(home: string): Promise<void> {
|
||||
await writeFiles(home, {
|
||||
"code/atlas/src/index.ts": "export const atlas = 1;\n",
|
||||
"code/atlas/src/app.tsx": "export const App = () => null;\n",
|
||||
"code/atlas/Dockerfile": "FROM scratch\n",
|
||||
"code/atlas/.github/workflows/ci.yml": "on: push\n",
|
||||
"code/garden/main.py": "print('garden')\n",
|
||||
"code/garden/util.py": "VALUE = 1\n",
|
||||
"notes/todo.md": "- water the plants\n",
|
||||
});
|
||||
await writeGitDirectory(path.join(home, "code/atlas"));
|
||||
await writeGitDirectory(path.join(home, "code/garden"));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user