File size: 10,218 Bytes
3201ca6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 | // @vitest-environment node
//
// Guards the pairing between advertising the editor prefix and serving it.
//
// `OH_VSCODE_BASE_PATH` changes what `/api/vscode/url` advertises: agent-server
// appends the prefix to the browser origin the frontend sends. Nothing about
// setting it makes that URL resolve — the origin has to route the prefix to the
// editor port as well. A launcher that sets it without registering the route
// advertises `<origin>/vscode/…`, which serves the canvas SPA shell, so the
// editor button opens a second copy of the canvas.
//
// `buildAgentServerEnv` is shared by every launcher, so when the prefix was a
// field on its config object it was on for all of them while only some had the
// route. It is now an explicit argument, and this file asserts that no launcher
// passes it without also serving it.
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
buildAgentServerEnv,
buildSafeDevConfig,
} from "../../scripts/dev-safe.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
function readScript(name: string): string {
return readFileSync(path.join(repoRoot, "scripts", name), "utf-8");
}
/**
* Source text of every `buildAgentServerEnv(...)` call in `source`, with
* balanced parentheses so nested calls and object literals are included.
*/
function buildAgentServerEnvCalls(source: string): string[] {
const calls: string[] = [];
const needle = "buildAgentServerEnv(";
let from = 0;
for (;;) {
const start = source.indexOf(needle, from);
if (start === -1) break;
from = start + needle.length;
// Skip the definition and the import/export lists, which are not calls.
const lineStart = source.lastIndexOf("\n", start) + 1;
const line = source.slice(lineStart, start);
if (line.includes("function ")) continue;
let depth = 0;
let end = start + needle.length - 1;
for (let i = end; i < source.length; i += 1) {
if (source[i] === "(") depth += 1;
else if (source[i] === ")") {
depth -= 1;
if (depth === 0) {
end = i;
break;
}
}
}
calls.push(source.slice(start, end + 1));
}
return calls;
}
function optsIntoPrefixMode(source: string): boolean {
return buildAgentServerEnvCalls(source).some((call) =>
call.includes("vscodeBasePath"),
);
}
describe("editor base path is opt-in", () => {
it("omits OH_VSCODE_BASE_PATH unless the caller asks for it", () => {
const config = buildSafeDevConfig(process.cwd(), {
OH_SESSION_API_KEY_PATH: path.join(repoRoot, "node_modules", ".test-key"),
});
const env = buildAgentServerEnv(config);
// Not "" or undefined-but-present: agent-server reads the variable's
// presence, so an empty string would still put it into prefix-mode.
expect(env).not.toHaveProperty("OH_VSCODE_BASE_PATH");
// The port is unconditional — the editor still runs, it is just advertised
// on its own port rather than under a prefix.
expect(env.OH_VSCODE_PORT).toBe(String(config.vscodePort));
});
it("sets OH_VSCODE_BASE_PATH to exactly what the caller passed", () => {
const config = buildSafeDevConfig(process.cwd(), {
OH_SESSION_API_KEY_PATH: path.join(repoRoot, "node_modules", ".test-key"),
});
const env = buildAgentServerEnv(config, { vscodeBasePath: "/editor" });
expect(env.OH_VSCODE_BASE_PATH).toBe("/editor");
});
});
describe("every launcher that advertises the prefix also serves it", () => {
it("dev-with-automation opts in and routes through getLocalServiceRoutes", () => {
const source = readScript("dev-with-automation.mjs");
expect(optsIntoPrefixMode(source)).toBe(true);
// The route lives in getLocalServiceRoutes, which both the static server
// and the ingress build their tables from. Behaviour is asserted directly
// in dev-with-automation.test.ts.
expect(source).toContain("function getLocalServiceRoutes");
// IPv4 loopback, not `localhost`: these services bind to 127.0.0.1 and
// `localhost` can resolve to ::1, which is why every other local proxy
// target in this repo is written the same way.
expect(source).toMatch(
/config\.vscodeBasePath,\s*\n\s*`http:\/\/127\.0\.0\.1/,
);
});
it("dev-static opts in and reuses the same route table", () => {
const source = readScript("dev-static.mjs");
expect(optsIntoPrefixMode(source)).toBe(true);
// Both of its proxies (static server and ingress) must build their routes
// from the shared helper rather than a hand-maintained copy. The copy this
// replaced had already drifted: it was missing the editor prefix.
expect(source).toMatch(
/function buildLocalServiceRouteArgs[\s\S]*?buildRouteArgs\(\s*getLocalServiceRoutes\(/,
);
const routeArgUses = source.match(
/\.\.\.buildLocalServiceRouteArgs\(config\)/g,
);
expect(routeArgUses).toHaveLength(2);
});
it("dev:minimal opts in and proxies the prefix through Vite", () => {
// This mode runs agent-server and Vite with nothing in front of them, so
// Vite's own proxy is the only thing that can serve the prefix on the
// origin the browser is on.
const source = readScript("dev-safe.mjs");
expect(optsIntoPrefixMode(source)).toBe(true);
expect(source).toContain("VITE_VSCODE_BASE_PATH: config.vscodeBasePath");
expect(source).toContain("VITE_VSCODE_TARGET:");
const viteConfig = readFileSync(
path.join(repoRoot, "vite.config.ts"),
"utf-8",
);
expect(viteConfig).toContain("VITE_VSCODE_BASE_PATH");
// The editor upgrades to a WebSocket as soon as the workbench loads, so
// the proxy entry has to carry ws.
expect(viteConfig).toMatch(
/\[VITE_VSCODE_BASE_PATH\]: \{[^}]*target: VITE_VSCODE_TARGET,[^}]*ws: true,/s,
);
});
it("dev-extra-backend stays out of prefix-mode", () => {
// Its browser origin belongs to a different stack, so a prefix there either
// does not resolve or resolves to the bundled stack's editor — handing back
// another container's workspace. No global prefix can disambiguate them.
//
// Opting out is not by itself what hides the control: this launcher still
// starts the editor, so its /api/vscode/status reports it available. What
// hides it is that with no prefix configured, agent-server appends nothing
// to the origin and the URL comes back as the canvas root — which
// `isVSCodeUrlServedByOrigin` rejects. That behaviour is asserted in
// __tests__/hooks/use-unified-vscode-url.test.tsx; this only pins the
// launcher's half.
const source = readScript("dev-extra-backend.mjs");
expect(optsIntoPrefixMode(source)).toBe(false);
});
it("advertises the prefix on the servers that inject into the document", () => {
// Only the static server rewrites index.html, so only it can tell the
// frontend what this origin serves; the ingress in front of it routes the
// same prefix but proxies the document through untouched. Passing the flag
// to the ingress would also be a hard error — it does not accept it.
for (const script of ["dev-with-automation.mjs", "dev-static.mjs"]) {
const source = readScript(script);
const advertises = source.match(
/\.\.\.getVSCodeAdvertiseArgs\(config\)/g,
);
expect(advertises, `${script} advertises exactly once`).toHaveLength(1);
}
// Vite serves the document in full-stack dev mode, so the advertisement is
// an env var there rather than a server flag.
expect(readScript("dev-with-automation.mjs")).toContain(
"viteEnv.VITE_VSCODE_BASE_PATH = config.vscodeBasePath",
);
});
it("full-stack dev advertises the prefix only where Vite also proxies it", () => {
// This stack has two supported browser origins: the ingress, and Vite's own
// port — the latter is in AUTOMATION_CORS_ORIGINS precisely so it can be
// browsed directly. The ingress routes the prefix itself, but on the Vite
// origin only Vite's proxy can, and vite.config.ts registers that proxy
// only when it has a target as well as a prefix. Advertising the prefix
// without the target would put a visible button on the Vite origin whose
// URL falls through to the SPA — the dead button this gating exists to
// prevent. So the two env vars have to be set together, in one block.
const source = readScript("dev-with-automation.mjs");
// Matched to the block's closing brace at its own indent, so a `${...}`
// inside the body does not end the match early.
const block = source.match(
/if \(config\.launchAgentServer && config\.vscodeBasePath\) \{\n([\s\S]*?VITE_VSCODE[\s\S]*?)\n {2}\}/,
);
expect(
block,
"the viteEnv editor block is still recognizable",
).not.toBeNull();
expect(block?.[1]).toContain(
"VITE_VSCODE_BASE_PATH = config.vscodeBasePath",
);
// The editor is its own process on its own port, so the proxy target is
// that port and not the backend/ingress host.
expect(block?.[1]).toContain(
"VITE_VSCODE_TARGET = `http://127.0.0.1:${config.vscodePort}`",
);
});
it("gates advertising on exactly the condition that adds the route", () => {
// If these two guards ever disagree, one of the two failure modes returns:
// an advertised prefix with no route (control opens the SPA), or a routed
// prefix nobody advertises (feature silently off). static-server rejects
// the first at startup; this pins the source of both.
const source = readScript("dev-with-automation.mjs");
const advertiseGuard = source.match(
/function getVSCodeAdvertiseArgs\(config\) \{\s*if \(([^)]*)\) return \[\];/,
);
const referrerGuard = source.match(
/function getNoReferrerPrefixArgs\(config\) \{\s*if \(([^)]*)\) return \[\];/,
);
expect(advertiseGuard?.[1]).toBe(
"!config.launchAgentServer || !config.vscodeBasePath",
);
expect(advertiseGuard?.[1]).toBe(referrerGuard?.[1]);
});
});
|