@ashlr/stack-core 0.3.0 → 0.3.6
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 +3 -3
- 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 +6329 -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,125 @@
|
|
|
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
|
+
// Each of the template's own slot spellings, usable as a provider name that
|
|
14
|
+
// spells a known placeholder.
|
|
15
|
+
const SLOT_TOKENS = [
|
|
16
|
+
"${provider}", // the name slot's own spelling
|
|
17
|
+
"${capitalize(provider)}", // the handler-name slot's spelling
|
|
18
|
+
"${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
|
|
19
|
+
"${signatureHeaderFor(provider)}", // the header slot's spelling
|
|
20
|
+
"${verificationHintFor(provider)}", // the hint slot's spelling
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
describe("ai/phantom-wire known placeholder beside a carriage return", () => {
|
|
24
|
+
let harness: Harness;
|
|
25
|
+
let dir: string;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
harness = setupFakePhantom();
|
|
29
|
+
dir = mkdtempSync(join(tmpdir(), "wire-placeholder-cr-"));
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
harness.cleanup();
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
37
|
+
id: "placeholder-cr",
|
|
38
|
+
query: "q",
|
|
39
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
40
|
+
providers: names.map((name) => ({ name })),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it.each(SLOT_TOKENS.flatMap((token) => [`${token}\r`, `\r${token}`]))(
|
|
44
|
+
"replaces every slot and preserves the carriage return beside the carried token in %j",
|
|
45
|
+
(name) => {
|
|
46
|
+
// The name is exactly one known placeholder token with a carriage
|
|
47
|
+
// return seated beside it — before it or after it, no ordinary text.
|
|
48
|
+
// Expansion fills the five name-carrying sites with the name itself,
|
|
49
|
+
// so the rendered stub holds the token and the CR still adjacent in
|
|
50
|
+
// the input's own order: a resolver would resolve the token away, a
|
|
51
|
+
// sanitizer would strip or escape the control byte, a newline
|
|
52
|
+
// normalization would rewrite it as CRLF. None of that runs.
|
|
53
|
+
const stub = webhookStubFor(name);
|
|
54
|
+
const token = name.replace("\r", "");
|
|
55
|
+
|
|
56
|
+
// The three case-preserving sites carry the name byte-for-byte —
|
|
57
|
+
// token, CR and all. `capitalize` folds only the first code point,
|
|
58
|
+
// and both `$` and `\r` uppercase to themselves, so the handler site
|
|
59
|
+
// holds the identical bytes.
|
|
60
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
61
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
62
|
+
expect(stub).toContain(`handle${name}Webhook(req: Request)`);
|
|
63
|
+
// The two `toUpperCase()` reveal sites carry the folded name; `\r`
|
|
64
|
+
// folds to itself, so the CR sits beside the folded token there too.
|
|
65
|
+
expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
|
|
66
|
+
|
|
67
|
+
// Five name sites, five carriage returns — none stripped, doubled or
|
|
68
|
+
// moved. Each sits exactly where the name put it: adjacent to the
|
|
69
|
+
// carried token, on the same side it occupied in the input.
|
|
70
|
+
expect(stub.split("\r").length - 1).toBe(5);
|
|
71
|
+
const adjacent = name.startsWith("\r") ? /\r\$\{[^}]*\}/g : /\$\{[^}]*\}\r/g;
|
|
72
|
+
expect(stub.match(adjacent)).toHaveLength(5);
|
|
73
|
+
// The byte is data, not a line ending: the stub still contains no
|
|
74
|
+
// CRLF, and the template's own `.\n` still terminates the title line
|
|
75
|
+
// right after the CR-carrying name.
|
|
76
|
+
expect(stub).not.toContain("\r\n");
|
|
77
|
+
expect(stub).toContain(`${name}.\n`);
|
|
78
|
+
|
|
79
|
+
// Every `${...}` in the output is the carried token in that site's
|
|
80
|
+
// case — five expansion sites, five occurrences, none resolved away.
|
|
81
|
+
const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
|
|
82
|
+
expect(tokens).toHaveLength(5);
|
|
83
|
+
for (const t of tokens) expect(t.toLowerCase()).toBe(token.toLowerCase());
|
|
84
|
+
|
|
85
|
+
// The remaining slots took their own values in the same single pass:
|
|
86
|
+
// the generic signature header and the fallback hint — the carried
|
|
87
|
+
// token never reached their switch cases either.
|
|
88
|
+
expect(stub).toContain('req.headers.get("x-webhook-signature")');
|
|
89
|
+
expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
|
|
90
|
+
expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
it("skips a CR-carrying placeholder name byte-for-byte through wiring and writes nothing for it", async () => {
|
|
95
|
+
// Through `wirePhantomForRecipe` the name matches no catalog provider:
|
|
96
|
+
// it returns in `skipped` byte-for-byte — token and carriage return
|
|
97
|
+
// intact — gets no stub on disk, and provokes no vault write. The
|
|
98
|
+
// known provider listed beside it wires normally, unaffected by the
|
|
99
|
+
// control-byte neighbour.
|
|
100
|
+
const name = "${provider}\r";
|
|
101
|
+
const stubPath = join(dir, ".stack", "webhooks", "github.ts");
|
|
102
|
+
const result = await wirePhantomForRecipe(recipe(name, "github"), { cwd: dir });
|
|
103
|
+
|
|
104
|
+
expect(result).toEqual({
|
|
105
|
+
present: [],
|
|
106
|
+
missing: ["github:GITHUB_TOKEN"],
|
|
107
|
+
webhooks: [stubPath],
|
|
108
|
+
skipped: [name],
|
|
109
|
+
});
|
|
110
|
+
expect(result.skipped[0]).toBe(name);
|
|
111
|
+
|
|
112
|
+
// Only github's documented stub lands — nothing for the CR-carrying
|
|
113
|
+
// name, and the stub on disk is the same render `webhookStubFor`
|
|
114
|
+
// returns.
|
|
115
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["github.ts"]);
|
|
116
|
+
expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("github"));
|
|
117
|
+
expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
|
|
118
|
+
|
|
119
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
120
|
+
// still parses to an empty object.
|
|
121
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
122
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
123
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
describe("ai/phantom-wire case-changed placeholder name", () => {
|
|
16
|
+
let harness: Harness;
|
|
17
|
+
let dir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
harness = setupFakePhantom();
|
|
21
|
+
dir = mkdtempSync(join(tmpdir(), "wire-case-changed-"));
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
harness.cleanup();
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
29
|
+
id: "case-changed",
|
|
30
|
+
query: "q",
|
|
31
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
32
|
+
providers: names.map((name) => ({ name })),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each([
|
|
36
|
+
["${Provider}", "${provider}"], // name slot, first letter up
|
|
37
|
+
["${CAPITALIZE(provider)}", "${capitalize(provider)}"], // handler slot, callee uppercased
|
|
38
|
+
["${webhooksecretkeyfor(provider)}", "${webhookSecretKeyFor(provider)}"], // key slot, all lower
|
|
39
|
+
["${SignatureHeaderFor(Provider)}", "${signatureHeaderFor(provider)}"], // header slot, mixed
|
|
40
|
+
["${VERIFICATIONHINTFOR(PROVIDER)}", "${verificationHintFor(provider)}"], // hint slot, all caps
|
|
41
|
+
])(
|
|
42
|
+
"keeps the case-changed spelling %j literal at every site — never normalized to %j",
|
|
43
|
+
(name, slot) => {
|
|
44
|
+
// Placeholder matching is case-sensitive end to end: only the exact
|
|
45
|
+
// spelling names a slot. A whole-name token in any other case is plain
|
|
46
|
+
// data — it reaches all five expansion sites in its own case (or
|
|
47
|
+
// folded at the `toUpperCase()` sites), is never rewritten to the
|
|
48
|
+
// slot's exact spelling, and never resolves into the value the exact
|
|
49
|
+
// spelling would name. The template's real slots take their generic
|
|
50
|
+
// values around it.
|
|
51
|
+
const stub = webhookStubFor(name);
|
|
52
|
+
|
|
53
|
+
// The name lands byte-for-byte at the three case-preserving sites and
|
|
54
|
+
// whole-name uppercased at the two secret-key sites; `capitalize`
|
|
55
|
+
// folds only a leading letter and `$` uppercases to itself.
|
|
56
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
57
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
58
|
+
expect(stub).toContain(`handle${name}Webhook(req: Request)`);
|
|
59
|
+
expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
|
|
60
|
+
|
|
61
|
+
// The exact-case slot spelling appears nowhere: the carried token was
|
|
62
|
+
// emitted verbatim or folded, never recognized and re-emitted in
|
|
63
|
+
// canonical slot case.
|
|
64
|
+
expect(stub).not.toContain(slot);
|
|
65
|
+
|
|
66
|
+
// Five `${...}` spans — one per expansion site — every one the
|
|
67
|
+
// carried token in one of the two casings the name legitimately
|
|
68
|
+
// takes; none resolved away, none minted in the slot's own case.
|
|
69
|
+
const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
|
|
70
|
+
expect(tokens).toHaveLength(5);
|
|
71
|
+
for (const token of tokens) expect(token.toLowerCase()).toBe(name.toLowerCase());
|
|
72
|
+
|
|
73
|
+
// Unrelated text renders normally around the carried token: the
|
|
74
|
+
// signature-header and hint slots took the generic values the
|
|
75
|
+
// case-sensitive switches fall through to for a non-provider name.
|
|
76
|
+
expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
|
|
77
|
+
expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
|
|
78
|
+
expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
it("keeps the ordinary text flanking a case-changed token byte-for-byte at every site", () => {
|
|
83
|
+
// `pre${Provider}post` seats a case-changed `${provider}`-lookalike
|
|
84
|
+
// between unrelated text. The flanks and the token's own case reach
|
|
85
|
+
// every expansion site untouched: the token is never folded to the
|
|
86
|
+
// exact slot spelling and the surrounding `pre`/`post` is never eaten,
|
|
87
|
+
// doubled, or merged into a value.
|
|
88
|
+
const name = "pre${Provider}post";
|
|
89
|
+
const stub = webhookStubFor(name);
|
|
90
|
+
|
|
91
|
+
// Case-preserving sites carry the whole name byte-for-byte;
|
|
92
|
+
// `capitalize` folds only the first letter; the two reveal sites fold
|
|
93
|
+
// token and flanks together.
|
|
94
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
95
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
96
|
+
expect(stub).toContain("handlePre${Provider}postWebhook(req: Request)");
|
|
97
|
+
expect(count(stub, "phantom reveal PRE${PROVIDER}POST_WEBHOOK_SECRET")).toBe(2);
|
|
98
|
+
|
|
99
|
+
// Each token still sits between exactly one `pre` and one `post`, in
|
|
100
|
+
// whatever case that site's transform gives them — five sites, five
|
|
101
|
+
// seated occurrences — and never in the all-lowercase spelling an
|
|
102
|
+
// exact-match resolver would have used.
|
|
103
|
+
expect(stub.match(/pre\$\{provider\}post/gi)).toHaveLength(5);
|
|
104
|
+
expect(stub).not.toContain("pre${provider}post");
|
|
105
|
+
// `pre`+token+`post` verbatim at title and forge, `Pre`-folded at the
|
|
106
|
+
// handler site, fully folded at both reveal sites.
|
|
107
|
+
expect(count(stub, "pre${Provider}post")).toBe(2);
|
|
108
|
+
expect(count(stub, "Pre${Provider}post")).toBe(1);
|
|
109
|
+
expect(count(stub, "PRE${PROVIDER}POST")).toBe(2);
|
|
110
|
+
|
|
111
|
+
// The remaining slots took their generic values in the same single pass.
|
|
112
|
+
expect(stub).toContain('req.headers.get("x-webhook-signature")');
|
|
113
|
+
expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
|
|
114
|
+
expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each(["Stripe", "STRIPE", "sTrIpE"])(
|
|
118
|
+
"misses every case-sensitive lookup for the case-changed provider name %s",
|
|
119
|
+
(name) => {
|
|
120
|
+
// `stripe` is a known provider only in its exact spelling. Changing
|
|
121
|
+
// any letter's case makes every match miss: the signature header and
|
|
122
|
+
// the verification hint fall through to their generic defaults
|
|
123
|
+
// instead of stripe's — the contract is byte equality, not folding.
|
|
124
|
+
const stub = webhookStubFor(name);
|
|
125
|
+
const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
|
|
126
|
+
|
|
127
|
+
// The name sites still carry the supplied case verbatim.
|
|
128
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
129
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
130
|
+
expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
|
|
131
|
+
|
|
132
|
+
// The misses: generic header, generic hint — stripe's own values
|
|
133
|
+
// appear nowhere in the render.
|
|
134
|
+
expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
|
|
135
|
+
expect(stub).not.toContain("stripe-signature");
|
|
136
|
+
expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
|
|
137
|
+
expect(stub).not.toContain("stripe.webhooks.constructEvent");
|
|
138
|
+
expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
139
|
+
|
|
140
|
+
// Both reveal sites still spell `STRIPE_WEBHOOK_SECRET` — the default
|
|
141
|
+
// branch's whole-name `toUpperCase()`, not a match on stripe's case.
|
|
142
|
+
// Same bytes, different reason.
|
|
143
|
+
expect(count(stub, "phantom reveal STRIPE_WEBHOOK_SECRET")).toBe(2);
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
it("skips a case-changed provider name through wiring while the exact-case twin wires normally", async () => {
|
|
148
|
+
// `Stripe` differs from `stripe` by one letter's case. The catalog
|
|
149
|
+
// lookup and the webhook-provider set are case-sensitive: the variant
|
|
150
|
+
// returns in `skipped` byte-for-byte, writes no stub, provokes no vault
|
|
151
|
+
// write — while the exact-case name beside it wires normally.
|
|
152
|
+
const stubPath = join(dir, ".stack", "webhooks", "stripe.ts");
|
|
153
|
+
const result = await wirePhantomForRecipe(recipe("Stripe", "stripe"), { cwd: dir });
|
|
154
|
+
|
|
155
|
+
expect(result).toEqual({
|
|
156
|
+
present: [],
|
|
157
|
+
missing: ["stripe:STRIPE_SECRET_KEY"],
|
|
158
|
+
webhooks: [stubPath],
|
|
159
|
+
skipped: ["Stripe"],
|
|
160
|
+
});
|
|
161
|
+
// Byte-for-byte: the skipped name kept its case — not folded to `stripe`.
|
|
162
|
+
expect(result.skipped[0]).toBe("Stripe");
|
|
163
|
+
|
|
164
|
+
// Only stripe's documented stub lands — the directory listing holds
|
|
165
|
+
// exactly `stripe.ts` — and the stub on disk is the same render
|
|
166
|
+
// `webhookStubFor` returns. (`existsSync` can't stand in here: on a
|
|
167
|
+
// case-insensitive filesystem `Stripe.ts` resolves to `stripe.ts`, so
|
|
168
|
+
// the listing is the check that no second file was written.)
|
|
169
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["stripe.ts"]);
|
|
170
|
+
expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("stripe"));
|
|
171
|
+
|
|
172
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
173
|
+
// still parses to an empty object.
|
|
174
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
175
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
176
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("skips a whole-name case-changed token byte-for-byte through wiring and writes nothing for it", async () => {
|
|
180
|
+
// Through `wirePhantomForRecipe` the case-changed slot spelling matches
|
|
181
|
+
// no catalog provider: it returns in `skipped` byte-for-byte — its case
|
|
182
|
+
// intact, not normalized to the exact spelling — gets no stub on disk,
|
|
183
|
+
// and provokes no vault write. The known provider listed beside it
|
|
184
|
+
// wires normally, unaffected by the placeholder-shaped neighbour.
|
|
185
|
+
const name = "${Provider}";
|
|
186
|
+
const stubPath = join(dir, ".stack", "webhooks", "github.ts");
|
|
187
|
+
const result = await wirePhantomForRecipe(recipe(name, "github"), { cwd: dir });
|
|
188
|
+
|
|
189
|
+
expect(result).toEqual({
|
|
190
|
+
present: [],
|
|
191
|
+
missing: ["github:GITHUB_TOKEN"],
|
|
192
|
+
webhooks: [stubPath],
|
|
193
|
+
skipped: [name],
|
|
194
|
+
});
|
|
195
|
+
expect(result.skipped[0]).toBe(name);
|
|
196
|
+
|
|
197
|
+
// Only github's documented stub lands — nothing for the token name —
|
|
198
|
+
// and the stub on disk is the same render `webhookStubFor` returns.
|
|
199
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["github.ts"]);
|
|
200
|
+
expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("github"));
|
|
201
|
+
expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
|
|
202
|
+
|
|
203
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
204
|
+
// still parses to an empty object.
|
|
205
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
206
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
207
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { webhookStubFor } from "./phantom-wire.ts";
|
|
3
|
+
|
|
4
|
+
describe("webhook stub closing braces", () => {
|
|
5
|
+
it("preserves a lone closing brace and its surrounding interpolated prose", () => {
|
|
6
|
+
const stub = webhookStubFor("before}after");
|
|
7
|
+
|
|
8
|
+
expect(stub.split("\n")[0]).toBe(
|
|
9
|
+
"// Auto-generated by `stack apply` — webhook handler stub for before}after.",
|
|
10
|
+
);
|
|
11
|
+
expect(stub).toContain("// // to this endpoint can forge a before}after webhook.\n");
|
|
12
|
+
expect(stub).toContain(
|
|
13
|
+
'// // parse, trust, or act on the body.\n// return new Response("ok", { status: 200 });\n// }\n',
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { verificationHintFor, webhookStubFor } from "./phantom-wire.ts";
|
|
3
|
+
|
|
4
|
+
const crlfCount = (s: string) => s.split("\r\n").length - 1;
|
|
5
|
+
|
|
6
|
+
describe("ai/phantom-wire replacement text containing CRLF", () => {
|
|
7
|
+
it("keeps the exact CRLF bytes at all five expansion sites", () => {
|
|
8
|
+
// CR+LF in the name is data: it must come out as the two bytes 0x0D 0x0A
|
|
9
|
+
// at every site, not normalized to LF, not split, not escaped.
|
|
10
|
+
const stub = webhookStubFor("a\r\nb");
|
|
11
|
+
const flat = webhookStubFor("ab");
|
|
12
|
+
|
|
13
|
+
expect(stub).toContain("webhook handler stub for a\r\nb.");
|
|
14
|
+
expect(stub).toContain("forge a a\r\nb webhook.");
|
|
15
|
+
expect(stub).toContain("handleA\r\nbWebhook(req: Request)");
|
|
16
|
+
expect(stub.split("phantom reveal A\r\nB_WEBHOOK_SECRET").length - 1).toBe(2);
|
|
17
|
+
|
|
18
|
+
// Template is LF-only, so every CRLF in the output is one we inserted.
|
|
19
|
+
expect(crlfCount(webhookStubFor("a\nb"))).toBe(0);
|
|
20
|
+
expect(crlfCount(stub)).toBe(5);
|
|
21
|
+
expect(stub.split("\r").length - 1).toBe(5);
|
|
22
|
+
expect(stub.split("\n").length - 1).toBe(flat.split("\n").length - 1 + 5);
|
|
23
|
+
expect(stub).not.toContain("\\r");
|
|
24
|
+
expect(stub).not.toContain("\\n");
|
|
25
|
+
expect(stub).not.toContain("${");
|
|
26
|
+
|
|
27
|
+
// Byte-level pin right after the first site.
|
|
28
|
+
const at = stub.indexOf("stub for a") + "stub for a".length;
|
|
29
|
+
expect([stub.charCodeAt(at), stub.charCodeAt(at + 1)]).toEqual([0x0d, 0x0a]);
|
|
30
|
+
|
|
31
|
+
expect(verificationHintFor("a\r\nb")).toBe(verificationHintFor("zzq"));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it.each(["\r\n", "x\r\n", "\r\nx", "a\r\n\r\nb", "a\n\rb"])(
|
|
35
|
+
"preserves every CR and LF byte of %j in order",
|
|
36
|
+
(name) => {
|
|
37
|
+
const stub = webhookStubFor(name);
|
|
38
|
+
const flat = webhookStubFor(name.replace(/[\r\n]/g, ""));
|
|
39
|
+
const count = (s: string, ch: string) => s.split(ch).length - 1;
|
|
40
|
+
|
|
41
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
42
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
43
|
+
expect(count(stub, "\r")).toBe(count(flat, "\r") + 5 * count(name, "\r"));
|
|
44
|
+
expect(count(stub, "\n")).toBe(count(flat, "\n") + 5 * count(name, "\n"));
|
|
45
|
+
expect(crlfCount(stub)).toBe(crlfCount(flat) + 5 * crlfCount(name));
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
describe("ai/phantom-wire literal `$&` in replacement text", () => {
|
|
14
|
+
let harness: Harness;
|
|
15
|
+
let dir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
harness = setupFakePhantom();
|
|
19
|
+
dir = mkdtempSync(join(tmpdir(), "wire-dollar-amp-"));
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
harness.cleanup();
|
|
23
|
+
rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
27
|
+
id: "dollar-amp",
|
|
28
|
+
query: "q",
|
|
29
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
30
|
+
providers: names.map((name) => ({ name })),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("keeps the bare `$&` match token as the whole name byte-for-byte at every site", () => {
|
|
34
|
+
// `$&` is the String.replace replacement pattern that splices in the
|
|
35
|
+
// matched substring — the slot spelling itself here. If expansion ever
|
|
36
|
+
// ran through replace, a whole name of `$&` would vanish into
|
|
37
|
+
// `${provider}` (or `${webhookSecretKeyFor(provider)}`, etc.) at every
|
|
38
|
+
// site: the two characters gone, placeholder-shaped residue in their
|
|
39
|
+
// place. Carried as data through a single template pass the name is
|
|
40
|
+
// just `$&` — and since neither `$` nor `&` has a case mapping, even
|
|
41
|
+
// the `capitalize` and `toUpperCase` sites emit the identical bytes.
|
|
42
|
+
const stub = webhookStubFor("$&");
|
|
43
|
+
|
|
44
|
+
expect(stub).toContain("webhook handler stub for $&.");
|
|
45
|
+
expect(stub).toContain("handle$&Webhook(req: Request)");
|
|
46
|
+
expect(stub).toContain("forge a $& webhook.");
|
|
47
|
+
expect(stub.split("phantom reveal $&_WEBHOOK_SECRET").length - 1).toBe(2);
|
|
48
|
+
|
|
49
|
+
// Five expansion sites, one `$&` each — none spliced into the match. A
|
|
50
|
+
// replace-driven render would show zero `$&` and `${...}` residue.
|
|
51
|
+
expect(stub.split("$&").length - 1).toBe(5);
|
|
52
|
+
expect(stub).not.toContain("${");
|
|
53
|
+
|
|
54
|
+
// The remaining slots resolved to their own values in the same pass:
|
|
55
|
+
// the generic signature header and the fallback hint.
|
|
56
|
+
expect(stub).toContain('req.headers.get("x-webhook-signature")');
|
|
57
|
+
expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
|
|
58
|
+
expect(verificationHintFor("$&")).toBe(verificationHintFor("zzq"));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it.each(["$&hook", "hook$&", "$&$&", "a$&$&b"])(
|
|
62
|
+
"keeps every literal `$&` in %j at all five expansion sites",
|
|
63
|
+
(name) => {
|
|
64
|
+
// Leading, trailing, and adjacent tokens are the same data. Under
|
|
65
|
+
// replace semantics each `$&` would splice in the match — `a$&$&b`
|
|
66
|
+
// would come out `a${provider}${provider}b`. Rendered as a name every
|
|
67
|
+
// token survives at all five sites; the case folds touch only the
|
|
68
|
+
// letters around them, never the two characters themselves.
|
|
69
|
+
const stub = webhookStubFor(name);
|
|
70
|
+
const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
|
|
71
|
+
|
|
72
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
73
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
74
|
+
expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
|
|
75
|
+
expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
|
|
76
|
+
|
|
77
|
+
// Each `$&` in the name lands once per expansion site — the count
|
|
78
|
+
// scales with the token count — and no `${...}` residue was spliced.
|
|
79
|
+
expect(stub.split("$&").length - 1).toBe(5 * (name.split("$&").length - 1));
|
|
80
|
+
expect(stub).not.toContain("${");
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
it("preserves `$&` after an astral initial at every expansion site", () => {
|
|
85
|
+
const stub = webhookStubFor("𐐨$&tail");
|
|
86
|
+
|
|
87
|
+
expect(stub).toContain("webhook handler stub for 𐐨$&tail.");
|
|
88
|
+
expect(stub).toContain("handle𐐀$&tailWebhook(req: Request)");
|
|
89
|
+
expect(stub).toContain("forge a 𐐨$&tail webhook.");
|
|
90
|
+
expect(stub.split("phantom reveal 𐐀$&TAIL_WEBHOOK_SECRET")).toHaveLength(3);
|
|
91
|
+
expect(stub.split("$&")).toHaveLength(6);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("skips `$&` names byte-for-byte through wiring and writes nothing for them", async () => {
|
|
95
|
+
// Through `wirePhantomForRecipe` the bare and doubled tokens match no
|
|
96
|
+
// catalog provider: both return in `skipped` byte-for-byte — every `$`
|
|
97
|
+
// and `&` intact, in order — get no stub on disk, and provoke no vault
|
|
98
|
+
// write. The known provider listed beside them wires normally,
|
|
99
|
+
// unaffected by the match-token-shaped neighbours.
|
|
100
|
+
const stubPath = join(dir, ".stack", "webhooks", "clerk.ts");
|
|
101
|
+
const result = await wirePhantomForRecipe(recipe("$&", "$&$&", "clerk"), { cwd: dir });
|
|
102
|
+
|
|
103
|
+
expect(result).toEqual({
|
|
104
|
+
present: [],
|
|
105
|
+
missing: ["clerk:CLERK_SECRET_KEY"],
|
|
106
|
+
webhooks: [stubPath],
|
|
107
|
+
skipped: ["$&", "$&$&"],
|
|
108
|
+
});
|
|
109
|
+
// Byte-level pin: `$` is 0x24 and `&` is 0x26 — the pair survives the
|
|
110
|
+
// catalog lookup and the skip path unconsumed.
|
|
111
|
+
expect(result.skipped[0]?.charCodeAt(0)).toBe(0x24);
|
|
112
|
+
expect(result.skipped[0]?.charCodeAt(1)).toBe(0x26);
|
|
113
|
+
expect(result.skipped[1]?.split("$&").length - 1).toBe(2);
|
|
114
|
+
|
|
115
|
+
// Only clerk's documented stub lands — nothing for the `$&` names, and
|
|
116
|
+
// the stub on disk is the same render `webhookStubFor` returns.
|
|
117
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["clerk.ts"]);
|
|
118
|
+
expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("clerk"));
|
|
119
|
+
expect(existsSync(join(dir, ".stack", "webhooks", "$&.ts"))).toBe(false);
|
|
120
|
+
expect(existsSync(join(dir, ".stack", "webhooks", "$&$&.ts"))).toBe(false);
|
|
121
|
+
|
|
122
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
123
|
+
// still parses to an empty object.
|
|
124
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
125
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
126
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
describe("ai/phantom-wire double quote in replacement text", () => {
|
|
14
|
+
let harness: Harness;
|
|
15
|
+
let dir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
harness = setupFakePhantom();
|
|
19
|
+
dir = mkdtempSync(join(tmpdir(), "wire-double-quote-"));
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
harness.cleanup();
|
|
23
|
+
rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
27
|
+
id: "double-quote",
|
|
28
|
+
query: "q",
|
|
29
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
30
|
+
providers: names.map((name) => ({ name })),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// The template carries its own quotes: two around the fixed signature
|
|
34
|
+
// header in `req.headers.get("...")`, two in `new Response("Missing
|
|
35
|
+
// signature", ...)` and two in `new Response("ok", ...)`. Measure them off
|
|
36
|
+
// a quote-free name so the arithmetic tracks template drift instead of
|
|
37
|
+
// pinning a count by hand.
|
|
38
|
+
const templateDoubles = webhookStubFor("zzq").split('"').length - 1;
|
|
39
|
+
|
|
40
|
+
it.each([
|
|
41
|
+
'pay"hook', // interior quote
|
|
42
|
+
'"hook', // leading quote
|
|
43
|
+
'hook"', // trailing quote
|
|
44
|
+
'a""b', // adjacent pair inside the name
|
|
45
|
+
'"', // the entire name is one quote
|
|
46
|
+
'""', // the entire name is a quote pair
|
|
47
|
+
])("renders the double quote byte-for-byte at every expansion site for %j", (name) => {
|
|
48
|
+
const stub = webhookStubFor(name);
|
|
49
|
+
const nameQuotes = name.split('"').length - 1;
|
|
50
|
+
|
|
51
|
+
// Raw sites: the title line and the forgery warning carry the name
|
|
52
|
+
// unchanged — the quote is data, never escaped, encoded, or dropped.
|
|
53
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
54
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
55
|
+
|
|
56
|
+
// `capitalize(name)` site: only the first code point folds; `"` has no
|
|
57
|
+
// case mapping, so every quote in the name survives into the handler.
|
|
58
|
+
expect(stub).toContain(
|
|
59
|
+
`handle${name[0]?.toUpperCase() ?? ""}${name.slice(1)}Webhook(req: Request)`,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// `webhookSecretKeyFor(name)` sites: `toUpperCase()` leaves `"` alone,
|
|
63
|
+
// so the quote-bearing key lands in both `phantom reveal` lines.
|
|
64
|
+
expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
|
|
65
|
+
|
|
66
|
+
// Five expansion sites per name; the stub holds exactly the template's
|
|
67
|
+
// own quotes plus the ones the name carried in — none lost to escaping.
|
|
68
|
+
expect(stub.split('"').length - 1).toBe(templateDoubles + nameQuotes * 5);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it.each(['pay"hook', '"', 'a""b'])(
|
|
72
|
+
"emits no escaped or encoded form of the quote for %j",
|
|
73
|
+
(name) => {
|
|
74
|
+
const stub = webhookStubFor(name);
|
|
75
|
+
// A JSON.stringify-style pass would emit `\"`; an HTML/URI encoder
|
|
76
|
+
// would emit an entity or %22. None of those run: the quote is
|
|
77
|
+
// interpolated as plain text inside line comments.
|
|
78
|
+
for (const encoded of ['\\"', """, """, """, "%22", "\\u0022"]) {
|
|
79
|
+
expect(stub).not.toContain(encoded);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
it("keeps every quote inside `//` comments and the template's own string literals intact", () => {
|
|
85
|
+
// The only real string literals in the stub are the template's own:
|
|
86
|
+
// a `"` reaching one of those positions would corrupt the generated
|
|
87
|
+
// code's syntax. Every provider-name site sits in a line comment, so
|
|
88
|
+
// the carried quote can never terminate a literal — and the literals
|
|
89
|
+
// themselves keep exactly their template text.
|
|
90
|
+
const stub = webhookStubFor('pay"hook');
|
|
91
|
+
expect(stub).toContain('req.headers.get("x-webhook-signature")');
|
|
92
|
+
expect(stub).toContain('new Response("Missing signature", { status: 400 })');
|
|
93
|
+
expect(stub).toContain('new Response("ok", { status: 200 })');
|
|
94
|
+
expect(stub.split("\n").every((line) => line === "" || line.startsWith("//"))).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns the fallback hint unchanged for a quote-bearing name", () => {
|
|
98
|
+
// The hint is a fixed string per provider with no name interpolation:
|
|
99
|
+
// the quote in the replacement text never reaches it.
|
|
100
|
+
expect(verificationHintFor('pay"hook')).toBe(
|
|
101
|
+
"Verify per the provider's documented signature scheme.",
|
|
102
|
+
);
|
|
103
|
+
expect(verificationHintFor('"')).toBe(verificationHintFor("zzq"));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("skips a quote-bearing name byte-for-byte through wiring and writes nothing for it", async () => {
|
|
107
|
+
// `stripe"` matches no catalog provider (the lookup compares exact
|
|
108
|
+
// bytes): it returns in `skipped` with U+0022 intact, gets no stub on
|
|
109
|
+
// disk, and provokes no vault write. The known provider listed beside
|
|
110
|
+
// it wires normally, unaffected by the quoted neighbour.
|
|
111
|
+
const name = 'stripe"';
|
|
112
|
+
const stubPath = join(dir, ".stack", "webhooks", "github.ts");
|
|
113
|
+
const result = await wirePhantomForRecipe(recipe(name, "github"), { cwd: dir });
|
|
114
|
+
|
|
115
|
+
expect(result).toEqual({
|
|
116
|
+
present: [],
|
|
117
|
+
missing: ["github:GITHUB_TOKEN"],
|
|
118
|
+
webhooks: [stubPath],
|
|
119
|
+
skipped: [name],
|
|
120
|
+
});
|
|
121
|
+
expect(result.skipped[0]?.charCodeAt(6)).toBe(0x22);
|
|
122
|
+
|
|
123
|
+
// Only github's documented stub lands — nothing for the quoted name —
|
|
124
|
+
// and the stub on disk is the same render `webhookStubFor` returns.
|
|
125
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["github.ts"]);
|
|
126
|
+
expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("github"));
|
|
127
|
+
expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
|
|
128
|
+
|
|
129
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
130
|
+
// still parses to an empty object.
|
|
131
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
132
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
133
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
134
|
+
});
|
|
135
|
+
});
|