@funnelsgrove/cli 0.1.87 → 0.1.89
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/dist/apiClient.d.ts +1 -0
- package/dist/apiClient.js +32 -1
- package/funnel-contract-compatibility.json +28 -28
- package/package.json +3 -3
- package/template_docs/.funnelsgrove-docs.json +3 -3
- package/template_docs/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
- package/template_docs/funnel-docs.config.json +1 -1
- package/template_scaffold/.funnelsgrove-docs.json +3 -3
- package/template_scaffold/.funnelsgrove-scaffold.json +26 -471
- package/template_scaffold/PLAN.md +15 -29
- package/template_scaffold/context/build-context.md +3 -3
- package/template_scaffold/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
- package/template_scaffold/funnel-docs.config.json +1 -1
- package/template_scaffold/package-lock.json +9 -9
- package/template_scaffold/package.json +2 -2
- package/template_scaffold/public/assets/email-capture-preview.svg +5 -0
- package/template_scaffold/public/assets/paywall-preview.svg +6 -0
- package/template_scaffold/src/app/sdk/page.tsx +14 -26
- package/template_scaffold/src/components/FunnelFlow.tsx +8 -1
- package/template_scaffold/src/config/billing.plans.ts +71 -21
- package/template_scaffold/src/config/funnel.manifest.ts +20 -59
- package/template_scaffold/src/runtime/checkout-runtime-config.ts +0 -7
- package/template_scaffold/src/runtime/step-registry.ts +0 -28
- package/template_scaffold/src/steps/content/step-01.content.ts +4 -9
- package/template_scaffold/src/steps/content/step-32-paywall.content.ts +12 -50
- package/template_scaffold/src/steps/step-01.tsx +43 -50
- package/template_scaffold/src/steps/step-31-email-capture.tsx +18 -1
- package/template_scaffold/src/steps/step-32-paywall.tsx +93 -299
- package/template_scaffold/src/steps/step-33-subscription-started.tsx +1 -1
- package/template_scaffold/src/steps/step-content.registry.ts +1 -26
- package/template_scaffold/public/claimbee/bee.png +0 -0
- package/template_scaffold/public/claimbee/device-iphone.png +0 -0
- package/template_scaffold/public/claimbee/device-macbook.png +0 -0
- package/template_scaffold/public/claimbee/device-none.png +0 -0
- package/template_scaffold/public/claimbee/device-watch.png +0 -0
- package/template_scaffold/public/claimbee/glow.svg +0 -3
- package/template_scaffold/public/claimbee/steps-sprite-alt.png +0 -0
- package/template_scaffold/public/claimbee/steps-sprite-default.png +0 -0
- package/template_scaffold/public/claimbee/testimonial-avatar.png +0 -0
- package/template_scaffold/public/claimbee/testimonial-star.svg +0 -3
- package/template_scaffold/public/paywall/ai-claim-assistant.png +0 -0
- package/template_scaffold/public/paywall/claim-history.png +0 -0
- package/template_scaffold/public/paywall/clear-progress.png +0 -0
- package/template_scaffold/public/paywall/hero.png +0 -0
- package/template_scaffold/public/paywall/money-alerts.png +0 -0
- package/template_scaffold/public/paywall/money-back-badge.png +0 -0
- package/template_scaffold/public/paywall/paywall-image.png +0 -0
- package/template_scaffold/public/paywall/r1.png +0 -0
- package/template_scaffold/public/paywall/r2.png +0 -0
- package/template_scaffold/public/paywall/r3.png +0 -0
- package/template_scaffold/public/paywall/r4.png +0 -0
- package/template_scaffold/public/paywall/r5.png +0 -0
- package/template_scaffold/public/paywall/r6.png +0 -0
- package/template_scaffold/public/paywall/special-offer-gift.png +0 -0
- package/template_scaffold/public/please/bee.png +0 -0
- package/template_scaffold/public/please/device-iphone.png +0 -0
- package/template_scaffold/public/please/device-macbook.png +0 -0
- package/template_scaffold/public/please/device-none.png +0 -0
- package/template_scaffold/public/please/device-watch.png +0 -0
- package/template_scaffold/public/please/glow.svg +0 -3
- package/template_scaffold/public/please/steps-sprite-alt.png +0 -0
- package/template_scaffold/public/please/steps-sprite-default.png +0 -0
- package/template_scaffold/public/please/testimonial-avatar.png +0 -0
- package/template_scaffold/public/please/testimonial-star.svg +0 -3
- package/template_scaffold/public/upsell/upgrade-hero.png +0 -0
- package/template_scaffold/src/assets/claimbee/bee.png +0 -0
- package/template_scaffold/src/assets/claimbee/device-iphone.png +0 -0
- package/template_scaffold/src/assets/claimbee/device-macbook.png +0 -0
- package/template_scaffold/src/assets/claimbee/device-none.png +0 -0
- package/template_scaffold/src/assets/claimbee/device-watch.png +0 -0
- package/template_scaffold/src/assets/claimbee/glow.svg +0 -3
- package/template_scaffold/src/assets/claimbee/steps-sprite-alt.png +0 -0
- package/template_scaffold/src/assets/claimbee/steps-sprite-default.png +0 -0
- package/template_scaffold/src/assets/claimbee/testimonial-avatar.png +0 -0
- package/template_scaffold/src/assets/claimbee/testimonial-star.svg +0 -3
- package/template_scaffold/src/components/FunnelStepImage.tsx +0 -28
- package/template_scaffold/src/config/offer-sets.generated.ts +0 -4
- package/template_scaffold/src/steps/content/manage-subscription.content.ts +0 -62
- package/template_scaffold/src/steps/content/paywall.content.ts +0 -191
- package/template_scaffold/src/steps/content/step-02.content.ts +0 -77
- package/template_scaffold/src/steps/content/step-03.content.ts +0 -40
- package/template_scaffold/src/steps/content/step-04.content.ts +0 -49
- package/template_scaffold/src/steps/editor/manage-subscription.editor.ts +0 -179
- package/template_scaffold/src/steps/editor/paywall.editor.ts +0 -232
- package/template_scaffold/src/steps/editor/step-02.editor.ts +0 -41
- package/template_scaffold/src/steps/editor/step-03.editor.ts +0 -58
- package/template_scaffold/src/steps/editor/step-04.editor.ts +0 -98
- package/template_scaffold/src/steps/pricing/paywall-b.pricing.ts +0 -12
- package/template_scaffold/src/steps/step-02.tsx +0 -140
- package/template_scaffold/src/steps/step-03.tsx +0 -262
- package/template_scaffold/src/steps/step-04.tsx +0 -673
- package/template_scaffold/src/steps/step-32b-paywall-b.tsx +0 -35
- package/template_scaffold/src/steps/step-35-manage-subscription.tsx +0 -46
- package/template_scaffold/src/steps/use-claimbee-step-content.ts +0 -15
- package/template_scaffold/tests/funnel-agent-docs.test.ts +0 -751
- package/template_scaffold/tests/package.test.ts +0 -37
- package/template_scaffold/tests/src/app/[stepId]/page.test.tsx +0 -59
- package/template_scaffold/tests/src/app/layout.test.ts +0 -26
- package/template_scaffold/tests/src/components/FunnelFlow.test.tsx +0 -38
- package/template_scaffold/tests/src/components/FunnelStepImage.test.ts +0 -15
- package/template_scaffold/tests/src/config/billing.plans.test.ts +0 -143
- package/template_scaffold/tests/src/config/funnel.manifest.test.ts +0 -166
- package/template_scaffold/tests/src/contract/funnel-project.validation.test.ts +0 -79
- package/template_scaffold/tests/src/contract/golden/golden-fixtures.test.ts +0 -190
- package/template_scaffold/tests/src/contract/validate-funnel.cli.test.ts +0 -191
- package/template_scaffold/tests/src/runtime/checkout-runtime-config.test.ts +0 -60
- package/template_scaffold/tests/src/runtime/funnel-runtime.test.ts +0 -25
- package/template_scaffold/tests/src/runtime/step-content-context.test.ts +0 -38
- package/template_scaffold/tests/src/runtime/use-funnel-flow-controller.integration.test.ts +0 -203
- package/template_scaffold/tests/src/runtime/use-funnel-flow-controller.test.ts +0 -76
- package/template_scaffold/tests/src/steps/paywall-checkout-options.test.ts +0 -104
- package/template_scaffold/tests/src/steps/shared-header-contract.test.ts +0 -29
- package/template_scaffold/tests/src/steps/step-01.test.ts +0 -38
- package/template_scaffold/tests/src/steps/step-02.test.ts +0 -43
- package/template_scaffold/tests/src/steps/step-03.test.ts +0 -15
- package/template_scaffold/tests/src/steps/step-04.test.ts +0 -32
- package/template_scaffold/tests/src/steps/step-31-email-capture.test.ts +0 -22
- package/template_scaffold/tests/src/steps/step-32-paywall.test.ts +0 -773
- package/template_scaffold/tests/src/steps/step-33-subscription-started.test.ts +0 -44
- package/template_scaffold/tests/src/steps/step-34-upsell-form.test.ts +0 -27
- package/template_scaffold/tests/src/steps/step-image-migration.test.ts +0 -20
|
@@ -1,751 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { execFile as execFileCallback } from 'node:child_process';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
|
-
import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import { dirname, join, relative } from 'node:path';
|
|
7
|
-
import { promisify } from 'node:util';
|
|
8
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
-
import { describe, expect, it } from 'vitest';
|
|
10
|
-
|
|
11
|
-
const templateRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
12
|
-
const findRepositoryRoot = (): string => {
|
|
13
|
-
let current = templateRoot;
|
|
14
|
-
while (dirname(current) !== current) {
|
|
15
|
-
if (existsSync(join(current, 'apps/funnel-runtime/package.json'))) {
|
|
16
|
-
return current;
|
|
17
|
-
}
|
|
18
|
-
current = dirname(current);
|
|
19
|
-
}
|
|
20
|
-
throw new Error('FunnelsGrove repository root not found');
|
|
21
|
-
};
|
|
22
|
-
const repoRoot = findRepositoryRoot();
|
|
23
|
-
const cliDocsRoot = join(repoRoot, 'apps/cli/template_docs');
|
|
24
|
-
const contract = JSON.parse(
|
|
25
|
-
await readFile(join(repoRoot, 'apps/funnel-runtime/contracts/step-contract-v2.json'), 'utf8'),
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
const requiredOutline = [
|
|
29
|
-
'## Use when / do not use when',
|
|
30
|
-
'## Exact metadata contract',
|
|
31
|
-
'## Interaction and persisted answer shape',
|
|
32
|
-
'## Automatic lifecycle events',
|
|
33
|
-
'## Required semantic conversions',
|
|
34
|
-
'## Approved shared helpers',
|
|
35
|
-
'## MUST rules',
|
|
36
|
-
'## NEVER rules',
|
|
37
|
-
'## Golden template or fixture',
|
|
38
|
-
'## Validation command',
|
|
39
|
-
'## Manual QA and completion criteria',
|
|
40
|
-
] as const;
|
|
41
|
-
|
|
42
|
-
const sha256 = (content: string): string => createHash('sha256').update(content).digest('hex');
|
|
43
|
-
const execFile = promisify(execFileCallback);
|
|
44
|
-
const renderCodes = (values: readonly unknown[]): string => (
|
|
45
|
-
values.length === 0 ? '_None_' : values.map((value) => `\`${String(value)}\``).join(', ')
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
async function loadGenerator() {
|
|
49
|
-
return import(pathToFileURL(join(repoRoot, 'scripts/generate-funnel-agent-docs.mjs')).href);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
describe('funnel agent documentation supply', () => {
|
|
53
|
-
it('provides one complete generated-contract page per exact step type', async () => {
|
|
54
|
-
const stepTypes = Object.keys(contract.stepContracts).sort();
|
|
55
|
-
|
|
56
|
-
await Promise.all(stepTypes.map(async (stepType) => {
|
|
57
|
-
expect(stepType).toMatch(/^[a-z0-9_]+$/);
|
|
58
|
-
const content = await readFile(
|
|
59
|
-
join(templateRoot, `docs/funnelsgrove/steps/${stepType}.md`),
|
|
60
|
-
'utf8',
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
const headings = content.split('\n').filter((line) => line.startsWith('## '));
|
|
64
|
-
expect(headings).toEqual(requiredOutline);
|
|
65
|
-
|
|
66
|
-
expect(content).toContain(`contract-v3/step/${stepType}`);
|
|
67
|
-
expect(content).toContain('#### Golden fixture source');
|
|
68
|
-
expect(content).toContain('export const goldenFixture');
|
|
69
|
-
expect(content).toContain('[embedded canonical fixture](#golden-fixture-source)');
|
|
70
|
-
expect(content).not.toContain('../../../src/contract/golden/');
|
|
71
|
-
expect(content).toContain('fgrove validate');
|
|
72
|
-
}));
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('rejects missing, duplicate, nested, mismatched, and unknown generated markers', async () => {
|
|
76
|
-
const { parseGeneratedBlock } = await loadGenerator();
|
|
77
|
-
const valid = [
|
|
78
|
-
'before',
|
|
79
|
-
'<!-- funnelsgrove:generated:start contract-v3/step/intro_hero -->',
|
|
80
|
-
'generated',
|
|
81
|
-
'<!-- funnelsgrove:generated:end contract-v3/step/intro_hero -->',
|
|
82
|
-
'after',
|
|
83
|
-
'',
|
|
84
|
-
].join('\n');
|
|
85
|
-
|
|
86
|
-
expect(parseGeneratedBlock(valid, 'step/intro_hero')).toMatchObject({
|
|
87
|
-
logicalId: 'step/intro_hero',
|
|
88
|
-
body: 'generated\n',
|
|
89
|
-
});
|
|
90
|
-
expect(() => parseGeneratedBlock('no marker\n', 'step/intro_hero')).toThrow(/missing/i);
|
|
91
|
-
expect(() => parseGeneratedBlock(`${valid}${valid}`, 'step/intro_hero')).toThrow(/duplicate/i);
|
|
92
|
-
expect(() => parseGeneratedBlock(valid.replace(
|
|
93
|
-
'generated\n',
|
|
94
|
-
'generated\n<!-- funnelsgrove:generated:start contract-v3/step/intro_hero -->\n',
|
|
95
|
-
), 'step/intro_hero')).toThrow(/nested|duplicate/i);
|
|
96
|
-
expect(() => parseGeneratedBlock(valid.replace(
|
|
97
|
-
'end contract-v3/step/intro_hero',
|
|
98
|
-
'end contract-v3/step/value_prop_story',
|
|
99
|
-
), 'step/intro_hero')).toThrow(/mismatch/i);
|
|
100
|
-
expect(() => parseGeneratedBlock(
|
|
101
|
-
valid.replace('<!-- funnelsgrove:generated:start', '<!-- funnelsgrove:generated:start'),
|
|
102
|
-
'step/intro_hero',
|
|
103
|
-
)).toThrow(/malformed/i);
|
|
104
|
-
expect(() => parseGeneratedBlock(
|
|
105
|
-
valid.replace(
|
|
106
|
-
'<!-- funnelsgrove:generated:start contract-v3/step/intro_hero -->',
|
|
107
|
-
'inline <!-- funnelsgrove:generated:start contract-v3/step/intro_hero -->',
|
|
108
|
-
),
|
|
109
|
-
'step/intro_hero',
|
|
110
|
-
)).toThrow(/standalone|full line/i);
|
|
111
|
-
expect(() => parseGeneratedBlock(
|
|
112
|
-
`<!-- funnelsgrove:generated:start contract-v3/not-known -->\n${valid}\n<!-- funnelsgrove:generated:end contract-v3/not-known -->`,
|
|
113
|
-
'step/intro_hero',
|
|
114
|
-
)).toThrow(/unknown/i);
|
|
115
|
-
expect(() => parseGeneratedBlock(
|
|
116
|
-
`<!-- funnelsgrove:generated:start contract-v3/INVALID -->\n${valid}`,
|
|
117
|
-
'step/intro_hero',
|
|
118
|
-
)).toThrow(/malformed/i);
|
|
119
|
-
expect(() => parseGeneratedBlock(valid, 'unknown/id')).toThrow(/unknown/i);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('renders every normative contract field into generated step blocks', async () => {
|
|
123
|
-
const { buildBundle } = await loadGenerator();
|
|
124
|
-
const bundle = await buildBundle();
|
|
125
|
-
|
|
126
|
-
for (const [stepType, definition] of Object.entries(contract.stepContracts)) {
|
|
127
|
-
const path = `docs/funnelsgrove/steps/${stepType}.md`;
|
|
128
|
-
const page = bundle.files.get(path);
|
|
129
|
-
expect(page, `missing ${path}`).toBeDefined();
|
|
130
|
-
expect(page).toContain(`Step contract version: \`${contract.stepContractVersion}\``);
|
|
131
|
-
expect(page).toContain(`Contract hash: \`${contract.contractHash}\``);
|
|
132
|
-
expect(page).toContain(`Type: \`${stepType}\``);
|
|
133
|
-
expect(page).toContain(`Family: \`${definition.family}\``);
|
|
134
|
-
expect(page).toContain(definition.description);
|
|
135
|
-
expect(page).toContain(`Action owner: \`${definition.actionOwner}\``);
|
|
136
|
-
expect(page).toContain(`Terminal: \`${String(definition.terminal)}\``);
|
|
137
|
-
expect(page).toContain(`Analytics role: ${'analyticsRole' in definition ? `\`${definition.analyticsRole}\`` : '_none_'}`);
|
|
138
|
-
expect(page).toContain(`Required kind: ${'requiredKind' in definition ? `\`${definition.requiredKind}\`` : '_none_'}`);
|
|
139
|
-
expect(page).toContain(`Allowed kinds: ${renderCodes(definition.allowedKinds)}`);
|
|
140
|
-
expect(page).toContain(`Allowed semantic events: ${renderCodes(definition.allowedCustomEvents)}`);
|
|
141
|
-
expect(page).toContain(`Forbidden capabilities: ${renderCodes(definition.forbiddenCapabilities)}`);
|
|
142
|
-
if ('choice' in definition) {
|
|
143
|
-
expect(page).toContain(`Cardinality: \`${definition.choice.cardinality}\``);
|
|
144
|
-
expect(page).toContain(`Presentation: \`${definition.choice.presentation}\``);
|
|
145
|
-
expect(page).toContain(`Completion: \`${definition.choice.completion}\``);
|
|
146
|
-
expect(page).toContain(`Allow empty config: \`${String(definition.choice.allowEmptyConfig)}\``);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('renders package release order from the current package manifests', async () => {
|
|
152
|
-
const { buildBundle, parseGeneratedBlock } = await loadGenerator();
|
|
153
|
-
const [runtimePackage, analyticsPackage, paymentsPackage, cliPackage] = await Promise.all([
|
|
154
|
-
readFile(join(repoRoot, 'apps/funnel-runtime/package.json'), 'utf8').then(JSON.parse),
|
|
155
|
-
readFile(join(repoRoot, 'apps/funnel-analytics/package.json'), 'utf8').then(JSON.parse),
|
|
156
|
-
readFile(join(repoRoot, 'apps/funnel-payments/package.json'), 'utf8').then(JSON.parse),
|
|
157
|
-
readFile(join(repoRoot, 'apps/cli/package.json'), 'utf8').then(JSON.parse),
|
|
158
|
-
]);
|
|
159
|
-
const migration = (await buildBundle()).files.get(
|
|
160
|
-
'docs/funnelsgrove/migrations/step-contract-v3.md',
|
|
161
|
-
)!;
|
|
162
|
-
const generated = parseGeneratedBlock(migration, 'migration/step-contract-v3').body;
|
|
163
|
-
|
|
164
|
-
expect(generated).toContain('### Package release order');
|
|
165
|
-
expect(generated).toContain(
|
|
166
|
-
`Release \`@funnelsgrove/runtime\` \`${runtimePackage.version}\` first, `
|
|
167
|
-
+ `then \`@funnelsgrove/analytics\` \`${analyticsPackage.version}\`, `
|
|
168
|
-
+ `then \`@funnelsgrove/payments\` \`${paymentsPackage.version}\`. `
|
|
169
|
-
+ 'Deploy the API and funnel template, then confirm production `/health` reports the new docs identity. '
|
|
170
|
-
+ `Only then publish \`@funnelsgrove/cli\` \`${cliPackage.version}\`.`,
|
|
171
|
-
);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it('renders every capability and reserved-identity field exactly', async () => {
|
|
175
|
-
const { buildBundle } = await loadGenerator();
|
|
176
|
-
const bundle = await buildBundle();
|
|
177
|
-
const capabilityPage = bundle.files.get('docs/funnelsgrove/qa/local.md')!;
|
|
178
|
-
const reservedPage = bundle.files.get('docs/funnelsgrove/contracts/step-metadata.md')!;
|
|
179
|
-
|
|
180
|
-
for (const [name, rule] of Object.entries(contract.capabilityRules)) {
|
|
181
|
-
expect(capabilityPage).toContain([
|
|
182
|
-
`| \`${name}\``,
|
|
183
|
-
rule.allowedStepTypes === 'any' ? '`any`' : renderCodes(rule.allowedStepTypes),
|
|
184
|
-
rule.allowedStepIdentities === 'any' ? '`any`' : renderCodes(rule.allowedStepIdentities),
|
|
185
|
-
`\`${rule.requiredOwner}\` |`,
|
|
186
|
-
].join(' | '));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
for (const [name, definition] of Object.entries(contract.reservedStepContracts)) {
|
|
190
|
-
expect(reservedPage).toContain([
|
|
191
|
-
`| \`${name}\``,
|
|
192
|
-
`\`${definition.match}\``,
|
|
193
|
-
`\`${'id' in definition ? definition.id : definition.idPattern}\``,
|
|
194
|
-
`\`${'name' in definition ? definition.name : definition.namePattern}\``,
|
|
195
|
-
`\`${definition.type}\``,
|
|
196
|
-
'kind' in definition ? `\`${definition.kind}\`` : '—',
|
|
197
|
-
'primary' in definition ? `\`${String(definition.primary)}\`` : '—',
|
|
198
|
-
`\`${String(definition.unique)}\` |`,
|
|
199
|
-
].join(' | '));
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it('renders complete analytics event scopes and ownership from the contract', async () => {
|
|
204
|
-
const { buildBundle } = await loadGenerator();
|
|
205
|
-
const bundle = await buildBundle();
|
|
206
|
-
const page = bundle.files.get('docs/funnelsgrove/contracts/analytics-events.md')!;
|
|
207
|
-
const renderScope = (value: unknown): string => {
|
|
208
|
-
if (value === 'any') return '`any`';
|
|
209
|
-
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
210
|
-
throw new Error('Invalid analytics event scope fixture');
|
|
211
|
-
}
|
|
212
|
-
return renderCodes(value);
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
for (const [key, event] of Object.entries(contract.analyticsEventContracts)) {
|
|
216
|
-
expect(page).toContain(`${[
|
|
217
|
-
`| \`${key}\``,
|
|
218
|
-
`\`${event.publicName}\``,
|
|
219
|
-
`\`${event.rawName}\``,
|
|
220
|
-
renderScope(event.allowedStepTypes),
|
|
221
|
-
renderScope(event.allowedStepIdentities),
|
|
222
|
-
`\`${event.owner}\``,
|
|
223
|
-
`\`${String(event.automatic)}\``,
|
|
224
|
-
`\`${event.dedupeScope}\``,
|
|
225
|
-
`${renderCodes(event.requiredMetadata)} |`,
|
|
226
|
-
].join(' | ')}`);
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('documents experiment routing before visit creation without phantom lifecycle events', async () => {
|
|
231
|
-
const content = await readFile(
|
|
232
|
-
join(templateRoot, 'docs/funnelsgrove/recipes/add-experiment.md'),
|
|
233
|
-
'utf8',
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
expect(content).toContain(
|
|
237
|
-
'Resolve assignment and any redirect before creating the destination step visit.',
|
|
238
|
-
);
|
|
239
|
-
expect(content).toContain(
|
|
240
|
-
'Pre-activation, missing-assignment, invalid-assignment, and fallback paths must not create phantom `step_started`, `step_completed`, or `step_exited` events.',
|
|
241
|
-
);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it('documents the complete JSON-driven experiment creation workflow', async () => {
|
|
245
|
-
const content = await readFile(
|
|
246
|
-
join(templateRoot, 'docs/funnelsgrove/recipes/add-experiment.md'),
|
|
247
|
-
'utf8',
|
|
248
|
-
);
|
|
249
|
-
const examples = [...content.matchAll(/```json\n([\s\S]*?)\n```/g)]
|
|
250
|
-
.map((match) => JSON.parse(match[1]) as { type?: unknown });
|
|
251
|
-
|
|
252
|
-
expect(examples.map(({ type }) => type)).toEqual(['step', 'paywall', 'pricing']);
|
|
253
|
-
expect(content).toContain('fgrove experiments create --spec experiment.json --dir .');
|
|
254
|
-
expect(content).toContain('fgrove experiments create --spec experiment.json --dir . --json');
|
|
255
|
-
expect(content).toContain('`experimentId`, `experimentKey`, `draftVersionId`, and `writtenPaths`');
|
|
256
|
-
expect(content).toContain(
|
|
257
|
-
'Pricing variants additionally require `offerSetId` as a UUID.',
|
|
258
|
-
);
|
|
259
|
-
expect(content).not.toContain('offerSetKey');
|
|
260
|
-
expect(content).toContain('does not create or activate a PostHog flag');
|
|
261
|
-
expect(content).toContain('included in the version-bound runtime artifact');
|
|
262
|
-
expect(content).toContain('deprecated compatibility files');
|
|
263
|
-
expect(content).toContain('[FG-EXPERIMENT-STALE-DRAFT]');
|
|
264
|
-
expect(content).toContain('[FG-EXPERIMENT-DRAFT-CONFLICT]');
|
|
265
|
-
expect(content).toContain('[FG-EXPERIMENT-SPEC]');
|
|
266
|
-
expect(content).toContain('fgrove sync up');
|
|
267
|
-
expect(content).toContain('fgrove github pull');
|
|
268
|
-
expect(content).toContain('`.funnelsgrove-sync.json` stays local and ignored');
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('keeps intro engagement explicitly non-automatic', async () => {
|
|
272
|
-
const content = await readFile(
|
|
273
|
-
join(templateRoot, 'docs/funnelsgrove/steps/intro_hero.md'),
|
|
274
|
-
'utf8',
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
expect(content).toContain(
|
|
278
|
-
'`step_engaged` is registered as non-automatic and is not emitted by default.',
|
|
279
|
-
);
|
|
280
|
-
expect(content).toContain(
|
|
281
|
-
'Only an explicit, centralized, tested engagement policy may emit it through the approved controller/helper boundary; step components never emit it manually.',
|
|
282
|
-
);
|
|
283
|
-
expect(content).not.toContain('owns view, start, engagement, completion, and exit lifecycle tracking');
|
|
284
|
-
expect(content).not.toContain('after the engagement threshold is met');
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it('documents distinct terminal and non-terminal handoff completion modes', async () => {
|
|
288
|
-
const terminal = await readFile(
|
|
289
|
-
join(templateRoot, 'docs/funnelsgrove/steps/purchase_completed.md'),
|
|
290
|
-
'utf8',
|
|
291
|
-
);
|
|
292
|
-
const handoff = await readFile(
|
|
293
|
-
join(templateRoot, 'docs/funnelsgrove/steps/subscription_handoff.md'),
|
|
294
|
-
'utf8',
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
expect(terminal).toContain('`completeFunnel(stepId)` atomically claims the active terminal visit');
|
|
298
|
-
expect(terminal).toContain('flushes before outbound handoff');
|
|
299
|
-
expect(terminal).toContain('completionMode="funnel"');
|
|
300
|
-
expect(terminal).toContain('`purchase_completed` is trusted-server-only');
|
|
301
|
-
expect(terminal).toContain('terminal reach, `funnel_completed`');
|
|
302
|
-
expect(handoff).toContain('completion mode of `step`');
|
|
303
|
-
expect(handoff).toContain('never opt a non-terminal handoff into `completionMode="funnel"`');
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it('keeps legacy terminal names out of current v3 authoring instructions', async () => {
|
|
307
|
-
const { buildBundle } = await loadGenerator();
|
|
308
|
-
const bundle = await buildBundle();
|
|
309
|
-
|
|
310
|
-
for (const [path, content] of bundle.files) {
|
|
311
|
-
if (!path.endsWith('.md') || path.endsWith('/migrations/step-contract-v3.md')) continue;
|
|
312
|
-
expect(content, path).not.toMatch(/complete[-_]registration/i);
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
it('defines Quiz completed by unique paywall reach without conflating purchase verification', async () => {
|
|
317
|
-
const paths = [
|
|
318
|
-
'docs/funnelsgrove/qa/analytics.md',
|
|
319
|
-
'docs/funnelsgrove/contracts/analytics-events.md',
|
|
320
|
-
'docs/funnelsgrove/START-HERE.md',
|
|
321
|
-
'docs/funnelsgrove/contracts/payments.md',
|
|
322
|
-
];
|
|
323
|
-
const contents = await Promise.all(paths.map(async (path) => ({
|
|
324
|
-
path,
|
|
325
|
-
content: await readFile(join(templateRoot, path), 'utf8'),
|
|
326
|
-
})));
|
|
327
|
-
|
|
328
|
-
for (const { path, content } of contents) {
|
|
329
|
-
expect(content, path).toContain(
|
|
330
|
-
'Quiz completed counts each unique user who reaches a paywall step.',
|
|
331
|
-
);
|
|
332
|
-
expect(content, path).toContain(
|
|
333
|
-
'`purchase_completed` remains trusted-server-only purchase verification and does not define Quiz completed.',
|
|
334
|
-
);
|
|
335
|
-
expect(content, path).toContain(
|
|
336
|
-
'Browser terminal reach and `funnel_completed` remain lifecycle-only and are not purchase conversions.',
|
|
337
|
-
);
|
|
338
|
-
expect(content, path).not.toMatch(/Quiz completed requires[^.]*purchase_completed/i);
|
|
339
|
-
expect(content, path).not.toMatch(/terminal reach[^.]*not (?:count|counts) as Quiz completed/i);
|
|
340
|
-
}
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it('preserves the implementation-owned visitor metric while linking event ownership', async () => {
|
|
344
|
-
const content = await readFile(join(repoRoot, 'docs/product-specs/analytics.md'), 'utf8');
|
|
345
|
-
|
|
346
|
-
expect(content).toContain(
|
|
347
|
-
'Visitors are the sum of daily unique PostHog `distinct_id` values with a `first_step_viewed` or `step_engaged` event.',
|
|
348
|
-
);
|
|
349
|
-
expect(content).toContain(
|
|
350
|
-
'`step_engaged` is an optional qualified input, not a default 1-second runtime emission.',
|
|
351
|
-
);
|
|
352
|
-
expect(content).toContain(
|
|
353
|
-
'../../apps/funnel-template/docs/funnelsgrove/contracts/analytics-events.md',
|
|
354
|
-
);
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
it('builds a deterministic LF-only manifest over the full sorted managed set', async () => {
|
|
358
|
-
const { buildBundle, MANIFEST_PATH } = await loadGenerator();
|
|
359
|
-
const bundle = await buildBundle();
|
|
360
|
-
const manifestContent = bundle.files.get(MANIFEST_PATH);
|
|
361
|
-
expect(manifestContent).toBeDefined();
|
|
362
|
-
const manifest = JSON.parse(manifestContent!);
|
|
363
|
-
|
|
364
|
-
expect(manifest).toMatchObject({
|
|
365
|
-
schemaVersion: 1,
|
|
366
|
-
bundleVersion: '2.0.76',
|
|
367
|
-
stepContractVersion: contract.stepContractVersion,
|
|
368
|
-
contractHash: contract.contractHash,
|
|
369
|
-
});
|
|
370
|
-
expect(manifest.managedFiles.map((entry: { path: string }) => entry.path)).toEqual(
|
|
371
|
-
[...bundle.managedPaths].sort(),
|
|
372
|
-
);
|
|
373
|
-
for (const entry of manifest.managedFiles) {
|
|
374
|
-
expect(entry.sha256).toBe(sha256(bundle.files.get(entry.path)!));
|
|
375
|
-
}
|
|
376
|
-
for (const content of bundle.files.values()) {
|
|
377
|
-
expect(content).not.toContain('\r');
|
|
378
|
-
expect(content).not.toMatch(/[ \t]+$/m);
|
|
379
|
-
expect(content.endsWith('\n')).toBe(true);
|
|
380
|
-
}
|
|
381
|
-
expect([...bundle.files.keys()].at(-1)).toBe(MANIFEST_PATH);
|
|
382
|
-
expect(bundle.files.has(MANIFEST_PATH)).toBe(true);
|
|
383
|
-
expect(bundle.managedPaths).not.toContain(MANIFEST_PATH);
|
|
384
|
-
expect(manifest.managedFiles).not.toContainEqual(expect.objectContaining({ path: MANIFEST_PATH }));
|
|
385
|
-
expect(bundle.config.fullyGenerated).toContain(MANIFEST_PATH);
|
|
386
|
-
expect(bundle.config).toMatchObject({
|
|
387
|
-
contractSource: 'funnelsgrove-repository://apps/funnel-runtime/contracts/step-contract-v2.json',
|
|
388
|
-
});
|
|
389
|
-
expect(bundle.config).not.toHaveProperty('contractPath');
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
it('matches the generated in-memory bundle, resolves links, and mirrors every byte to the CLI', async () => {
|
|
393
|
-
const { buildBundle, validateBundleLinks } = await loadGenerator();
|
|
394
|
-
const bundle = await buildBundle();
|
|
395
|
-
await expect(validateBundleLinks(bundle, templateRoot)).resolves.toEqual([]);
|
|
396
|
-
const isolatedRoot = await mkdtemp(join(tmpdir(), 'fg-docs-self-contained-'));
|
|
397
|
-
try {
|
|
398
|
-
await expect(validateBundleLinks(bundle, isolatedRoot)).resolves.toEqual([]);
|
|
399
|
-
} finally {
|
|
400
|
-
await rm(isolatedRoot, { recursive: true, force: true });
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
for (const [path, expected] of bundle.files) {
|
|
404
|
-
await expect(readFile(join(templateRoot, path), 'utf8')).resolves.toBe(expected);
|
|
405
|
-
await expect(readFile(join(cliDocsRoot, path), 'utf8')).resolves.toBe(expected);
|
|
406
|
-
}
|
|
407
|
-
expect(bundle.files.has('AGENTS.project.md')).toBe(false);
|
|
408
|
-
await expect(stat(join(cliDocsRoot, 'AGENTS.project.md'))).rejects.toThrow();
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
it('detects broken managed links', async () => {
|
|
412
|
-
const { buildBundle, validateBundleLinks } = await loadGenerator();
|
|
413
|
-
const bundle = await buildBundle();
|
|
414
|
-
bundle.files.set('AGENTS.md', bundle.files.get('AGENTS.md')!
|
|
415
|
-
.replace('docs/funnelsgrove/START-HERE.md', 'docs/funnelsgrove/MISSING.md'));
|
|
416
|
-
|
|
417
|
-
await expect(validateBundleLinks(bundle, templateRoot)).resolves.toEqual([
|
|
418
|
-
'AGENTS.md: broken link: docs/funnelsgrove/MISSING.md',
|
|
419
|
-
]);
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
it('validates anchor-only and cross-file Markdown fragments', async () => {
|
|
423
|
-
const { buildBundle, validateBundleLinks } = await loadGenerator();
|
|
424
|
-
const bundle = await buildBundle();
|
|
425
|
-
const stepPath = 'docs/funnelsgrove/steps/paywall_offer.md';
|
|
426
|
-
bundle.files.set(stepPath, bundle.files.get(stepPath)!
|
|
427
|
-
.replace('#golden-fixture-source', '#missing-golden-fixture'));
|
|
428
|
-
bundle.files.set('AGENTS.md', `${bundle.files.get('AGENTS.md')}\n[Missing section](docs/funnelsgrove/START-HERE.md#missing-section)\n`);
|
|
429
|
-
|
|
430
|
-
await expect(validateBundleLinks(bundle, templateRoot)).resolves.toEqual([
|
|
431
|
-
'AGENTS.md: broken anchor: docs/funnelsgrove/START-HERE.md#missing-section',
|
|
432
|
-
`${stepPath}: broken anchor: #missing-golden-fixture`,
|
|
433
|
-
]);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
it('rejects symlinks and special entries while traversing a documentation mirror', async () => {
|
|
437
|
-
const { listFiles } = await loadGenerator();
|
|
438
|
-
const mirrorRoot = await mkdtemp(join(tmpdir(), 'fg-docs-unsafe-mirror-'));
|
|
439
|
-
try {
|
|
440
|
-
await symlink('/etc/passwd', join(mirrorRoot, 'escaped-link'));
|
|
441
|
-
await expect(listFiles(mirrorRoot)).rejects.toThrow(/escaped-link.*symlink/i);
|
|
442
|
-
} finally {
|
|
443
|
-
await rm(mirrorRoot, { recursive: true, force: true });
|
|
444
|
-
}
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
it('refuses generation through managed-file or ancestor symlinks before writing anything', async () => {
|
|
448
|
-
const { buildBundle, writeBundleRoot } = await loadGenerator();
|
|
449
|
-
const bundle = await buildBundle();
|
|
450
|
-
const sandbox = await mkdtemp(join(tmpdir(), 'fg-docs-safe-write-'));
|
|
451
|
-
const managedRoot = join(sandbox, 'managed');
|
|
452
|
-
const ancestorRoot = join(sandbox, 'ancestor');
|
|
453
|
-
const outsideFile = join(sandbox, 'outside.md');
|
|
454
|
-
const outsideDirectory = join(sandbox, 'outside-directory');
|
|
455
|
-
const lateManagedPath = 'docs/funnelsgrove/steps/value_prop_story.md';
|
|
456
|
-
|
|
457
|
-
try {
|
|
458
|
-
await mkdir(dirname(join(managedRoot, lateManagedPath)), { recursive: true });
|
|
459
|
-
await writeFile(outsideFile, 'outside stays unchanged\n');
|
|
460
|
-
await symlink(outsideFile, join(managedRoot, lateManagedPath));
|
|
461
|
-
|
|
462
|
-
await expect(writeBundleRoot(managedRoot, bundle, false)).rejects.toThrow(
|
|
463
|
-
/value_prop_story\.md.*symlink/i,
|
|
464
|
-
);
|
|
465
|
-
await expect(readFile(outsideFile, 'utf8')).resolves.toBe('outside stays unchanged\n');
|
|
466
|
-
await expect(stat(join(managedRoot, 'AGENTS.md'))).rejects.toThrow();
|
|
467
|
-
|
|
468
|
-
await mkdir(ancestorRoot, { recursive: true });
|
|
469
|
-
await mkdir(outsideDirectory, { recursive: true });
|
|
470
|
-
await symlink(outsideDirectory, join(ancestorRoot, 'docs'));
|
|
471
|
-
await expect(writeBundleRoot(ancestorRoot, bundle, false)).rejects.toThrow(
|
|
472
|
-
/docs.*symlink/i,
|
|
473
|
-
);
|
|
474
|
-
await expect(stat(join(ancestorRoot, 'AGENTS.md'))).rejects.toThrow();
|
|
475
|
-
} finally {
|
|
476
|
-
await rm(sandbox, { recursive: true, force: true });
|
|
477
|
-
}
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
it('rejects stray files in canonical managed namespaces without claiming legacy docs', async () => {
|
|
481
|
-
const { buildBundle, findUnexpectedCanonicalFiles } = await loadGenerator();
|
|
482
|
-
const bundle = await buildBundle();
|
|
483
|
-
const candidateRoot = await mkdtemp(join(tmpdir(), 'fg-docs-canonical-'));
|
|
484
|
-
try {
|
|
485
|
-
await mkdir(join(candidateRoot, 'docs/funnelsgrove'), { recursive: true });
|
|
486
|
-
await writeFile(join(candidateRoot, 'docs/funnelsgrove/stray.md'), 'stray\n');
|
|
487
|
-
await mkdir(join(candidateRoot, 'docs/legacy'), { recursive: true });
|
|
488
|
-
await writeFile(join(candidateRoot, 'docs/legacy/compat.md'), 'unmanaged\n');
|
|
489
|
-
await writeFile(join(candidateRoot, 'AGENTS.project.md'), 'product context\n');
|
|
490
|
-
|
|
491
|
-
await expect(findUnexpectedCanonicalFiles(bundle, candidateRoot)).resolves.toEqual([
|
|
492
|
-
'template unmanaged canonical file: docs/funnelsgrove/stray.md',
|
|
493
|
-
]);
|
|
494
|
-
} finally {
|
|
495
|
-
await rm(candidateRoot, { recursive: true, force: true });
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it('applies optional project guidance without allowing it to override the managed contract', async () => {
|
|
500
|
-
const { buildBundle } = await loadGenerator();
|
|
501
|
-
const agents = (await buildBundle()).files.get('AGENTS.md')!;
|
|
502
|
-
|
|
503
|
-
expect(agents).toContain('read and apply `AGENTS.project.md` after this managed contract');
|
|
504
|
-
expect(agents).toContain('the managed contract wins');
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
it('preserves privacy boundaries for analytics and transactional email capture', async () => {
|
|
508
|
-
const pages = await Promise.all([
|
|
509
|
-
'docs/funnelsgrove/contracts/analytics-events.md',
|
|
510
|
-
'docs/funnelsgrove/steps/form_input.md',
|
|
511
|
-
'docs/funnelsgrove/qa/analytics.md',
|
|
512
|
-
].map((path) => readFile(join(templateRoot, path), 'utf8')));
|
|
513
|
-
|
|
514
|
-
for (const page of pages) {
|
|
515
|
-
expect(page).toContain('Never put raw email or other PII in analytics event metadata or logs.');
|
|
516
|
-
expect(page).toContain('Only the server delivery boundary may use raw email as Meta hashing input.');
|
|
517
|
-
expect(page).toContain('PostHog properties must remain non-PII.');
|
|
518
|
-
}
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
it('preserves sync, planning, theme, asset, image, and responsive safety guidance', async () => {
|
|
522
|
-
const { buildBundle } = await loadGenerator();
|
|
523
|
-
const files = (await buildBundle()).files;
|
|
524
|
-
const agents = files.get('AGENTS.md')!;
|
|
525
|
-
const content = files.get('docs/funnelsgrove/contracts/content-answers.md')!;
|
|
526
|
-
const start = files.get('docs/funnelsgrove/START-HERE.md')!;
|
|
527
|
-
const flow = files.get('docs/funnelsgrove/contracts/flow-routing.md')!;
|
|
528
|
-
const localQa = files.get('docs/funnelsgrove/qa/local.md')!;
|
|
529
|
-
const publishQa = files.get('docs/funnelsgrove/qa/publish.md')!;
|
|
530
|
-
|
|
531
|
-
for (const token of [
|
|
532
|
-
'fgrove status',
|
|
533
|
-
'git status --short',
|
|
534
|
-
'fgrove sync down',
|
|
535
|
-
'remote compare-and-swap',
|
|
536
|
-
'fgrove github pull',
|
|
537
|
-
'fgrove sync up',
|
|
538
|
-
'fgrove env pull',
|
|
539
|
-
]) expect(agents).toContain(token);
|
|
540
|
-
for (const token of ['PRODUCT_SENSE.md', '## Strategic Pre-Work', 'PLAN.md', '## Funnel Fuel']) {
|
|
541
|
-
expect(content).toContain(token);
|
|
542
|
-
}
|
|
543
|
-
for (const token of ['src/theme/theme.ts', 'FunnelTheme', 'createThemeCssVariables', 'CSS variables']) {
|
|
544
|
-
expect(start).toContain(token);
|
|
545
|
-
}
|
|
546
|
-
for (const token of ['funnelManifest.assets', 'assetIds', "preload: 'idle'", 'AVIF/WebP']) {
|
|
547
|
-
expect(flow).toContain(token);
|
|
548
|
-
}
|
|
549
|
-
for (const token of ['no horizontal scrolling', '44×44 CSS px', 'assetId']) {
|
|
550
|
-
expect(localQa).toContain(token);
|
|
551
|
-
}
|
|
552
|
-
for (const token of ['imageVariants', 'AVIF/WebP', 'original raster']) {
|
|
553
|
-
expect(publishQa).toContain(token);
|
|
554
|
-
}
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
it('keeps root guidance on real workspace commands and package names', async () => {
|
|
558
|
-
const paths = [
|
|
559
|
-
'docs/guides/build-funnel-step-by-step.md',
|
|
560
|
-
'docs/guides/edit-funnel-template-safely.md',
|
|
561
|
-
'docs/product-specs/funnel-template-step-authoring.md',
|
|
562
|
-
'docs/product-specs/funnel-template-agent-editing.md',
|
|
563
|
-
];
|
|
564
|
-
const content = (await Promise.all(paths.map((path) => readFile(join(repoRoot, path), 'utf8'))))
|
|
565
|
-
.join('\n');
|
|
566
|
-
|
|
567
|
-
expect(content).not.toContain('npx tsc --noEmit --workspace funnel-template');
|
|
568
|
-
expect(content).not.toContain('@funnelsgrove/funnel-analytics');
|
|
569
|
-
expect(content).toContain('npm run build --workspace funnel-template');
|
|
570
|
-
expect(content).toContain('@funnelsgrove/analytics');
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
it('requires a bundle-version bump when managed bytes change', async () => {
|
|
574
|
-
const { validateBundleVersionReuse } = await loadGenerator();
|
|
575
|
-
const previous = {
|
|
576
|
-
bundleVersion: '2.0.0',
|
|
577
|
-
managedFiles: [{ path: 'AGENTS.md', sha256: 'old' }],
|
|
578
|
-
};
|
|
579
|
-
|
|
580
|
-
expect(validateBundleVersionReuse(previous, {
|
|
581
|
-
bundleVersion: '2.0.0',
|
|
582
|
-
managedFiles: [{ path: 'AGENTS.md', sha256: 'new' }],
|
|
583
|
-
})).toEqual(['Managed documentation bytes changed without changing bundleVersion 2.0.0']);
|
|
584
|
-
expect(validateBundleVersionReuse(previous, {
|
|
585
|
-
bundleVersion: '2.0.1',
|
|
586
|
-
managedFiles: [{ path: 'AGENTS.md', sha256: 'new' }],
|
|
587
|
-
})).toEqual([]);
|
|
588
|
-
|
|
589
|
-
expect(validateBundleVersionReuse({
|
|
590
|
-
...previous,
|
|
591
|
-
contractHash: 'old-contract',
|
|
592
|
-
}, {
|
|
593
|
-
...previous,
|
|
594
|
-
contractHash: 'new-contract',
|
|
595
|
-
})).toEqual(['Managed documentation bytes changed without changing bundleVersion 2.0.0']);
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
it('compares bundle versions to a real base revision even after changed bytes are committed', async () => {
|
|
599
|
-
const {
|
|
600
|
-
readManifestAtRevision,
|
|
601
|
-
resolveBundleBaseRevision,
|
|
602
|
-
validateBundleVersionAgainstBase,
|
|
603
|
-
} = await loadGenerator();
|
|
604
|
-
const gitRoot = await mkdtemp(join(tmpdir(), 'fg-docs-version-git-'));
|
|
605
|
-
const manifestPath = join(gitRoot, 'apps/funnel-template/.funnelsgrove-docs.json');
|
|
606
|
-
const oldManifest = {
|
|
607
|
-
schemaVersion: 1,
|
|
608
|
-
bundleVersion: '2.0.0',
|
|
609
|
-
stepContractVersion: 2,
|
|
610
|
-
contractHash: 'a'.repeat(64),
|
|
611
|
-
managedFiles: [{ path: 'AGENTS.md', sha256: 'b'.repeat(64) }],
|
|
612
|
-
};
|
|
613
|
-
const changedManifest = {
|
|
614
|
-
...oldManifest,
|
|
615
|
-
managedFiles: [{ path: 'AGENTS.md', sha256: 'c'.repeat(64) }],
|
|
616
|
-
};
|
|
617
|
-
|
|
618
|
-
try {
|
|
619
|
-
await execFile('git', ['init', '--quiet'], { cwd: gitRoot });
|
|
620
|
-
await execFile('git', ['config', 'user.name', 'Funnel Docs Test'], { cwd: gitRoot });
|
|
621
|
-
await execFile('git', ['config', 'user.email', 'docs-test@example.invalid'], { cwd: gitRoot });
|
|
622
|
-
await writeFile(join(gitRoot, 'README.md'), 'first\n');
|
|
623
|
-
await execFile('git', ['add', '.'], { cwd: gitRoot });
|
|
624
|
-
await execFile('git', ['commit', '--quiet', '-m', 'initial'], { cwd: gitRoot });
|
|
625
|
-
const { stdout: introductionBaseOutput } = await execFile('git', ['rev-parse', 'HEAD'], { cwd: gitRoot });
|
|
626
|
-
const introductionBase = introductionBaseOutput.trim();
|
|
627
|
-
|
|
628
|
-
await expect(readManifestAtRevision({ cwd: gitRoot, revision: introductionBase }))
|
|
629
|
-
.resolves.toBeNull();
|
|
630
|
-
await expect(validateBundleVersionAgainstBase(oldManifest, {
|
|
631
|
-
cwd: gitRoot,
|
|
632
|
-
baseRevision: introductionBase,
|
|
633
|
-
env: {},
|
|
634
|
-
})).resolves.toEqual([]);
|
|
635
|
-
|
|
636
|
-
await mkdir(dirname(manifestPath), { recursive: true });
|
|
637
|
-
await writeFile(manifestPath, `${JSON.stringify(oldManifest, null, 2)}\n`);
|
|
638
|
-
await execFile('git', ['add', '.'], { cwd: gitRoot });
|
|
639
|
-
await execFile('git', ['commit', '--quiet', '-m', 'introduce docs'], { cwd: gitRoot });
|
|
640
|
-
const { stdout: oldRevisionOutput } = await execFile('git', ['rev-parse', 'HEAD'], { cwd: gitRoot });
|
|
641
|
-
const oldRevision = oldRevisionOutput.trim();
|
|
642
|
-
|
|
643
|
-
const compactOldManifest = `${JSON.stringify(oldManifest)}\n`;
|
|
644
|
-
await writeFile(manifestPath, compactOldManifest);
|
|
645
|
-
await expect(validateBundleVersionAgainstBase(oldManifest, {
|
|
646
|
-
cwd: gitRoot,
|
|
647
|
-
currentManifestBytes: compactOldManifest,
|
|
648
|
-
env: {},
|
|
649
|
-
})).resolves.toEqual([
|
|
650
|
-
'Managed documentation bytes changed without changing bundleVersion 2.0.0',
|
|
651
|
-
]);
|
|
652
|
-
|
|
653
|
-
await writeFile(manifestPath, `${JSON.stringify(changedManifest, null, 2)}\n`);
|
|
654
|
-
await execFile('git', ['add', '.'], { cwd: gitRoot });
|
|
655
|
-
await execFile('git', ['commit', '--quiet', '-m', 'change docs without bump'], { cwd: gitRoot });
|
|
656
|
-
|
|
657
|
-
await expect(resolveBundleBaseRevision({
|
|
658
|
-
cwd: gitRoot,
|
|
659
|
-
currentManifest: changedManifest,
|
|
660
|
-
env: { FUNNEL_DOCS_BASE_REF: oldRevision },
|
|
661
|
-
})).resolves.toBe(oldRevision);
|
|
662
|
-
await expect(resolveBundleBaseRevision({
|
|
663
|
-
cwd: gitRoot,
|
|
664
|
-
currentManifest: changedManifest,
|
|
665
|
-
env: {},
|
|
666
|
-
})).resolves.toBe(oldRevision);
|
|
667
|
-
await expect(validateBundleVersionAgainstBase(changedManifest, {
|
|
668
|
-
cwd: gitRoot,
|
|
669
|
-
env: {},
|
|
670
|
-
})).resolves.toEqual([
|
|
671
|
-
'Managed documentation bytes changed without changing bundleVersion 2.0.0',
|
|
672
|
-
]);
|
|
673
|
-
|
|
674
|
-
await writeFile(join(gitRoot, 'README.md'), 'unrelated follow-up\n');
|
|
675
|
-
await execFile('git', ['add', '.'], { cwd: gitRoot });
|
|
676
|
-
await execFile('git', ['commit', '--quiet', '-m', 'unrelated follow-up'], { cwd: gitRoot });
|
|
677
|
-
await expect(resolveBundleBaseRevision({
|
|
678
|
-
cwd: gitRoot,
|
|
679
|
-
currentManifest: changedManifest,
|
|
680
|
-
env: {},
|
|
681
|
-
})).resolves.toBe(oldRevision);
|
|
682
|
-
await expect(validateBundleVersionAgainstBase(changedManifest, {
|
|
683
|
-
cwd: gitRoot,
|
|
684
|
-
env: {},
|
|
685
|
-
})).resolves.toEqual([
|
|
686
|
-
'Managed documentation bytes changed without changing bundleVersion 2.0.0',
|
|
687
|
-
]);
|
|
688
|
-
|
|
689
|
-
await expect(validateBundleVersionAgainstBase({
|
|
690
|
-
...changedManifest,
|
|
691
|
-
bundleVersion: '2.0.1',
|
|
692
|
-
}, {
|
|
693
|
-
cwd: gitRoot,
|
|
694
|
-
baseRevision: oldRevision,
|
|
695
|
-
env: {},
|
|
696
|
-
})).resolves.toEqual([]);
|
|
697
|
-
await expect(validateBundleVersionAgainstBase(oldManifest, {
|
|
698
|
-
cwd: gitRoot,
|
|
699
|
-
baseRevision: oldRevision,
|
|
700
|
-
currentManifestBytes: `${JSON.stringify(oldManifest)}\n`,
|
|
701
|
-
env: {},
|
|
702
|
-
})).resolves.toEqual([
|
|
703
|
-
'Managed documentation bytes changed without changing bundleVersion 2.0.0',
|
|
704
|
-
]);
|
|
705
|
-
|
|
706
|
-
await expect(validateBundleVersionAgainstBase(changedManifest, {
|
|
707
|
-
cwd: gitRoot,
|
|
708
|
-
baseRevision: 'missing-base-ref',
|
|
709
|
-
env: {},
|
|
710
|
-
})).rejects.toThrow(/missing-base-ref/);
|
|
711
|
-
|
|
712
|
-
await writeFile(manifestPath, '{ corrupt json\n');
|
|
713
|
-
await execFile('git', ['add', '.'], { cwd: gitRoot });
|
|
714
|
-
await execFile('git', ['commit', '--quiet', '-m', 'corrupt manifest'], { cwd: gitRoot });
|
|
715
|
-
const { stdout: corruptRevisionOutput } = await execFile('git', ['rev-parse', 'HEAD'], { cwd: gitRoot });
|
|
716
|
-
await expect(readManifestAtRevision({
|
|
717
|
-
cwd: gitRoot,
|
|
718
|
-
revision: corruptRevisionOutput.trim(),
|
|
719
|
-
})).rejects.toThrow(/invalid JSON/i);
|
|
720
|
-
} finally {
|
|
721
|
-
await rm(gitRoot, { recursive: true, force: true });
|
|
722
|
-
}
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
it('keeps legacy template documentation as non-normative compatibility links', async () => {
|
|
726
|
-
const links = new Map([
|
|
727
|
-
['docs/AGENT_FUNNEL_IMPLEMENTATION_SPEC.md', 'funnelsgrove/START-HERE.md'],
|
|
728
|
-
['docs/ARCHITECTURE.md', 'funnelsgrove/contracts/step-metadata.md'],
|
|
729
|
-
['docs/FLOW_CONFIG_AND_ROUTING.md', 'funnelsgrove/contracts/flow-routing.md'],
|
|
730
|
-
['docs/PAYWALL_AND_PAYMENTS.md', 'funnelsgrove/contracts/payments.md'],
|
|
731
|
-
['docs/README.md', 'funnelsgrove/START-HERE.md'],
|
|
732
|
-
['docs/references/funnel-sdk-user-answers.md', '../funnelsgrove/contracts/content-answers.md'],
|
|
733
|
-
]);
|
|
734
|
-
|
|
735
|
-
for (const [path, target] of links) {
|
|
736
|
-
const content = await readFile(join(templateRoot, path), 'utf8');
|
|
737
|
-
expect(content).toContain(target);
|
|
738
|
-
expect(content).toContain('Compatibility');
|
|
739
|
-
expect(content).not.toContain('| Step type |');
|
|
740
|
-
}
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
it('keeps all managed paths inside the template and reports them repo-relatively', async () => {
|
|
744
|
-
const { buildBundle } = await loadGenerator();
|
|
745
|
-
const bundle = await buildBundle();
|
|
746
|
-
for (const path of bundle.files.keys()) {
|
|
747
|
-
const absolutePath = join(templateRoot, path);
|
|
748
|
-
expect(relative(templateRoot, absolutePath)).not.toMatch(/^\.\./);
|
|
749
|
-
}
|
|
750
|
-
});
|
|
751
|
-
});
|