返回 CodeWhale
transcript.test.ts
根目录 / extensions / vscode / src / test / transcript.test.ts
1 import assert from "node:assert/strict";
2 import * as fs from "node:fs";
3 import * as os from "node:os";
4 import * as path from "node:path";
5 import { afterEach, beforeEach, describe, it } from "node:test";
6 import {
7 extractFilePath,
8 isInsideRoot,
9 isRealPathInsideRoot,
10 projectItem,
11 statusForEvent,
12 } from "../transcript";
13 import type { ItemRecord } from "../api";
14
15 const agent = (over: Partial<ItemRecord> = {}): ItemRecord => ({
16 id: "i1",
17 kind: "agent_message",
18 summary: "",
19 ...over,
20 });
21
22 describe("projectItem", () => {
23 it("renders the full detail body, not the truncated summary", () => {
24 const stub = "Here is the plan" + "…".repeat(10);
25 const view = projectItem(
26 agent({ status: "completed", summary: stub, detail: "Here is the plan, in full, with steps." }),
27 undefined,
28 );
29 assert.equal(view.summary, "Here is the plan, in full, with steps.");
30 assert.ok(view.html?.includes("in full"));
31 assert.ok(!view.html?.includes("…"));
32 });
33
34 it("falls back to summary when detail is absent on the wire", () => {
35 const view = projectItem(agent({ status: "completed", summary: "short reply" }), undefined);
36 assert.equal(view.summary, "short reply");
37 assert.ok(view.html?.includes("short reply"));
38 });
39
40 it("keeps streamed text when a completion payload carries neither field", () => {
41 const streaming = projectItem(agent({ status: "in_progress" }), {
42 id: "i1",
43 kind: "agent_message",
44 summary: "partial",
45 streamText: "partial",
46 rev: 1,
47 });
48 assert.equal(streaming.streamText, "partial");
49 const done = projectItem(agent({ status: "completed" }), streaming);
50 assert.equal(done.summary, "partial");
51 assert.equal(done.rev, streaming.rev + 1);
52 });
53
54 it("carries a parsed file path onto non-agent items", () => {
55 const view = projectItem(
56 {
57 id: "t1",
58 kind: "file_change",
59 summary: "Edited a file",
60 metadata: { tool_input: JSON.stringify({ file_path: "src/chat.ts" }) },
61 },
62 undefined,
63 );
64 assert.equal(view.filePath, "src/chat.ts");
65 });
66
67 it("merges detail and metadata forward from the existing view", () => {
68 const first = projectItem(
69 { id: "t1", kind: "tool_call", summary: "run", detail: "line one", metadata: { path: "a.ts" } },
70 undefined,
71 );
72 const second = projectItem({ id: "t1", kind: "tool_call", summary: "run" }, first, "item.completed");
73 assert.equal(second.detail, "line one");
74 assert.equal(second.filePath, "a.ts");
75 });
76 });
77
78 describe("extractFilePath", () => {
79 it("reads the path out of a JSON tool_input string", () => {
80 assert.equal(
81 extractFilePath({ tool_input: '{"file_path":"/repo/src/main.rs","content":"x"}' }),
82 "/repo/src/main.rs",
83 );
84 });
85
86 it("reads an already-parsed tool_input object", () => {
87 assert.equal(extractFilePath({ tool_input: { path: "docs/README.md" } }), "docs/README.md");
88 });
89
90 it("prefers a direct metadata path when present", () => {
91 assert.equal(extractFilePath({ path: "direct.ts", tool_input: '{"file_path":"other.ts"}' }), "direct.ts");
92 });
93
94 it("survives malformed or missing tool_input", () => {
95 assert.equal(extractFilePath(undefined), undefined);
96 assert.equal(extractFilePath({}), undefined);
97 assert.equal(extractFilePath({ tool_input: "not json {" }), undefined);
98 assert.equal(extractFilePath({ tool_input: "[]" }), undefined);
99 assert.equal(extractFilePath({ tool_input: '{"command":"ls"}' }), undefined);
100 assert.equal(extractFilePath({ tool_input: '{"file_path":42}' }), undefined);
101 });
102
103 it("refuses paths carrying control characters", () => {
104 assert.equal(extractFilePath({ path: "src/\u0000etc/passwd" }), undefined);
105 assert.equal(extractFilePath({ tool_input: '{"file_path":"a\\u001bb.ts"}' }), undefined);
106 });
107 });
108
109 describe("isInsideRoot", () => {
110 const root = path.resolve("/repo/project");
111
112 it("accepts a workspace-relative path", () => {
113 assert.equal(isInsideRoot(root, "src/chat.ts"), true);
114 assert.equal(isInsideRoot(root, path.join(root, "src", "chat.ts")), true);
115 });
116
117 it("refuses traversal and outside-workspace paths", () => {
118 assert.equal(isInsideRoot(root, "../secrets.env"), false);
119 assert.equal(isInsideRoot(root, "src/../../secrets.env"), false);
120 assert.equal(isInsideRoot(root, path.resolve("/etc/passwd")), false);
121 assert.equal(isInsideRoot(root, root), false);
122 assert.equal(isInsideRoot("", "src/chat.ts"), false);
123 });
124 });
125
126 describe("isInsideRoot dot-prefixed names", () => {
127 it("accepts a file whose name merely starts with two dots", () => {
128 const root = path.resolve("/repo/project");
129 assert.equal(isInsideRoot(root, "..notes.md"), true);
130 assert.equal(isInsideRoot(root, "..hidden/file.ts"), true);
131 assert.equal(isInsideRoot(root, "../project-sibling/file.ts"), false);
132 });
133 });
134
135 describe("isRealPathInsideRoot", () => {
136 let tmp: string;
137 let root: string;
138 let outside: string;
139
140 beforeEach(() => {
141 // realpath: on macOS the temp dir itself sits behind /var -> /private/var.
142 tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "cw-vscode-root-")));
143 root = path.join(tmp, "workspace");
144 outside = path.join(tmp, "outside");
145 fs.mkdirSync(path.join(root, "src"), { recursive: true });
146 fs.mkdirSync(outside);
147 fs.writeFileSync(path.join(root, "src", "inside.ts"), "x");
148 fs.writeFileSync(path.join(outside, "secret.env"), "x");
149 });
150
151 afterEach(() => {
152 fs.rmSync(tmp, { recursive: true, force: true });
153 });
154
155 it("accepts real files, not-yet-existing files and a root reached through a link", async () => {
156 assert.equal(await isRealPathInsideRoot(root, "src/inside.ts"), true);
157 assert.equal(await isRealPathInsideRoot(root, "src/not-created-yet.ts"), true);
158 assert.equal(await isRealPathInsideRoot(root, path.join(root, "src", "inside.ts")), true);
159 const viaLink = path.join(tmp, "workspace-link");
160 fs.symlinkSync(root, viaLink);
161 assert.equal(await isRealPathInsideRoot(viaLink, "src/inside.ts"), true);
162 });
163
164 it("refuses a link inside the workspace that points outside it", async () => {
165 fs.symlinkSync(path.join(outside, "secret.env"), path.join(root, "src", "linked.env"));
166 fs.symlinkSync(outside, path.join(root, "linked-dir"));
167 // Lexically inside, really outside: the lexical check alone is fooled.
168 assert.equal(isInsideRoot(root, "src/linked.env"), true);
169 assert.equal(await isRealPathInsideRoot(root, "src/linked.env"), false);
170 assert.equal(await isRealPathInsideRoot(root, "linked-dir/secret.env"), false);
171 assert.equal(await isRealPathInsideRoot(root, path.join(root, "linked-dir", "secret.env")), false);
172 // A file that does not exist yet behind a linked directory is outside too.
173 assert.equal(await isRealPathInsideRoot(root, "linked-dir/new.ts"), false);
174 });
175
176 it("refuses a dangling link and a link that stays inside the workspace is fine", async () => {
177 fs.symlinkSync(path.join(outside, "missing"), path.join(root, "dangling"));
178 assert.equal(await isRealPathInsideRoot(root, "dangling"), false);
179 fs.symlinkSync(path.join(root, "src", "inside.ts"), path.join(root, "alias.ts"));
180 assert.equal(await isRealPathInsideRoot(root, "alias.ts"), true);
181 });
182
183 it("still refuses traversal, the root itself and an unresolvable root", async () => {
184 assert.equal(await isRealPathInsideRoot(root, "../outside/secret.env"), false);
185 assert.equal(await isRealPathInsideRoot(root, root), false);
186 assert.equal(await isRealPathInsideRoot(path.join(tmp, "no-such-root"), "a.ts"), false);
187 assert.equal(await isRealPathInsideRoot("", "a.ts"), false);
188 });
189 });
190
191 describe("statusForEvent", () => {
192 it("maps terminal events", () => {
193 assert.equal(statusForEvent("item.completed"), "completed");
194 assert.equal(statusForEvent("item.failed"), "failed");
195 assert.equal(statusForEvent("item.interrupted"), "interrupted");
196 assert.equal(statusForEvent("item.canceled"), "interrupted");
197 assert.equal(statusForEvent("item.started"), "in_progress");
198 });
199 });
200
200 lines TYPESCRIPT