@ashlr/stack-core 0.3.1 → 0.3.7
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/package.json +1 -1
- package/src/__tests__/_harness.ts +65 -39
- package/src/__tests__/apply-placeholder-history.test.ts +55 -0
- package/src/__tests__/apply-placeholder-operator-checklist.test.ts +28 -0
- package/src/__tests__/apply-terminology-examples.test.ts +46 -0
- package/src/__tests__/config.test.ts +36 -0
- package/src/__tests__/credential-staging.test.ts +7 -6
- package/src/__tests__/deps.test.ts +35 -0
- package/src/__tests__/detect-source.test.ts +97 -0
- package/src/__tests__/fixtures/stack-manifest.v1.json +3 -3
- package/src/__tests__/phantom-process.test.ts +21 -3
- package/src/__tests__/phantom-wire-failing-subprocess.test.ts +126 -0
- package/src/__tests__/phantom-wire-repeated-placeholders.test.ts +84 -0
- package/src/__tests__/phantom-wire-unicode-combining.test.ts +100 -0
- package/src/__tests__/phantom-wire.test.ts +6381 -363
- package/src/__tests__/pipeline-integration.test.ts +26 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +2 -2
- package/src/__tests__/secret-redaction.test.ts +13 -3
- package/src/ai/phantom-wire-adjacent-different-providers.test.ts +78 -0
- package/src/ai/phantom-wire-adjacent-known-placeholders.test.ts +266 -0
- package/src/ai/phantom-wire-args.test.ts +63 -0
- package/src/ai/phantom-wire-blank-lines.test.ts +174 -0
- package/src/ai/phantom-wire-carriage-return.test.ts +125 -0
- package/src/ai/phantom-wire-case-changed-placeholder.test.ts +209 -0
- package/src/ai/phantom-wire-closing-brace.test.ts +16 -0
- package/src/ai/phantom-wire-crlf.test.ts +48 -0
- package/src/ai/phantom-wire-dollar-amp.test.ts +128 -0
- package/src/ai/phantom-wire-double-quote.test.ts +135 -0
- package/src/ai/phantom-wire-empty-replacement.test.ts +122 -0
- package/src/ai/phantom-wire-empty-stderr.test.ts +145 -0
- package/src/ai/phantom-wire-empty-template.test.ts +81 -0
- package/src/ai/phantom-wire-empty-values.test.ts +129 -0
- package/src/ai/phantom-wire-errors.test.ts +296 -0
- package/src/ai/phantom-wire-known-placeholder-adjacent-punctuation.test.ts +188 -0
- package/src/ai/phantom-wire-known-placeholder-at-end.test.ts +160 -0
- package/src/ai/phantom-wire-known-placeholder.test.ts +170 -0
- package/src/ai/phantom-wire-malformed-json.test.ts +227 -0
- package/src/ai/phantom-wire-missing.test.ts +123 -0
- package/src/ai/phantom-wire-mixed-placeholders.test.ts +183 -0
- package/src/ai/phantom-wire-nested-placeholder.test.ts +149 -0
- package/src/ai/phantom-wire-newline.test.ts +136 -0
- package/src/ai/phantom-wire-order.test.ts +173 -0
- package/src/ai/phantom-wire-placeholder-two-lines.test.ts +196 -0
- package/src/ai/phantom-wire-repeated-known-placeholder.test.ts +241 -0
- package/src/ai/phantom-wire-repeated-unknown-placeholder.test.ts +55 -0
- package/src/ai/phantom-wire-replacement-dollar.test.ts +170 -0
- package/src/ai/phantom-wire-single-quote.test.ts +22 -0
- package/src/ai/phantom-wire-tab.test.ts +42 -0
- package/src/ai/phantom-wire-unknown-placeholder.test.ts +189 -0
- package/src/ai/phantom-wire-vault-name-case.test.ts +46 -0
- package/src/ai/phantom-wire.test.ts +144 -0
- package/src/ai/phantom-wire.ts +11 -4
- package/src/phantom.ts +10 -1
- package/src/stack-manifest.ts +3 -3
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
|
|
6
|
+
import { verificationHintFor, webhookStubFor, wirePhantomForRecipe } from "./phantom-wire.ts";
|
|
7
|
+
import type { Recipe } from "./recipe.ts";
|
|
8
|
+
|
|
9
|
+
// The wiring check spawns the fake `phantom` shim (bash + python3); keep the
|
|
10
|
+
// sibling files' raised timeout.
|
|
11
|
+
setDefaultTimeout(30_000);
|
|
12
|
+
|
|
13
|
+
const count = (s: string, needle: string) => s.split(needle).length - 1;
|
|
14
|
+
|
|
15
|
+
// A name whose only byte has no case mapping: `capitalize` folds it to itself
|
|
16
|
+
// and `toUpperCase` leaves it, so it lands byte-identically at all five
|
|
17
|
+
// name-fed expansion sites — while still exercising the normal codePointAt
|
|
18
|
+
// path, since the `s === ""` early return only fires for the truly empty name.
|
|
19
|
+
const SENTINEL = "\u0000";
|
|
20
|
+
|
|
21
|
+
describe("ai/phantom-wire known placeholder replaced with an empty value", () => {
|
|
22
|
+
let harness: Harness;
|
|
23
|
+
let dir: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
harness = setupFakePhantom();
|
|
27
|
+
dir = mkdtempSync(join(tmpdir(), "wire-empty-replacement-"));
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
harness.cleanup();
|
|
31
|
+
rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
35
|
+
id: "empty-replacement",
|
|
36
|
+
query: "q",
|
|
37
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
38
|
+
providers: names.map((name) => ({ name })),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("replaces the known placeholder with the empty string and leaves all surrounding text byte-for-byte", () => {
|
|
42
|
+
// Whole-stub differential oracle: the sentinelled render marks every
|
|
43
|
+
// expansion site, and deleting each mark must reproduce the empty render
|
|
44
|
+
// exactly — the strongest form of "surrounding text remains", covering
|
|
45
|
+
// every byte of the template, not just the lines the placeholder sits on.
|
|
46
|
+
const sent = webhookStubFor(SENTINEL);
|
|
47
|
+
|
|
48
|
+
// The sentinel reached exactly five sites — one per name-fed slot — so
|
|
49
|
+
// the oracle below is not vacuous: every placeholder was exercised.
|
|
50
|
+
expect(count(sent, SENTINEL)).toBe(5);
|
|
51
|
+
|
|
52
|
+
// Removing the placeholder's replacement value leaves the empty render:
|
|
53
|
+
// the slot emitted "" and nothing else moved anywhere in the output.
|
|
54
|
+
expect(sent.replaceAll(SENTINEL, "")).toBe(webhookStubFor(""));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("emits the empty replacement at all five sites with each surrounding line intact", () => {
|
|
58
|
+
// Full-line equality (not substring presence): each placeholder line is
|
|
59
|
+
// byte-exact — the slot vanished into "" and the rest of the line kept
|
|
60
|
+
// its fixed text with no extra residue appended to it.
|
|
61
|
+
const stub = webhookStubFor("");
|
|
62
|
+
const lines = stub.split("\n");
|
|
63
|
+
|
|
64
|
+
// `${provider}` sites: the name vanished between the fixed prose — a
|
|
65
|
+
// trailing period after "for " and a doubled space inside "forge a".
|
|
66
|
+
expect(lines).toContain("// Auto-generated by `stack apply` — webhook handler stub for .");
|
|
67
|
+
expect(lines).toContain("// // to this endpoint can forge a webhook.");
|
|
68
|
+
|
|
69
|
+
// `${capitalize(provider)}` site: the `s === ""` early return keeps
|
|
70
|
+
// `codePointAt(0)`'s undefined out of `String.fromCodePoint`, which
|
|
71
|
+
// would throw a RangeError on NaN — the handler name is just "handle".
|
|
72
|
+
expect(lines).toContain(
|
|
73
|
+
"// export async function handleWebhook(req: Request): Promise<Response> {",
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// `${webhookSecretKeyFor(provider)}` sites: `"".toUpperCase()` is "" —
|
|
77
|
+
// the default switch case emitted the bare `_WEBHOOK_SECRET` suffix at
|
|
78
|
+
// both reveal lines, each otherwise byte-exact.
|
|
79
|
+
expect(lines).toContain("// phantom reveal _WEBHOOK_SECRET");
|
|
80
|
+
expect(lines).toContain("// // (`phantom reveal _WEBHOOK_SECRET`) and compare in");
|
|
81
|
+
expect(count(stub, "phantom reveal _WEBHOOK_SECRET")).toBe(2);
|
|
82
|
+
|
|
83
|
+
// Single pass, every slot resolved: no `${...}` residue anywhere.
|
|
84
|
+
expect(stub).not.toContain("${");
|
|
85
|
+
|
|
86
|
+
// The sibling slots took their generic defaults in the same pass — the
|
|
87
|
+
// empty name reaches none of the known-provider switch cases.
|
|
88
|
+
expect(lines).toContain('// const signature = req.headers.get("x-webhook-signature");');
|
|
89
|
+
expect(lines).toContain("// // Verify per the provider's documented signature scheme.");
|
|
90
|
+
expect(verificationHintFor("")).toBe(verificationHintFor("zzq"));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("reports the empty name in skipped byte-for-byte beside a wired known provider", async () => {
|
|
94
|
+
// Through `wirePhantomForRecipe` the empty name matches no catalog
|
|
95
|
+
// provider: `skipped` holds the empty string itself — pushed, not
|
|
96
|
+
// dropped — while the known provider listed beside it wires normally,
|
|
97
|
+
// unaffected by the empty neighbour.
|
|
98
|
+
const stubPath = join(dir, ".stack", "webhooks", "github.ts");
|
|
99
|
+
const result = await wirePhantomForRecipe(recipe("", "github"), { cwd: dir });
|
|
100
|
+
|
|
101
|
+
expect(result).toEqual({
|
|
102
|
+
present: [],
|
|
103
|
+
missing: ["github:GITHUB_TOKEN"],
|
|
104
|
+
webhooks: [stubPath],
|
|
105
|
+
skipped: [""],
|
|
106
|
+
});
|
|
107
|
+
expect(result.skipped).toHaveLength(1);
|
|
108
|
+
expect(result.skipped[0]).toBe("");
|
|
109
|
+
|
|
110
|
+
// Only github's documented stub lands — nothing for the empty name, and
|
|
111
|
+
// the stub on disk is the same render `webhookStubFor` returns.
|
|
112
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["github.ts"]);
|
|
113
|
+
expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("github"));
|
|
114
|
+
expect(existsSync(join(dir, ".stack", "webhooks", ".ts"))).toBe(false);
|
|
115
|
+
|
|
116
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
117
|
+
// still parses to an empty object — an empty name provokes no write.
|
|
118
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
119
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
120
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
|
|
5
|
+
import { exec, isPhantomInstalled, listSecrets } from "../phantom.ts";
|
|
6
|
+
import { wirePhantomForRecipe } from "./phantom-wire.ts";
|
|
7
|
+
import type { Recipe } from "./recipe.ts";
|
|
8
|
+
|
|
9
|
+
// Each test spawns the fake `phantom` shim (bash + python3).
|
|
10
|
+
setDefaultTimeout(30_000);
|
|
11
|
+
|
|
12
|
+
const RECIPE: Recipe = {
|
|
13
|
+
id: "silent-failure",
|
|
14
|
+
query: "payments",
|
|
15
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
16
|
+
providers: [{ name: "stripe" }, { name: "neon" }],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Rewrite the harness `phantom`'s `list` arm to exit nonzero without a single
|
|
21
|
+
* stderr byte — a real crash shape (a killed wrapper, a denied helper, a
|
|
22
|
+
* build that prints nothing). `--version` still answers so the install probe
|
|
23
|
+
* passes and `list --json` is the subprocess that fails. The logging preamble
|
|
24
|
+
* is preserved so `callsTo` still sees every spawned command. `stdout`
|
|
25
|
+
* optionally carries output the failed call must not legitimise.
|
|
26
|
+
*/
|
|
27
|
+
function failListSilently(h: Harness, stdout = ""): void {
|
|
28
|
+
const bin = join(h.dir, "phantom");
|
|
29
|
+
const shim = readFileSync(bin, "utf-8");
|
|
30
|
+
expect(shim).toContain(" list)\n");
|
|
31
|
+
writeFileSync(
|
|
32
|
+
bin,
|
|
33
|
+
shim.replace(" list)\n", ` list)\n printf '%s' '${stdout}'\n exit 9\n`),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rewrite only the `--version` arm to exit nonzero with empty stderr, so the
|
|
39
|
+
* install probe itself is the silently failing subprocess.
|
|
40
|
+
*/
|
|
41
|
+
function failVersionSilently(h: Harness): void {
|
|
42
|
+
const bin = join(h.dir, "phantom");
|
|
43
|
+
const shim = readFileSync(bin, "utf-8");
|
|
44
|
+
expect(shim).toContain(" --version)\n");
|
|
45
|
+
writeFileSync(bin, shim.replace(" --version)\n", " --version)\n exit 9\n"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("ai/phantom-wire when the subprocess exits nonzero with empty stderr", () => {
|
|
49
|
+
let harness: Harness;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
// The vault file really holds STRIPE_SECRET_KEY: a successful inventory
|
|
53
|
+
// would report it `present`. The failed `list` must not.
|
|
54
|
+
harness = setupFakePhantom({ STRIPE_SECRET_KEY: "x" });
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
harness.cleanup();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("keeps a silently failing inventory distinct from a successful read and reports why", async () => {
|
|
61
|
+
failListSilently(harness);
|
|
62
|
+
|
|
63
|
+
// The returned exec failure is distinguishable from success on its own:
|
|
64
|
+
// nonzero code, an "exited" completion, and genuinely empty streams.
|
|
65
|
+
const result = await exec(["list", "--json"], { cwd: harness.dir, allowFailure: true });
|
|
66
|
+
expect(result).toMatchObject({ code: 9, stderr: "", stdout: "", completion: "exited" });
|
|
67
|
+
expect(result.code).not.toBe(0);
|
|
68
|
+
|
|
69
|
+
// The thrown diagnostic names the unavailable inventory outright — it
|
|
70
|
+
// never depended on stderr carrying a message.
|
|
71
|
+
await expect(listSecrets(harness.dir)).rejects.toMatchObject({
|
|
72
|
+
code: "PHANTOM_METADATA_UNAVAILABLE",
|
|
73
|
+
message: "Phantom secret inventory is unavailable; stored names are unknown.",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Wire keeps both providers in `skipped` — the failure stays
|
|
77
|
+
// distinguishable from a successful read, which would have put
|
|
78
|
+
// `stripe:STRIPE_SECRET_KEY` in `present` and the Neon secrets in
|
|
79
|
+
// `missing`. Neither success-shaped entry may appear.
|
|
80
|
+
const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
|
|
81
|
+
expect(await wirePhantomForRecipe(RECIPE, { cwd: harness.dir })).toEqual({
|
|
82
|
+
present: [],
|
|
83
|
+
missing: [],
|
|
84
|
+
webhooks: [stubPath],
|
|
85
|
+
skipped: ["stripe", "neon"],
|
|
86
|
+
});
|
|
87
|
+
expect(existsSync(stubPath)).toBe(true);
|
|
88
|
+
expect(readFileSync(stubPath, "utf-8")).toContain("stripe-signature");
|
|
89
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("never treats stdout from a silently failed list as a successful inventory", async () => {
|
|
93
|
+
// A plausible-looking payload on stdout does not upgrade a nonzero exit
|
|
94
|
+
// to success: the vault stays unread and every provider is skipped.
|
|
95
|
+
failListSilently(harness, '[{"name":"STRIPE_SECRET_KEY"}]');
|
|
96
|
+
|
|
97
|
+
await expect(listSecrets(harness.dir)).rejects.toMatchObject({
|
|
98
|
+
code: "PHANTOM_METADATA_UNAVAILABLE",
|
|
99
|
+
});
|
|
100
|
+
const wire = await wirePhantomForRecipe(RECIPE, { cwd: harness.dir });
|
|
101
|
+
expect(wire.present).toEqual([]);
|
|
102
|
+
expect(wire.missing).toEqual([]);
|
|
103
|
+
expect(wire.skipped).toEqual(["stripe", "neon"]);
|
|
104
|
+
expect(wire.webhooks).toEqual([join(harness.dir, ".stack/webhooks/stripe.ts")]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("maps a silently crashing --version probe to not-installed, never to success", async () => {
|
|
108
|
+
failVersionSilently(harness);
|
|
109
|
+
|
|
110
|
+
// The probe reads the nonzero close as missing — distinguishable from an
|
|
111
|
+
// installed phantom — and the typed diagnostic carries the install hint.
|
|
112
|
+
expect(await isPhantomInstalled()).toBe(false);
|
|
113
|
+
await expect(listSecrets(harness.dir)).rejects.toMatchObject({
|
|
114
|
+
code: "PHANTOM_NOT_INSTALLED",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Wire skips every provider, still writes the stub, and never reaches
|
|
118
|
+
// the inventory subprocess at all.
|
|
119
|
+
const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
|
|
120
|
+
expect(await wirePhantomForRecipe(RECIPE, { cwd: harness.dir })).toEqual({
|
|
121
|
+
present: [],
|
|
122
|
+
missing: [],
|
|
123
|
+
webhooks: [stubPath],
|
|
124
|
+
skipped: ["stripe", "neon"],
|
|
125
|
+
});
|
|
126
|
+
expect(existsSync(stubPath)).toBe(true);
|
|
127
|
+
expect(harness.callsTo("--version")).toEqual([{ cmd: "--version", args: [] }]);
|
|
128
|
+
expect(harness.callsTo("list")).toEqual([]);
|
|
129
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("keeps the command and exit code in the rejection when stderr is empty", async () => {
|
|
133
|
+
failListSilently(harness);
|
|
134
|
+
|
|
135
|
+
// Callers that do not allowFailure get a rejection that still diagnoses:
|
|
136
|
+
// the argv and exit code are interpolated into the message, not the
|
|
137
|
+
// (empty) stderr text.
|
|
138
|
+
await expect(exec(["list", "--json"], { cwd: harness.dir })).rejects.toMatchObject({
|
|
139
|
+
code: "PHANTOM_EXEC_FAILED",
|
|
140
|
+
});
|
|
141
|
+
await expect(exec(["list", "--json"], { cwd: harness.dir })).rejects.toThrow(
|
|
142
|
+
/phantom list --json failed \(exit 9\)/,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
|
|
6
|
+
import { verificationHintFor, webhookStubFor, wirePhantomForRecipe } from "./phantom-wire.ts";
|
|
7
|
+
import type { Recipe } from "./recipe.ts";
|
|
8
|
+
|
|
9
|
+
// The wiring check spawns the fake `phantom` shim (bash + python3); keep the
|
|
10
|
+
// sibling files' raised timeout.
|
|
11
|
+
setDefaultTimeout(30_000);
|
|
12
|
+
|
|
13
|
+
describe("ai/phantom-wire empty template (the empty name as replacement text)", () => {
|
|
14
|
+
let harness: Harness;
|
|
15
|
+
let dir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
harness = setupFakePhantom();
|
|
19
|
+
dir = mkdtempSync(join(tmpdir(), "wire-empty-template-"));
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
harness.cleanup();
|
|
23
|
+
rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
27
|
+
id: "empty-template",
|
|
28
|
+
query: "q",
|
|
29
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
30
|
+
providers: names.map((name) => ({ name })),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("substitutes the empty string at every expansion site", () => {
|
|
34
|
+
// The name is the empty template: every slot the name feeds resolves
|
|
35
|
+
// to the empty string. Substitution returns "" — the slot vanishes
|
|
36
|
+
// between the template's own fixed text instead of leaving residue.
|
|
37
|
+
const stub = webhookStubFor("");
|
|
38
|
+
|
|
39
|
+
// Case-preserving sites collapse to nothing: `forge a webhook.` keeps
|
|
40
|
+
// the double space where the name sat, proving the slot emitted ""
|
|
41
|
+
// rather than being skipped (a skipped slot would print `${provider}`).
|
|
42
|
+
expect(stub).toContain("webhook handler stub for .");
|
|
43
|
+
expect(stub).toContain("forge a webhook.");
|
|
44
|
+
|
|
45
|
+
// `capitalize("")` must return "": its `s === ""` early return is the
|
|
46
|
+
// only thing keeping `codePointAt(0)`'s undefined out of
|
|
47
|
+
// `String.fromCodePoint`, which would throw a RangeError on NaN.
|
|
48
|
+
expect(stub).toContain("handleWebhook(req: Request)");
|
|
49
|
+
|
|
50
|
+
// `"".toUpperCase()` is "" at both reveal sites — the secret-key slot
|
|
51
|
+
// ran through the default switch case and emitted the bare suffix.
|
|
52
|
+
expect(stub.split("phantom reveal _WEBHOOK_SECRET").length - 1).toBe(2);
|
|
53
|
+
|
|
54
|
+
// Single pass, every slot resolved: the render holds zero `${...}`
|
|
55
|
+
// spans and no doubled placeholder text.
|
|
56
|
+
expect(stub).not.toContain("${");
|
|
57
|
+
|
|
58
|
+
// The sibling slots took their generic defaults in the same pass — ""
|
|
59
|
+
// reaches none of the known-provider switch cases.
|
|
60
|
+
expect(stub).toContain('req.headers.get("x-webhook-signature")');
|
|
61
|
+
expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
|
|
62
|
+
expect(verificationHintFor("")).toBe(verificationHintFor("zzq"));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reports the empty name in skipped byte-for-byte and writes nothing for it", async () => {
|
|
66
|
+
// "" matches no catalog provider and no webhook set: `skipped` is a
|
|
67
|
+
// single-element array holding the empty string itself — pushed, not
|
|
68
|
+
// dropped — and no stub lands on disk.
|
|
69
|
+
const result = await wirePhantomForRecipe(recipe(""), { cwd: dir });
|
|
70
|
+
|
|
71
|
+
expect(result).toEqual({ present: [], missing: [], webhooks: [], skipped: [""] });
|
|
72
|
+
expect(result.skipped).toHaveLength(1);
|
|
73
|
+
expect(readdirSync(dir)).toEqual([]);
|
|
74
|
+
|
|
75
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
76
|
+
// still parses to an empty object — an empty name provokes no write.
|
|
77
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
78
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
79
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
|
|
6
|
+
import { wirePhantomForRecipe } from "./phantom-wire.ts";
|
|
7
|
+
import type { Recipe } from "./recipe.ts";
|
|
8
|
+
|
|
9
|
+
// Each test spawns the fake `phantom` shim (bash + python3).
|
|
10
|
+
setDefaultTimeout(30_000);
|
|
11
|
+
|
|
12
|
+
const EMPTY_RESULT = { present: [], missing: [], webhooks: [], skipped: [] };
|
|
13
|
+
|
|
14
|
+
describe("ai/phantom-wire empty set of requested values", () => {
|
|
15
|
+
let harness: Harness;
|
|
16
|
+
let dir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
harness = setupFakePhantom();
|
|
20
|
+
dir = mkdtempSync(join(tmpdir(), "stack wire empty-values-"));
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
harness.cleanup();
|
|
24
|
+
rmSync(dir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
28
|
+
id: "empty-values",
|
|
29
|
+
query: "q",
|
|
30
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
31
|
+
providers: names.map((name) => ({ name })),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const vaultText = () => readFileSync(harness.vaultPath, "utf-8");
|
|
35
|
+
// Every logged phantom call must be a read: the install probe or `list`.
|
|
36
|
+
// A placeholder write could only arrive through `add`/`init`/`remove`/`sync`.
|
|
37
|
+
const onlyReadCalls = () =>
|
|
38
|
+
harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list");
|
|
39
|
+
|
|
40
|
+
it("writes no placeholder for a recipe with no providers", async () => {
|
|
41
|
+
const before = vaultText();
|
|
42
|
+
const result = await wirePhantomForRecipe(recipe(), { cwd: dir });
|
|
43
|
+
|
|
44
|
+
// Nothing was requested, so nothing can be present, missing, skipped or
|
|
45
|
+
// stubbed — the documented empty result.
|
|
46
|
+
expect(result).toEqual(EMPTY_RESULT);
|
|
47
|
+
expect(onlyReadCalls()).toBe(true);
|
|
48
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
49
|
+
// The vault file is byte-identical and no placeholder artifact lands on
|
|
50
|
+
// disk: no `.stack/` tree, no `.env`, nothing.
|
|
51
|
+
expect(vaultText()).toBe(before);
|
|
52
|
+
expect(JSON.parse(vaultText())).toEqual({});
|
|
53
|
+
expect(readdirSync(dir)).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("makes no external process calls for a recipe with no providers", async () => {
|
|
57
|
+
const result = await wirePhantomForRecipe(recipe(), { cwd: dir });
|
|
58
|
+
|
|
59
|
+
// Not even the `--version` install probe or `list --json` may run.
|
|
60
|
+
expect(result).toEqual(EMPTY_RESULT);
|
|
61
|
+
expect(harness.calls()).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("leaves a populated vault byte-identical when nothing is requested", async () => {
|
|
65
|
+
writeFileSync(
|
|
66
|
+
harness.vaultPath,
|
|
67
|
+
JSON.stringify({ STRIPE_SECRET_KEY: "stored-real-value", GITHUB_TOKEN: "gh-tok" }),
|
|
68
|
+
);
|
|
69
|
+
const before = vaultText();
|
|
70
|
+
|
|
71
|
+
const result = await wirePhantomForRecipe(recipe(), { cwd: dir });
|
|
72
|
+
|
|
73
|
+
// An empty request reports nothing — not even the stored names — and
|
|
74
|
+
// must not reconcile the vault: nothing moves to `present`, no stored
|
|
75
|
+
// name is dropped or rewritten.
|
|
76
|
+
expect(result).toEqual(EMPTY_RESULT);
|
|
77
|
+
expect(onlyReadCalls()).toBe(true);
|
|
78
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
79
|
+
expect(vaultText()).toBe(before);
|
|
80
|
+
expect(JSON.parse(vaultText())).toEqual({
|
|
81
|
+
STRIPE_SECRET_KEY: "stored-real-value",
|
|
82
|
+
GITHUB_TOKEN: "gh-tok",
|
|
83
|
+
});
|
|
84
|
+
expect(readdirSync(dir)).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("writes no placeholder when every named provider misses the catalog", async () => {
|
|
88
|
+
const before = vaultText();
|
|
89
|
+
const result = await wirePhantomForRecipe(recipe("nope", "also-nope"), { cwd: dir });
|
|
90
|
+
|
|
91
|
+
// Unknown names resolve no secret list, so the requested set stays
|
|
92
|
+
// empty: they are reported skipped, never missing, and no stub lands.
|
|
93
|
+
expect(result).toEqual({
|
|
94
|
+
present: [],
|
|
95
|
+
missing: [],
|
|
96
|
+
webhooks: [],
|
|
97
|
+
skipped: ["nope", "also-nope"],
|
|
98
|
+
});
|
|
99
|
+
expect(onlyReadCalls()).toBe(true);
|
|
100
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
101
|
+
expect(vaultText()).toBe(before);
|
|
102
|
+
expect(readdirSync(dir)).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("writes no placeholder when the vault cannot be read", async () => {
|
|
106
|
+
// Corrupt the vault file so `list --json` exits nonzero. The requested
|
|
107
|
+
// set is still empty and the failed read must not trigger a write.
|
|
108
|
+
writeFileSync(harness.vaultPath, "not json");
|
|
109
|
+
|
|
110
|
+
const result = await wirePhantomForRecipe(recipe(), { cwd: dir });
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual(EMPTY_RESULT);
|
|
113
|
+
expect(onlyReadCalls()).toBe(true);
|
|
114
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
115
|
+
// The unreadable file is left alone — no repair write, no placeholder.
|
|
116
|
+
expect(vaultText()).toBe("not json");
|
|
117
|
+
expect(readdirSync(dir)).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("spawns nothing at all when the empty request opts out with noWire", async () => {
|
|
121
|
+
const before = vaultText();
|
|
122
|
+
const result = await wirePhantomForRecipe(recipe(), { cwd: dir, noWire: true });
|
|
123
|
+
|
|
124
|
+
expect(result).toEqual(EMPTY_RESULT);
|
|
125
|
+
expect(harness.calls()).toEqual([]);
|
|
126
|
+
expect(vaultText()).toBe(before);
|
|
127
|
+
expect(existsSync(join(dir, ".stack"))).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|