@firedrill-tools/unstructured 0.1.4
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/LICENSE +201 -0
- package/README.md +275 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/conformance.suite.json +21 -0
- package/firedrill/overloaded.scenario.json +11 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/run-response-lost.scenario.json +11 -0
- package/firedrill/small-responses.scenario.json +21 -0
- package/firedrill/tight-limits.scenario.json +21 -0
- package/firedrill/tools/unstructured/behavior.mjs +69 -0
- package/firedrill/tools/unstructured/lib/connectors.mjs +68 -0
- package/firedrill/tools/unstructured/lib/errors.mjs +58 -0
- package/firedrill/tools/unstructured/lib/gzip.mjs +38 -0
- package/firedrill/tools/unstructured/lib/identity.mjs +19 -0
- package/firedrill/tools/unstructured/lib/ids.mjs +36 -0
- package/firedrill/tools/unstructured/lib/jobs-derive.mjs +92 -0
- package/firedrill/tools/unstructured/lib/multipart.mjs +171 -0
- package/firedrill/tools/unstructured/lib/pages.mjs +40 -0
- package/firedrill/tools/unstructured/lib/partition/chunk.mjs +243 -0
- package/firedrill/tools/unstructured/lib/partition/csv.mjs +85 -0
- package/firedrill/tools/unstructured/lib/partition/csvout.mjs +25 -0
- package/firedrill/tools/unstructured/lib/partition/elements.mjs +171 -0
- package/firedrill/tools/unstructured/lib/partition/email.mjs +269 -0
- package/firedrill/tools/unstructured/lib/partition/html-tokens.mjs +134 -0
- package/firedrill/tools/unstructured/lib/partition/html-util.mjs +99 -0
- package/firedrill/tools/unstructured/lib/partition/html.mjs +211 -0
- package/firedrill/tools/unstructured/lib/partition/index.mjs +122 -0
- package/firedrill/tools/unstructured/lib/partition/markdown.mjs +220 -0
- package/firedrill/tools/unstructured/lib/partition/other.mjs +118 -0
- package/firedrill/tools/unstructured/lib/partition/text.mjs +53 -0
- package/firedrill/tools/unstructured/lib/sha256.mjs +161 -0
- package/firedrill/tools/unstructured/lib/store.mjs +33 -0
- package/firedrill/tools/unstructured/lib/util.mjs +149 -0
- package/firedrill/tools/unstructured/lib/validate.mjs +115 -0
- package/firedrill/tools/unstructured/lib/wire-multipart.mjs +78 -0
- package/firedrill/tools/unstructured/lib/wire.mjs +154 -0
- package/firedrill/tools/unstructured/ops/connectors.mjs +129 -0
- package/firedrill/tools/unstructured/ops/jobs.mjs +83 -0
- package/firedrill/tools/unstructured/ops/nodes.mjs +107 -0
- package/firedrill/tools/unstructured/ops/partition.mjs +112 -0
- package/firedrill/tools/unstructured/ops/workflows.mjs +180 -0
- package/firedrill/tools/unstructured/unstructured.tool.json +4892 -0
- package/firedrill/unstructured-archivist.drill.json +68 -0
- package/firedrill/unstructured-chunking.drill.json +67 -0
- package/firedrill/unstructured-connectors.drill.json +121 -0
- package/firedrill/unstructured-denied.drill.json +58 -0
- package/firedrill/unstructured-fresh-actor.drill.json +68 -0
- package/firedrill/unstructured-overloaded.drill.json +51 -0
- package/firedrill/unstructured-partition-errors.drill.json +66 -0
- package/firedrill/unstructured-partition.drill.json +95 -0
- package/firedrill/unstructured-rate-limited.drill.json +66 -0
- package/firedrill/unstructured-revoked-key.drill.json +773 -0
- package/firedrill/unstructured-run-lost.drill.json +51 -0
- package/firedrill/unstructured-small-responses.drill.json +173 -0
- package/firedrill/unstructured-tight-limits.drill.json +203 -0
- package/firedrill/unstructured-workflows-jobs.drill.json +167 -0
- package/firedrill/world.json +1556 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +1114 -0
- package/test/conformance.mjs +37 -0
- package/test/flows/access.mjs +54 -0
- package/test/flows/chunking.mjs +95 -0
- package/test/flows/connectors.mjs +76 -0
- package/test/flows/errors.mjs +115 -0
- package/test/flows/faults.mjs +73 -0
- package/test/flows/partition.mjs +225 -0
- package/test/flows/workflows.mjs +123 -0
- package/test/hostile-gen.mjs +0 -0
- package/test/hostile.mjs +155 -0
- package/test/lib.mjs +113 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Partition flow: element rules per document type, ids, languages, pages, multi-file requests, CSV output.
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { HEX32, ID, UUID, fixtures, op, partition, types } from "../lib.mjs";
|
|
4
|
+
|
|
5
|
+
export async function partitionFlow() {
|
|
6
|
+
const fx = fixtures();
|
|
7
|
+
const file = (name) => ({ filename: name, content: fx.get(name).content });
|
|
8
|
+
|
|
9
|
+
// 1. Markdown: heading depths, parent chain, lists, table, code, emphasis, links, ids, metadata.
|
|
10
|
+
const md = (await partition([file("handbook.md")])).json;
|
|
11
|
+
assert.ok(Array.isArray(md) && md.length > 15, `handbook elements ${md.length}`);
|
|
12
|
+
const titles = md.filter((e) => e.type === "Title");
|
|
13
|
+
assert.deepEqual(titles.map((e) => e.metadata.category_depth), [0, 1, 2, 1, 2, 1, 1]);
|
|
14
|
+
assert.equal(titles[0].text, "Northgate Research Employee Handbook");
|
|
15
|
+
assert.equal(titles[1].metadata.parent_id, titles[0].element_id, "h2 parent is h1");
|
|
16
|
+
assert.equal(titles[2].metadata.parent_id, titles[1].element_id, "h3 parent is h2");
|
|
17
|
+
const remote = md[md.indexOf(titles[2]) + 1];
|
|
18
|
+
assert.equal(remote.type, "NarrativeText");
|
|
19
|
+
assert.equal(remote.metadata.parent_id, titles[2].element_id);
|
|
20
|
+
assert.deepEqual(remote.metadata.link_texts, ["people team"]);
|
|
21
|
+
assert.deepEqual(remote.metadata.link_urls, ["https://people.northgate-research.test/requests"]);
|
|
22
|
+
assert.deepEqual(remote.metadata.emphasized_text_contents, ["three"]);
|
|
23
|
+
assert.deepEqual(remote.metadata.emphasized_text_tags, ["i"]);
|
|
24
|
+
assert.equal(md.filter((e) => e.type === "ListItem").length, 8);
|
|
25
|
+
assert.deepEqual(md.filter((e) => e.type === "ListItem").slice(0, 5).map((e) => e.metadata.category_depth), [0, 0, 1, 1, 0]);
|
|
26
|
+
const table = md.find((e) => e.type === "Table");
|
|
27
|
+
assert.ok(table.metadata.text_as_html.startsWith("<table><tr><td>Item</td><td>Standard</td>"), table.metadata.text_as_html);
|
|
28
|
+
assert.equal(table.text.split("\n").length, 4);
|
|
29
|
+
assert.equal(md.find((e) => e.type === "CodeSnippet").text, "ngctl ticket create --tag equipment --cost-centre 4410");
|
|
30
|
+
for (const element of md) {
|
|
31
|
+
assert.match(element.element_id, HEX32);
|
|
32
|
+
assert.deepEqual(element.metadata.languages, ["eng"]);
|
|
33
|
+
assert.equal(element.metadata.filename, "handbook.md");
|
|
34
|
+
assert.equal(element.metadata.filetype, "text/markdown");
|
|
35
|
+
assert.equal(element.metadata.page_number, 1);
|
|
36
|
+
}
|
|
37
|
+
assert.equal(new Set(md.map((e) => e.element_id)).size, md.length, "ids are unique");
|
|
38
|
+
|
|
39
|
+
// 2. unique_element_ids -> UUIDs; 3. repeated languages[] parts.
|
|
40
|
+
const uuids = (await partition([file("handbook.md")], { unique_element_ids: "true" })).json;
|
|
41
|
+
for (const element of uuids) assert.match(element.element_id, UUID);
|
|
42
|
+
const deu = (await partition([file("2026-09-08-site-visit.txt")], { languages: ["deu", "fra"] })).json;
|
|
43
|
+
assert.equal(deu.length, 4);
|
|
44
|
+
for (const element of deu) assert.deepEqual(element.metadata.languages, ["deu", "fra"]);
|
|
45
|
+
|
|
46
|
+
// 4. Plain text with a form feed: page breaks, Address, EmailAddress, ListItem; 5. starting_page_number.
|
|
47
|
+
const refund = (await partition([file("refund-policy.txt")], { include_page_breaks: "true" })).json;
|
|
48
|
+
const breakAt = refund.findIndex((e) => e.type === "PageBreak");
|
|
49
|
+
assert.ok(breakAt > 0, "PageBreak present");
|
|
50
|
+
assert.ok(refund.slice(0, breakAt).every((e) => e.metadata.page_number === 1));
|
|
51
|
+
assert.ok(refund.slice(breakAt + 1).every((e) => e.metadata.page_number === 2) && refund.length > breakAt + 2);
|
|
52
|
+
assert.ok(types(refund).includes("Address") && types(refund).includes("EmailAddress"));
|
|
53
|
+
assert.equal(refund.find((e) => e.type === "EmailAddress").text, "billing@northgate-research.test");
|
|
54
|
+
assert.equal(refund.filter((e) => e.type === "ListItem").length, 3);
|
|
55
|
+
assert.equal(refund[0].type, "Title");
|
|
56
|
+
const paged = (await partition([file("refund-policy.txt")], { starting_page_number: "7" })).json;
|
|
57
|
+
assert.equal(paged[0].metadata.page_number, 7);
|
|
58
|
+
assert.equal(paged[paged.length - 1].metadata.page_number, 8);
|
|
59
|
+
assert.ok(!types(paged).includes("PageBreak"));
|
|
60
|
+
|
|
61
|
+
// 6. CSV: one Table with header + 12 rows, quoted field with a comma and a newline parsed.
|
|
62
|
+
const csv = (await partition([file("vendors.csv")])).json;
|
|
63
|
+
assert.equal(csv.length, 1);
|
|
64
|
+
assert.equal(csv[0].type, "Table");
|
|
65
|
+
assert.equal(csv[0].text.split("\n").length, 13);
|
|
66
|
+
assert.equal((csv[0].metadata.text_as_html.match(/<tr>/g) ?? []).length, 13);
|
|
67
|
+
assert.ok(csv[0].metadata.text_as_html.includes("<td>Preferred, quarterly review</td>"));
|
|
68
|
+
|
|
69
|
+
// 7. HTML: script dropped, Header/Footer, headings, list, table, code, links, emphasis.
|
|
70
|
+
const html = (await partition([file("release-notes.html")])).json;
|
|
71
|
+
assert.ok(html.every((e) => !e.text.includes("never shown") && !e.text.includes("<script")));
|
|
72
|
+
assert.equal(html[0].type, "Header");
|
|
73
|
+
assert.equal(html[html.length - 1].type, "Footer");
|
|
74
|
+
assert.equal(html[html.length - 1].text, "© 2026 Northgate Research. Confidential.");
|
|
75
|
+
assert.deepEqual(html.filter((e) => e.type === "Title").map((e) => e.metadata.category_depth), [0, 1, 1, 1]);
|
|
76
|
+
assert.equal(html.filter((e) => e.type === "ListItem").length, 3);
|
|
77
|
+
const layers = html.find((e) => Array.isArray(e.metadata.link_urls) && e.metadata.link_urls.includes("https://fieldkit.northgate-research.test/docs/layers"));
|
|
78
|
+
assert.ok(layers && layers.metadata.link_texts.includes("layer catalogue"));
|
|
79
|
+
assert.ok(html.find((e) => e.type === "NarrativeText").metadata.emphasized_text_contents.includes("38 percent"));
|
|
80
|
+
assert.equal(html.find((e) => e.type === "Table").text.split("\n").length, 4);
|
|
81
|
+
assert.equal(html.find((e) => e.type === "CodeSnippet").text, "ngctl fieldkit migrate --to 4.2\nngctl fieldkit verify");
|
|
82
|
+
|
|
83
|
+
// 8. RFC 822: subject as Title, addresses in metadata, folded header, list in body.
|
|
84
|
+
const eml = (await partition([file("inbound.eml")])).json;
|
|
85
|
+
assert.equal(eml[0].type, "Title");
|
|
86
|
+
assert.equal(eml[0].text, "Site visit follow-up: Harbour Street");
|
|
87
|
+
for (const element of eml) {
|
|
88
|
+
assert.deepEqual(element.metadata.sent_from, ["Priya Raman <priya.raman@northgate-research.test>"]);
|
|
89
|
+
assert.equal(element.metadata.sent_to.length, 2);
|
|
90
|
+
assert.deepEqual(element.metadata.cc_recipient, ["archive@northgate-research.test"]);
|
|
91
|
+
assert.equal(element.metadata.email_message_id, "<20260908-1715.4a1@mail.northgate-research.test>");
|
|
92
|
+
assert.equal(element.metadata.filetype, "message/rfc822");
|
|
93
|
+
}
|
|
94
|
+
assert.equal(eml.filter((e) => e.type === "ListItem").length, 2);
|
|
95
|
+
|
|
96
|
+
// 9. Pre-partitioned JSON passthrough; 10. empty file.
|
|
97
|
+
const json = (await partition([file("elements.json")])).json;
|
|
98
|
+
assert.deepEqual(types(json), ["Title", "NarrativeText", "ListItem"]);
|
|
99
|
+
assert.equal(json[0].element_id, "4f3c2a1b9d8e7f6a5b4c3d2e1f0a9b8c");
|
|
100
|
+
assert.match(json[2].element_id, HEX32);
|
|
101
|
+
assert.equal(json[2].metadata.page_number, 2);
|
|
102
|
+
assert.deepEqual((await partition([file("empty.txt")])).json, []);
|
|
103
|
+
|
|
104
|
+
// 11. Two files in one request are concatenated in part order.
|
|
105
|
+
const both = (await partition([file("vendors.csv"), file("handbook.md")])).json;
|
|
106
|
+
assert.equal(both.length, csv.length + md.length);
|
|
107
|
+
assert.equal(both[0].metadata.filename, "vendors.csv");
|
|
108
|
+
assert.equal(both[1].metadata.filename, "handbook.md");
|
|
109
|
+
|
|
110
|
+
// 12. CSV output: fixed header + one row per element, quoted cells.
|
|
111
|
+
const out = await partition([file("2026-09-08-site-visit.txt")], { output_format: "text/csv" });
|
|
112
|
+
assert.ok((out.headers.get("content-type") ?? "").startsWith("text/csv"), out.headers.get("content-type"));
|
|
113
|
+
assert.equal(out.headers.get("unstructured-api-version"), "0.1.0-firedrill");
|
|
114
|
+
const lines = out.text.split("\r\n").filter((line) => line.length > 0);
|
|
115
|
+
assert.equal(lines[0], "type,element_id,text,filename,filetype,languages,page_number,parent_id,category_depth,text_as_html,last_modified,link_urls,link_texts,emphasized_text_contents,emphasized_text_tags,is_continuation,orig_elements");
|
|
116
|
+
assert.equal(lines.length, 1 + deu.length);
|
|
117
|
+
assert.ok(lines[1].startsWith("Title,"));
|
|
118
|
+
|
|
119
|
+
// 13. Canonical operation call returns the same array as the wire route.
|
|
120
|
+
const canonical = await op("general.partition", { files: [{ filename: "handbook.md", content: fx.get("handbook.md").content }] });
|
|
121
|
+
assert.deepEqual(canonical, md);
|
|
122
|
+
|
|
123
|
+
// 14. Markdown edge shapes that must partition in linear time: a pipe row before a 200,000-space line, an unclosed
|
|
124
|
+
// image, a heading padded with spaces, and a pre-formatted trailing-space line. Each answers in well under a second.
|
|
125
|
+
const hostile = [
|
|
126
|
+
["a|b\n" + " ".repeat(200000) + "x", ["UncategorizedText"]],
|
|
127
|
+
[", ["UncategorizedText", "UncategorizedText", "UncategorizedText"]],
|
|
128
|
+
["# " + " ".repeat(200000) + "x", ["Title"]],
|
|
129
|
+
["1 " + " ".repeat(200000) + "x", ["UncategorizedText", "UncategorizedText", "UncategorizedText"]],
|
|
130
|
+
];
|
|
131
|
+
for (const [content, expected] of hostile) {
|
|
132
|
+
const started = Date.now();
|
|
133
|
+
const result = (await partition([{ filename: "edge.md", content }])).json;
|
|
134
|
+
assert.ok(Date.now() - started < 3000, `hostile markdown took ${Date.now() - started}ms`);
|
|
135
|
+
assert.deepEqual(types(result), expected, JSON.stringify(types(result)));
|
|
136
|
+
}
|
|
137
|
+
const gfm = (await partition([{ filename: "t.md", content: "| a | b |\n| --- | :-: |\n| 1 | 2 |\n\n\n" }])).json;
|
|
138
|
+
assert.deepEqual(types(gfm), ["Table", "Image"]);
|
|
139
|
+
assert.equal(gfm[0].metadata.text_as_html, "<table><tr><td>a</td><td>b</td></tr><tr><td>1</td><td>2</td></tr></table>");
|
|
140
|
+
assert.equal(gfm[1].text, "alt");
|
|
141
|
+
assert.equal(gfm[1].metadata.image_url, "img.png");
|
|
142
|
+
|
|
143
|
+
// 15. HTML shapes that must partition in linear time and keep everything they carry: a 300 KB attribute, a table at
|
|
144
|
+
// the 10,000-row bound (every row kept), 200 nested divs, 50,000 links in one block (no cap on link metadata), 100,000
|
|
145
|
+
// unmatched close tags, and a 200 KB Markdown run of "[" (a forward search that is never rescanned).
|
|
146
|
+
const hostileHtml = [
|
|
147
|
+
[`<p class="${"a".repeat(300000)}">x</p>`, "h.html", ["UncategorizedText"]],
|
|
148
|
+
["<table>" + "<tr><td>a</td><td>b</td></tr>".repeat(10000) + "</table>", "h.html", ["Table"]],
|
|
149
|
+
["<div>a".repeat(200), "h.html", Array(200).fill("UncategorizedText")],
|
|
150
|
+
["<a href=\"u\">t</a> ".repeat(50000), "h.html", ["UncategorizedText"]],
|
|
151
|
+
["<span>".repeat(200) + "t" + "</div>".repeat(100000), "h.html", ["UncategorizedText"]],
|
|
152
|
+
["[a".repeat(100000), "h.md", ["UncategorizedText", "UncategorizedText"]],
|
|
153
|
+
];
|
|
154
|
+
const kept = [];
|
|
155
|
+
for (const [content, filename, expected] of hostileHtml) {
|
|
156
|
+
const started = Date.now();
|
|
157
|
+
const result = (await partition([{ filename, content }])).json;
|
|
158
|
+
assert.ok(Date.now() - started < 3000, `hostile ${filename} took ${Date.now() - started}ms`);
|
|
159
|
+
assert.deepEqual(types(result), expected, `${filename}: ${JSON.stringify(types(result)).slice(0, 120)}`);
|
|
160
|
+
kept.push(result);
|
|
161
|
+
}
|
|
162
|
+
assert.equal(kept[1][0].text.split("\n").length, 10000, "every table row is kept at the bound");
|
|
163
|
+
assert.equal(kept[1][0].metadata.text_as_html.split("<tr>").length - 1, 10000);
|
|
164
|
+
assert.equal(kept[3][0].metadata.link_urls.length, 50000, "link metadata is never cut");
|
|
165
|
+
assert.equal(kept[3][0].metadata.link_texts.length, 50000);
|
|
166
|
+
|
|
167
|
+
// 16. Entity runs: 500,000 `&` without a `;` in HTML text, an HTML attribute and XML text decode in linear time (the `;`
|
|
168
|
+
// search is bounded to 12 code units), every `&` kept literally; the text splits into 100,000-character elements.
|
|
169
|
+
const ampersands = "&".repeat(500000);
|
|
170
|
+
const entityRuns = [
|
|
171
|
+
[`<p>${ampersands}</p>`, "amp.html", Array(5).fill("UncategorizedText")],
|
|
172
|
+
[`<p class="${ampersands}">x</p>`, "attr.html", ["UncategorizedText"]],
|
|
173
|
+
[`<r>${ampersands}</r>`, "amp.xml", Array(5).fill("UncategorizedText")],
|
|
174
|
+
];
|
|
175
|
+
for (const [content, filename, expected] of entityRuns) {
|
|
176
|
+
const started = Date.now();
|
|
177
|
+
const result = (await partition([{ filename, content }])).json;
|
|
178
|
+
assert.ok(Date.now() - started < 3000, `entity run ${filename} took ${Date.now() - started}ms`);
|
|
179
|
+
assert.deepEqual(types(result), expected, `${filename}: ${JSON.stringify(types(result)).slice(0, 120)}`);
|
|
180
|
+
if (expected.length === 5) assert.equal(result.reduce((sum, e) => sum + e.text.length, 0), 500000, `${filename}: every & is kept`);
|
|
181
|
+
}
|
|
182
|
+
const decoded = (await partition([{ filename: "ent.html", content: "<p>a & b <c> AB &unknown; &</p>" }])).json;
|
|
183
|
+
assert.equal(decoded[0].text, "a & b <c> AB &unknown; &");
|
|
184
|
+
|
|
185
|
+
// 17. JSON re-partition returns caller metadata as given (custom keys, scalar arrays, typed keys) — never a silent drop.
|
|
186
|
+
const given = { custom: "v", tags: [1, "a", null, true], languages: ["eng", "deu"], page_number: 3, note: "n".repeat(10000) };
|
|
187
|
+
const asGiven = (await partition([{ filename: "given.json", content: JSON.stringify([{ type: "Title", text: "t", metadata: given }]) }])).json;
|
|
188
|
+
assert.equal(asGiven.length, 1);
|
|
189
|
+
for (const key of Object.keys(given)) assert.deepEqual(asGiven[0].metadata[key], given[key], `metadata.${key} returned as given`);
|
|
190
|
+
|
|
191
|
+
// 18. RFC 2046 multipart e-mail. A mixed-case boundary is matched case-sensitively (CRLF headers, a 5 KB text/plain body kept
|
|
192
|
+
// whole); a non-text first part is skipped for the following text part; a nested multipart/alternative yields its text part;
|
|
193
|
+
// a boundary delimiter with no line break after it, a missing close delimiter and a 71-character boundary answer 422 at once.
|
|
194
|
+
const paragraph = "Quarterly field notes from the harbour survey were filed today.";
|
|
195
|
+
const body5k = Array.from({ length: 80 }, () => paragraph).join(" ");
|
|
196
|
+
assert.ok(body5k.length >= 5000, `body is ${body5k.length} characters`);
|
|
197
|
+
const mixedCase = (await partition([{ filename: "mixed.eml", content: `From: a@example.test\r\nTo: b@example.test\r\nSubject: Survey\r\nContent-Type: Multipart/Mixed;\r\n boundary="----=_Part_123_ABC"\r\n\r\npreamble\r\n------=_Part_123_ABC\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${body5k}\r\n------=_Part_123_ABC--\r\n` }])).json;
|
|
198
|
+
assert.deepEqual(types(mixedCase), ["Title", "NarrativeText"]);
|
|
199
|
+
assert.equal(mixedCase[1].text, body5k, "the whole text part is partitioned");
|
|
200
|
+
const nonTextFirst = (await partition([{ filename: "attach.eml", content: "From: a@example.test\nSubject: Attachment\nContent-Type: multipart/mixed; boundary=b\n\n--b\nContent-Type: application/octet-stream\n\nAAECAw==\n--b\nContent-Type: text/plain\n\nThe report is attached to this message.\n--b--\n" }])).json;
|
|
201
|
+
assert.deepEqual(types(nonTextFirst), ["Title", "NarrativeText"]);
|
|
202
|
+
assert.equal(nonTextFirst[1].text, "The report is attached to this message.");
|
|
203
|
+
const nestedAlt = (await partition([{ filename: "nested.eml", content: "From: a@example.test\nSubject: Nested\nContent-Type: multipart/mixed; boundary=outer\n\n--outer\nContent-Type: multipart/alternative; boundary=inner\n\n--inner\nContent-Type: text/plain\n\nPlain text version of the message body.\n--inner\nContent-Type: text/html\n\n<p>HTML version</p>\n--inner--\n--outer\nContent-Type: application/pdf\n\nJVBERi0=\n--outer--\n" }])).json;
|
|
204
|
+
assert.deepEqual(types(nestedAlt), ["Title", "NarrativeText"]);
|
|
205
|
+
assert.equal(nestedAlt[1].text, "Plain text version of the message body.");
|
|
206
|
+
const malformed = [
|
|
207
|
+
["From: a@b.c\nSubject: s\nContent-Type: multipart/mixed; boundary=b\n\nContent-Type: application/octet-stream\n\n--b", "is not followed by a line break"],
|
|
208
|
+
["From: a@b.c\nSubject: s\nContent-Type: multipart/mixed; boundary=b\n\n--b\n\ntext\n", "no closing"],
|
|
209
|
+
[`From: a@b.c\nSubject: s\nContent-Type: multipart/mixed; boundary=${"b".repeat(71)}\n\n--b\n`, "1-70"],
|
|
210
|
+
];
|
|
211
|
+
for (const [content, fragment] of malformed) {
|
|
212
|
+
const started = Date.now();
|
|
213
|
+
const rejected = await partition([{ filename: "bad.eml", content }], {}, { status: 422 });
|
|
214
|
+
assert.ok(Date.now() - started < 1000, `malformed e-mail took ${Date.now() - started}ms`);
|
|
215
|
+
assert.ok(JSON.stringify(rejected.json.detail).includes(fragment), rejected.text.slice(0, 300));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 19. A deterministic slice of the hostile-input harness (test/hostile.mjs): 300 cases across every format and the chunker,
|
|
219
|
+
// each within 250 ms, answering elements or a declared error.
|
|
220
|
+
const { runHostile } = await import("../hostile.mjs");
|
|
221
|
+
// Correctness only: no throw, only declared codes, no hang. Wall-clock latency is machine-dependent, so the 250 ms bound
|
|
222
|
+
// belongs to `node test/hostile.mjs`, not to this determinism check (a slower CI runner exceeded it on correct results).
|
|
223
|
+
const report = await runHostile({ seed: 20260916, cases: 300, limitMs: Infinity, hangMs: 60000 });
|
|
224
|
+
assert.deepEqual(report.failures, [], JSON.stringify(report.failures.slice(0, 5)));
|
|
225
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Workflow and job flow: listing/sorting/paging, node validation, run, job lifecycle, download, cancel, delete.
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { ID, api, del, detail, fixtures, get, multipart, partition, post, put } from "../lib.mjs";
|
|
4
|
+
|
|
5
|
+
const RUNTIME = (files, bytes) => { const s = 10 * files + Math.floor(bytes / 4096); return `PT${Math.floor(s / 60)}M${s % 60}S`; };
|
|
6
|
+
|
|
7
|
+
export async function workflowsFlow() {
|
|
8
|
+
const all = (await get("/api/v1/workflows")).json;
|
|
9
|
+
assert.deepEqual(all.map((w) => w.name), ["policy-ingest", "notes-basic", "legacy-scan"]);
|
|
10
|
+
assert.deepEqual(all[0].sources, [ID.S1]);
|
|
11
|
+
assert.deepEqual(all[0].destinations, [ID.D2]);
|
|
12
|
+
assert.equal(all[0].workflow_nodes.length, 3);
|
|
13
|
+
assert.deepEqual((await get("/api/v1/workflows?status=inactive")).json.map((w) => w.id), [ID.W3]);
|
|
14
|
+
assert.deepEqual((await get("/api/v1/workflows?page_size=1&page=2&sort_by=name&sort_direction=desc")).json.map((w) => w.name), ["notes-basic"]);
|
|
15
|
+
assert.deepEqual((await get(`/api/v1/workflows?source_id=${ID.S2}`)).json.map((w) => w.id), [ID.W2]);
|
|
16
|
+
assert.deepEqual((await get("/api/v1/workflows?name=legacy-scan")).json.map((w) => w.id), [ID.W3]);
|
|
17
|
+
assert.deepEqual((await get("/api/v1/workflows?page=4")).json, []);
|
|
18
|
+
await detail("GET", "/api/v1/workflows?show_only_soft_deleted=true", 422, "not supported by this Tool");
|
|
19
|
+
await detail("GET", "/api/v1/workflows?page=0", 422, "query.page");
|
|
20
|
+
await detail("GET", "/api/v1/workflows?page_size=101", 422, "less than or equal to 100");
|
|
21
|
+
await detail("GET", "/api/v1/workflows?sort_by=colour", 422, "query.sort_by");
|
|
22
|
+
await detail("GET", `/api/v1/workflows/${ID.W4}`, 404, "Workflow not found");
|
|
23
|
+
|
|
24
|
+
const nodes = (partitionSub, chunk) => [{ name: "Partitioner", type: "partition", subtype: partitionSub }, ...(chunk ? [chunk] : [])];
|
|
25
|
+
await detail("POST", "/api/v1/workflows", 422, "must be the first node", { body: { name: "bad", workflow_type: "custom", workflow_nodes: [{ name: "c", type: "chunk", subtype: "chunk_by_title" }, { name: "p", type: "partition", subtype: "auto" }] } });
|
|
26
|
+
await detail("POST", "/api/v1/workflows", 422, "at most one chunk node", { body: { name: "bad", workflow_type: "custom", workflow_nodes: [...nodes("auto", { name: "c", type: "chunk", subtype: "chunk_by_title" }), { name: "c2", type: "chunk", subtype: "chunk_by_page" }] } });
|
|
27
|
+
await detail("POST", "/api/v1/workflows", 422, "by_similarity is not supported", { body: { name: "bad", workflow_type: "custom", workflow_nodes: nodes("auto", { name: "c", type: "chunk", subtype: "chunk_by_similarity" }) } });
|
|
28
|
+
// The partition node's languages setting is bounded like the partition route's parameter: refused, never cut.
|
|
29
|
+
await detail("POST", "/api/v1/workflows", 422, "at most 20 items", { body: { name: "bad", workflow_type: "custom", workflow_nodes: [{ name: "p", type: "partition", subtype: "auto", settings: { languages: Array.from({ length: 21 }, (_, i) => `l${i}`) } }] } });
|
|
30
|
+
await detail("POST", "/api/v1/workflows", 422, "at most 20 characters", { body: { name: "bad", workflow_type: "custom", workflow_nodes: [{ name: "p", type: "partition", subtype: "auto", settings: { languages: "eng,abcdefghijklmnopqrstuvwxyz" } }] } });
|
|
31
|
+
await detail("POST", "/api/v1/workflows", 422, "body.schedule", { body: { name: "bad", workflow_type: "auto", schedule: "hourly" } });
|
|
32
|
+
const foreign = await detail("POST", "/api/v1/workflows", 422, "Source connector not found", { body: { name: "bad", workflow_type: "auto", source_id: ID.S4 } });
|
|
33
|
+
assert.deepEqual(foreign.json.detail[0].loc, ["body", "source_id"]);
|
|
34
|
+
await detail("POST", "/api/v1/workflows", 422, "template_id is not supported", { body: { name: "bad", workflow_type: "auto", template_id: "t" } });
|
|
35
|
+
await detail("POST", "/api/v1/workflows", 422, "Field required", { body: { workflow_type: "auto" } });
|
|
36
|
+
await detail("POST", "/api/v1/workflows", 422, "already exists", { body: { name: "dup", workflow_type: "auto", key: "policy-ingest" } });
|
|
37
|
+
|
|
38
|
+
const w5 = (await post("/api/v1/workflows", { name: "notes-chars", workflow_type: "custom", source_id: ID.S2, destination_id: ID.D1, workflow_nodes: nodes("auto", { name: "Chunker", type: "chunk", subtype: "chunk_by_character", settings: { max_characters: 300 } }), key: "notes-chars" })).json;
|
|
39
|
+
assert.equal(w5.status, "active");
|
|
40
|
+
assert.equal(w5.workflow_nodes.length, 2);
|
|
41
|
+
assert.match(w5.workflow_nodes[0].id, /^[0-9a-f-]{36}$/);
|
|
42
|
+
const auto = (await post("/api/v1/workflows", { name: "auto-flow", workflow_type: "auto", source_id: ID.S1 })).json;
|
|
43
|
+
assert.deepEqual(auto.workflow_nodes.map((n) => n.subtype), ["auto", "chunk_by_title"]);
|
|
44
|
+
assert.equal((await get(`/api/v1/workflows/${w5.id}`)).json.name, "notes-chars");
|
|
45
|
+
|
|
46
|
+
assert.equal((await put(`/api/v1/workflows/${w5.id}`, { status: "inactive" })).json.status, "inactive");
|
|
47
|
+
await detail("POST", `/api/v1/workflows/${w5.id}/run`, 422, "Workflow is not active");
|
|
48
|
+
assert.equal((await put(`/api/v1/workflows/${w5.id}`, { status: "active", schedule: "weekly" })).json.schedule, "weekly");
|
|
49
|
+
await detail("PUT", `/api/v1/workflows/${w5.id}`, 422, "body.status", { body: { status: "sleeping" } });
|
|
50
|
+
await detail("PUT", `/api/v1/workflows/${ID.NONE}`, 404, "Workflow not found", { body: { name: "x" } });
|
|
51
|
+
await detail("POST", `/api/v1/workflows/${ID.W3}/run`, 422, "Workflow is not active");
|
|
52
|
+
await detail("POST", `/api/v1/workflows/${ID.W4}/run`, 404, "Workflow not found");
|
|
53
|
+
await detail("POST", `/api/v1/workflows/${w5.id}/run`, 422, "Expected multipart/form-data", { body: { input_files: [] } });
|
|
54
|
+
const noSource = (await post("/api/v1/workflows", { name: "adhoc", workflow_type: "auto" })).json;
|
|
55
|
+
await detail("POST", `/api/v1/workflows/${noSource.id}/run`, 422, "body.input_files");
|
|
56
|
+
|
|
57
|
+
const job = (await api("POST", `/api/v1/workflows/${w5.id}/run`, { status: 202 })).json;
|
|
58
|
+
assert.equal(job.status, "SCHEDULED");
|
|
59
|
+
assert.equal(job.input_file_ids.length, 3);
|
|
60
|
+
assert.deepEqual(job.output_node_files, []);
|
|
61
|
+
assert.equal(job.runtime, null);
|
|
62
|
+
assert.equal((await get(`/api/v1/jobs/${job.id}`)).json.status, "SCHEDULED");
|
|
63
|
+
const details = (await get(`/api/v1/jobs/${job.id}/details`)).json;
|
|
64
|
+
assert.equal(details.processing_status, "SCHEDULED");
|
|
65
|
+
assert.deepEqual(details.node_stats.map((n) => n.ready), [3, 3]);
|
|
66
|
+
const upload = multipart([{ name: "input_files", filename: "memo.md", value: "# Memo\n\nA short memo body for the runtime upload.", contentType: "text/markdown" }, { name: "input_files", filename: "scan.pdf", value: "%PDF", contentType: "application/pdf" }]);
|
|
67
|
+
const job2 = (await api("POST", `/api/v1/workflows/${w5.id}/run`, { status: 202, raw: upload.raw, contentType: upload.contentType })).json;
|
|
68
|
+
assert.equal(job2.input_file_ids.length, 5);
|
|
69
|
+
assert.deepEqual((await get(`/api/v1/jobs/${job2.id}/failed-files`)).json.failed_files, [{ document: "upload://scan.pdf", error: "application/pdf not currently supported" }]);
|
|
70
|
+
assert.deepEqual((await get(`/api/v1/jobs/${job.id}/failed-files`)).json.failed_files, []);
|
|
71
|
+
|
|
72
|
+
assert.equal((await get(`/api/v1/jobs?workflow_id=${w5.id}`)).json.length, 2);
|
|
73
|
+
assert.deepEqual((await get("/api/v1/jobs?status=COMPLETED")).json.map((j) => j.id), [ID.J2, ID.J1]);
|
|
74
|
+
const pages = [];
|
|
75
|
+
for (let page = 1; page <= 5; page += 1) pages.push((await get(`/api/v1/jobs?page_size=2&page=${page}`)).json);
|
|
76
|
+
assert.deepEqual(pages.map((p) => p.length), [2, 2, 2, 2, 0]);
|
|
77
|
+
assert.equal(new Set(pages.flat().map((j) => j.id)).size, 8);
|
|
78
|
+
await detail("GET", "/api/v1/jobs?page=0", 422, "query.page");
|
|
79
|
+
await detail("GET", "/api/v1/jobs?status=DONE", 422, "query.status");
|
|
80
|
+
|
|
81
|
+
const j1 = (await get(`/api/v1/jobs/${ID.J1}`)).json;
|
|
82
|
+
assert.equal(j1.status, "COMPLETED");
|
|
83
|
+
assert.equal(j1.runtime, RUNTIME(7, 1446 + 965 + 754 + 1544 + 826 + 0 + 56014));
|
|
84
|
+
assert.equal(j1.output_node_files.length, 7);
|
|
85
|
+
assert.ok(j1.output_node_files.every((f) => f.node_id === ID.W1_EMBED_NODE && f.node_type === "embed"));
|
|
86
|
+
const fileId = `${ID.S1}:handbook-md`;
|
|
87
|
+
const download = (await get(`/api/v1/jobs/${ID.J2}/download?file_id=${encodeURIComponent(fileId)}&node_id=${ID.W1_EMBED_NODE}`)).json;
|
|
88
|
+
const direct = (await partition([{ filename: "handbook.md", content: fixtures().get("handbook.md").content }], { chunking_strategy: "by_title", max_characters: "800", new_after_n_chars: "800", combine_under_n_chars: "200", include_orig_elements: "true" })).json;
|
|
89
|
+
assert.deepEqual(download.map((c) => [c.type, c.text]), direct.map((c) => [c.type, c.text]));
|
|
90
|
+
await detail("GET", `/api/v1/jobs/${ID.J2}/download?file_id=${encodeURIComponent(fileId)}&node_id=${ID.NONE}`, 404, "Job output file not found");
|
|
91
|
+
await detail("GET", `/api/v1/jobs/${ID.J2}/download?node_id=${ID.W1_EMBED_NODE}`, 422, "query.file_id");
|
|
92
|
+
await detail("GET", `/api/v1/jobs/${ID.J6}/download?file_id=${encodeURIComponent(fileId)}&node_id=${ID.W1_EMBED_NODE}`, 422, "not available yet");
|
|
93
|
+
await detail("GET", `/api/v1/jobs/${ID.J2}/download?file_id=${encodeURIComponent(`${ID.S1}:ledger-txt`)}&node_id=${ID.W1_EMBED_NODE}`, 413, "exceeds the 921600 byte response limit");
|
|
94
|
+
await detail("GET", `/api/v1/jobs/${ID.NONE}/download?file_id=x&node_id=y`, 404, "Job not found");
|
|
95
|
+
|
|
96
|
+
const j4 = (await get(`/api/v1/jobs/${ID.J4}/details`)).json;
|
|
97
|
+
assert.equal(j4.processing_status, "FAILED");
|
|
98
|
+
assert.deepEqual(j4.node_stats.map((n) => n.failure), [2]);
|
|
99
|
+
assert.equal((await get(`/api/v1/jobs/${ID.J4}`)).json.reason, "All files failed");
|
|
100
|
+
const failed = (await get(`/api/v1/jobs/${ID.J4}/failed-files`)).json.failed_files;
|
|
101
|
+
assert.deepEqual(failed.map((f) => f.document), ["az://legacy-scans/scan-001.pdf", "az://legacy-scans/photo.png"]);
|
|
102
|
+
assert.equal(failed[0].error, "File is encrypted. Please decrypt it with password.");
|
|
103
|
+
assert.equal((await get(`/api/v1/jobs/${ID.J3}/details`)).json.processing_status, "STOPPED");
|
|
104
|
+
assert.equal((await get(`/api/v1/jobs/${ID.J3}`)).json.reason, "Cancelled by user");
|
|
105
|
+
assert.equal((await get(`/api/v1/jobs/${ID.J5}`)).json.status, "IN_PROGRESS");
|
|
106
|
+
assert.deepEqual((await get(`/api/v1/jobs/${ID.J5}/details`)).json.node_stats.map((n) => n.in_progress), [3, 3]);
|
|
107
|
+
await detail("GET", `/api/v1/jobs/${ID.NONE}`, 404, "Job not found");
|
|
108
|
+
await detail("GET", `/api/v1/jobs/${ID.NONE}/details`, 404, "Job not found");
|
|
109
|
+
await detail("GET", `/api/v1/jobs/${ID.NONE}/failed-files`, 404, "Job not found");
|
|
110
|
+
|
|
111
|
+
const cancelled = (await post(`/api/v1/jobs/${ID.J5}/cancel`, undefined)).json;
|
|
112
|
+
assert.equal(cancelled.status, "STOPPED");
|
|
113
|
+
assert.equal((await get(`/api/v1/jobs/${ID.J5}`)).json.status, "STOPPED");
|
|
114
|
+
assert.equal((await get(`/api/v1/jobs/${ID.J5}`)).json.stop_requested, true);
|
|
115
|
+
await detail("POST", `/api/v1/jobs/${ID.J1}/cancel`, 422, "already finished");
|
|
116
|
+
await detail("POST", `/api/v1/jobs/${ID.NONE}/cancel`, 404, "Job not found");
|
|
117
|
+
|
|
118
|
+
await detail("DELETE", `/api/v1/workflows/${ID.W1}`, 422, "running job");
|
|
119
|
+
await detail("DELETE", `/api/v1/workflows/${w5.id}`, 422, "running job");
|
|
120
|
+
await detail("DELETE", `/api/v1/workflows/${ID.NONE}`, 404, "Workflow not found");
|
|
121
|
+
assert.deepEqual((await del(`/api/v1/workflows/${noSource.id}`)).json, {});
|
|
122
|
+
await detail("GET", `/api/v1/workflows/${noSource.id}`, 404, "Workflow not found");
|
|
123
|
+
}
|
|
Binary file
|
package/test/hostile.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Hostile-input harness for the partitioners and the chunker. Node built-ins only.
|
|
2
|
+
// node test/hostile.mjs [seed=1] [cases=20000]
|
|
3
|
+
// Cases run in a worker thread; the main thread watches a shared case counter and terminates the worker when one case runs
|
|
4
|
+
// longer than the hang bound, so a non-terminating partitioner is reported instead of hanging the harness. A case fails when
|
|
5
|
+
// it takes more than LIMIT_MS (partition plus JSON encoding of the result, as the route does), throws anything, or answers a
|
|
6
|
+
// code that is not one of the package's declared error codes. Exit status 1 on any failure.
|
|
7
|
+
// The latency bound is machine-dependent, so only the command line applies it. Packaged conformance calls runHostile with
|
|
8
|
+
// `limitMs: Infinity` and a long hang bound: a determinism check must not pass on a fast machine and fail on a slow one,
|
|
9
|
+
// while a case that throws, answers an undeclared code or never terminates still fails everywhere.
|
|
10
|
+
// Every MULTIPART_EVERY-th case also decodes a hostile multipart/form-data request envelope through both route decoders
|
|
11
|
+
// (partition and workflow run) and checks that a decoded `languages` list is either accepted whole or refused, never cut.
|
|
12
|
+
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
|
|
13
|
+
import { performance } from "node:perf_hooks";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { makeCase, makeMultipartCase } from "./hostile-gen.mjs";
|
|
16
|
+
|
|
17
|
+
export const LIMIT_MS = 250;
|
|
18
|
+
export const HANG_MS = 5000;
|
|
19
|
+
const RSS_LIMIT = 2 * 1024 * 1024 * 1024;
|
|
20
|
+
const MULTIPART_EVERY = 4;
|
|
21
|
+
const DECLARED = new Set(["INVALID_REQUEST", "UNSUPPORTED_FILE_TYPE", "RESPONSE_TOO_LARGE", "INVALID_FILE", "VALIDATION_ERROR"]);
|
|
22
|
+
|
|
23
|
+
async function workerMain() {
|
|
24
|
+
const { seed, cases, from, shared, limitMs = LIMIT_MS } = workerData;
|
|
25
|
+
const progress = new Int32Array(shared);
|
|
26
|
+
const { partitionFile } = await import(new URL("../firedrill/tools/unstructured/lib/partition/index.mjs", import.meta.url));
|
|
27
|
+
const { decodePartition, decodeRun } = await import(new URL("../firedrill/tools/unstructured/lib/wire-multipart.mjs", import.meta.url));
|
|
28
|
+
const { parseLanguages, MAX_LANGUAGES, MAX_LANGUAGE_LENGTH } = await import(new URL("../firedrill/tools/unstructured/lib/validate.mjs", import.meta.url));
|
|
29
|
+
const { RESERVED } = await import(new URL("../firedrill/tools/unstructured/lib/wire.mjs", import.meta.url));
|
|
30
|
+
const multipartOutcome = (m) => {
|
|
31
|
+
const request = { headers: { "content-type": [m.contentType] }, path: { workflow_id: "w" }, body: { kind: "text", value: m.body } };
|
|
32
|
+
const args = decodePartition(request).arguments;
|
|
33
|
+
decodeRun(request);
|
|
34
|
+
if (Object.hasOwn(args, RESERVED)) return /^(400|422):/.test(args[RESERVED]) ? "refused" : `undeclared ${args[RESERVED].slice(0, 60)}`;
|
|
35
|
+
if (args.languages === undefined) return "ok";
|
|
36
|
+
const issues = { items: [], add(loc, message) { this.items.push(`${loc}: ${message}`); } };
|
|
37
|
+
const list = parseLanguages(args.languages, "body.languages", issues);
|
|
38
|
+
const flat = Array.isArray(args.languages) ? args.languages : String(args.languages).split(",").map((x) => x.trim()).filter((x) => x.length > 0);
|
|
39
|
+
const fits = flat.length <= MAX_LANGUAGES && flat.every((x) => typeof x === "string" && x.length <= MAX_LANGUAGE_LENGTH);
|
|
40
|
+
if (fits) return list !== null && list.length === flat.length && list.every((x, i) => x === flat[i]) ? "ok" : "undeclared languages changed";
|
|
41
|
+
return list === null && issues.items.length > 0 ? "VALIDATION_ERROR" : "undeclared languages cut";
|
|
42
|
+
};
|
|
43
|
+
const formats = new Map();
|
|
44
|
+
const failures = [];
|
|
45
|
+
let peakRss = 0;
|
|
46
|
+
// Warm the partitioners once so JIT compilation is not charged to the first measured case of each format.
|
|
47
|
+
for (let i = 0; i < 18; i += 1) {
|
|
48
|
+
const c = makeCase(seed + 7919, i);
|
|
49
|
+
partitionFile({ filename: c.filename, content: c.content.slice(0, 2048) }, c.options, null);
|
|
50
|
+
}
|
|
51
|
+
for (let index = from; index < cases; index += 1) {
|
|
52
|
+
Atomics.store(progress, 0, index);
|
|
53
|
+
const c = makeCase(seed, index);
|
|
54
|
+
const started = performance.now();
|
|
55
|
+
let outcome;
|
|
56
|
+
try {
|
|
57
|
+
const result = partitionFile({ filename: c.filename, content: c.content, bad_transfer: c.badTransfer }, c.options, null);
|
|
58
|
+
if (result.ok) {
|
|
59
|
+
JSON.stringify(result.elements);
|
|
60
|
+
outcome = "ok";
|
|
61
|
+
} else outcome = DECLARED.has(result.code) && typeof result.message === "string" ? result.code : `undeclared ${result.code}`;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
outcome = `threw ${String(error?.stack ?? error).slice(0, 300)}`;
|
|
64
|
+
}
|
|
65
|
+
const ms = performance.now() - started;
|
|
66
|
+
const key = c.options.chunk === null ? c.format : `${c.format}+chunk`;
|
|
67
|
+
const stat = formats.get(key) ?? { cases: 0, worst: 0, worstCase: -1, totalMs: 0, outcomes: {} };
|
|
68
|
+
stat.cases += 1;
|
|
69
|
+
stat.totalMs += ms;
|
|
70
|
+
stat.outcomes[outcome.split(" ")[0]] = (stat.outcomes[outcome.split(" ")[0]] ?? 0) + 1;
|
|
71
|
+
if (ms > stat.worst) Object.assign(stat, { worst: ms, worstCase: index, worstShape: c.shape, worstSize: c.content.length });
|
|
72
|
+
formats.set(key, stat);
|
|
73
|
+
if (ms > limitMs || outcome.startsWith("threw") || outcome.startsWith("undeclared")) {
|
|
74
|
+
failures.push({ index, format: key, shape: c.shape, size: c.content.length, ms: Math.round(ms), outcome });
|
|
75
|
+
}
|
|
76
|
+
if (index % MULTIPART_EVERY === MULTIPART_EVERY - 1) {
|
|
77
|
+
const m = makeMultipartCase(seed, index);
|
|
78
|
+
const mStarted = performance.now();
|
|
79
|
+
let mOutcome;
|
|
80
|
+
try {
|
|
81
|
+
mOutcome = multipartOutcome(m);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
mOutcome = `threw ${String(error?.stack ?? error).slice(0, 300)}`;
|
|
84
|
+
}
|
|
85
|
+
const mMs = performance.now() - mStarted;
|
|
86
|
+
const mStat = formats.get("multipart") ?? { cases: 0, worst: 0, worstCase: -1, totalMs: 0, outcomes: {} };
|
|
87
|
+
mStat.cases += 1;
|
|
88
|
+
mStat.totalMs += mMs;
|
|
89
|
+
mStat.outcomes[mOutcome.split(" ")[0]] = (mStat.outcomes[mOutcome.split(" ")[0]] ?? 0) + 1;
|
|
90
|
+
if (mMs > mStat.worst) Object.assign(mStat, { worst: mMs, worstCase: index, worstShape: m.shape, worstSize: m.body.length });
|
|
91
|
+
formats.set("multipart", mStat);
|
|
92
|
+
if (mMs > limitMs || mOutcome.startsWith("threw") || mOutcome.startsWith("undeclared")) failures.push({ index, format: "multipart", shape: m.shape, size: m.body.length, ms: Math.round(mMs), outcome: mOutcome });
|
|
93
|
+
}
|
|
94
|
+
if (index % 250 === 0) {
|
|
95
|
+
const rss = process.memoryUsage().rss;
|
|
96
|
+
peakRss = Math.max(peakRss, rss);
|
|
97
|
+
if (rss > RSS_LIMIT) {
|
|
98
|
+
failures.push({ index, format: key, shape: c.shape, size: c.content.length, ms: Math.round(ms), outcome: `rss ${rss} bytes over ${RSS_LIMIT}` });
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
Atomics.store(progress, 0, -1);
|
|
104
|
+
parentPort.postMessage({ formats: Object.fromEntries(formats), failures, peakRss });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Runs `cases` hostile cases of `seed` and resolves { cases, formats, failures, peakRss, elapsedMs }. A hung case is reported
|
|
109
|
+
* as a failure (the worker is terminated) rather than blocking the caller.
|
|
110
|
+
*/
|
|
111
|
+
export function runHostile({ seed = 1, cases = 20000, limitMs = LIMIT_MS, hangMs = HANG_MS } = {}) {
|
|
112
|
+
const shared = new SharedArrayBuffer(4);
|
|
113
|
+
const progress = new Int32Array(shared);
|
|
114
|
+
const started = Date.now();
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
const worker = new Worker(fileURLToPath(import.meta.url), { workerData: { seed, cases, from: 0, shared, limitMs }, resourceLimits: { maxOldGenerationSizeMb: 4096 } });
|
|
117
|
+
let last = -2;
|
|
118
|
+
let since = Date.now();
|
|
119
|
+
let settled = false;
|
|
120
|
+
const finish = (value) => {
|
|
121
|
+
if (settled) return;
|
|
122
|
+
settled = true;
|
|
123
|
+
clearInterval(timer);
|
|
124
|
+
resolve({ cases, elapsedMs: Date.now() - started, ...value });
|
|
125
|
+
};
|
|
126
|
+
const timer = setInterval(() => {
|
|
127
|
+
const current = Atomics.load(progress, 0);
|
|
128
|
+
if (current !== last) {
|
|
129
|
+
last = current;
|
|
130
|
+
since = Date.now();
|
|
131
|
+
} else if (current >= 0 && Date.now() - since > hangMs) {
|
|
132
|
+
const c = makeCase(seed, current);
|
|
133
|
+
void worker.terminate();
|
|
134
|
+
finish({ formats: {}, peakRss: 0, failures: [{ index: current, format: c.format, shape: c.shape, size: c.content.length, ms: Date.now() - since, outcome: "hang (worker terminated)" }] });
|
|
135
|
+
}
|
|
136
|
+
}, 100);
|
|
137
|
+
worker.on("message", (message) => finish(message));
|
|
138
|
+
worker.on("error", (error) => finish({ formats: {}, peakRss: 0, failures: [{ index: Atomics.load(progress, 0), outcome: `worker error ${String(error?.stack ?? error).slice(0, 300)}` }] }));
|
|
139
|
+
worker.on("exit", (code) => finish({ formats: {}, peakRss: 0, failures: [{ index: Atomics.load(progress, 0), outcome: `worker exited ${code} without a result` }] }));
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!isMainThread && workerData?.shared !== undefined) await workerMain();
|
|
144
|
+
else if (isMainThread && process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
145
|
+
const seed = Number.parseInt(process.argv[2] ?? "1", 10);
|
|
146
|
+
const cases = Number.parseInt(process.argv[3] ?? "20000", 10);
|
|
147
|
+
const report = await runHostile({ seed, cases });
|
|
148
|
+
const rows = Object.entries(report.formats).sort(([a], [b]) => a.localeCompare(b));
|
|
149
|
+
for (const [format, stat] of rows) {
|
|
150
|
+
console.log(`${format.padEnd(15)} cases=${String(stat.cases).padStart(5)} worst=${stat.worst.toFixed(1)}ms (case ${stat.worstCase}, ${stat.worstShape}, ${stat.worstSize} chars) mean=${(stat.totalMs / stat.cases).toFixed(2)}ms ${JSON.stringify(stat.outcomes)}`);
|
|
151
|
+
}
|
|
152
|
+
console.log(`seed=${seed} cases=${cases} elapsed=${report.elapsedMs}ms peakRss=${Math.round(report.peakRss / 1048576)}MiB failures=${report.failures.length}`);
|
|
153
|
+
for (const failure of report.failures.slice(0, 50)) console.log(`FAIL ${JSON.stringify(failure)}`);
|
|
154
|
+
process.exitCode = report.failures.length > 0 ? 1 : 0;
|
|
155
|
+
}
|
package/test/lib.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Shared transport helpers, starter ids and fixture access for the conformance flows. Node built-ins only.
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
export const HTTP = process.env.FIREDRILL_HTTP_URL;
|
|
6
|
+
export const TOKEN = process.env.FIREDRILL_HTTP_TOKEN;
|
|
7
|
+
assert.ok(HTTP && TOKEN, "the http binding is required");
|
|
8
|
+
|
|
9
|
+
export const ID = {
|
|
10
|
+
S1: "7c1a9e2f-3b4d-4c5e-8f60-a1b2c3d4e501", S2: "7c1a9e2f-3b4d-4c5e-8f60-a1b2c3d4e502", S3: "7c1a9e2f-3b4d-4c5e-8f60-a1b2c3d4e503", S4: "7c1a9e2f-3b4d-4c5e-8f60-a1b2c3d4e504",
|
|
11
|
+
D1: "9d2b0f3a-4c5e-4d6f-9a71-b2c3d4e5f601", D2: "9d2b0f3a-4c5e-4d6f-9a71-b2c3d4e5f602", D3: "9d2b0f3a-4c5e-4d6f-9a71-b2c3d4e5f603",
|
|
12
|
+
W1: "b3c4d5e6-5f60-4718-8a92-c3d4e5f60701", W2: "b3c4d5e6-5f60-4718-8a92-c3d4e5f60702", W3: "b3c4d5e6-5f60-4718-8a92-c3d4e5f60703", W4: "b3c4d5e6-5f60-4718-8a92-c3d4e5f60704",
|
|
13
|
+
J1: "e5f60718-7a92-4b3c-9d4e-f60718293a01", J2: "e5f60718-7a92-4b3c-9d4e-f60718293a02", J3: "e5f60718-7a92-4b3c-9d4e-f60718293a03",
|
|
14
|
+
J4: "e5f60718-7a92-4b3c-9d4e-f60718293a04", J5: "e5f60718-7a92-4b3c-9d4e-f60718293a05", J6: "e5f60718-7a92-4b3c-9d4e-f60718293a06",
|
|
15
|
+
W1_EMBED_NODE: "f1a2b3c4-0001-4a01-8000-000000000003", NONE: "00000000-0000-4000-8000-000000000000",
|
|
16
|
+
};
|
|
17
|
+
export const NOW = "2026-09-16T09:00:00.000Z";
|
|
18
|
+
export const ALL_OPERATIONS = [
|
|
19
|
+
"general.partition", "sources.list", "sources.create", "sources.get", "sources.update", "sources.delete", "sources.check_connection", "sources.get_connection_check",
|
|
20
|
+
"destinations.list", "destinations.create", "destinations.get", "destinations.update", "destinations.delete",
|
|
21
|
+
"workflows.list", "workflows.create", "workflows.get", "workflows.update", "workflows.delete", "workflows.run",
|
|
22
|
+
"jobs.list", "jobs.get", "jobs.cancel", "jobs.get_details", "jobs.get_failed_files", "jobs.download_output",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Seeded source files by filename (content computed from the package's own starter.json, never pasted). */
|
|
26
|
+
export function fixtures() {
|
|
27
|
+
const starter = JSON.parse(readFileSync("starter.json", "utf8"));
|
|
28
|
+
const out = new Map();
|
|
29
|
+
for (const entry of starter.state) if (entry.namespace === "source-files") out.set(entry.value.filename, entry.value);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Provider-shaped request with the unstructured-api-key header; asserts the status and returns the parsed body. */
|
|
34
|
+
export async function api(method, path, { body, raw, contentType, status = 200, headers = {}, token = TOKEN } = {}) {
|
|
35
|
+
const hasBody = body !== undefined || raw !== undefined;
|
|
36
|
+
const response = await fetch(`${HTTP}${path}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers: { "unstructured-api-key": token, ...(hasBody ? { "content-type": contentType ?? "application/json" } : {}), ...headers },
|
|
39
|
+
...(hasBody ? { body: raw ?? JSON.stringify(body) } : {}),
|
|
40
|
+
});
|
|
41
|
+
const text = await response.text();
|
|
42
|
+
let json;
|
|
43
|
+
try {
|
|
44
|
+
json = text.length > 0 ? JSON.parse(text) : undefined;
|
|
45
|
+
} catch {
|
|
46
|
+
json = undefined;
|
|
47
|
+
}
|
|
48
|
+
assert.equal(response.status, status, `${method} ${path} ${body === undefined ? "" : JSON.stringify(body).slice(0, 160)} -> ${response.status} ${text.slice(0, 400)}`);
|
|
49
|
+
return { json, text, headers: response.headers, status: response.status };
|
|
50
|
+
}
|
|
51
|
+
export const get = (path, options) => api("GET", path, options);
|
|
52
|
+
export const post = (path, body, options) => api("POST", path, { ...options, body });
|
|
53
|
+
export const put = (path, body, options) => api("PUT", path, { ...options, body });
|
|
54
|
+
export const del = (path, options) => api("DELETE", path, options);
|
|
55
|
+
|
|
56
|
+
/** Expects the FastAPI `{ detail }` envelope; `fragment` must appear in the message (string) or first msg (array). */
|
|
57
|
+
export async function detail(method, path, status, fragment, options = {}) {
|
|
58
|
+
const result = await api(method, path, { ...options, status });
|
|
59
|
+
assert.ok(result.json && typeof result.json === "object" && Object.hasOwn(result.json, "detail"), `${method} ${path}: no detail envelope: ${result.text.slice(0, 300)}`);
|
|
60
|
+
const d = result.json.detail;
|
|
61
|
+
if (typeof d === "string") {
|
|
62
|
+
if (fragment) assert.ok(d.includes(fragment), `${method} ${path}: expected ${JSON.stringify(fragment)} in ${d.slice(0, 300)}`);
|
|
63
|
+
} else {
|
|
64
|
+
assert.ok(Array.isArray(d) && d.length > 0 && Array.isArray(d[0].loc) && typeof d[0].msg === "string" && typeof d[0].type === "string", `${method} ${path}: malformed detail array ${result.text.slice(0, 300)}`);
|
|
65
|
+
if (fragment) assert.ok(d.some((item) => item.msg.includes(fragment) || item.loc.join(".").includes(fragment)), `${method} ${path}: expected ${JSON.stringify(fragment)} in ${JSON.stringify(d).slice(0, 400)}`);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Canonical operation endpoint; `expected` is "ok", "denied", "invalid" or a Tool error code. */
|
|
71
|
+
export async function op(operationId, args, expected = "ok", token = TOKEN) {
|
|
72
|
+
const response = await fetch(`${HTTP}/v1/operations/unstructured/${operationId}`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ arguments: args }) });
|
|
73
|
+
const json = await response.json();
|
|
74
|
+
assert.ok(json.outcome, `canonical ${operationId}: ${JSON.stringify(json).slice(0, 300)}`);
|
|
75
|
+
if (expected === "ok") {
|
|
76
|
+
assert.equal(json.outcome.status, "ok", `${operationId} ${JSON.stringify(args).slice(0, 200)}: ${JSON.stringify(json.outcome).slice(0, 400)}`);
|
|
77
|
+
return json.outcome.value;
|
|
78
|
+
}
|
|
79
|
+
if (expected === "denied" || expected === "invalid") {
|
|
80
|
+
assert.equal(json.outcome.status, expected, `${operationId}: ${JSON.stringify(json.outcome).slice(0, 400)}`);
|
|
81
|
+
return json.outcome;
|
|
82
|
+
}
|
|
83
|
+
assert.equal(json.outcome.status, "tool_error", `${operationId}: ${JSON.stringify(json.outcome).slice(0, 400)}`);
|
|
84
|
+
assert.equal(json.outcome.error.code, `tool.${expected}`, `${operationId}: ${JSON.stringify(json.outcome.error)}`);
|
|
85
|
+
return json.outcome.error;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Hand-built multipart/form-data body. parts: [{ name, value, filename?, contentType?, headers? }]. */
|
|
89
|
+
export function multipart(parts, boundary = "FiredrillBoundary7d2c") {
|
|
90
|
+
let body = "";
|
|
91
|
+
for (const part of parts) {
|
|
92
|
+
body += `--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"${part.filename !== undefined ? `; filename="${part.filename}"` : ""}\r\n`;
|
|
93
|
+
if (part.contentType) body += `Content-Type: ${part.contentType}\r\n`;
|
|
94
|
+
for (const [name, value] of Object.entries(part.headers ?? {})) body += `${name}: ${value}\r\n`;
|
|
95
|
+
body += `\r\n${part.value}\r\n`;
|
|
96
|
+
}
|
|
97
|
+
return { raw: `${body}--${boundary}--\r\n`, contentType: `multipart/form-data; boundary=${boundary}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** POST /general/v0/general with files [{ filename, content, contentType? }] and scalar/array fields. */
|
|
101
|
+
export async function partition(files, fields = {}, options = {}) {
|
|
102
|
+
const parts = files.map((file) => ({ name: "files", filename: file.filename, value: file.content, contentType: file.contentType, headers: file.headers }));
|
|
103
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
104
|
+
if (Array.isArray(value)) for (const item of value) parts.push({ name: `${name}[]`, value: item });
|
|
105
|
+
else parts.push({ name, value: String(value) });
|
|
106
|
+
}
|
|
107
|
+
const { raw, contentType } = multipart(parts);
|
|
108
|
+
return api("POST", "/general/v0/general", { raw, contentType, ...options });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const HEX32 = /^[0-9a-f]{32}$/;
|
|
112
|
+
export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
113
|
+
export const types = (elements) => elements.map((element) => element.type);
|