popeye-cli 1.4.7 → 1.6.0
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/CHANGELOG.md +54 -0
- package/README.md +264 -63
- package/dist/adapters/gemini.d.ts +1 -0
- package/dist/adapters/gemini.d.ts.map +1 -1
- package/dist/adapters/gemini.js +9 -4
- package/dist/adapters/gemini.js.map +1 -1
- package/dist/adapters/grok.d.ts +1 -0
- package/dist/adapters/grok.d.ts.map +1 -1
- package/dist/adapters/grok.js +9 -4
- package/dist/adapters/grok.js.map +1 -1
- package/dist/adapters/openai.d.ts +1 -1
- package/dist/adapters/openai.d.ts.map +1 -1
- package/dist/adapters/openai.js +35 -9
- package/dist/adapters/openai.js.map +1 -1
- package/dist/cli/commands/create.d.ts.map +1 -1
- package/dist/cli/commands/create.js +54 -4
- package/dist/cli/commands/create.js.map +1 -1
- package/dist/cli/interactive.d.ts +29 -0
- package/dist/cli/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +132 -7
- package/dist/cli/interactive.js.map +1 -1
- package/dist/generators/all.d.ts +8 -2
- package/dist/generators/all.d.ts.map +1 -1
- package/dist/generators/all.js +37 -316
- package/dist/generators/all.js.map +1 -1
- package/dist/generators/doc-parser.d.ts +64 -0
- package/dist/generators/doc-parser.d.ts.map +1 -0
- package/dist/generators/doc-parser.js +407 -0
- package/dist/generators/doc-parser.js.map +1 -0
- package/dist/generators/frontend-design-analyzer.d.ts +30 -0
- package/dist/generators/frontend-design-analyzer.d.ts.map +1 -0
- package/dist/generators/frontend-design-analyzer.js +208 -0
- package/dist/generators/frontend-design-analyzer.js.map +1 -0
- package/dist/generators/shared-packages.d.ts +45 -0
- package/dist/generators/shared-packages.d.ts.map +1 -0
- package/dist/generators/shared-packages.js +456 -0
- package/dist/generators/shared-packages.js.map +1 -0
- package/dist/generators/templates/index.d.ts +8 -0
- package/dist/generators/templates/index.d.ts.map +1 -1
- package/dist/generators/templates/index.js +8 -0
- package/dist/generators/templates/index.js.map +1 -1
- package/dist/generators/templates/website-components.d.ts +33 -0
- package/dist/generators/templates/website-components.d.ts.map +1 -0
- package/dist/generators/templates/website-components.js +303 -0
- package/dist/generators/templates/website-components.js.map +1 -0
- package/dist/generators/templates/website-config.d.ts +55 -0
- package/dist/generators/templates/website-config.d.ts.map +1 -0
- package/dist/generators/templates/website-config.js +425 -0
- package/dist/generators/templates/website-config.js.map +1 -0
- package/dist/generators/templates/website-conversion.d.ts +27 -0
- package/dist/generators/templates/website-conversion.d.ts.map +1 -0
- package/dist/generators/templates/website-conversion.js +326 -0
- package/dist/generators/templates/website-conversion.js.map +1 -0
- package/dist/generators/templates/website-landing.d.ts +24 -0
- package/dist/generators/templates/website-landing.d.ts.map +1 -0
- package/dist/generators/templates/website-landing.js +276 -0
- package/dist/generators/templates/website-landing.js.map +1 -0
- package/dist/generators/templates/website-layout.d.ts +42 -0
- package/dist/generators/templates/website-layout.d.ts.map +1 -0
- package/dist/generators/templates/website-layout.js +408 -0
- package/dist/generators/templates/website-layout.js.map +1 -0
- package/dist/generators/templates/website-pricing.d.ts +11 -0
- package/dist/generators/templates/website-pricing.d.ts.map +1 -0
- package/dist/generators/templates/website-pricing.js +313 -0
- package/dist/generators/templates/website-pricing.js.map +1 -0
- package/dist/generators/templates/website-sections.d.ts +102 -0
- package/dist/generators/templates/website-sections.d.ts.map +1 -0
- package/dist/generators/templates/website-sections.js +444 -0
- package/dist/generators/templates/website-sections.js.map +1 -0
- package/dist/generators/templates/website-seo.d.ts +76 -0
- package/dist/generators/templates/website-seo.d.ts.map +1 -0
- package/dist/generators/templates/website-seo.js +326 -0
- package/dist/generators/templates/website-seo.js.map +1 -0
- package/dist/generators/templates/website.d.ts +10 -83
- package/dist/generators/templates/website.d.ts.map +1 -1
- package/dist/generators/templates/website.js +12 -875
- package/dist/generators/templates/website.js.map +1 -1
- package/dist/generators/website-content-scanner.d.ts +37 -0
- package/dist/generators/website-content-scanner.d.ts.map +1 -0
- package/dist/generators/website-content-scanner.js +165 -0
- package/dist/generators/website-content-scanner.js.map +1 -0
- package/dist/generators/website-context.d.ts +119 -0
- package/dist/generators/website-context.d.ts.map +1 -0
- package/dist/generators/website-context.js +350 -0
- package/dist/generators/website-context.js.map +1 -0
- package/dist/generators/website-debug.d.ts +68 -0
- package/dist/generators/website-debug.d.ts.map +1 -0
- package/dist/generators/website-debug.js +93 -0
- package/dist/generators/website-debug.js.map +1 -0
- package/dist/generators/website.d.ts +5 -0
- package/dist/generators/website.d.ts.map +1 -1
- package/dist/generators/website.js +136 -11
- package/dist/generators/website.js.map +1 -1
- package/dist/generators/workspace-root.d.ts +27 -0
- package/dist/generators/workspace-root.d.ts.map +1 -0
- package/dist/generators/workspace-root.js +100 -0
- package/dist/generators/workspace-root.js.map +1 -0
- package/dist/state/index.d.ts +35 -0
- package/dist/state/index.d.ts.map +1 -1
- package/dist/state/index.js +40 -0
- package/dist/state/index.js.map +1 -1
- package/dist/types/consensus.d.ts +3 -0
- package/dist/types/consensus.d.ts.map +1 -1
- package/dist/types/consensus.js +1 -0
- package/dist/types/consensus.js.map +1 -1
- package/dist/types/website-strategy.d.ts +263 -0
- package/dist/types/website-strategy.d.ts.map +1 -0
- package/dist/types/website-strategy.js +105 -0
- package/dist/types/website-strategy.js.map +1 -0
- package/dist/types/workflow.d.ts +21 -0
- package/dist/types/workflow.d.ts.map +1 -1
- package/dist/types/workflow.js +8 -0
- package/dist/types/workflow.js.map +1 -1
- package/dist/upgrade/handlers.d.ts +15 -0
- package/dist/upgrade/handlers.d.ts.map +1 -1
- package/dist/upgrade/handlers.js +52 -0
- package/dist/upgrade/handlers.js.map +1 -1
- package/dist/workflow/auto-fix-bundler.d.ts +37 -0
- package/dist/workflow/auto-fix-bundler.d.ts.map +1 -0
- package/dist/workflow/auto-fix-bundler.js +320 -0
- package/dist/workflow/auto-fix-bundler.js.map +1 -0
- package/dist/workflow/auto-fix.d.ts.map +1 -1
- package/dist/workflow/auto-fix.js +10 -3
- package/dist/workflow/auto-fix.js.map +1 -1
- package/dist/workflow/consensus.d.ts.map +1 -1
- package/dist/workflow/consensus.js +2 -0
- package/dist/workflow/consensus.js.map +1 -1
- package/dist/workflow/execution-mode.d.ts.map +1 -1
- package/dist/workflow/execution-mode.js +18 -0
- package/dist/workflow/execution-mode.js.map +1 -1
- package/dist/workflow/index.d.ts +4 -0
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +37 -0
- package/dist/workflow/index.js.map +1 -1
- package/dist/workflow/overview.d.ts +89 -0
- package/dist/workflow/overview.d.ts.map +1 -0
- package/dist/workflow/overview.js +358 -0
- package/dist/workflow/overview.js.map +1 -0
- package/dist/workflow/plan-mode.d.ts +6 -4
- package/dist/workflow/plan-mode.d.ts.map +1 -1
- package/dist/workflow/plan-mode.js +148 -6
- package/dist/workflow/plan-mode.js.map +1 -1
- package/dist/workflow/website-strategy.d.ts +79 -0
- package/dist/workflow/website-strategy.d.ts.map +1 -0
- package/dist/workflow/website-strategy.js +310 -0
- package/dist/workflow/website-strategy.js.map +1 -0
- package/dist/workflow/website-updater.d.ts +17 -0
- package/dist/workflow/website-updater.d.ts.map +1 -0
- package/dist/workflow/website-updater.js +116 -0
- package/dist/workflow/website-updater.js.map +1 -0
- package/dist/workflow/workflow-logger.d.ts +1 -1
- package/dist/workflow/workflow-logger.d.ts.map +1 -1
- package/dist/workflow/workflow-logger.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/gemini.ts +10 -4
- package/src/adapters/grok.ts +10 -4
- package/src/adapters/openai.ts +38 -6
- package/src/cli/commands/create.ts +58 -4
- package/src/cli/interactive.ts +143 -7
- package/src/generators/all.ts +49 -332
- package/src/generators/doc-parser.ts +449 -0
- package/src/generators/frontend-design-analyzer.ts +261 -0
- package/src/generators/shared-packages.ts +500 -0
- package/src/generators/templates/index.ts +8 -0
- package/src/generators/templates/website-components.ts +330 -0
- package/src/generators/templates/website-config.ts +444 -0
- package/src/generators/templates/website-conversion.ts +341 -0
- package/src/generators/templates/website-landing.ts +331 -0
- package/src/generators/templates/website-layout.ts +443 -0
- package/src/generators/templates/website-pricing.ts +330 -0
- package/src/generators/templates/website-sections.ts +541 -0
- package/src/generators/templates/website-seo.ts +370 -0
- package/src/generators/templates/website.ts +38 -905
- package/src/generators/website-content-scanner.ts +208 -0
- package/src/generators/website-context.ts +493 -0
- package/src/generators/website-debug.ts +130 -0
- package/src/generators/website.ts +178 -20
- package/src/generators/workspace-root.ts +113 -0
- package/src/state/index.ts +56 -0
- package/src/types/consensus.ts +3 -0
- package/src/types/website-strategy.ts +243 -0
- package/src/types/workflow.ts +21 -0
- package/src/upgrade/handlers.ts +65 -0
- package/src/workflow/auto-fix-bundler.ts +392 -0
- package/src/workflow/auto-fix.ts +11 -3
- package/src/workflow/consensus.ts +2 -0
- package/src/workflow/execution-mode.ts +21 -0
- package/src/workflow/index.ts +37 -0
- package/src/workflow/overview.ts +475 -0
- package/src/workflow/plan-mode.ts +193 -8
- package/src/workflow/website-strategy.ts +379 -0
- package/src/workflow/website-updater.ts +142 -0
- package/src/workflow/workflow-logger.ts +1 -0
- package/tests/adapters/persona-switching.test.ts +63 -0
- package/tests/cli/project-naming.test.ts +136 -0
- package/tests/generators/doc-parser.test.ts +121 -0
- package/tests/generators/frontend-design-analyzer.test.ts +90 -0
- package/tests/generators/quality-gate.test.ts +183 -0
- package/tests/generators/shared-packages.test.ts +83 -0
- package/tests/generators/website-components.test.ts +159 -0
- package/tests/generators/website-config.test.ts +84 -0
- package/tests/generators/website-content-scanner.test.ts +181 -0
- package/tests/generators/website-context.test.ts +331 -0
- package/tests/generators/website-debug.test.ts +77 -0
- package/tests/generators/website-landing.test.ts +188 -0
- package/tests/generators/website-pricing.test.ts +98 -0
- package/tests/generators/website-sections.test.ts +245 -0
- package/tests/generators/website-seo-quality.test.ts +246 -0
- package/tests/generators/workspace-root.test.ts +105 -0
- package/tests/upgrade/handlers.test.ts +162 -0
- package/tests/workflow/auto-fix-bundler.test.ts +242 -0
- package/tests/workflow/overview.test.ts +392 -0
- package/tests/workflow/plan-mode.test.ts +111 -1
- package/tests/workflow/website-strategy.test.ts +246 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for website-context module
|
|
3
|
+
* Verifies doc discovery, brand asset detection, and context building
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import {
|
|
11
|
+
discoverProjectDocs,
|
|
12
|
+
readProjectDocs,
|
|
13
|
+
findBrandAssets,
|
|
14
|
+
buildWebsiteContext,
|
|
15
|
+
validateWebsiteContextOrThrow,
|
|
16
|
+
type WebsiteContentContext,
|
|
17
|
+
} from '../../src/generators/website-context.js';
|
|
18
|
+
|
|
19
|
+
describe('discoverProjectDocs', () => {
|
|
20
|
+
let tmpDir: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'popeye-test-'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('finds .md files matching spec/pricing/color patterns', async () => {
|
|
31
|
+
await fs.writeFile(path.join(tmpDir, 'product-spec.md'), '# Product Spec');
|
|
32
|
+
await fs.writeFile(path.join(tmpDir, 'pricing.md'), '# Pricing');
|
|
33
|
+
await fs.writeFile(path.join(tmpDir, 'color-scheme.md'), '# Colors');
|
|
34
|
+
await fs.writeFile(path.join(tmpDir, 'random-notes.md'), '# Notes');
|
|
35
|
+
|
|
36
|
+
const docs = await discoverProjectDocs(tmpDir);
|
|
37
|
+
|
|
38
|
+
expect(docs.length).toBe(3);
|
|
39
|
+
expect(docs.some((d) => d.includes('product-spec.md'))).toBe(true);
|
|
40
|
+
expect(docs.some((d) => d.includes('pricing.md'))).toBe(true);
|
|
41
|
+
expect(docs.some((d) => d.includes('color-scheme.md'))).toBe(true);
|
|
42
|
+
// random-notes.md should NOT match
|
|
43
|
+
expect(docs.some((d) => d.includes('random-notes.md'))).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('ignores node_modules and .popeye directories', async () => {
|
|
47
|
+
await fs.mkdir(path.join(tmpDir, 'node_modules'), { recursive: true });
|
|
48
|
+
await fs.writeFile(
|
|
49
|
+
path.join(tmpDir, 'node_modules', 'spec.md'),
|
|
50
|
+
'# Should be ignored'
|
|
51
|
+
);
|
|
52
|
+
await fs.mkdir(path.join(tmpDir, '.popeye'), { recursive: true });
|
|
53
|
+
await fs.writeFile(
|
|
54
|
+
path.join(tmpDir, '.popeye', 'spec.md'),
|
|
55
|
+
'# Should be ignored'
|
|
56
|
+
);
|
|
57
|
+
await fs.writeFile(path.join(tmpDir, 'real-spec.md'), '# Real spec');
|
|
58
|
+
|
|
59
|
+
const docs = await discoverProjectDocs(tmpDir);
|
|
60
|
+
|
|
61
|
+
expect(docs.length).toBe(1);
|
|
62
|
+
expect(docs[0]).toContain('real-spec.md');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns empty for directory with no docs', async () => {
|
|
66
|
+
await fs.writeFile(path.join(tmpDir, 'index.ts'), 'export const x = 1;');
|
|
67
|
+
|
|
68
|
+
const docs = await discoverProjectDocs(tmpDir);
|
|
69
|
+
|
|
70
|
+
expect(docs).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('scans docs/ subdirectory', async () => {
|
|
74
|
+
await fs.mkdir(path.join(tmpDir, 'docs'), { recursive: true });
|
|
75
|
+
await fs.writeFile(
|
|
76
|
+
path.join(tmpDir, 'docs', 'api-guide.md'),
|
|
77
|
+
'# API Guide'
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const docs = await discoverProjectDocs(tmpDir);
|
|
81
|
+
|
|
82
|
+
expect(docs.length).toBe(1);
|
|
83
|
+
expect(docs[0]).toContain('api-guide.md');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('readProjectDocs', () => {
|
|
88
|
+
let tmpDir: string;
|
|
89
|
+
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'popeye-test-'));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('concatenates content with headers and caps at maxLength', async () => {
|
|
99
|
+
const file1 = path.join(tmpDir, 'spec.md');
|
|
100
|
+
const file2 = path.join(tmpDir, 'pricing.md');
|
|
101
|
+
await fs.writeFile(file1, 'Spec content here');
|
|
102
|
+
await fs.writeFile(file2, 'Pricing content here');
|
|
103
|
+
|
|
104
|
+
const result = await readProjectDocs([file1, file2]);
|
|
105
|
+
|
|
106
|
+
expect(result).toContain('--- spec.md ---');
|
|
107
|
+
expect(result).toContain('Spec content here');
|
|
108
|
+
expect(result).toContain('--- pricing.md ---');
|
|
109
|
+
expect(result).toContain('Pricing content here');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('respects maxLength cap', async () => {
|
|
113
|
+
const file1 = path.join(tmpDir, 'big.md');
|
|
114
|
+
await fs.writeFile(file1, 'x'.repeat(10000));
|
|
115
|
+
|
|
116
|
+
const result = await readProjectDocs([file1], 100);
|
|
117
|
+
|
|
118
|
+
expect(result.length).toBeLessThanOrEqual(120); // header + trimmed content
|
|
119
|
+
expect(result).toContain('...');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('findBrandAssets', () => {
|
|
124
|
+
let tmpDir: string;
|
|
125
|
+
|
|
126
|
+
beforeEach(async () => {
|
|
127
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'popeye-test-'));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
afterEach(async () => {
|
|
131
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('finds logo PNG/SVG files', async () => {
|
|
135
|
+
await fs.writeFile(path.join(tmpDir, 'Company Logo.png'), 'fake-png');
|
|
136
|
+
|
|
137
|
+
const result = await findBrandAssets(tmpDir);
|
|
138
|
+
|
|
139
|
+
expect(result.logoPath).toBeDefined();
|
|
140
|
+
expect(result.logoPath).toContain('Logo.png');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('returns empty when no logo found', async () => {
|
|
144
|
+
await fs.writeFile(path.join(tmpDir, 'screenshot.png'), 'fake-png');
|
|
145
|
+
|
|
146
|
+
const result = await findBrandAssets(tmpDir);
|
|
147
|
+
|
|
148
|
+
expect(result.logoPath).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('buildWebsiteContext', () => {
|
|
153
|
+
let tmpDir: string;
|
|
154
|
+
|
|
155
|
+
beforeEach(async () => {
|
|
156
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'popeye-test-'));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
afterEach(async () => {
|
|
160
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('creates structured context from docs with specification', async () => {
|
|
164
|
+
await fs.writeFile(
|
|
165
|
+
path.join(tmpDir, 'color-scheme.md'),
|
|
166
|
+
'# Colors\nPrimary: #2563EB\n'
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const context = await buildWebsiteContext(
|
|
170
|
+
tmpDir,
|
|
171
|
+
'my-project',
|
|
172
|
+
'# Overview\nA permission-aware retrieval layer for AI systems.\n## Features\n- Access control'
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
expect(context.productName).toBe('my-project');
|
|
176
|
+
expect(context.description).toContain('permission-aware');
|
|
177
|
+
expect(context.brand?.primaryColor).toBe('#2563EB');
|
|
178
|
+
expect(context.rawDocs).toContain('color-scheme.md');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('returns minimal context when no docs exist', async () => {
|
|
182
|
+
const context = await buildWebsiteContext(tmpDir, 'empty-project');
|
|
183
|
+
|
|
184
|
+
expect(context.productName).toBe('empty-project');
|
|
185
|
+
expect(context.features).toEqual([]);
|
|
186
|
+
expect(context.rawDocs).toBe('');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('extracts product name, tagline, features, and pricing from rich docs', async () => {
|
|
190
|
+
// Simulate Gateco-like docs (wrapped in code fences like the real files)
|
|
191
|
+
await fs.writeFile(
|
|
192
|
+
path.join(tmpDir, 'Gateco-spec.md'),
|
|
193
|
+
'```md\n# Gateco — Permission-Aware Retrieval for AI Systems\n\n## 2. What Is Gateco?\n\n**Gateco** is a permission-aware retrieval layer that sits between AI agents and vector databases.\n\nIt enforces:\n- organizational permissions\n- identity-based access control\n\n## 3. Core Design Principles\n\n1. **Vector DB agnostic**\n2. **Embedding agnostic**\n3. **Identity-driven** - not prompt-driven\n4. **Late-binding authorization**\n```'
|
|
194
|
+
);
|
|
195
|
+
await fs.writeFile(
|
|
196
|
+
path.join(tmpDir, 'Gateco-pricing.md'),
|
|
197
|
+
'```md\n# Gateco Pricing\n\n## Pricing Overview\n\n| Plan | Price |\n|---|---|\n| **Free (Dev & POC)** | Free |\n| **Pro (Usage-Based)** | $99 / month minimum |\n| **Enterprise** | Custom pricing |\n\n## Plan Positioning\n\n- **Free (Dev & POC)**\n *Build and test safely.*\n\n- **Pro (Usage-Based)**\n *Run production AI workloads.*\n\n- **Enterprise**\n *Deploy in regulated environments.*\n```'
|
|
198
|
+
);
|
|
199
|
+
await fs.writeFile(
|
|
200
|
+
path.join(tmpDir, 'color-scheme.md'),
|
|
201
|
+
'# Colors\n\n| Token | Hex | Usage |\n|---|---|---|\n| `bg-primary` | `#0F172A` | Dark background |\n| `accent-primary` | `#2563EB` | Primary CTA, links |\n'
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const context = await buildWebsiteContext(tmpDir, 'read-all-files');
|
|
205
|
+
|
|
206
|
+
// Product name extracted from spec heading, not folder name
|
|
207
|
+
expect(context.productName).toBe('Gateco');
|
|
208
|
+
// Tagline from "— tagline" pattern
|
|
209
|
+
expect(context.tagline).toContain('Permission-Aware Retrieval');
|
|
210
|
+
// Description from "What Is Gateco?" section
|
|
211
|
+
expect(context.description).toContain('permission-aware retrieval layer');
|
|
212
|
+
// Features extracted from Core Design Principles
|
|
213
|
+
expect(context.features.length).toBeGreaterThanOrEqual(2);
|
|
214
|
+
expect(context.features.some((f) => f.title.includes('Vector DB'))).toBe(true);
|
|
215
|
+
// Pricing tiers extracted
|
|
216
|
+
expect(context.pricing).toBeDefined();
|
|
217
|
+
expect(context.pricing!.length).toBe(3);
|
|
218
|
+
expect(context.pricing![0].name).toContain('Free');
|
|
219
|
+
expect(context.pricing![1].price).toBe('$99');
|
|
220
|
+
expect(context.pricing![2].price).toBe('Custom');
|
|
221
|
+
// Primary color is accent-primary (#2563EB), NOT bg-primary (#0F172A)
|
|
222
|
+
expect(context.brand?.primaryColor).toBe('#2563EB');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('discovers docs from parent directory via workspace root', async () => {
|
|
226
|
+
// Simulate: tmpDir has docs, tmpDir/project/.popeye/ is the project dir
|
|
227
|
+
const projectDir = path.join(tmpDir, 'project');
|
|
228
|
+
await fs.mkdir(path.join(projectDir, '.popeye'), { recursive: true });
|
|
229
|
+
await fs.writeFile(
|
|
230
|
+
path.join(tmpDir, 'product-spec.md'),
|
|
231
|
+
'# MyProduct — A great product\n## Features\n- Feature 1'
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const context = await buildWebsiteContext(projectDir, 'project');
|
|
235
|
+
|
|
236
|
+
// Should find docs from parent directory
|
|
237
|
+
expect(context.rawDocs).toContain('product-spec.md');
|
|
238
|
+
expect(context.productName).toBe('MyProduct');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('finds brand assets from parent directory', async () => {
|
|
242
|
+
const projectDir = path.join(tmpDir, 'project');
|
|
243
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
244
|
+
await fs.writeFile(path.join(tmpDir, 'company-logo.png'), 'fake-png');
|
|
245
|
+
|
|
246
|
+
const result = await findBrandAssets(projectDir);
|
|
247
|
+
|
|
248
|
+
// findBrandAssets now scans parent dirs via workspace root
|
|
249
|
+
// The logo should be found in tmpDir
|
|
250
|
+
expect(result.logoPath).toBeDefined();
|
|
251
|
+
expect(result.logoPath).toContain('company-logo.png');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('enforces per-file cap to prevent single large doc from consuming budget', async () => {
|
|
255
|
+
await fs.writeFile(path.join(tmpDir, 'huge-spec.md'), 'x'.repeat(20000));
|
|
256
|
+
await fs.writeFile(path.join(tmpDir, 'color-scheme.md'), '# Colors\nPrimary: #FF0000');
|
|
257
|
+
|
|
258
|
+
const docs = await discoverProjectDocs(tmpDir);
|
|
259
|
+
const content = await readProjectDocs(docs);
|
|
260
|
+
|
|
261
|
+
// Color-scheme should be present (it's prioritized and small)
|
|
262
|
+
expect(content).toContain('color-scheme.md');
|
|
263
|
+
// The huge spec should be capped at 8000 chars
|
|
264
|
+
const specSection = content.split('--- huge-spec.md ---')[1];
|
|
265
|
+
if (specSection) {
|
|
266
|
+
// Per-file cap is 8000 + "..." suffix
|
|
267
|
+
expect(specSection.length).toBeLessThan(8100);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('validateWebsiteContextOrThrow', () => {
|
|
273
|
+
const baseContext: WebsiteContentContext = {
|
|
274
|
+
productName: 'Gateco',
|
|
275
|
+
features: [{ title: 'Auth', description: 'Access control' }],
|
|
276
|
+
rawDocs: 'x'.repeat(200),
|
|
277
|
+
strategy: {
|
|
278
|
+
icp: { primaryPersona: 'devs', painPoints: [], goals: [], objections: [] },
|
|
279
|
+
positioning: { category: 'Security', differentiators: [], valueProposition: 'Secure AI', proofPoints: [] },
|
|
280
|
+
messaging: { headline: 'h', subheadline: 's', elevatorPitch: 'e', longDescription: 'l' },
|
|
281
|
+
seoStrategy: { primaryKeywords: [], secondaryKeywords: [], longTailKeywords: [], titleTemplates: {}, metaDescriptions: {} },
|
|
282
|
+
siteArchitecture: { pages: [], navigation: [], footerSections: [] },
|
|
283
|
+
conversionStrategy: { primaryCta: { text: 'Go', href: '/' }, secondaryCta: { text: 'More', href: '/' }, trustSignals: [], socialProof: [], leadCapture: 'none' },
|
|
284
|
+
competitiveContext: { category: 'sec', competitors: [], differentiators: [] },
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
it('passes with valid context', () => {
|
|
289
|
+
const result = validateWebsiteContextOrThrow(baseContext, 'gateco');
|
|
290
|
+
expect(result.passed).toBe(true);
|
|
291
|
+
expect(result.issues).toEqual([]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('fails when strategy is missing', () => {
|
|
295
|
+
const ctx: WebsiteContentContext = { ...baseContext, strategy: undefined };
|
|
296
|
+
expect(() => validateWebsiteContextOrThrow(ctx, 'gateco')).toThrow('strategy missing');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('fails when features are empty', () => {
|
|
300
|
+
const ctx: WebsiteContentContext = { ...baseContext, features: [] };
|
|
301
|
+
expect(() => validateWebsiteContextOrThrow(ctx, 'gateco')).toThrow('features');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('fails when product name looks like a directory', () => {
|
|
305
|
+
const ctx: WebsiteContentContext = { ...baseContext, productName: 'my-cool-project' };
|
|
306
|
+
expect(() => validateWebsiteContextOrThrow(ctx, 'x')).toThrow('directory name');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('fails when no docs found', () => {
|
|
310
|
+
const ctx: WebsiteContentContext = { ...baseContext, rawDocs: '' };
|
|
311
|
+
expect(() => validateWebsiteContextOrThrow(ctx, 'gateco')).toThrow('documentation');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('fails when brand/color docs exist but no color extracted', () => {
|
|
315
|
+
const ctx: WebsiteContentContext = {
|
|
316
|
+
...baseContext,
|
|
317
|
+
rawDocs: 'x'.repeat(200) + 'color brand guide',
|
|
318
|
+
brand: undefined,
|
|
319
|
+
};
|
|
320
|
+
expect(() => validateWebsiteContextOrThrow(ctx, 'gateco')).toThrow('primary color');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('fails when logo found but output path not resolved', () => {
|
|
324
|
+
const ctx: WebsiteContentContext = {
|
|
325
|
+
...baseContext,
|
|
326
|
+
brand: { logoPath: '/some/logo.png' },
|
|
327
|
+
brandAssets: undefined,
|
|
328
|
+
};
|
|
329
|
+
expect(() => validateWebsiteContextOrThrow(ctx, 'gateco')).toThrow('output path not resolved');
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for website-debug module
|
|
3
|
+
* Verifies trace includes sections + validation info
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { formatDebugTrace, type WebsiteDebugTrace } from '../../src/generators/website-debug.js';
|
|
8
|
+
|
|
9
|
+
function makeTrace(overrides?: Partial<WebsiteDebugTrace>): WebsiteDebugTrace {
|
|
10
|
+
return {
|
|
11
|
+
workspaceRoot: '/tmp/project',
|
|
12
|
+
docsFound: [{ path: '/tmp/project/spec.md', size: 1000 }],
|
|
13
|
+
brandAssets: { logoPath: '/tmp/logo.png', logoOutputPath: 'public/brand/logo.png' },
|
|
14
|
+
productName: { value: 'Gateco', source: 'docs' },
|
|
15
|
+
primaryColor: { value: '#2563EB', source: 'brand-docs' },
|
|
16
|
+
strategyStatus: 'success',
|
|
17
|
+
templateValues: { headline: 'Secure AI', features: 3, pricingTiers: 3 },
|
|
18
|
+
sectionsRendered: [
|
|
19
|
+
{ name: 'Hero', dataSource: 'strategy', itemCount: 1 },
|
|
20
|
+
{ name: 'PainPoints', dataSource: 'strategy', itemCount: 3 },
|
|
21
|
+
{ name: 'Features', dataSource: 'docs', itemCount: 3 },
|
|
22
|
+
{ name: 'FAQ', dataSource: 'skipped', itemCount: 0 },
|
|
23
|
+
],
|
|
24
|
+
validationPassed: true,
|
|
25
|
+
validationIssues: [],
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('formatDebugTrace', () => {
|
|
31
|
+
it('formats basic trace fields', () => {
|
|
32
|
+
const output = formatDebugTrace(makeTrace());
|
|
33
|
+
expect(output).toContain('WEBSITE GENERATION DEBUG TRACE');
|
|
34
|
+
expect(output).toContain('Gateco');
|
|
35
|
+
expect(output).toContain('#2563EB');
|
|
36
|
+
expect(output).toContain('success');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('includes sections rendered with data sources', () => {
|
|
40
|
+
const output = formatDebugTrace(makeTrace());
|
|
41
|
+
expect(output).toContain('Sections Rendered (4)');
|
|
42
|
+
expect(output).toContain('Hero: strategy (1 items)');
|
|
43
|
+
expect(output).toContain('PainPoints: strategy (3 items)');
|
|
44
|
+
expect(output).toContain('Features: docs (3 items)');
|
|
45
|
+
expect(output).toContain('FAQ: skipped (0 items)');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('shows validation passed', () => {
|
|
49
|
+
const output = formatDebugTrace(makeTrace());
|
|
50
|
+
expect(output).toContain('Validation: PASSED');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('shows validation failed with issues', () => {
|
|
54
|
+
const output = formatDebugTrace(makeTrace({
|
|
55
|
+
validationPassed: false,
|
|
56
|
+
validationIssues: ['Strategy missing', 'No features found'],
|
|
57
|
+
}));
|
|
58
|
+
expect(output).toContain('Validation: FAILED');
|
|
59
|
+
expect(output).toContain('Strategy missing');
|
|
60
|
+
expect(output).toContain('No features found');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('handles empty sections list', () => {
|
|
64
|
+
const output = formatDebugTrace(makeTrace({ sectionsRendered: [] }));
|
|
65
|
+
expect(output).toContain('Sections Rendered (0)');
|
|
66
|
+
expect(output).toContain('(none)');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('shows strategy error when present', () => {
|
|
70
|
+
const output = formatDebugTrace(makeTrace({
|
|
71
|
+
strategyStatus: 'failed',
|
|
72
|
+
strategyError: 'Rate limit exceeded',
|
|
73
|
+
}));
|
|
74
|
+
expect(output).toContain('Strategy: failed');
|
|
75
|
+
expect(output).toContain('Error: Rate limit exceeded');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for website-landing module
|
|
3
|
+
* Verifies 10-section landing page generation with strategy data flow
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { generateWebsiteLandingPageWithInfo } from '../../src/generators/templates/website-landing.js';
|
|
8
|
+
import type { WebsiteContentContext } from '../../src/generators/website-context.js';
|
|
9
|
+
import type { WebsiteStrategyDocument } from '../../src/types/website-strategy.js';
|
|
10
|
+
|
|
11
|
+
function makeStrategy(overrides?: Partial<WebsiteStrategyDocument>): WebsiteStrategyDocument {
|
|
12
|
+
return {
|
|
13
|
+
icp: {
|
|
14
|
+
primaryPersona: 'Engineering teams',
|
|
15
|
+
painPoints: ['Data leaks', 'No access control', 'Compliance gaps'],
|
|
16
|
+
goals: ['Secure AI access'],
|
|
17
|
+
objections: ['Is it secure?', 'Does it scale?'],
|
|
18
|
+
},
|
|
19
|
+
positioning: {
|
|
20
|
+
category: 'AI Security',
|
|
21
|
+
differentiators: ['Zero-trust by default', 'Vector DB agnostic'],
|
|
22
|
+
valueProposition: 'Secure AI data access without complexity',
|
|
23
|
+
proofPoints: ['SOC2 compliant', '10K+ queries/sec'],
|
|
24
|
+
},
|
|
25
|
+
messaging: {
|
|
26
|
+
headline: 'Secure AI Retrieval',
|
|
27
|
+
subheadline: 'Permission-aware access for AI agents',
|
|
28
|
+
elevatorPitch: 'Stop worrying about data leaks',
|
|
29
|
+
longDescription: 'A permission-aware retrieval layer for AI systems.',
|
|
30
|
+
},
|
|
31
|
+
seoStrategy: {
|
|
32
|
+
primaryKeywords: ['AI security'],
|
|
33
|
+
secondaryKeywords: [],
|
|
34
|
+
longTailKeywords: [],
|
|
35
|
+
titleTemplates: { home: 'Gateco - AI Security' },
|
|
36
|
+
metaDescriptions: { home: 'Secure AI retrieval' },
|
|
37
|
+
},
|
|
38
|
+
siteArchitecture: {
|
|
39
|
+
pages: [],
|
|
40
|
+
navigation: [{ label: 'Features', href: '/#features' }],
|
|
41
|
+
footerSections: [],
|
|
42
|
+
},
|
|
43
|
+
conversionStrategy: {
|
|
44
|
+
primaryCta: { text: 'Try Free', href: '/signup' },
|
|
45
|
+
secondaryCta: { text: 'View Docs', href: '/docs' },
|
|
46
|
+
trustSignals: ['Enterprise ready', 'SOC2 compliant'],
|
|
47
|
+
socialProof: ['Great product!'],
|
|
48
|
+
leadCapture: 'none',
|
|
49
|
+
},
|
|
50
|
+
competitiveContext: { category: 'Security', competitors: [], differentiators: [] },
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeContext(overrides?: Partial<WebsiteContentContext>): WebsiteContentContext {
|
|
56
|
+
return {
|
|
57
|
+
productName: 'Gateco',
|
|
58
|
+
features: [
|
|
59
|
+
{ title: 'Access Control', description: 'Fine-grained permissions' },
|
|
60
|
+
{ title: 'Vector Search', description: 'Fast semantic search' },
|
|
61
|
+
{ title: 'Audit Trail', description: 'Full audit logging' },
|
|
62
|
+
],
|
|
63
|
+
rawDocs: 'docs content here',
|
|
64
|
+
description: 'A permission-aware retrieval layer for AI systems.',
|
|
65
|
+
pricing: [
|
|
66
|
+
{ name: 'Free', price: 'Free', description: 'Dev & POC', features: ['Basic'], cta: 'Start', featured: false },
|
|
67
|
+
{ name: 'Pro', price: '$99', period: '/month', description: 'Production', features: ['All'], cta: 'Subscribe', featured: true },
|
|
68
|
+
],
|
|
69
|
+
strategy: makeStrategy(),
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('generateWebsiteLandingPageWithInfo', () => {
|
|
75
|
+
it('generates page with all 10 sections when full context provided', () => {
|
|
76
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', makeContext());
|
|
77
|
+
|
|
78
|
+
// Hero
|
|
79
|
+
expect(result.code).toContain('Secure AI Retrieval');
|
|
80
|
+
expect(result.code).toContain('AI Security'); // eyebrow category
|
|
81
|
+
|
|
82
|
+
// Sections rendered info should have all sections
|
|
83
|
+
const sectionNames = result.sections.map(s => s.name);
|
|
84
|
+
expect(sectionNames).toContain('Hero');
|
|
85
|
+
expect(sectionNames).toContain('PainPoints');
|
|
86
|
+
expect(sectionNames).toContain('Differentiators');
|
|
87
|
+
expect(sectionNames).toContain('Features');
|
|
88
|
+
expect(sectionNames).toContain('HowItWorks');
|
|
89
|
+
expect(sectionNames).toContain('Stats');
|
|
90
|
+
expect(sectionNames).toContain('SocialProof');
|
|
91
|
+
expect(sectionNames).toContain('PricingTeaser');
|
|
92
|
+
expect(sectionNames).toContain('FAQ');
|
|
93
|
+
expect(sectionNames).toContain('FinalCTA');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('includes strategy headline in hero', () => {
|
|
97
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', makeContext());
|
|
98
|
+
expect(result.code).toContain('Secure AI Retrieval');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('includes pain points from strategy', () => {
|
|
102
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', makeContext());
|
|
103
|
+
expect(result.code).toContain('Data leaks');
|
|
104
|
+
expect(result.code).toContain('No access control');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('includes differentiators from strategy', () => {
|
|
108
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', makeContext());
|
|
109
|
+
expect(result.code).toContain('Zero-trust by default');
|
|
110
|
+
expect(result.code).toContain('Vector DB agnostic');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('renders features with lucide icons', () => {
|
|
114
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', makeContext());
|
|
115
|
+
expect(result.code).toContain('Access Control');
|
|
116
|
+
expect(result.code).toContain('lucide-react');
|
|
117
|
+
expect(result.code).toContain('ICON_MAP');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('renders FAQ from strategy objections', () => {
|
|
121
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', makeContext());
|
|
122
|
+
expect(result.code).toContain('Is it secure?');
|
|
123
|
+
expect(result.code).toContain('Does it scale?');
|
|
124
|
+
expect(result.code).toContain('FaqItem');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('renders pricing teaser from context pricing', () => {
|
|
128
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', makeContext());
|
|
129
|
+
expect(result.code).toContain('View full pricing');
|
|
130
|
+
expect(result.code).toContain('$99');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('skips sections when data is missing', () => {
|
|
134
|
+
const ctx = makeContext({
|
|
135
|
+
strategy: makeStrategy({
|
|
136
|
+
icp: { primaryPersona: 'devs', painPoints: [], goals: [], objections: [] },
|
|
137
|
+
positioning: { category: 'SaaS', differentiators: [], valueProposition: '', proofPoints: [] },
|
|
138
|
+
conversionStrategy: {
|
|
139
|
+
primaryCta: { text: 'Go', href: '/' },
|
|
140
|
+
secondaryCta: { text: 'More', href: '/' },
|
|
141
|
+
trustSignals: [],
|
|
142
|
+
socialProof: [],
|
|
143
|
+
leadCapture: 'none',
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
pricing: undefined,
|
|
147
|
+
});
|
|
148
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', ctx);
|
|
149
|
+
|
|
150
|
+
const skipped = result.sections.filter(s => s.dataSource === 'skipped');
|
|
151
|
+
expect(skipped.map(s => s.name)).toContain('PainPoints');
|
|
152
|
+
expect(skipped.map(s => s.name)).toContain('SocialProof');
|
|
153
|
+
expect(skipped.map(s => s.name)).toContain('FAQ');
|
|
154
|
+
expect(skipped.map(s => s.name)).toContain('PricingTeaser');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('uses product name as headline when no strategy', () => {
|
|
158
|
+
const ctx = makeContext({ strategy: undefined });
|
|
159
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', ctx);
|
|
160
|
+
expect(result.code).toContain('Gateco');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('renders only a single H1', () => {
|
|
164
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', makeContext());
|
|
165
|
+
const h1Count = (result.code.match(/<h1/g) || []).length;
|
|
166
|
+
expect(h1Count).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('includes trust signals in hero', () => {
|
|
170
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', makeContext());
|
|
171
|
+
expect(result.code).toContain('Enterprise ready');
|
|
172
|
+
expect(result.code).toContain('SOC2 compliant');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('includes dual CTAs from strategy', () => {
|
|
176
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', makeContext());
|
|
177
|
+
expect(result.code).toContain('Try Free');
|
|
178
|
+
expect(result.code).toContain('View Docs');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('includes JSON-LD schemas', () => {
|
|
182
|
+
const result = generateWebsiteLandingPageWithInfo('gateco', makeContext());
|
|
183
|
+
expect(result.code).toContain('Organization');
|
|
184
|
+
expect(result.code).toContain('SoftwareApplication');
|
|
185
|
+
// FAQ schema when objections exist
|
|
186
|
+
expect(result.code).toContain('FAQPage');
|
|
187
|
+
});
|
|
188
|
+
});
|