Compare commits
1 Commits
claude/ama
...
claude/ele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2797eaf6ca |
18
.github/workflows/publish-server-image.yml
vendored
18
.github/workflows/publish-server-image.yml
vendored
@ -256,23 +256,9 @@ jobs:
|
||||
grep -Fqx "$want" <<< "$platforms" || { echo "$first_tag is missing $want" >&2; exit 1; }
|
||||
done
|
||||
echo "digest=$(docker buildx imagetools inspect "$first_tag" --format '{{.Manifest.Digest}}')" >> "$GITHUB_OUTPUT"
|
||||
# Owner type is not exposed on the github context; resolve it from the repository metadata API.
|
||||
- name: Resolve registry attestation eligibility
|
||||
id: attestation
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')"
|
||||
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')"
|
||||
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."
|
||||
else
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# GitHub artifact attestations are unavailable for private repos on personal accounts.
|
||||
- name: Attest the published image
|
||||
if: steps.attestation.outputs.enabled == 'true'
|
||||
if: github.event.repository.private != true || github.repository_owner_type != 'User'
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-name: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
- Keep code simple: reuse existing primitives and one source of truth, remove duplication and unused flexibility, and avoid speculative abstractions. Add an interface when it protects a real external or platform boundary, not for its own sake.
|
||||
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
|
||||
- The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push.
|
||||
- Work autonomously. Make routine calls yourself, such as opening or reopening a pull request, linking CI screenshots, or fixing gaps you found that belong to the task, then verify and report the outcome. Ask the maintainer only before irreversible actions or for product decisions without a sensible default.
|
||||
- After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
|
||||
- PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author.
|
||||
- Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown.
|
||||
|
||||
@ -1,11 +1,42 @@
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type * as db from "@rakazo/db";
|
||||
import { SpaceLimitError } from "@rakazo/db";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||
|
||||
const owner = { userId: "user-owner", isDeploymentOwner: true };
|
||||
const member = { userId: "user-member", isDeploymentOwner: false };
|
||||
const createSpaceForMember = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@rakazo/db", async (original) => ({
|
||||
...(await original<typeof db>()),
|
||||
createSpaceForMember,
|
||||
}));
|
||||
|
||||
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
|
||||
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
|
||||
|
||||
type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } };
|
||||
|
||||
/** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */
|
||||
function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) {
|
||||
const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = [];
|
||||
createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => {
|
||||
if (spaces.length >= spaceLimit) throw new SpaceLimitError();
|
||||
const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null };
|
||||
spaces.push(space);
|
||||
return { id: space.id, name: space.name };
|
||||
});
|
||||
const prisma = {
|
||||
space: {
|
||||
findMany: async () => spaces.filter((space) => space.workspacePath),
|
||||
update: async ({ where, data }: SpaceUpdate) => {
|
||||
const space = spaces.find((candidate) => candidate.id === where.id);
|
||||
if (space) space.workspacePath = data.workspacePath;
|
||||
},
|
||||
},
|
||||
} as unknown as db.PrismaClient;
|
||||
return { prisma, spaces };
|
||||
}
|
||||
|
||||
async function scanHomeWithOneFile(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
||||
@ -14,6 +45,12 @@ async function scanHomeWithOneFile(): Promise<string> {
|
||||
return home;
|
||||
}
|
||||
|
||||
async function scanHomeWithRepos(...repos: string[]): Promise<string> {
|
||||
const home = await scanHomeWithOneFile();
|
||||
for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true });
|
||||
return home;
|
||||
}
|
||||
|
||||
async function completed(scanId: string, userId: string) {
|
||||
return await vi.waitFor(
|
||||
async () => {
|
||||
@ -25,13 +62,21 @@ async function completed(scanId: string, userId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The emulated runtime that the shared test setup pins keeps the scan simulated. */
|
||||
function allowHostScan() {
|
||||
vi.stubEnv("AGENT_RUNTIME", "");
|
||||
vi.stubEnv("SANDBOX_PROVIDER", "");
|
||||
}
|
||||
|
||||
describe("onboarding scan", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
createSpaceForMember.mockReset();
|
||||
});
|
||||
|
||||
it("hides a scan from every account except the one that started it", async () => {
|
||||
const { scanId } = await startOnboardingScan({ actor: member }, {});
|
||||
const { prisma } = fakeDatabase();
|
||||
const { scanId } = await startOnboardingScan({ actor: member }, prisma);
|
||||
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
||||
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
||||
status: expect.any(String),
|
||||
@ -40,25 +85,77 @@ describe("onboarding scan", () => {
|
||||
});
|
||||
|
||||
it("never reads the host without a configured scan home", async () => {
|
||||
const { scanId } = await startOnboardingScan({ actor: owner }, {});
|
||||
const { prisma } = fakeDatabase();
|
||||
const { scanId } = await startOnboardingScan({ actor: owner }, prisma);
|
||||
const status = await completed(scanId, owner.userId);
|
||||
expect(status.status).toBe("complete");
|
||||
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
||||
});
|
||||
|
||||
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();
|
||||
allowHostScan();
|
||||
const scanHome = await scanHomeWithRepos("Projects/app");
|
||||
const { prisma } = fakeDatabase();
|
||||
|
||||
const asMember = await startOnboardingScan({ actor: member, scanHome }, {});
|
||||
const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma);
|
||||
const memberStatus = await completed(asMember.scanId, member.userId);
|
||||
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
||||
expect(createSpaceForMember).not.toHaveBeenCalled();
|
||||
|
||||
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {});
|
||||
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
||||
expect(ownerStatus.status).toBe("complete");
|
||||
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
||||
});
|
||||
|
||||
it("links each repository of the deployment owner to one project of its own", async () => {
|
||||
allowHostScan();
|
||||
const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim");
|
||||
const app = await realpath(join(scanHome, "Projects", "app"));
|
||||
const { prisma, spaces } = fakeDatabase();
|
||||
|
||||
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
const firstStatus = await completed(first.scanId, owner.userId);
|
||||
expect(firstStatus.status).toBe("complete");
|
||||
expect(firstStatus.result).toMatchObject({
|
||||
localRepos: [{ name: "app" }],
|
||||
createdProjects: [{ spaceId: "space-1", name: "app", path: app }],
|
||||
});
|
||||
expect(createSpaceForMember).toHaveBeenCalledWith(prisma, {
|
||||
currentSpaceId: owner.spaceId,
|
||||
userId: owner.userId,
|
||||
name: "app",
|
||||
});
|
||||
expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]);
|
||||
|
||||
// Scanning again finds the linked project instead of creating a second one.
|
||||
const again = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
const againStatus = await completed(again.scanId, owner.userId);
|
||||
expect(againStatus.result).toMatchObject({ createdProjects: [] });
|
||||
expect(createSpaceForMember).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("joins a running scan instead of linking its repositories twice", async () => {
|
||||
allowHostScan();
|
||||
const scanHome = await scanHomeWithRepos("Projects/app");
|
||||
const { prisma, spaces } = fakeDatabase();
|
||||
|
||||
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
const second = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
expect(second.scanId).toBe(first.scanId);
|
||||
await completed(first.scanId, owner.userId);
|
||||
expect(spaces).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stops creating projects at the space limit and still completes", async () => {
|
||||
allowHostScan();
|
||||
const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c");
|
||||
const { prisma } = fakeDatabase({ spaceLimit: 1 });
|
||||
|
||||
const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||
const status = await completed(scanId, owner.userId);
|
||||
expect(status.status).toBe("complete");
|
||||
expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] });
|
||||
expect(createSpaceForMember).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
// Onboarding scan — real filesystem scan, Git analysis, and profile compilation.
|
||||
// Runs synchronously on the host machine (no sandbox needed).
|
||||
import { spawn } from "node:child_process";
|
||||
import { access, readdir, stat } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { access, readdir, realpath, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import {
|
||||
createSpaceForMember,
|
||||
InvalidSpaceNameError,
|
||||
SpaceLimitError,
|
||||
wireProjectWorkspace,
|
||||
} from "@rakazo/db";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@ -54,10 +61,9 @@ interface ProfileSummary {
|
||||
}
|
||||
|
||||
interface CreatedProject {
|
||||
id: string;
|
||||
name: string;
|
||||
spaceId: string;
|
||||
description: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ScanRecord {
|
||||
@ -115,8 +121,10 @@ async function runSimulatedScan(record: ScanRecord) {
|
||||
record.completedAt = new Date();
|
||||
}
|
||||
|
||||
type ScanActor = Pick<Actor, "userId" | "spaceId" | "isDeploymentOwner">;
|
||||
|
||||
export interface OnboardingScanRequest {
|
||||
actor: Pick<Actor, "userId" | "isDeploymentOwner">;
|
||||
actor: ScanActor;
|
||||
botId?: string;
|
||||
/** Home-style directory to discover. Unset keeps the scan off the host filesystem. */
|
||||
scanHome?: string;
|
||||
@ -124,8 +132,12 @@ export interface OnboardingScanRequest {
|
||||
|
||||
export async function startOnboardingScan(
|
||||
{ actor, botId, scanHome }: OnboardingScanRequest,
|
||||
prisma: any,
|
||||
prisma: PrismaClient,
|
||||
): Promise<{ scanId: string }> {
|
||||
// A repeated start (a remount, a retry) joins the running scan, so no repository is linked twice.
|
||||
for (const running of scanRegistry.values()) {
|
||||
if (running.ownerUserId === actor.userId && !running.completedAt) return { scanId: running.id };
|
||||
}
|
||||
const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const record: ScanRecord = {
|
||||
id: scanId,
|
||||
@ -154,7 +166,7 @@ export async function startOnboardingScan(
|
||||
await runSimulatedScan(record);
|
||||
return;
|
||||
}
|
||||
const result = await executeFullScan(scanHome, botId ?? "", prisma);
|
||||
const result = await executeFullScan(scanHome, actor, prisma);
|
||||
record.status = "complete";
|
||||
record.progress = 100;
|
||||
record.message = "Scan complete — profile ready";
|
||||
@ -189,7 +201,11 @@ export async function getScanStatus(
|
||||
// Full scan pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function executeFullScan(homeDir: string, _botId: string, prisma: any): Promise<ScanResult> {
|
||||
async function executeFullScan(
|
||||
homeDir: string,
|
||||
actor: ScanActor,
|
||||
prisma: PrismaClient,
|
||||
): Promise<ScanResult> {
|
||||
// Phase 1: Local file scan
|
||||
const [fileSummary, projectFolders] = await scanFiles(homeDir);
|
||||
|
||||
@ -200,8 +216,8 @@ async function executeFullScan(homeDir: string, _botId: string, prisma: any): Pr
|
||||
// Phase 4: Profile compilation from findings
|
||||
const profileSummary = compileProfile(localRepos, fileSummary, projectFolders);
|
||||
|
||||
// Phase 6: Project folder creation
|
||||
const createdProjects = await createProjectFolders(localRepos, prisma);
|
||||
// Phase 6: Projects for discovered repositories
|
||||
const createdProjects = await createProjects(localRepos, actor, prisma);
|
||||
|
||||
// Phase 7: Sub-agent role creation
|
||||
const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos);
|
||||
@ -413,9 +429,9 @@ async function findGitDirectories(dir: string, depth: number = 0): Promise<strin
|
||||
if (entry.name === ".git") {
|
||||
results.push(dirname(fullPath));
|
||||
} else if (
|
||||
// Dot folders hold tools and caches (shell and editor plugins), not the person's projects.
|
||||
!entry.name.startsWith(".") &&
|
||||
entry.name !== "node_modules" &&
|
||||
entry.name !== ".git" &&
|
||||
entry.name !== ".venv" &&
|
||||
entry.name !== "dist"
|
||||
) {
|
||||
results.push(...(await findGitDirectories(fullPath, depth + 1)));
|
||||
@ -622,38 +638,43 @@ function generateBriefing(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 5: Project folder creation
|
||||
// Phase 5: Projects for discovered repositories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise<CreatedProject[]> {
|
||||
/** Give each discovered repository a project linked to its folder, unless one already links it. */
|
||||
async function createProjects(
|
||||
repos: RepoInfo[],
|
||||
actor: ScanActor,
|
||||
prisma: PrismaClient,
|
||||
): Promise<CreatedProject[]> {
|
||||
const linked = await prisma.space.findMany({
|
||||
where: { workspacePath: { not: null }, memberships: { some: { userId: actor.userId } } },
|
||||
select: { workspacePath: true },
|
||||
});
|
||||
const linkedFolders = new Set(linked.map((space) => space.workspacePath));
|
||||
const created: CreatedProject[] = [];
|
||||
|
||||
for (const repo of repos) {
|
||||
// Compare and link the real folder, so the next scan recognizes the projects of this one.
|
||||
const folder = await realpath(repo.path).catch(() => null);
|
||||
if (!folder || linkedFolders.has(folder)) continue;
|
||||
let space: { id: string; name: string };
|
||||
try {
|
||||
// Check if space already exists
|
||||
const existing = await prisma.space.findFirst({ where: { title: repo.name } });
|
||||
if (existing) {
|
||||
created.push({
|
||||
id: existing.id,
|
||||
name: repo.name,
|
||||
spaceId: existing.id,
|
||||
description: repo.remote || repo.path,
|
||||
space = await createSpaceForMember(prisma, {
|
||||
currentSpaceId: actor.spaceId,
|
||||
userId: actor.userId,
|
||||
name: basename(folder),
|
||||
});
|
||||
continue;
|
||||
} catch (error) {
|
||||
if (error instanceof SpaceLimitError) break;
|
||||
if (error instanceof InvalidSpaceNameError) continue;
|
||||
throw error;
|
||||
}
|
||||
// Create new space
|
||||
const space = await prisma.space.create({
|
||||
data: { title: repo.name, path: repo.path },
|
||||
});
|
||||
created.push({
|
||||
id: space.id,
|
||||
name: repo.name,
|
||||
const workspace = await wireProjectWorkspace(prisma, {
|
||||
spaceId: space.id,
|
||||
description: repo.remote || repo.path,
|
||||
workspacePath: folder,
|
||||
});
|
||||
} catch {
|
||||
// Space might already exist or DB unavailable — skip
|
||||
}
|
||||
created.push({ spaceId: space.id, name: space.name, path: workspace.path });
|
||||
}
|
||||
|
||||
return created;
|
||||
|
||||
@ -16,12 +16,11 @@ test.use({ viewport: { width: 1280, height: 1000 } });
|
||||
|
||||
/**
|
||||
* Texts in `locator` below WCAG AA against the surface they are painted on. With `inside`, every
|
||||
* visible text and typed value within it counts, except in disabled controls, which WCAG exempts,
|
||||
* and in elements matching `skip`.
|
||||
* visible text and typed value within it counts, except in disabled controls, which WCAG exempts.
|
||||
*/
|
||||
function unreadableTexts(locator: Locator, inside: boolean, skip: string | null) {
|
||||
function unreadableTexts(locator: Locator, inside: boolean) {
|
||||
return locator.evaluate(
|
||||
(root, { inside, skip, min }) => {
|
||||
(root, { inside, min }) => {
|
||||
const context = document
|
||||
.createElement("canvas")
|
||||
.getContext("2d", { willReadFrequently: true });
|
||||
@ -82,11 +81,7 @@ function unreadableTexts(locator: Locator, inside: boolean, skip: string | null)
|
||||
};
|
||||
const elements = inside
|
||||
? [root, ...root.querySelectorAll("*")].filter(
|
||||
(element) =>
|
||||
ownText(element) &&
|
||||
shown(element) &&
|
||||
!element.closest(":disabled") &&
|
||||
!(skip && element.closest(skip)),
|
||||
(element) => ownText(element) && shown(element) && !element.closest(":disabled"),
|
||||
)
|
||||
: [root];
|
||||
return elements.flatMap((element) => {
|
||||
@ -95,18 +90,14 @@ function unreadableTexts(locator: Locator, inside: boolean, skip: string | null)
|
||||
return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : [];
|
||||
});
|
||||
},
|
||||
{ inside, skip, min: MIN_CONTRAST },
|
||||
{ inside, min: MIN_CONTRAST },
|
||||
);
|
||||
}
|
||||
|
||||
async function expectReadable(
|
||||
locator: Locator,
|
||||
what: string,
|
||||
{ inside = false, skip = null }: { inside?: boolean; skip?: string | null } = {},
|
||||
) {
|
||||
async function expectReadable(locator: Locator, what: string, { inside = false } = {}) {
|
||||
await expect(locator).toBeVisible();
|
||||
await expect
|
||||
.poll(() => unreadableTexts(locator, inside, skip), { message: `${what} is readable` })
|
||||
.poll(() => unreadableTexts(locator, inside), { message: `${what} is readable` })
|
||||
.toEqual([]);
|
||||
}
|
||||
|
||||
@ -222,9 +213,6 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
|
||||
await composer.fill("Draft a note about owls");
|
||||
const sidebar = page.getByTestId("bots-sidebar");
|
||||
const sidePanel = page.getByTestId("side-panel");
|
||||
await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible();
|
||||
await expect(page.getByTestId("trading-mode-status")).toBeVisible();
|
||||
|
||||
await inEachTheme(page, async (theme) => {
|
||||
await captureScreenshot(page, testInfo, `shell-contrast-${theme}`);
|
||||
@ -232,9 +220,27 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
|
||||
await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, {
|
||||
inside: true,
|
||||
});
|
||||
// The header carries the logo, which WCAG exempts; its create button is checked on its own.
|
||||
await expectReadable(sidebar, `${theme}: the sidebar`, { inside: true, skip: ".app-drag" });
|
||||
await expectReadable(page.getByTestId("create-menu-trigger"), `${theme}: the create button`);
|
||||
await expectReadable(
|
||||
sidebar.getByRole("button", { name: "Heute", exact: true }),
|
||||
`${theme}: Heute`,
|
||||
{ inside: true },
|
||||
);
|
||||
await expectReadable(
|
||||
sidebar.locator("[data-main-conversation]"),
|
||||
`${theme}: the main conversation row`,
|
||||
{ inside: true },
|
||||
);
|
||||
await expectReadable(
|
||||
sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" }),
|
||||
`${theme}: the selected bot row`,
|
||||
{ inside: true },
|
||||
);
|
||||
await expectReadable(page.getByTestId("trading-mode-status"), `${theme}: trading status`, {
|
||||
inside: true,
|
||||
});
|
||||
await expectReadable(page.getByTestId("user-menu-trigger"), `${theme}: the user menu`, {
|
||||
inside: true,
|
||||
});
|
||||
});
|
||||
|
||||
await openNewBot(page);
|
||||
@ -253,12 +259,4 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
|
||||
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`);
|
||||
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true });
|
||||
});
|
||||
|
||||
await sidebar.getByRole("button", { name: "Heute", exact: true }).click();
|
||||
const heute = page.getByTestId("heute-brief");
|
||||
await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
|
||||
await inEachTheme(page, async (theme) => {
|
||||
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
|
||||
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });
|
||||
});
|
||||
});
|
||||
|
||||
@ -10,16 +10,16 @@ const TRUST_ITEMS = [
|
||||
export const DataTrustPanel = memo(function DataTrustPanel() {
|
||||
return (
|
||||
<div className="hidden xl:flex flex-col items-start gap-1.5 rounded-xl border border-border/40 bg-muted/30 px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-sidebar-foreground">
|
||||
<CheckCircle2 className="size-3.5 text-success" />
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
<span>Datenschutz aktiv</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{TRUST_ITEMS.map(({ icon: Icon, label, desc }) => (
|
||||
<div key={label} className="flex items-center gap-1.5 rk-sidebar-muted">
|
||||
<Icon className="size-3" />
|
||||
<span className="text-[11px] leading-tight">{label}</span>
|
||||
<span className="text-[10px] leading-tight">· {desc}</span>
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<Icon className="size-3 text-muted-foreground/60" />
|
||||
<span className="text-[11px] leading-tight text-muted-foreground">{label}</span>
|
||||
<span className="text-[10px] leading-tight text-muted-foreground/50">· {desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -45,7 +45,7 @@ export function TradingModeIndicator({
|
||||
<TradingModeLabel label={presentation.label} />
|
||||
</span>
|
||||
{presentation.subtitle ? (
|
||||
<span className="block truncate text-ui-body-sm rk-sidebar-muted">
|
||||
<span className="block truncate text-ui-body-sm text-muted-foreground/70">
|
||||
<TradingModeSubtitleText subtitle={presentation.subtitle} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@ -178,7 +178,7 @@ export function HeuteBrief({
|
||||
href={MISSION_CONTROL_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground transition-colors hover:text-foreground"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground/70 transition-colors hover:text-muted-foreground"
|
||||
data-testid="heute-mission-control-link"
|
||||
>
|
||||
<Trans>Mission Control</Trans>
|
||||
@ -235,7 +235,7 @@ function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots:
|
||||
|
||||
function ActionableChip({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-foreground dark:text-warning">
|
||||
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-warning">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
@ -3456,7 +3456,7 @@ export function ShellPage() {
|
||||
className={`app-no-drag flex size-control-sm items-center justify-center rounded-full ${
|
||||
activityMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "rk-sidebar-muted hover:text-foreground/75"
|
||||
: "text-muted-foreground/70 hover:text-foreground/75"
|
||||
}`}
|
||||
>
|
||||
<Bell
|
||||
@ -3469,7 +3469,7 @@ export function ShellPage() {
|
||||
|
||||
<Popover open={createMenuOpen} onOpenChange={setCreateMenuOpen}>
|
||||
<PopoverTrigger
|
||||
className="app-no-drag text-[21px] rk-sidebar-muted hover:text-foreground/75"
|
||||
className="app-no-drag text-[21px] text-muted-foreground/70 hover:text-foreground/75"
|
||||
title={t`Create`}
|
||||
data-testid="create-menu-trigger"
|
||||
>
|
||||
@ -3592,7 +3592,7 @@ export function ShellPage() {
|
||||
type="button"
|
||||
aria-label={t`Neue Unterhaltung`}
|
||||
data-testid="conversation-picker-trigger"
|
||||
className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
|
||||
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
||||
>
|
||||
<Plus strokeWidth={2.5} className="size-icon-md" />
|
||||
</PopoverTrigger>
|
||||
@ -3633,7 +3633,7 @@ export function ShellPage() {
|
||||
</div>
|
||||
) : null}
|
||||
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
|
||||
<div className="p-4 text-center text-ui-label rk-sidebar-muted">
|
||||
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
||||
<p>{t`Noch keine Unterhaltung.`}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -3657,7 +3657,7 @@ export function ShellPage() {
|
||||
<div className="flex items-center pt-3 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
||||
onClick={() => {
|
||||
if (group.emptySpaceId) {
|
||||
openSpaceChat(group.emptySpaceId, "/onboarding");
|
||||
@ -3841,7 +3841,7 @@ export function ShellPage() {
|
||||
{item.chat.name}
|
||||
</span>
|
||||
{needsYouChipLabel(item.chat.status) ? (
|
||||
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-foreground dark:text-warning">
|
||||
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-warning">
|
||||
{needsYouChipLabel(item.chat.status)}
|
||||
</span>
|
||||
) : item.chat.title ? (
|
||||
@ -3887,7 +3887,7 @@ export function ShellPage() {
|
||||
[group.key]: !prev[group.key],
|
||||
}))
|
||||
}
|
||||
className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium rk-sidebar-muted hover:bg-sidebar-accent hover:text-foreground transition-colors"
|
||||
className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium text-muted-foreground hover:bg-sidebar-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
{expandedTeamGroups[group.key]
|
||||
? "Weniger anzeigen"
|
||||
@ -3902,7 +3902,7 @@ export function ShellPage() {
|
||||
<div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-sidebar-border px-2.5">
|
||||
<div>
|
||||
<div className="rk-shell-section-label">{t`Projekte`}</div>
|
||||
<p className="mt-0.5 px-0.5 text-ui-caption rk-sidebar-muted">
|
||||
<p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70">
|
||||
{t`Threads by topic · folders`}
|
||||
</p>
|
||||
</div>
|
||||
@ -3912,14 +3912,14 @@ export function ShellPage() {
|
||||
aria-label={t`Create a project`}
|
||||
data-testid="create-project-trigger"
|
||||
onClick={() => openNewSpaceDialog("project")}
|
||||
className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
|
||||
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
||||
>
|
||||
<Plus strokeWidth={2.5} className="size-icon-md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{projectSidebarGroups.length === 0 ? (
|
||||
<div className="p-4 text-center text-ui-label rk-sidebar-muted">
|
||||
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
||||
<p>{t`No projects yet.`}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -3957,7 +3957,7 @@ export function ShellPage() {
|
||||
</ProjectDragGrip>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
||||
onClick={() => {
|
||||
if (group.emptySpaceId) {
|
||||
openSpaceChat(group.emptySpaceId, "/app");
|
||||
@ -4153,7 +4153,7 @@ export function ShellPage() {
|
||||
type="button"
|
||||
aria-expanded={archivedOpen}
|
||||
onClick={() => setArchivedOpen((open) => !open)}
|
||||
className="flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-ui-body rk-sidebar-muted hover:bg-sidebar-accent"
|
||||
className="flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-ui-body text-muted-foreground hover:bg-sidebar-accent"
|
||||
>
|
||||
<span>
|
||||
<Trans>Archived</Trans>
|
||||
|
||||
@ -11,7 +11,9 @@ export function rosterNameClass(unread: boolean): string {
|
||||
return unread ? "font-bold text-sidebar-foreground" : "font-semibold text-sidebar-foreground";
|
||||
}
|
||||
|
||||
export const rosterTimeClass = "text-ui-body-sm rk-sidebar-muted tabular-nums font-medium";
|
||||
// Muted text measures about 4.4:1 on the light sidebar; a dimmed foreground stays above 4.5:1.
|
||||
export const rosterTimeClass =
|
||||
"text-ui-body-sm text-sidebar-foreground/65 tabular-nums font-medium";
|
||||
|
||||
export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-sidebar-foreground";
|
||||
|
||||
|
||||
@ -73,7 +73,7 @@ body,
|
||||
/* Shell layout utilities — token-backed, use instead of one-off pixel classes */
|
||||
@layer components {
|
||||
.rk-shell-section-label {
|
||||
@apply text-ui-label font-bold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80;
|
||||
@apply text-ui-label font-bold tracking-wider uppercase text-muted-foreground/80;
|
||||
}
|
||||
|
||||
.rk-shell-icon-btn {
|
||||
@ -84,12 +84,6 @@ body,
|
||||
@apply text-ui-body-sm text-muted-foreground;
|
||||
}
|
||||
|
||||
/* Secondary sidebar text and icons. Muted text measures 4.4:1 on the light sidebar;
|
||||
a dimmed foreground stays above 4.5:1. */
|
||||
.rk-sidebar-muted {
|
||||
@apply text-sidebar-foreground/65;
|
||||
}
|
||||
|
||||
.rk-shell-row-title {
|
||||
@apply text-ui-body font-medium text-foreground;
|
||||
}
|
||||
|
||||
@ -38,14 +38,12 @@ describe("server image publish workflow", () => {
|
||||
expect(publish.needs).toBe("build");
|
||||
expect(workflowText).toContain("push-by-digest=true");
|
||||
expect(workflowText).toContain("docker buildx imagetools create");
|
||||
expect(workflowText).toContain(`if [[ "\${#sources[@]}" -ne 1 ]]`);
|
||||
expect(workflowText).toContain('if [[ "${#sources[@]}" -ne 1 ]]');
|
||||
expect(workflowText).toContain("for want in linux/amd64; do");
|
||||
expect(workflowText).toContain("actions/attest-build-provenance@");
|
||||
expect(workflowText).not.toContain("github.repository_owner_type");
|
||||
expect(workflowText).toContain(`gh api "repos/\${GITHUB_REPOSITORY}"`);
|
||||
expect(workflowText).toContain(".owner.type");
|
||||
expect(workflowText).toContain("if: steps.attestation.outputs.enabled == 'true'");
|
||||
expect(workflowText).toContain('[[ "$owner_type" == "User" && "$private" == "true" ]]');
|
||||
expect(workflowText).toContain(
|
||||
"if: github.event.repository.private != true || github.repository_owner_type != 'User'",
|
||||
);
|
||||
});
|
||||
|
||||
it("stores digest markers under RUNNER_TEMP so self-hosted runners do not leak stale files", () => {
|
||||
@ -54,11 +52,11 @@ describe("server image publish workflow", () => {
|
||||
});
|
||||
|
||||
it("names every image under the lowercase repository namespace", () => {
|
||||
expect(workflowText).not.toContain(`ghcr.io/\${{ github.repository }}`);
|
||||
expect(workflowText).not.toContain("ghcr.io/${{ github.repository }}");
|
||||
const references =
|
||||
workflowText.match(/ghcr\.io\/\$\{\{ steps\.ns\.outputs\.repo \}\}\//g) ?? [];
|
||||
expect(references.length).toBeGreaterThanOrEqual(6);
|
||||
expect(workflowText).toContain(`repo=\${GITHUB_REPOSITORY,,}`);
|
||||
expect(workflowText).toContain("repo=${GITHUB_REPOSITORY,,}");
|
||||
});
|
||||
|
||||
it("keeps pull requests read-only and every action pinned to a commit", () => {
|
||||
|
||||
@ -1,19 +1,10 @@
|
||||
// Server-only. Kept out of the package index so browser bundles never load `node:child_process`.
|
||||
import { spawn } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js";
|
||||
import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js";
|
||||
|
||||
// Lazy import to avoid bundling `node:child_process` in browser bundles.
|
||||
// `defaultGitRunner` is server-only; lazy-loading ensures tree-shaking works.
|
||||
type SpawnFn = typeof import("node:child_process").spawn;
|
||||
let _spawn: SpawnFn | undefined;
|
||||
const getSpawn = (): SpawnFn => {
|
||||
if (_spawn === undefined) {
|
||||
_spawn = require("node:child_process").spawn as SpawnFn;
|
||||
}
|
||||
return _spawn;
|
||||
};
|
||||
|
||||
export interface GitCheckoutStatus {
|
||||
present: boolean;
|
||||
remoteUrl: string | null;
|
||||
@ -32,7 +23,6 @@ export type GitRunner = (args: string[], cwd: string) => Promise<GitRunnerResult
|
||||
|
||||
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
|
||||
return new Promise((resolve) => {
|
||||
const spawn = getSpawn();
|
||||
const child = spawn("git", args, { cwd, env: process.env });
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user