Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions apps/connector/src/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
CONNECTOR_UPDATE_REQUIRED,
} from "@chopin/protocol/connector";
import { redacted } from "./connection";
import { CONNECTOR_HEADERS, ConnectorError, refusal } from "./mcp";
import { CONNECTOR_HEADERS, ConnectorError, jsonObject, refusal } from "./mcp";
import type { Output } from "./output";

/**
Expand Down Expand Up @@ -188,9 +188,7 @@ async function post(
});
let refused = await refusal(response);
if (refused) throw refused;
// Bun reads an empty body sent with Content-Length 0, as a gateway's 502 is, as JSON null.
let parsed: unknown = await response.json().catch(() => undefined);
let value = (parsed && typeof parsed === "object" ? parsed : {}) as Record<string, unknown>;
let value = await jsonObject(response);
if (!response.ok) {
let code = typeof value.error === "string" ? value.error : `http-${response.status}`;
throw new ConnectorError(
Expand Down
15 changes: 11 additions & 4 deletions apps/connector/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,23 @@ export const CONNECTOR_HEADERS = {
[CONNECTOR_PROTOCOL_HEADER]: String(CONNECTOR_PROTOCOL),
};

/**
* The JSON object a response carries, or an empty one when its body is anything else. Bun reads an
* empty body sent with Content-Length 0, as a gateway's 502 is, as JSON null rather than failing.
*/
export async function jsonObject(response: Response) {
let parsed: unknown = await response.json().catch(() => undefined);
return (parsed && typeof parsed === "object" ? parsed : {}) as Record<string, unknown>;
}

/**
* The server's refusal of this connector's version, which no retry can fix. Its message names the
* release to install.
*/
export async function refusal(response: Response) {
if (response.status !== 409) return;
let body = await response.clone().json().catch(() => undefined) as
| { error?: unknown; message?: unknown }
| undefined;
if (body?.error === CONNECTOR_UPDATE_REQUIRED && typeof body.message === "string") {
let body = await jsonObject(response.clone());
if (body.error === CONNECTOR_UPDATE_REQUIRED && typeof body.message === "string") {
return new ConnectorError(CONNECTOR_UPDATE_REQUIRED, body.message, 409);
}
}
Expand Down
11 changes: 11 additions & 0 deletions apps/connector/src/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,17 @@ test("status still reports when Chopin is down, and says when it refuses the mac
await hung.stop(true);
});

test("status reports the HTTP status of a gateway that answers with an empty body", async () => {
// A proxy such as Caddy answers for a Chopin that went away with 502 and Content-Length 0.
using gateway = chopin(() => new Response(null, { status: 502 }));
let paired = await machine(gateway.url);
let report = await collectStatus({ ...paired, server: gateway.url, source: "config" });
expect(report.machine).toMatchObject({ check: "error", accepted: null, detail: "http-502" });
expect(formatStatus(report).join("\n")).toContain(
"Machine: paired as build-box; Chopin's answer was http-502",
);
});

// Run under a temporary HOME, so the service check sees no unit and runs nothing.
test.skipIf(process.platform === "win32")(
"chopin status --json reads the base dir it is given and prints the report",
Expand Down
4 changes: 2 additions & 2 deletions apps/connector/src/status.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type ConnectSettings, readConfig } from "./config";
import { machineLabel, machineToken, SELF } from "./machine";
import { CONNECTOR_HEADERS, refusal } from "./mcp";
import { CONNECTOR_HEADERS, jsonObject, refusal } from "./mcp";
import { logPath, type ServiceHost, type ServiceStatus, serviceStatus } from "./service";
import { appCannotPush } from "./github-token";
import { CONNECTOR_PROTOCOL, VERSION } from "./version";
Expand Down Expand Up @@ -130,7 +130,7 @@ async function check(url: string, token: string, timeoutMs: number) {
if (refused) {
return { check: "update-required" as const, accepted: false, detail: refused.message };
}
let body = await response.json().catch(() => ({})) as Record<string, unknown>;
let body = await jsonObject(response);
if (response.ok) {
return {
check: "accepted" as const,
Expand Down
17 changes: 17 additions & 0 deletions apps/connector/src/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,23 @@ describe.skipIf(!binary)("chopin update", () => {
expect(await readFile(at.chopin, "utf8")).toBe(fakeChopin("99.0.0"));
});

test("installs the newest release when the Chopin server answers with an empty body", async () => {
using release = releases();
let empty = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response(null) });
let url = `http://127.0.0.1:${empty.port}`;
try {
let at = await installed();
let result = await at.update({ ...release.env, CHOPIN_URL: url });
expect(result.code).toBe(0);
expect(result.stdout).toContain(
`Could not ask ${url} which chopin to install (it recommends none), so installing the `
+ "newest release, 99.0.0, instead.",
);
} finally {
await empty.stop(true);
}
});

test("moves to an older release only with --allow-downgrade", async () => {
using release = releases();
using chopin = chopinServer("0.0.0");
Expand Down
5 changes: 2 additions & 3 deletions apps/connector/src/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { jsonObject } from "./mcp";
import { VERSION } from "./version";

/**
Expand Down Expand Up @@ -171,9 +172,7 @@ async function newest(api: string) {
/** The release a Chopin server recommends to its connectors, if it can be asked. */
async function recommended(server: string) {
try {
let body = await (await download(`${server}/api/connector/version`)).json() as {
version?: unknown;
};
let body = await jsonObject(await download(`${server}/api/connector/version`));
if (typeof body.version !== "string") return { reason: "it recommends none" };
return { version: releaseVersion(body.version) };
} catch (error) {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"@chopin/viewport": "workspace:*",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"mdast-util-from-markdown": "2.0.2",
"mdast-util-to-string": "4.0.0",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-markdown": "^10.1.0"
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/build-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ import {
startingHint,
syncHint,
syncStatus,
taskRowName,
taskStartsOpen,
unfinishedReason,
} from "./build-model";
import { inlineText } from "./chat/inline-text";

import type {
BuildRequest,
Expand Down Expand Up @@ -213,6 +215,21 @@ describe("presentation", () => {
expect(taskStartsOpen("queued", false)).toBe(false);
expect(taskStartsOpen("completed", true)).toBe(true);
});

it("names a task row by its title as shown, then its state, with no space before a comma", () => {
let title = inlineText("Deploy on `main`");
expect(taskRowName(title, "completed", true)).toBe("Deploy on main, done, no change needed");
expect(taskRowName(title, "completed", false)).toBe("Deploy on main, done");
expect(taskRowName(inlineText("Read [the docs](https://example.com)"), "blocked", false))
.toBe("Read the docs, blocked");
expect(taskRowName(" Wire the\nagent ", "in_progress", false)).toBe(
"Wire the agent, in progress",
);
expect(taskRowName("Wire the agent", "queued", false)).toBe("Wire the agent, queued");
// A title that shows no text, such as only an image, leaves the state alone.
expect(taskRowName(inlineText("![image](https://example.invalid/i.png)"), "queued", false))
.toBe("queued");
});
});

describe("attribution", () => {
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/build-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,17 @@ export function taskStateLabel(state: TaskState, noChange: boolean): string {
return state === "completed" && noChange ? "done, no change needed" : TASK_STATE_LABEL[state];
}

/**
* A task row's accessible name: the title as shown (`inlineText`), then its state. Given as a
* label, since a name read from the row's content gains a space before the comma: browsers set
* a visually hidden, absolutely positioned state apart from the title with one.
*/
export function taskRowName(title: string, state: TaskState, noChange: boolean): string {
let text = title.replace(/\s+/g, " ").trim();
let label = taskStateLabel(state, noChange);
return text ? `${text}, ${label}` : label;
}

/** Active and blocked rows start open; so does a linked task. */
export function taskStartsOpen(state: TaskState, linked: boolean): boolean {
return linked || state === "in_progress" || state === "blocked";
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/build-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ import {
pullRequestCommits,
pullRequestNumber,
retryAction,
taskRowName,
taskStartsOpen,
taskStateLabel,
unfinishedReason,
} from "./build-model";
import {
Expand All @@ -37,6 +37,7 @@ import {
implementationResponse as response,
stopBuild,
} from "./build-start";
import { inlineText } from "./chat/inline-text";
import { InlineMarkdown, MessageMarkdown } from "./chat/markdown";
import { LocalAgentNote } from "./local-agent-note";
import { MachineGlyph } from "./machine-button";
Expand Down Expand Up @@ -641,6 +642,7 @@ function TaskRow(
<button
aria-controls={panel}
aria-expanded={expanded}
aria-label={taskRowName(inlineText(task.title), state, !!noChange)}
className="build-task-toggle"
onClick={onToggle}
type="button"
Expand All @@ -649,7 +651,6 @@ function TaskRow(
<span className="build-task-title">
<InlineMarkdown links={false} source={task.title} />
</span>
<span className="sr-only">, {taskStateLabel(state, !!noChange)}</span>
</button>
{pullRequest && (
<a
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/chat/inline-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import { toString as plainText } from "mdast-util-to-string";

import { inlineSource } from "./markdown";

/**
* The text `InlineMarkdown` shows for `source`, parsed as it parses it, with single spaces: what
* an accessible name built around it should read. Raw HTML and images show nothing.
*/
export function inlineText(source: string): string {
return plainText(fromMarkdown(inlineSource(source)), {
includeHtml: false,
includeImageAlt: false,
})
.replace(/\s+/g, " ").trim();
}
19 changes: 19 additions & 0 deletions apps/web/src/chat/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, test } from "bun:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";

import { inlineText } from "./inline-text";
import { InlineMarkdown, MessageMarkdown } from "./markdown";
import { referenceRenderModel } from "./references";

Expand Down Expand Up @@ -265,3 +266,21 @@ test("inline Markdown formats code, emphasis and links without blocks", () => {
expect(inline("<b>raw</b> ![image](https://example.invalid/i.png)")).toBe("raw ");
expect(inline("one\n\ntwo")).toBe("one two");
});

test("the plain text of inline Markdown is the text it shows, with single spaces", () => {
let sources = [
"Rewire `login` to **call** _three_ [helpers](https://example.com)",
"Deploy on `main`",
"Read [the docs](https://example.com)",
"<b>raw</b> ![image](https://example.invalid/i.png)",
"> quote &amp; `a b`",
"one\n\ntwo",
];
for (let source of sources) {
let shown = inline(source, false).replace(/<[^>]+>/g, "").replaceAll("&gt;", ">")
.replaceAll("&amp;", "&");
expect(inlineText(source)).toBe(shown.replace(/\s+/g, " ").trim());
}
expect(inlineText("Deploy on `main`")).toBe("Deploy on main");
expect(inlineText("<b>raw</b> ![image](https://example.invalid/i.png)")).toBe("raw");
});
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 10 additions & 5 deletions e2e/implementation-launcher.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,9 +839,12 @@ test("a build that ran for an hour reads as built when it finished, not when it

test("a task that needed no change reads as done without a pull request, with its evidence", async ({ seed, join: enter }) => {
let now = Date.now();
let graph = preparedGraph();
// A title ending in inline code reads with its state straight after it, as any title does.
graph.versions[0]!.definition.tasks[0]!.title = "Connect the local agent on `main`";
await seed(
"# Checked build\n\nOne task was already satisfied.\n",
builtBuild(preparedGraph(), new Date(now - 10 * 60_000), new Date(now - 60_000), {
builtBuild(graph, new Date(now - 10 * 60_000), new Date(now - 60_000), {
connect: {
reason: "The connection already reports blockers on the base commit.",
evidence: "- Ran `bun test connector` in run 4821: **36 passed**, 0 failed.",
Expand All @@ -856,10 +859,12 @@ test("a task that needed no change reads as done without a pull request, with it
await expect(view.getByText("1 pull request, 1 task needed no change", { exact: true }))
.toBeVisible();
let tasks = view.getByRole("list", { name: "Tasks" });
let checked = tasks.getByRole("button", {
name: /^Connect the local agent\s*, done, no change needed$/,
});
await expect(checked).toBeVisible();
let checked = tasks.getByRole("button", { name: /^Connect the local agent/ });
await expect(checked).toHaveAccessibleName(
"Connect the local agent on main, done, no change needed",
);
await expect(tasks.getByRole("button", { name: /^Review the connection/ }))
.toHaveAccessibleName("Review the connection, done");
// The task that needed no change has no pull request link; the other task keeps its own.
await expect(tasks.getByRole("link", { name: /^Pull request/ })).toHaveCount(1);
await expect(tasks.getByText("No change needed", { exact: true })).toBeVisible();
Expand Down
Loading