skyloom 1.17.0 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli/main.js +9 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +8 -5
- package/dist/core/agent.js.map +1 -1
- package/dist/core/bus.d.ts.map +1 -1
- package/dist/core/bus.js +5 -3
- package/dist/core/bus.js.map +1 -1
- package/dist/core/logger.d.ts +15 -0
- package/dist/core/logger.d.ts.map +1 -1
- package/dist/core/logger.js +72 -2
- package/dist/core/logger.js.map +1 -1
- package/dist/core/patch.d.ts +59 -0
- package/dist/core/patch.d.ts.map +1 -0
- package/dist/core/patch.js +220 -0
- package/dist/core/patch.js.map +1 -0
- package/dist/core/protocol.d.ts +11 -0
- package/dist/core/protocol.d.ts.map +1 -0
- package/dist/core/protocol.js +39 -0
- package/dist/core/protocol.js.map +1 -0
- package/dist/core/search.d.ts +41 -0
- package/dist/core/search.d.ts.map +1 -0
- package/dist/core/search.js +156 -0
- package/dist/core/search.js.map +1 -0
- package/dist/core/security.d.ts.map +1 -1
- package/dist/core/security.js +2 -1
- package/dist/core/security.js.map +1 -1
- package/dist/core/tool.d.ts +8 -0
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +7 -0
- package/dist/core/tool.js.map +1 -1
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +68 -4
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/websearch.d.ts +7 -1
- package/dist/tools/websearch.d.ts.map +1 -1
- package/dist/tools/websearch.js +19 -4
- package/dist/tools/websearch.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/main.ts +7 -0
- package/src/core/agent.ts +7 -5
- package/src/core/bus.ts +6 -3
- package/src/core/logger.ts +40 -2
- package/src/core/patch.ts +176 -0
- package/src/core/protocol.ts +36 -0
- package/src/core/search.ts +138 -0
- package/src/core/security.ts +2 -1
- package/src/core/tool.ts +15 -0
- package/src/tools/builtin.ts +63 -4
- package/src/tools/websearch.ts +22 -6
- package/tests/logger.test.ts +44 -0
- package/tests/patch.test.ts +128 -0
- package/tests/protocol.test.ts +27 -0
- package/tests/search.test.ts +87 -0
- package/tests/tool.test.ts +44 -0
- package/tests/websearch.test.ts +24 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { getLogger, setLogSink, setLogFile, silenceLogs } from "../src/core/logger";
|
|
6
|
+
|
|
7
|
+
afterEach(() => { silenceLogs(); }); // never leak to the terminal between tests
|
|
8
|
+
|
|
9
|
+
describe("logger · sink routing", () => {
|
|
10
|
+
it("routes log lines to a custom sink instead of stderr", () => {
|
|
11
|
+
const lines: string[] = [];
|
|
12
|
+
setLogSink((l) => lines.push(l));
|
|
13
|
+
getLogger("test-sink").warn("hello_world", { a: 1 });
|
|
14
|
+
expect(lines.length).toBe(1);
|
|
15
|
+
const entry = JSON.parse(lines[0]);
|
|
16
|
+
expect(entry.msg).toBe("hello_world");
|
|
17
|
+
expect(entry.level).toBe("warn");
|
|
18
|
+
expect(entry.a).toBe(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("silenceLogs discards output (keeps a TUI clean)", () => {
|
|
22
|
+
let count = 0;
|
|
23
|
+
setLogSink(() => { count++; });
|
|
24
|
+
silenceLogs();
|
|
25
|
+
getLogger("test-silence").error("should_be_dropped");
|
|
26
|
+
expect(count).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("setLogFile appends log lines to a file, not the terminal", () => {
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-log-"));
|
|
31
|
+
const file = path.join(dir, "sky.log");
|
|
32
|
+
try {
|
|
33
|
+
const resolved = setLogFile(file);
|
|
34
|
+
expect(resolved).toBe(file);
|
|
35
|
+
getLogger("test-file").warn("to_file", { n: 7 });
|
|
36
|
+
const content = fs.readFileSync(file, "utf8");
|
|
37
|
+
expect(content).toContain("to_file");
|
|
38
|
+
expect(content).toContain('"n":7');
|
|
39
|
+
} finally {
|
|
40
|
+
silenceLogs();
|
|
41
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { parsePatch, applyPatch } from "../src/core/patch";
|
|
6
|
+
|
|
7
|
+
describe("patch · parsePatch", () => {
|
|
8
|
+
it("parses update / add / delete operations", () => {
|
|
9
|
+
const text = [
|
|
10
|
+
"*** Begin Patch",
|
|
11
|
+
"*** Update File: a.ts",
|
|
12
|
+
"<<<<<<< SEARCH",
|
|
13
|
+
"const x = 1;",
|
|
14
|
+
"=======",
|
|
15
|
+
"const x = 2;",
|
|
16
|
+
">>>>>>> REPLACE",
|
|
17
|
+
"*** Add File: b.ts",
|
|
18
|
+
"export const y = 3;",
|
|
19
|
+
"*** Delete File: c.ts",
|
|
20
|
+
"*** End Patch",
|
|
21
|
+
].join("\n");
|
|
22
|
+
const r = parsePatch(text) as any;
|
|
23
|
+
expect(r.error).toBeUndefined();
|
|
24
|
+
expect(r.ops).toHaveLength(3);
|
|
25
|
+
expect(r.ops[0]).toMatchObject({ op: "update", path: "a.ts" });
|
|
26
|
+
expect(r.ops[0].blocks[0]).toEqual({ search: "const x = 1;", replace: "const x = 2;" });
|
|
27
|
+
expect(r.ops[1]).toMatchObject({ op: "add", path: "b.ts", content: "export const y = 3;\n" });
|
|
28
|
+
expect(r.ops[2]).toMatchObject({ op: "delete", path: "c.ts" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("errors on an unterminated SEARCH", () => {
|
|
32
|
+
const text = "*** Update File: a.ts\n<<<<<<< SEARCH\nfoo\n";
|
|
33
|
+
expect((parsePatch(text) as any).error).toContain("Unterminated SEARCH");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("errors on stray content outside a file section", () => {
|
|
37
|
+
expect((parsePatch("hello world") as any).error).toContain("Unexpected line");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("patch · applyPatch (atomic, multi-file)", () => {
|
|
42
|
+
let dir: string;
|
|
43
|
+
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-patch-")); });
|
|
44
|
+
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
45
|
+
|
|
46
|
+
function write(name: string, content: string) {
|
|
47
|
+
const p = path.join(dir, name);
|
|
48
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
49
|
+
fs.writeFileSync(p, content);
|
|
50
|
+
}
|
|
51
|
+
const read = (name: string) => fs.readFileSync(path.join(dir, name), "utf8");
|
|
52
|
+
|
|
53
|
+
it("applies update + add + delete in one shot", () => {
|
|
54
|
+
write("a.ts", "const x = 1;\nkeep me\n");
|
|
55
|
+
write("c.ts", "delete me\n");
|
|
56
|
+
const patch = [
|
|
57
|
+
"*** Update File: a.ts",
|
|
58
|
+
"<<<<<<< SEARCH",
|
|
59
|
+
"const x = 1;",
|
|
60
|
+
"=======",
|
|
61
|
+
"const x = 42;",
|
|
62
|
+
">>>>>>> REPLACE",
|
|
63
|
+
"*** Add File: sub/b.ts",
|
|
64
|
+
"export const y = 3;",
|
|
65
|
+
"*** Delete File: c.ts",
|
|
66
|
+
].join("\n");
|
|
67
|
+
const out = applyPatch(patch, { cwd: dir });
|
|
68
|
+
expect(out).toContain("Applied patch");
|
|
69
|
+
expect(read("a.ts")).toBe("const x = 42;\nkeep me\n");
|
|
70
|
+
expect(read("sub/b.ts")).toBe("export const y = 3;\n");
|
|
71
|
+
expect(fs.existsSync(path.join(dir, "c.ts"))).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("applies multiple blocks to one file", () => {
|
|
75
|
+
write("m.ts", "alpha\nbeta\ngamma\n");
|
|
76
|
+
const patch = [
|
|
77
|
+
"*** Update File: m.ts",
|
|
78
|
+
"<<<<<<< SEARCH", "alpha", "=======", "ALPHA", ">>>>>>> REPLACE",
|
|
79
|
+
"<<<<<<< SEARCH", "gamma", "=======", "GAMMA", ">>>>>>> REPLACE",
|
|
80
|
+
].join("\n");
|
|
81
|
+
applyPatch(patch, { cwd: dir });
|
|
82
|
+
expect(read("m.ts")).toBe("ALPHA\nbeta\nGAMMA\n");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("is atomic: a failing block leaves ALL files untouched", () => {
|
|
86
|
+
write("a.ts", "good\n");
|
|
87
|
+
write("b.ts", "target\n");
|
|
88
|
+
const patch = [
|
|
89
|
+
"*** Update File: a.ts",
|
|
90
|
+
"<<<<<<< SEARCH", "good", "=======", "changed", ">>>>>>> REPLACE",
|
|
91
|
+
"*** Update File: b.ts",
|
|
92
|
+
"<<<<<<< SEARCH", "NOT THERE", "=======", "x", ">>>>>>> REPLACE",
|
|
93
|
+
].join("\n");
|
|
94
|
+
const out = applyPatch(patch, { cwd: dir });
|
|
95
|
+
expect(out).toContain("SEARCH block not found");
|
|
96
|
+
expect(read("a.ts")).toBe("good\n"); // first file NOT written
|
|
97
|
+
expect(read("b.ts")).toBe("target\n");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("rejects an ambiguous SEARCH block", () => {
|
|
101
|
+
write("d.ts", "dup\ndup\n");
|
|
102
|
+
const patch = ["*** Update File: d.ts", "<<<<<<< SEARCH", "dup", "=======", "x", ">>>>>>> REPLACE"].join("\n");
|
|
103
|
+
expect(applyPatch(patch, { cwd: dir })).toContain("ambiguous");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("refuses to Add over an existing file", () => {
|
|
107
|
+
write("exists.ts", "already\n");
|
|
108
|
+
const patch = "*** Add File: exists.ts\nnew content\n";
|
|
109
|
+
const out = applyPatch(patch, { cwd: dir });
|
|
110
|
+
expect(out).toContain("already exists");
|
|
111
|
+
expect(read("exists.ts")).toBe("already\n");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("treats $-patterns in replacement literally", () => {
|
|
115
|
+
write("e.ts", "VAL\n");
|
|
116
|
+
const patch = ["*** Update File: e.ts", "<<<<<<< SEARCH", "VAL", "=======", "$1$&x", ">>>>>>> REPLACE"].join("\n");
|
|
117
|
+
applyPatch(patch, { cwd: dir });
|
|
118
|
+
expect(read("e.ts")).toBe("$1$&x\n");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("honors the fence check by aborting", () => {
|
|
122
|
+
write("a.ts", "good\n");
|
|
123
|
+
const patch = ["*** Update File: a.ts", "<<<<<<< SEARCH", "good", "=======", "bad", ">>>>>>> REPLACE"].join("\n");
|
|
124
|
+
const out = applyPatch(patch, { cwd: dir, fenceCheck: () => "路径越界" });
|
|
125
|
+
expect(out).toContain("路径越界");
|
|
126
|
+
expect(read("a.ts")).toBe("good\n");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { engineeringProtocol } from "../src/core/protocol";
|
|
3
|
+
|
|
4
|
+
describe("protocol · engineeringProtocol", () => {
|
|
5
|
+
it("zh protocol covers the senior-engineer discipline and names real tools", () => {
|
|
6
|
+
const p = engineeringProtocol("zh");
|
|
7
|
+
expect(p).toContain("工程标准");
|
|
8
|
+
expect(p).toContain("根因"); // root-cause, not symptom
|
|
9
|
+
expect(p).toContain("最小"); // minimal diffs
|
|
10
|
+
expect(p).toContain("先理解"); // understand before changing
|
|
11
|
+
expect(p).toContain("get_diagnostics"); // verify loop wired to real tool
|
|
12
|
+
expect(p).toContain("code_search");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("en protocol mirrors the zh one", () => {
|
|
16
|
+
const p = engineeringProtocol("en");
|
|
17
|
+
expect(p).toContain("Engineering Standard");
|
|
18
|
+
expect(p).toContain("Root cause");
|
|
19
|
+
expect(p).toContain("Minimal");
|
|
20
|
+
expect(p).toContain("get_diagnostics");
|
|
21
|
+
expect(p).toContain("code_search");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("defaults to zh", () => {
|
|
25
|
+
expect(engineeringProtocol()).toBe(engineeringProtocol("zh"));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { searchCode, formatSearchResult } from "../src/core/search";
|
|
6
|
+
|
|
7
|
+
describe("search · searchCode (pure JS)", () => {
|
|
8
|
+
let root: string;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "sky-search-"));
|
|
11
|
+
fs.writeFileSync(path.join(root, "a.ts"), "const Foo = 1;\nexport function useFoo() { return Foo; }\n");
|
|
12
|
+
fs.writeFileSync(path.join(root, "b.js"), "// foo lower\nconst x = 2;\n");
|
|
13
|
+
fs.mkdirSync(path.join(root, "sub"));
|
|
14
|
+
fs.writeFileSync(path.join(root, "sub", "c.ts"), "import { useFoo } from '../a';\n");
|
|
15
|
+
// should be ignored by default
|
|
16
|
+
fs.mkdirSync(path.join(root, "node_modules", "dep"), { recursive: true });
|
|
17
|
+
fs.writeFileSync(path.join(root, "node_modules", "dep", "x.ts"), "const Foo = 999;\n");
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => { try { fs.rmSync(root, { recursive: true, force: true }); } catch {} });
|
|
20
|
+
|
|
21
|
+
it("finds matches with file:line", () => {
|
|
22
|
+
const res = searchCode({ pattern: "useFoo", root });
|
|
23
|
+
const files = res.matches.map((m) => m.file).sort();
|
|
24
|
+
expect(files).toContain("a.ts");
|
|
25
|
+
expect(files).toContain("sub/c.ts");
|
|
26
|
+
const a = res.matches.find((m) => m.file === "a.ts")!;
|
|
27
|
+
expect(a.line).toBe(2);
|
|
28
|
+
expect(a.text).toContain("useFoo");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("skips node_modules by default", () => {
|
|
32
|
+
const res = searchCode({ pattern: "Foo", root });
|
|
33
|
+
expect(res.matches.some((m) => m.file.includes("node_modules"))).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("restricts by glob", () => {
|
|
37
|
+
const res = searchCode({ pattern: "foo", root, glob: "**/*.ts", ignoreCase: true });
|
|
38
|
+
expect(res.matches.some((m) => m.file === "b.js")).toBe(false);
|
|
39
|
+
expect(res.matches.some((m) => m.file === "a.ts")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("honors ignoreCase", () => {
|
|
43
|
+
// b.js contains lowercase "foo"; capital "Foo" only matches case-insensitively.
|
|
44
|
+
expect(searchCode({ pattern: "Foo", root, glob: "b.js" }).matches.length).toBe(0);
|
|
45
|
+
expect(searchCode({ pattern: "Foo", root, glob: "b.js", ignoreCase: true }).matches.length).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns context lines", () => {
|
|
49
|
+
const res = searchCode({ pattern: "useFoo", root, glob: "a.ts", context: 1 });
|
|
50
|
+
const m = res.matches[0];
|
|
51
|
+
expect(m.before).toEqual(["const Foo = 1;"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("treats pattern as literal when regex=false", () => {
|
|
55
|
+
fs.writeFileSync(path.join(root, "d.ts"), "a.b.c\n");
|
|
56
|
+
const asRegex = searchCode({ pattern: "a.b", root, glob: "d.ts" }); // '.' = any char
|
|
57
|
+
const literal = searchCode({ pattern: "a.b", root, glob: "d.ts", regex: false });
|
|
58
|
+
expect(asRegex.matches.length).toBe(1);
|
|
59
|
+
expect(literal.matches.length).toBe(1);
|
|
60
|
+
const noLit = searchCode({ pattern: "axb", root, glob: "d.ts", regex: false });
|
|
61
|
+
expect(noLit.matches.length).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("caps results and flags truncation", () => {
|
|
65
|
+
fs.writeFileSync(path.join(root, "many.ts"), Array.from({ length: 50 }, () => "hit").join("\n"));
|
|
66
|
+
const res = searchCode({ pattern: "hit", root, glob: "many.ts", maxResults: 10 });
|
|
67
|
+
expect(res.matches.length).toBe(10);
|
|
68
|
+
expect(res.truncated).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("reports an invalid regex instead of throwing", () => {
|
|
72
|
+
const res = searchCode({ pattern: "(", root });
|
|
73
|
+
expect(res.error).toContain("invalid regex");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("search · formatSearchResult", () => {
|
|
78
|
+
it("renders file:line and a no-match message", () => {
|
|
79
|
+
expect(formatSearchResult({ matches: [], filesScanned: 3, truncated: false })).toBe("No matches found.");
|
|
80
|
+
const s = formatSearchResult({
|
|
81
|
+
matches: [{ file: "a.ts", line: 2, text: " return Foo;" }],
|
|
82
|
+
filesScanned: 1, truncated: false,
|
|
83
|
+
});
|
|
84
|
+
expect(s).toContain("a.ts:2:");
|
|
85
|
+
expect(s).toContain("return Foo");
|
|
86
|
+
});
|
|
87
|
+
});
|
package/tests/tool.test.ts
CHANGED
|
@@ -16,6 +16,7 @@ function makeTool(overrides: Partial<ToolDefinition> & { name: string }): ToolDe
|
|
|
16
16
|
maxRetries: overrides.maxRetries,
|
|
17
17
|
retryDelay: overrides.retryDelay,
|
|
18
18
|
timeout: overrides.timeout,
|
|
19
|
+
validateOutput: overrides.validateOutput,
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -184,6 +185,49 @@ describe('ToolRegistry · input validation + coercion', () => {
|
|
|
184
185
|
});
|
|
185
186
|
});
|
|
186
187
|
|
|
188
|
+
describe('ToolRegistry · output validation', () => {
|
|
189
|
+
let registry: ToolRegistry;
|
|
190
|
+
beforeEach(() => { registry = new ToolRegistry(); });
|
|
191
|
+
|
|
192
|
+
it('fails the call when validateOutput rejects the result', async () => {
|
|
193
|
+
registry.register(makeTool({
|
|
194
|
+
name: 'guarded',
|
|
195
|
+
maxRetries: 0,
|
|
196
|
+
handler: async () => 'garbage',
|
|
197
|
+
validateOutput: (r) => (r === 'garbage' ? 'looks like garbage' : null),
|
|
198
|
+
}));
|
|
199
|
+
const res = await registry.execute('guarded', {});
|
|
200
|
+
expect(res.success).toBe(false);
|
|
201
|
+
expect(res.error).toContain('invalid tool output');
|
|
202
|
+
expect(res.error).toContain('looks like garbage');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('passes when validateOutput accepts the result', async () => {
|
|
206
|
+
registry.register(makeTool({
|
|
207
|
+
name: 'ok',
|
|
208
|
+
handler: async () => 'fine',
|
|
209
|
+
validateOutput: () => null,
|
|
210
|
+
}));
|
|
211
|
+
const res = await registry.execute('ok', {});
|
|
212
|
+
expect(res.success).toBe(true);
|
|
213
|
+
expect(res.result).toBe('fine');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('retries a rejected output through the normal retry path', async () => {
|
|
217
|
+
let n = 0;
|
|
218
|
+
registry.register(makeTool({
|
|
219
|
+
name: 'retryout',
|
|
220
|
+
maxRetries: 1,
|
|
221
|
+
retryDelay: 0,
|
|
222
|
+
handler: async () => `v${++n}`,
|
|
223
|
+
validateOutput: (r) => (r === 'v1' ? 'first is bad' : null),
|
|
224
|
+
}));
|
|
225
|
+
const res = await registry.execute('retryout', {});
|
|
226
|
+
expect(res.success).toBe(true);
|
|
227
|
+
expect(res.result).toBe('v2');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
187
231
|
describe('stableStringify', () => {
|
|
188
232
|
it('produces an order-independent key for objects', async () => {
|
|
189
233
|
const { stableStringify } = await import('../src/core/tool');
|
package/tests/websearch.test.ts
CHANGED
|
@@ -140,6 +140,30 @@ describe("websearch · waterfall", () => {
|
|
|
140
140
|
expect(formatSearchResults(res)).toContain("No search results");
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
it("flags that all providers errored/timed out for a clear message", async () => {
|
|
144
|
+
const http = stubHttp([
|
|
145
|
+
{ match: "s.jina.ai", throws: "timeout" },
|
|
146
|
+
{ match: "duckduckgo", throws: "timeout" }, { match: "bing", throws: "timeout" },
|
|
147
|
+
{ match: "baidu", throws: "timeout" }, { match: "sogou", throws: "timeout" },
|
|
148
|
+
]);
|
|
149
|
+
const res = await webSearch("q", { env: {}, http });
|
|
150
|
+
expect(res.errors).toBeGreaterThan(0);
|
|
151
|
+
expect(formatSearchResults(res)).toMatch(/errored or timed out/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("stops the waterfall once the time budget is exceeded", async () => {
|
|
155
|
+
// jina resolves (empty) but slowly; the budget then cuts off the scrapers.
|
|
156
|
+
const http = {
|
|
157
|
+
calls: [] as string[],
|
|
158
|
+
async getJson(url: string) { this.calls.push(url); await new Promise((r) => setTimeout(r, 30)); return { data: [] }; },
|
|
159
|
+
async getText(url: string) { this.calls.push(url); return ""; },
|
|
160
|
+
async postJson(url: string) { this.calls.push(url); return {}; },
|
|
161
|
+
};
|
|
162
|
+
const res = await webSearch("q", { env: {}, http: http as any, budgetMs: 5 });
|
|
163
|
+
expect(res.provider).toBe("none");
|
|
164
|
+
expect(res.tried).toEqual(["jina"]); // scrapers skipped — out of budget
|
|
165
|
+
});
|
|
166
|
+
|
|
143
167
|
it("rejects an empty query", async () => {
|
|
144
168
|
await expect(webSearch(" ", {})).rejects.toThrow(/query/);
|
|
145
169
|
});
|