Harden PRD page coverage and migration audit
This commit is contained in:
1 parent
83a51c6881
commit
f107ecba31
12 files changed
+235
-25
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
||||
import type { CreateMigrationReportInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
@@ -37,13 +38,35 @@ export class MigrationService {
|
||||
.filter((table) => availableTables.has(table))
|
||||
.map((table) => [table, countRows(source.prepare(`select count(*) as count from ${table}`))]),
|
||||
);
|
||||
const tableChecksums = Object.fromEntries(
|
||||
LEGACY_TABLES
|
||||
.filter((table) => availableTables.has(table))
|
||||
.map((table) => [table, tableChecksum(source, table)]),
|
||||
);
|
||||
const idMaps = Object.fromEntries(
|
||||
LEGACY_TABLES
|
||||
.filter((table) => availableTables.has(table))
|
||||
.map((table) => [table, tableIds(source, table)]),
|
||||
);
|
||||
const missingTables = LEGACY_TABLES.filter((table) => !availableTables.has(table));
|
||||
const failures = missingTables.map((table) => ({ table, reason: "missing_table" }));
|
||||
const reportId = createId("mig");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into migration_reports(id, org_id, source_ref, report_json, created_at)
|
||||
values (?, ?, ?, ?, ?)
|
||||
`).run(reportId, input.orgId, input.sourceDatabasePath, JSON.stringify({ tableCounts, missingTables }), now);
|
||||
`).run(reportId, input.orgId, input.sourceDatabasePath, JSON.stringify({
|
||||
tableCounts,
|
||||
tableChecksums,
|
||||
idMaps,
|
||||
missingTables,
|
||||
failures,
|
||||
rollbackPack: {
|
||||
sourceDatabasePath: input.sourceDatabasePath,
|
||||
capturedAt: now,
|
||||
restoreStrategy: "legacy_snapshot_restore",
|
||||
},
|
||||
}), now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "migration_report",
|
||||
@@ -63,3 +86,16 @@ export class MigrationService {
|
||||
function countRows(statement: StatementSync): number {
|
||||
return (statement.get() as { count: number }).count;
|
||||
}
|
||||
|
||||
function tableChecksum(source: DatabaseSync, table: string): string {
|
||||
const hash = createHash("sha256");
|
||||
const rows = source.prepare(`select * from ${table} order by rowid`).all();
|
||||
for (const row of rows) hash.update(JSON.stringify(row));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function tableIds(source: DatabaseSync, table: string): string[] {
|
||||
const columns = source.prepare(`pragma table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (!columns.some((column) => column.name === "id")) return [];
|
||||
return source.prepare(`select id from ${table} order by id`).all().map((row) => String((row as { id: unknown }).id));
|
||||
}
|
||||
@@ -71,6 +71,8 @@ export class SnapshotService {
|
||||
skillBindings,
|
||||
skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"),
|
||||
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
|
||||
socialAccountGrants: list(this.db, "social_account_grants", "created_at asc"),
|
||||
browserActions: list(this.db, "browser_actions", "created_at desc"),
|
||||
policyRules: list(this.db, "policy_rules", "created_at asc"),
|
||||
migrationReports: list(this.db, "migration_reports", "created_at desc"),
|
||||
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import type { ChatSpaceRecord } from "../../../shared/domain";
|
||||
import type { ChatAttachmentRecord, ChatSpaceRecord, FileObjectRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
|
||||
@@ -18,10 +18,12 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
const spaces = snapshot.chatSpaces.filter((item) => item.orgId === orgId);
|
||||
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
||||
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
||||
const files = snapshot.files.filter((item) => item.orgId === orgId);
|
||||
const [spaceId, setSpaceId] = useState(spaces[0]?.id ?? "");
|
||||
const [spaceOpen, setSpaceOpen] = useState(false);
|
||||
const activeSpace = spaces.find((space) => space.id === spaceId) ?? spaces[0];
|
||||
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace?.id);
|
||||
if (!activeSpace) throw new Error("At least one chat space is required.");
|
||||
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace.id);
|
||||
const sender = employees[0];
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -31,6 +33,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
const bodyMd = messageBody(form);
|
||||
if (!bodyMd) return;
|
||||
event.currentTarget.reset();
|
||||
const attachmentFileId = value(form, "attachmentFileId");
|
||||
void actions.run(() => window.agentCompany.sendMessage({
|
||||
orgId,
|
||||
spaceId: activeSpace.id,
|
||||
@@ -38,6 +41,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
senderType: "employee",
|
||||
senderId: sender.id,
|
||||
bodyMd,
|
||||
attachmentFileIds: attachmentFileId ? [attachmentFileId] : [],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -82,10 +86,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
</aside>
|
||||
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
||||
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<Icon name={activeSpace?.visibility === "restricted" ? "lock" : "hash"} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{activeSpace?.name ?? "Messages"}</div>
|
||||
<Icon name={activeSpace.visibility === "restricted" ? "lock" : "hash"} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{activeSpace.name}</div>
|
||||
<div style={{ color: "var(--text-3)", borderLeft: "1px solid var(--border)", paddingLeft: 10 }}>
|
||||
{activeSpace?.topic ?? ""}
|
||||
{activeSpace.topic}
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}><Icon name="search" /></div>
|
||||
</header>
|
||||
@@ -99,6 +103,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
time={new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
name={displayName(message.senderType, message.senderId, employees, agents)}
|
||||
bot={message.senderType === "agent"}
|
||||
files={messageFiles(message.id, snapshot.chatAttachments, files)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -107,7 +112,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
<textarea className="input" name="message" rows={2} aria-label="Message" />
|
||||
<div className="row" style={{ paddingTop: 8, borderTop: "1px solid var(--border-soft)" }}>
|
||||
<Icon name="attach" />
|
||||
<Icon name="social" />
|
||||
<select className="input" name="attachmentFileId" aria-label="File attachment" style={{ maxWidth: 220 }}>
|
||||
<option value="">File</option>
|
||||
{files.map((file) => <option key={file.id} value={file.id}>{file.name}</option>)}
|
||||
</select>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" size="small" type="submit" disabled={!activeSpace || !sender}><Icon name="send" /> Send</Button>
|
||||
</div>
|
||||
@@ -158,7 +166,7 @@ function SpaceItem({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
||||
<button type="button" aria-label={space.name} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
||||
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} />
|
||||
<span>{space.name}</span>
|
||||
{count > 0 && <span className="nav-badge">{count}</span>}
|
||||
@@ -166,7 +174,19 @@ function SpaceItem({
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({ name, body, time, bot }: { name: string; body: string; time: string; bot: boolean }) {
|
||||
function MessageRow({
|
||||
name,
|
||||
body,
|
||||
time,
|
||||
bot,
|
||||
files,
|
||||
}: {
|
||||
name: string;
|
||||
body: string;
|
||||
time: string;
|
||||
bot: boolean;
|
||||
files: FileObjectRecord[];
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
|
||||
<Avatar name={name} bot={bot} />
|
||||
@@ -176,11 +196,34 @@ function MessageRow({ name, body, time, bot }: { name: string; body: string; tim
|
||||
<span className="num" style={{ color: "var(--text-3)", fontSize: 11 }}>{time}</span>
|
||||
</div>
|
||||
<div style={{ lineHeight: 1.5, whiteSpace: "pre-wrap" }}>{body}</div>
|
||||
{files.length > 0 && (
|
||||
<div className="row" style={{ marginTop: 8, flexWrap: "wrap" }}>
|
||||
{files.map((file) => (
|
||||
<span key={file.id} className="attachment-pill">
|
||||
<Icon name="file" size={13} /> {file.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function messageFiles(
|
||||
messageId: string,
|
||||
attachments: ChatAttachmentRecord[],
|
||||
files: FileObjectRecord[],
|
||||
): FileObjectRecord[] {
|
||||
return attachments
|
||||
.filter((attachment) => attachment.messageId === messageId)
|
||||
.map((attachment) => {
|
||||
const file = files.find((item) => item.id === attachment.fileId);
|
||||
if (!file) throw new Error("Chat attachment file is missing.");
|
||||
return file;
|
||||
});
|
||||
}
|
||||
|
||||
function displayName(
|
||||
senderType: string,
|
||||
senderId: string | null,
|
||||
|
||||
@@ -67,7 +67,7 @@ function TreeItem({
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
||||
<button type="button" aria-label={label} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
||||
<Icon name={icon} />
|
||||
<span>{label}</span>
|
||||
{typeof count === "number" && <span className="nav-badge">{count}</span>}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { activeOrgId, money } from "./types";
|
||||
|
||||
export function HomePage({ snapshot }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const organization = snapshot.organizations.find((item) => item.id === orgId)!;
|
||||
const organization = snapshot.organizations.find((item) => item.id === orgId);
|
||||
if (!organization) throw new Error("Active organization is missing.");
|
||||
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
|
||||
const approvals = snapshot.approvals.filter((item) => item.orgId === orgId);
|
||||
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
||||
|
||||
@@ -102,7 +102,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
}
|
||||
|
||||
function SettingsNav({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
||||
return <button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>{label}</button>;
|
||||
return <button type="button" aria-label={label} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>{label}</button>;
|
||||
}
|
||||
|
||||
function ProvidersPanel({ providers, onManage }: { providers: ProviderRecord[]; onManage: (provider: ProviderRecord) => void }) {
|
||||
@@ -231,8 +231,9 @@ function MigrationPanel({ reports, onCreate }: { reports: MigrationReportRecord[
|
||||
|
||||
function MigrationReportRow({ report }: { report: MigrationReportRecord }) {
|
||||
const body = parseMigrationReport(report.reportJson);
|
||||
const idCount = Object.values(body.idMaps).reduce((sum, ids) => sum + ids.length, 0);
|
||||
return (
|
||||
<div className="table-row" style={{ gridTemplateColumns: "1.4fr 1fr 1fr" }}>
|
||||
<div className="table-row" style={{ gridTemplateColumns: "1.4fr 1fr 1fr 1fr" }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={report.sourceRef}>{report.sourceRef}</div>
|
||||
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{new Date(report.createdAt).toLocaleString()}</div>
|
||||
@@ -240,15 +241,36 @@ function MigrationReportRow({ report }: { report: MigrationReportRecord }) {
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
{Object.entries(body.tableCounts).map(([table, count]) => <Pill key={table}>{table}: {count}</Pill>)}
|
||||
</div>
|
||||
<Pill>{body.missingTables.length} missing</Pill>
|
||||
<Pill>{Object.keys(body.tableChecksums).length} checksums</Pill>
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
<Pill>{idCount} ids</Pill>
|
||||
<Pill>{body.failures.length} failures</Pill>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseMigrationReport(reportJson: string): { tableCounts: Record<string, number>; missingTables: string[] } {
|
||||
const parsed = JSON.parse(reportJson) as { tableCounts?: Record<string, number>; missingTables?: string[] };
|
||||
if (!parsed.tableCounts || !Array.isArray(parsed.missingTables)) throw new Error("Migration report is invalid.");
|
||||
return { tableCounts: parsed.tableCounts, missingTables: parsed.missingTables };
|
||||
function parseMigrationReport(reportJson: string): {
|
||||
tableCounts: Record<string, number>;
|
||||
tableChecksums: Record<string, string>;
|
||||
idMaps: Record<string, string[]>;
|
||||
failures: Array<{ table: string; reason: string }>;
|
||||
} {
|
||||
const parsed = JSON.parse(reportJson) as {
|
||||
tableCounts?: Record<string, number>;
|
||||
tableChecksums?: Record<string, string>;
|
||||
idMaps?: Record<string, string[]>;
|
||||
failures?: Array<{ table: string; reason: string }>;
|
||||
};
|
||||
if (!parsed.tableCounts || !parsed.tableChecksums || !parsed.idMaps || !Array.isArray(parsed.failures)) {
|
||||
throw new Error("Migration report is invalid.");
|
||||
}
|
||||
return {
|
||||
tableCounts: parsed.tableCounts,
|
||||
tableChecksums: parsed.tableChecksums,
|
||||
idMaps: parsed.idMaps,
|
||||
failures: parsed.failures,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRevision(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Avatar, Button, Field, Icon } from "../shared/ui";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import { SOCIAL_PLATFORM, type SocialAccountRecord, type SocialPlatform } from "../../../shared/domain";
|
||||
import {
|
||||
SOCIAL_PLATFORM,
|
||||
type SocialAccountGrantRecord,
|
||||
type SocialAccountRecord,
|
||||
type SocialPlatform,
|
||||
} from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
|
||||
@@ -13,6 +18,8 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const [open, setOpen] = useState(false);
|
||||
const accounts = snapshot.socialAccounts.filter((item) => item.orgId === orgId);
|
||||
const grants = snapshot.socialAccountGrants.filter((item) => item.orgId === orgId);
|
||||
const actionsByAccount = snapshot.browserActions.filter((item) => item.orgId === orgId);
|
||||
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -47,6 +54,8 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
||||
key={account.id}
|
||||
account={account}
|
||||
employeeName={authorizedEmployeeName(account.authorizedEmployeeId, employees)}
|
||||
grants={grants.filter((grant) => grant.socialAccountId === account.id)}
|
||||
actionCount={actionsByAccount.filter((action) => action.socialAccountId === account.id).length}
|
||||
onLogin={() => void actions.run(() => window.agentCompany.launchSocialLogin({ socialAccountId: account.id }))}
|
||||
/>
|
||||
))}
|
||||
@@ -76,10 +85,14 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
||||
function AccountCard({
|
||||
account,
|
||||
employeeName,
|
||||
grants,
|
||||
actionCount,
|
||||
onLogin,
|
||||
}: {
|
||||
account: SocialAccountRecord;
|
||||
employeeName: string | null;
|
||||
grants: SocialAccountGrantRecord[];
|
||||
actionCount: number;
|
||||
onLogin: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -97,6 +110,10 @@ function AccountCard({
|
||||
<span>{employeeName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ color: "var(--text-3)", fontSize: 11 }}>
|
||||
<span>{grants.length} grants</span>
|
||||
<span>{actionCount} actions</span>
|
||||
</div>
|
||||
<Button size="small" onClick={onLogin}>Sign in</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,10 @@ export function Shell({
|
||||
}) {
|
||||
const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId);
|
||||
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId);
|
||||
const activeRoute = navItems.find((item) => item.id === route);
|
||||
if (!organization) throw new Error("Active organization is missing.");
|
||||
if (!employee) throw new Error("Active employee is missing.");
|
||||
if (!activeRoute) throw new Error("Active route is missing.");
|
||||
const badges = {
|
||||
work: snapshot.tasks.filter((item) => item.orgId === snapshot.activeOrgId && item.status !== "done").length,
|
||||
chat: snapshot.chatMessages.filter((item) => item.orgId === snapshot.activeOrgId).length,
|
||||
@@ -41,7 +45,7 @@ export function Shell({
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">A</div>
|
||||
<div className="brand-name">{organization?.name ?? "AgentCompany"}</div>
|
||||
<div className="brand-name">{organization.name}</div>
|
||||
<Icon name="chevronDown" size={12} />
|
||||
</div>
|
||||
<nav className="nav">
|
||||
@@ -49,6 +53,7 @@ export function Shell({
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-label={item.label}
|
||||
className={`nav-item ${route === item.id ? "active" : ""}`}
|
||||
onClick={() => setRoute(item.id)}
|
||||
>
|
||||
@@ -59,16 +64,16 @@ export function Shell({
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<Avatar name={employee?.displayName ?? "User"} size="sm" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{employee?.displayName ?? "User"}</div>
|
||||
<Avatar name={employee.displayName} size="sm" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{employee.displayName}</div>
|
||||
<Icon name="bell" />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">
|
||||
<header className="topbar">
|
||||
<span className="crumb-sub">{organization?.name ?? "Workspace"}</span>
|
||||
<span className="crumb-sub">{organization.name}</span>
|
||||
<span style={{ color: "var(--text-4)" }}>/</span>
|
||||
<span className="crumb">{navItems.find((item) => item.id === route)?.label}</span>
|
||||
<span className="crumb">{activeRoute.label}</span>
|
||||
<div className="topbar-actions">
|
||||
{actions}
|
||||
{window.agentCompany.platform !== "darwin" && (
|
||||
|
||||
@@ -177,6 +177,20 @@ button { cursor: pointer; }
|
||||
.pill.amber { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
|
||||
.pill.green { background: var(--green-soft); color: var(--green); border-color: transparent; }
|
||||
.pill.violet { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
|
||||
.attachment-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 260px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--panel-2);
|
||||
color: var(--text-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.avatar {
|
||||
width: 28px;
|
||||
|
||||
@@ -324,6 +324,26 @@ export interface SocialAccountRecord {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SocialAccountGrantRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
employeeId: string;
|
||||
role: string;
|
||||
scopeJson: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface BrowserActionRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
socialAccountId: string;
|
||||
actionType: string;
|
||||
policyDecision: string;
|
||||
evidenceFileId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuditEventRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
@@ -380,6 +400,8 @@ export interface AppSnapshot {
|
||||
skillBindings: SkillBindingRecord[];
|
||||
skillRuntimeSnapshots: SkillRuntimeSnapshotRecord[];
|
||||
socialAccounts: SocialAccountRecord[];
|
||||
socialAccountGrants: SocialAccountGrantRecord[];
|
||||
browserActions: BrowserActionRecord[];
|
||||
policyRules: PolicyRuleRecord[];
|
||||
migrationReports: MigrationReportRecord[];
|
||||
auditEvents: AuditEventRecord[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { _electron as electron, expect, test } from "@playwright/test";
|
||||
import { _electron as electron, expect, test, type Page } from "@playwright/test";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
@@ -50,7 +50,45 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
|
||||
await page.getByRole("button", { name: "Approvals" }).click();
|
||||
await expect(page.getByText("Production deploy").first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Messages" }).click();
|
||||
await page.getByRole("button", { name: "New space" }).click();
|
||||
await page.getByLabel("Name").fill("release");
|
||||
await page.getByLabel("Topic").fill("Release coordination");
|
||||
await page.locator(".modal").getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByText("release").first()).toBeVisible();
|
||||
await page.getByRole("textbox", { name: "Message" }).fill("Release channel opened.");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
await expect(page.getByText("Release channel opened.")).toBeVisible();
|
||||
|
||||
await verifyPagesDoNotOverflow(page);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
|
||||
for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) {
|
||||
await page.setViewportSize(size);
|
||||
for (const nav of ["Home", "Team", "Work", "Messages", "Files", "Approvals", "Social", "Settings"]) {
|
||||
await page.getByRole("button", { name: nav }).click();
|
||||
await expectPageToFit(page);
|
||||
}
|
||||
for (const tab of ["Providers", "Skills", "Policies", "Migration"]) {
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: tab }).click();
|
||||
await expectPageToFit(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expectPageToFit(page: Page): Promise<void> {
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
documentScrollWidth: document.documentElement.scrollWidth,
|
||||
documentClientWidth: document.documentElement.clientWidth,
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
bodyClientWidth: document.body.clientWidth,
|
||||
}));
|
||||
expect(dimensions.documentScrollWidth).toBeLessThanOrEqual(dimensions.documentClientWidth + 1);
|
||||
expect(dimensions.bodyScrollWidth).toBeLessThanOrEqual(dimensions.bodyClientWidth + 1);
|
||||
}
|
||||
@@ -198,6 +198,16 @@ describe("domain services", () => {
|
||||
const state = snapshot.load();
|
||||
expect(state.migrationReports).toHaveLength(1);
|
||||
expect(state.migrationReports[0]?.reportJson).toContain("\"agents\":1");
|
||||
const report = JSON.parse(state.migrationReports[0]!.reportJson) as {
|
||||
tableChecksums: Record<string, string>;
|
||||
idMaps: Record<string, string[]>;
|
||||
failures: Array<{ table: string; reason: string }>;
|
||||
rollbackPack: { sourceDatabasePath: string };
|
||||
};
|
||||
expect(report.tableChecksums.agents).toHaveLength(64);
|
||||
expect(report.idMaps.agents).toEqual(["agent-1"]);
|
||||
expect(report.failures.some((failure) => failure.reason === "missing_table")).toBe(true);
|
||||
expect(report.rollbackPack.sourceDatabasePath).toBe(legacyPath);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user