popeye-cli 1.4.7 → 1.5.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/README.md +222 -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/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +42 -0
- package/dist/cli/interactive.js.map +1 -1
- package/dist/generators/all.d.ts +4 -1
- package/dist/generators/all.d.ts.map +1 -1
- package/dist/generators/all.js +2 -1
- package/dist/generators/all.js.map +1 -1
- package/dist/generators/doc-parser.d.ts +49 -0
- package/dist/generators/doc-parser.d.ts.map +1 -0
- package/dist/generators/doc-parser.js +336 -0
- package/dist/generators/doc-parser.js.map +1 -0
- package/dist/generators/templates/index.d.ts +4 -0
- package/dist/generators/templates/index.d.ts.map +1 -1
- package/dist/generators/templates/index.js +4 -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 +278 -0
- package/dist/generators/templates/website-components.js.map +1 -0
- package/dist/generators/templates/website-config.d.ts +41 -0
- package/dist/generators/templates/website-config.d.ts.map +1 -0
- package/dist/generators/templates/website-config.js +283 -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-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 +14 -47
- package/dist/generators/templates/website.d.ts.map +1 -1
- package/dist/generators/templates/website.js +412 -499
- package/dist/generators/templates/website.js.map +1 -1
- package/dist/generators/website-context.d.ts +83 -0
- package/dist/generators/website-context.d.ts.map +1 -0
- package/dist/generators/website-context.js +190 -0
- package/dist/generators/website-context.js.map +1 -0
- package/dist/generators/website.d.ts +3 -0
- package/dist/generators/website.d.ts.map +1 -1
- package/dist/generators/website.js +73 -10
- package/dist/generators/website.js.map +1 -1
- package/dist/state/index.d.ts +27 -0
- package/dist/state/index.d.ts.map +1 -1
- package/dist/state/index.js +30 -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 +15 -0
- package/dist/types/workflow.d.ts.map +1 -1
- package/dist/types/workflow.js +6 -0
- package/dist/types/workflow.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 +3 -0
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +25 -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 +354 -0
- package/dist/workflow/overview.js.map +1 -0
- package/dist/workflow/plan-mode.d.ts +2 -1
- package/dist/workflow/plan-mode.d.ts.map +1 -1
- package/dist/workflow/plan-mode.js +83 -5
- package/dist/workflow/plan-mode.js.map +1 -1
- package/dist/workflow/website-strategy.d.ts +70 -0
- package/dist/workflow/website-strategy.d.ts.map +1 -0
- package/dist/workflow/website-strategy.js +238 -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 +105 -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/interactive.ts +47 -0
- package/src/generators/all.ts +6 -1
- package/src/generators/doc-parser.ts +372 -0
- package/src/generators/templates/index.ts +4 -0
- package/src/generators/templates/website-components.ts +305 -0
- package/src/generators/templates/website-config.ts +291 -0
- package/src/generators/templates/website-conversion.ts +341 -0
- package/src/generators/templates/website-seo.ts +370 -0
- package/src/generators/templates/website.ts +451 -505
- package/src/generators/website-context.ts +265 -0
- package/src/generators/website.ts +109 -19
- package/src/state/index.ts +42 -0
- package/src/types/consensus.ts +3 -0
- package/src/types/website-strategy.ts +243 -0
- package/src/types/workflow.ts +15 -0
- package/src/workflow/consensus.ts +2 -0
- package/src/workflow/execution-mode.ts +21 -0
- package/src/workflow/index.ts +25 -0
- package/src/workflow/overview.ts +469 -0
- package/src/workflow/plan-mode.ts +115 -4
- package/src/workflow/website-strategy.ts +305 -0
- package/src/workflow/website-updater.ts +131 -0
- package/src/workflow/workflow-logger.ts +1 -0
- package/tests/adapters/persona-switching.test.ts +63 -0
- package/tests/generators/website-components.test.ts +159 -0
- package/tests/generators/website-context.test.ts +222 -0
- package/tests/generators/website-seo-quality.test.ts +246 -0
- package/tests/workflow/overview.test.ts +392 -0
- package/tests/workflow/website-strategy.test.ts +191 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for overview module
|
|
3
|
+
* Verifies overview generation, formatting, analysis, and fix capabilities
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import { generateOverview, formatOverview, fixOverviewIssues } from '../../src/workflow/overview.js';
|
|
11
|
+
import type { ProjectOverview, OverviewFixResult } from '../../src/workflow/overview.js';
|
|
12
|
+
|
|
13
|
+
// Mock the state module to avoid filesystem coupling in unit tests
|
|
14
|
+
vi.mock('../../src/state/index.js', async () => {
|
|
15
|
+
const actual = await vi.importActual('../../src/state/index.js');
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
loadProject: vi.fn(),
|
|
19
|
+
getProgress: vi.fn(),
|
|
20
|
+
storeUserDocs: vi.fn(),
|
|
21
|
+
storeBrandContext: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Mock website-context to control doc discovery
|
|
26
|
+
vi.mock('../../src/generators/website-context.js', async () => {
|
|
27
|
+
const actual = await vi.importActual('../../src/generators/website-context.js');
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
discoverProjectDocs: vi.fn().mockResolvedValue([]),
|
|
31
|
+
readProjectDocs: vi.fn().mockResolvedValue(''),
|
|
32
|
+
findBrandAssets: vi.fn().mockResolvedValue({}),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Mock website-updater
|
|
37
|
+
vi.mock('../../src/workflow/website-updater.js', () => ({
|
|
38
|
+
updateWebsiteContent: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
import { loadProject, getProgress, storeUserDocs, storeBrandContext } from '../../src/state/index.js';
|
|
42
|
+
import { discoverProjectDocs, readProjectDocs, findBrandAssets } from '../../src/generators/website-context.js';
|
|
43
|
+
|
|
44
|
+
const mockLoadProject = vi.mocked(loadProject);
|
|
45
|
+
const mockGetProgress = vi.mocked(getProgress);
|
|
46
|
+
const mockStoreUserDocs = vi.mocked(storeUserDocs);
|
|
47
|
+
const mockStoreBrandContext = vi.mocked(storeBrandContext);
|
|
48
|
+
const mockDiscoverDocs = vi.mocked(discoverProjectDocs);
|
|
49
|
+
const mockReadDocs = vi.mocked(readProjectDocs);
|
|
50
|
+
const mockFindBrand = vi.mocked(findBrandAssets);
|
|
51
|
+
|
|
52
|
+
const baseState = {
|
|
53
|
+
id: 'test-id',
|
|
54
|
+
name: 'test-project',
|
|
55
|
+
idea: 'A test project',
|
|
56
|
+
language: 'typescript' as const,
|
|
57
|
+
openaiModel: 'gpt-4o',
|
|
58
|
+
phase: 'execution' as const,
|
|
59
|
+
status: 'in-progress' as const,
|
|
60
|
+
specification: '# Overview\nA great project for testing.\n## Core Features\n- Feature A\n- Feature B',
|
|
61
|
+
plan: '# Plan\n...',
|
|
62
|
+
milestones: [
|
|
63
|
+
{
|
|
64
|
+
id: 'm1',
|
|
65
|
+
name: 'Setup',
|
|
66
|
+
description: 'Initial setup',
|
|
67
|
+
status: 'complete' as const,
|
|
68
|
+
tasks: [
|
|
69
|
+
{ id: 't1', name: 'Init project', description: 'Initialize', status: 'complete' as const },
|
|
70
|
+
{ id: 't2', name: 'Add config', description: 'Configuration', status: 'complete' as const },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'm2',
|
|
75
|
+
name: 'Core Features',
|
|
76
|
+
description: 'Build core features',
|
|
77
|
+
status: 'in-progress' as const,
|
|
78
|
+
tasks: [
|
|
79
|
+
{ id: 't3', name: 'Build API', description: 'API endpoints', status: 'complete' as const },
|
|
80
|
+
{ id: 't4', name: 'Build UI', description: 'User interface', status: 'pending' as const },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
currentMilestone: 'm2',
|
|
85
|
+
currentTask: 't4',
|
|
86
|
+
consensusHistory: [],
|
|
87
|
+
createdAt: '2024-01-01',
|
|
88
|
+
updatedAt: '2024-01-02',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
describe('generateOverview', () => {
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
vi.clearAllMocks();
|
|
94
|
+
mockDiscoverDocs.mockResolvedValue([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('reads state and produces correct structure', async () => {
|
|
98
|
+
mockLoadProject.mockResolvedValue({
|
|
99
|
+
...baseState,
|
|
100
|
+
userDocs: '--- spec.md ---\nSome docs',
|
|
101
|
+
brandContext: { primaryColor: '#2563EB' },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
mockGetProgress.mockResolvedValue({
|
|
105
|
+
totalMilestones: 2,
|
|
106
|
+
completedMilestones: 1,
|
|
107
|
+
totalTasks: 4,
|
|
108
|
+
completedTasks: 3,
|
|
109
|
+
percentComplete: 75,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const overview = await generateOverview('/fake/project/dir');
|
|
113
|
+
|
|
114
|
+
expect(overview.name).toBe('test-project');
|
|
115
|
+
expect(overview.language).toBe('typescript');
|
|
116
|
+
expect(overview.phase).toBe('execution');
|
|
117
|
+
expect(overview.plan.totalMilestones).toBe(2);
|
|
118
|
+
expect(overview.plan.totalTasks).toBe(4);
|
|
119
|
+
expect(overview.progress.percentComplete).toBe(75);
|
|
120
|
+
expect(overview.progress.completedTasks).toBe(3);
|
|
121
|
+
expect(overview.userDocs).toEqual(['spec.md']);
|
|
122
|
+
expect(overview.brandContext?.primaryColor).toBe('#2563EB');
|
|
123
|
+
expect(overview.specification.keyFeatures).toContain('Feature A');
|
|
124
|
+
expect(overview.specification.keyFeatures).toContain('Feature B');
|
|
125
|
+
expect(overview.issues).toBeDefined();
|
|
126
|
+
expect(overview.availableDocs).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('detects missing docs when docs available in CWD', async () => {
|
|
130
|
+
mockLoadProject.mockResolvedValue({ ...baseState });
|
|
131
|
+
mockGetProgress.mockResolvedValue({
|
|
132
|
+
totalMilestones: 2, completedMilestones: 1,
|
|
133
|
+
totalTasks: 4, completedTasks: 3, percentComplete: 75,
|
|
134
|
+
});
|
|
135
|
+
mockDiscoverDocs.mockResolvedValue(['/fake/spec.md', '/fake/pricing.md']);
|
|
136
|
+
|
|
137
|
+
const overview = await generateOverview('/fake/project/dir');
|
|
138
|
+
|
|
139
|
+
expect(overview.availableDocs).toEqual(['spec.md', 'pricing.md']);
|
|
140
|
+
const docsIssue = overview.issues.find((i) => i.category === 'docs');
|
|
141
|
+
expect(docsIssue).toBeDefined();
|
|
142
|
+
expect(docsIssue?.message).toContain('2 doc(s)');
|
|
143
|
+
expect(docsIssue?.fix).toContain('/overview fix');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('detects generic specification', async () => {
|
|
147
|
+
mockLoadProject.mockResolvedValue({
|
|
148
|
+
...baseState,
|
|
149
|
+
specification: "Here's a comprehensive software specification for your project...",
|
|
150
|
+
});
|
|
151
|
+
mockGetProgress.mockResolvedValue({
|
|
152
|
+
totalMilestones: 2, completedMilestones: 1,
|
|
153
|
+
totalTasks: 4, completedTasks: 3, percentComplete: 75,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const overview = await generateOverview('/fake/project/dir');
|
|
157
|
+
|
|
158
|
+
const specIssue = overview.issues.find((i) => i.category === 'spec');
|
|
159
|
+
expect(specIssue).toBeDefined();
|
|
160
|
+
expect(specIssue?.message).toContain('generic AI-generated');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('detects no brand context', async () => {
|
|
164
|
+
mockLoadProject.mockResolvedValue({ ...baseState });
|
|
165
|
+
mockGetProgress.mockResolvedValue({
|
|
166
|
+
totalMilestones: 2, completedMilestones: 1,
|
|
167
|
+
totalTasks: 4, completedTasks: 3, percentComplete: 75,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const overview = await generateOverview('/fake/project/dir');
|
|
171
|
+
|
|
172
|
+
const brandIssue = overview.issues.find((i) => i.category === 'brand');
|
|
173
|
+
expect(brandIssue).toBeDefined();
|
|
174
|
+
expect(brandIssue?.message).toContain('No brand context');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('detects very few tasks', async () => {
|
|
178
|
+
mockLoadProject.mockResolvedValue({
|
|
179
|
+
...baseState,
|
|
180
|
+
milestones: [{
|
|
181
|
+
id: 'm1', name: 'Only', description: 'Only milestone',
|
|
182
|
+
status: 'pending',
|
|
183
|
+
tasks: [{ id: 't1', name: 'Only task', description: 'Only', status: 'pending' }],
|
|
184
|
+
}],
|
|
185
|
+
});
|
|
186
|
+
mockGetProgress.mockResolvedValue({
|
|
187
|
+
totalMilestones: 1, completedMilestones: 0,
|
|
188
|
+
totalTasks: 1, completedTasks: 0, percentComplete: 0,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const overview = await generateOverview('/fake/project/dir');
|
|
192
|
+
|
|
193
|
+
const planIssue = overview.issues.find((i) => i.category === 'plan');
|
|
194
|
+
expect(planIssue).toBeDefined();
|
|
195
|
+
expect(planIssue?.message).toContain('only 1 task(s)');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('formatOverview', () => {
|
|
200
|
+
it('produces formatted string with progress info', () => {
|
|
201
|
+
const overview: ProjectOverview = {
|
|
202
|
+
name: 'my-project',
|
|
203
|
+
idea: 'A test idea',
|
|
204
|
+
language: 'typescript',
|
|
205
|
+
phase: 'execution',
|
|
206
|
+
status: 'in-progress',
|
|
207
|
+
specification: {
|
|
208
|
+
summary: 'A great project',
|
|
209
|
+
keyFeatures: ['Feature A', 'Feature B'],
|
|
210
|
+
},
|
|
211
|
+
plan: {
|
|
212
|
+
totalMilestones: 2,
|
|
213
|
+
totalTasks: 4,
|
|
214
|
+
milestones: [
|
|
215
|
+
{
|
|
216
|
+
name: 'Setup',
|
|
217
|
+
status: 'complete',
|
|
218
|
+
taskCount: 2,
|
|
219
|
+
completedTasks: 2,
|
|
220
|
+
tasks: [
|
|
221
|
+
{ name: 'Init', status: 'complete' },
|
|
222
|
+
{ name: 'Config', status: 'complete' },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: 'Core',
|
|
227
|
+
status: 'in-progress',
|
|
228
|
+
taskCount: 2,
|
|
229
|
+
completedTasks: 1,
|
|
230
|
+
tasks: [
|
|
231
|
+
{ name: 'API', status: 'complete' },
|
|
232
|
+
{ name: 'UI', status: 'pending' },
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
progress: {
|
|
238
|
+
completedMilestones: 1,
|
|
239
|
+
completedTasks: 3,
|
|
240
|
+
percentComplete: 75,
|
|
241
|
+
},
|
|
242
|
+
issues: [],
|
|
243
|
+
availableDocs: [],
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const output = formatOverview(overview);
|
|
247
|
+
|
|
248
|
+
expect(output).toContain('my-project');
|
|
249
|
+
expect(output).toContain('75%');
|
|
250
|
+
expect(output).toContain('3/4 tasks');
|
|
251
|
+
expect(output).toContain('Setup');
|
|
252
|
+
expect(output).toContain('Core');
|
|
253
|
+
expect(output).toContain('[x]'); // complete status
|
|
254
|
+
expect(output).toContain('[ ]'); // pending status
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('handles project with no milestones', () => {
|
|
258
|
+
const overview: ProjectOverview = {
|
|
259
|
+
name: 'empty-project',
|
|
260
|
+
idea: 'Nothing yet',
|
|
261
|
+
language: 'python',
|
|
262
|
+
phase: 'plan',
|
|
263
|
+
status: 'pending',
|
|
264
|
+
specification: {
|
|
265
|
+
summary: '',
|
|
266
|
+
keyFeatures: [],
|
|
267
|
+
},
|
|
268
|
+
plan: {
|
|
269
|
+
totalMilestones: 0,
|
|
270
|
+
totalTasks: 0,
|
|
271
|
+
milestones: [],
|
|
272
|
+
},
|
|
273
|
+
progress: {
|
|
274
|
+
completedMilestones: 0,
|
|
275
|
+
completedTasks: 0,
|
|
276
|
+
percentComplete: 0,
|
|
277
|
+
},
|
|
278
|
+
issues: [],
|
|
279
|
+
availableDocs: [],
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const output = formatOverview(overview);
|
|
283
|
+
|
|
284
|
+
expect(output).toContain('empty-project');
|
|
285
|
+
expect(output).toContain('0%');
|
|
286
|
+
expect(output).toContain('No milestones defined yet');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('shows analysis section when issues exist', () => {
|
|
290
|
+
const overview: ProjectOverview = {
|
|
291
|
+
name: 'test-project',
|
|
292
|
+
idea: 'Test',
|
|
293
|
+
language: 'website',
|
|
294
|
+
phase: 'complete',
|
|
295
|
+
status: 'complete',
|
|
296
|
+
specification: { summary: 'Test', keyFeatures: [] },
|
|
297
|
+
plan: { totalMilestones: 1, totalTasks: 5, milestones: [] },
|
|
298
|
+
progress: { completedMilestones: 1, completedTasks: 5, percentComplete: 100 },
|
|
299
|
+
issues: [
|
|
300
|
+
{ severity: 'warning', category: 'docs', message: 'No docs found', fix: 'Add docs' },
|
|
301
|
+
{ severity: 'error', category: 'website', message: 'Placeholder content', fix: 'Run /overview fix' },
|
|
302
|
+
],
|
|
303
|
+
availableDocs: ['spec.md'],
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const output = formatOverview(overview);
|
|
307
|
+
|
|
308
|
+
expect(output).toContain('ANALYSIS');
|
|
309
|
+
expect(output).toContain('[!] DOCS: No docs found');
|
|
310
|
+
expect(output).toContain('[!!] WEBSITE: Placeholder content');
|
|
311
|
+
expect(output).toContain('-> Add docs');
|
|
312
|
+
expect(output).toContain('-> Run /overview fix');
|
|
313
|
+
expect(output).toContain('/overview fix');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('shows available docs section when docs not yet imported', () => {
|
|
317
|
+
const overview: ProjectOverview = {
|
|
318
|
+
name: 'test-project',
|
|
319
|
+
idea: 'Test',
|
|
320
|
+
language: 'typescript',
|
|
321
|
+
phase: 'execution',
|
|
322
|
+
status: 'in-progress',
|
|
323
|
+
specification: { summary: 'Test', keyFeatures: [] },
|
|
324
|
+
plan: { totalMilestones: 1, totalTasks: 5, milestones: [] },
|
|
325
|
+
progress: { completedMilestones: 0, completedTasks: 2, percentComplete: 40 },
|
|
326
|
+
issues: [],
|
|
327
|
+
availableDocs: ['spec.md', 'pricing.md'],
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const output = formatOverview(overview);
|
|
331
|
+
|
|
332
|
+
expect(output).toContain('AVAILABLE DOCS (not yet imported)');
|
|
333
|
+
expect(output).toContain('spec.md');
|
|
334
|
+
expect(output).toContain('pricing.md');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('fixOverviewIssues', () => {
|
|
339
|
+
beforeEach(() => {
|
|
340
|
+
vi.clearAllMocks();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('discovers docs and stores them', async () => {
|
|
344
|
+
const stateWithDocs = { ...baseState, userDocs: '--- spec.md ---\nContent' };
|
|
345
|
+
mockLoadProject.mockResolvedValue({ ...baseState });
|
|
346
|
+
mockDiscoverDocs.mockResolvedValue(['/parent/spec.md']);
|
|
347
|
+
mockReadDocs.mockResolvedValue('--- spec.md ---\nSpec content');
|
|
348
|
+
mockFindBrand.mockResolvedValue({});
|
|
349
|
+
mockStoreUserDocs.mockResolvedValue(stateWithDocs);
|
|
350
|
+
|
|
351
|
+
const result = await fixOverviewIssues('/parent/project/dir');
|
|
352
|
+
|
|
353
|
+
expect(result.docsDiscovered).toBe(1);
|
|
354
|
+
expect(result.docsStored).toBe(true);
|
|
355
|
+
expect(result.messages.some((m) => m.includes('spec.md'))).toBe(true);
|
|
356
|
+
expect(mockStoreUserDocs).toHaveBeenCalledWith('/parent/project/dir', '--- spec.md ---\nSpec content');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('finds brand assets and extracts colors', async () => {
|
|
360
|
+
const stateWithBrand = {
|
|
361
|
+
...baseState,
|
|
362
|
+
userDocs: '# Colors\nPrimary: #2563EB',
|
|
363
|
+
brandContext: { logoPath: '/parent/logo.png', primaryColor: '#2563EB' },
|
|
364
|
+
};
|
|
365
|
+
mockLoadProject.mockResolvedValue({ ...baseState });
|
|
366
|
+
mockDiscoverDocs.mockResolvedValue(['/parent/colors.md']);
|
|
367
|
+
mockReadDocs.mockResolvedValue('# Colors\nPrimary: #2563EB');
|
|
368
|
+
mockFindBrand.mockResolvedValue({ logoPath: '/parent/logo.png' });
|
|
369
|
+
mockStoreUserDocs.mockResolvedValue({ ...baseState, userDocs: '# Colors\nPrimary: #2563EB' });
|
|
370
|
+
mockStoreBrandContext
|
|
371
|
+
.mockResolvedValueOnce({ ...baseState, brandContext: { logoPath: '/parent/logo.png' }, userDocs: '# Colors\nPrimary: #2563EB' })
|
|
372
|
+
.mockResolvedValueOnce(stateWithBrand);
|
|
373
|
+
|
|
374
|
+
const result = await fixOverviewIssues('/parent/project/dir');
|
|
375
|
+
|
|
376
|
+
expect(result.brandFound).toBe(true);
|
|
377
|
+
expect(result.messages.some((m) => m.includes('logo.png'))).toBe(true);
|
|
378
|
+
expect(result.messages.some((m) => m.includes('#2563EB'))).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('shows tip when no docs or brand found', async () => {
|
|
382
|
+
mockLoadProject.mockResolvedValue({ ...baseState });
|
|
383
|
+
mockDiscoverDocs.mockResolvedValue([]);
|
|
384
|
+
mockFindBrand.mockResolvedValue({});
|
|
385
|
+
|
|
386
|
+
const result = await fixOverviewIssues('/parent/project/dir');
|
|
387
|
+
|
|
388
|
+
expect(result.docsDiscovered).toBe(0);
|
|
389
|
+
expect(result.brandFound).toBe(false);
|
|
390
|
+
expect(result.messages.some((m) => m.includes('Tip:'))).toBe(true);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Website strategy storage, formatting, and hash staleness tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import {
|
|
10
|
+
storeWebsiteStrategy,
|
|
11
|
+
loadWebsiteStrategy,
|
|
12
|
+
formatStrategyForPlanContext,
|
|
13
|
+
isStrategyStale,
|
|
14
|
+
} from '../../src/workflow/website-strategy.js';
|
|
15
|
+
import type {
|
|
16
|
+
WebsiteStrategyDocument,
|
|
17
|
+
StrategyMetadata,
|
|
18
|
+
} from '../../src/types/website-strategy.js';
|
|
19
|
+
|
|
20
|
+
const mockStrategy: WebsiteStrategyDocument = {
|
|
21
|
+
icp: {
|
|
22
|
+
primaryPersona: 'DevOps engineers',
|
|
23
|
+
painPoints: ['Slow pipelines'],
|
|
24
|
+
goals: ['Ship faster'],
|
|
25
|
+
objections: ['Migration effort'],
|
|
26
|
+
},
|
|
27
|
+
positioning: {
|
|
28
|
+
category: 'CI/CD',
|
|
29
|
+
differentiators: ['AI-powered'],
|
|
30
|
+
valueProposition: 'Deploy 10x faster with AI',
|
|
31
|
+
proofPoints: ['500+ teams'],
|
|
32
|
+
},
|
|
33
|
+
messaging: {
|
|
34
|
+
headline: 'Ship Code 10x Faster',
|
|
35
|
+
subheadline: 'AI-Powered CI/CD',
|
|
36
|
+
elevatorPitch: 'Deploy confidently with AI.',
|
|
37
|
+
longDescription: 'AI-powered CI/CD platform.',
|
|
38
|
+
},
|
|
39
|
+
seoStrategy: {
|
|
40
|
+
primaryKeywords: ['CI/CD', 'DevOps'],
|
|
41
|
+
secondaryKeywords: ['deployment'],
|
|
42
|
+
longTailKeywords: ['AI CI/CD platform'],
|
|
43
|
+
titleTemplates: { home: 'Ship Faster', pricing: 'Pricing' },
|
|
44
|
+
metaDescriptions: { home: 'AI CI/CD', pricing: 'Plans' },
|
|
45
|
+
},
|
|
46
|
+
siteArchitecture: {
|
|
47
|
+
pages: [
|
|
48
|
+
{ path: '/', title: 'Home', purpose: 'conversion', pageType: 'landing', sections: ['hero'], seoKeywords: ['ci/cd'], conversionGoal: 'sign up' },
|
|
49
|
+
{ path: '/pricing', title: 'Pricing', purpose: 'monetization', pageType: 'pricing', sections: ['tiers'], seoKeywords: ['pricing'], conversionGoal: 'trial' },
|
|
50
|
+
],
|
|
51
|
+
navigation: [{ label: 'Pricing', href: '/pricing' }],
|
|
52
|
+
footerSections: [{ title: 'Product', links: [{ label: 'Home', href: '/' }] }],
|
|
53
|
+
},
|
|
54
|
+
conversionStrategy: {
|
|
55
|
+
primaryCta: { text: 'Start Trial', href: '/pricing' },
|
|
56
|
+
secondaryCta: { text: 'Docs', href: '/docs' },
|
|
57
|
+
trustSignals: ['SOC 2'],
|
|
58
|
+
socialProof: ['500+ teams'],
|
|
59
|
+
leadCapture: 'webhook',
|
|
60
|
+
},
|
|
61
|
+
competitiveContext: {
|
|
62
|
+
category: 'CI/CD',
|
|
63
|
+
competitors: ['CircleCI'],
|
|
64
|
+
differentiators: ['AI optimization'],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const mockMetadata: StrategyMetadata = {
|
|
69
|
+
inputHash: 'abc123',
|
|
70
|
+
generatedAt: new Date().toISOString(),
|
|
71
|
+
version: 1,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe('storeWebsiteStrategy and loadWebsiteStrategy', () => {
|
|
75
|
+
let tempDir: string;
|
|
76
|
+
|
|
77
|
+
beforeEach(async () => {
|
|
78
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'popeye-strategy-'));
|
|
79
|
+
await fs.mkdir(path.join(tempDir, '.popeye'), { recursive: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(async () => {
|
|
83
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('stores and loads strategy with metadata from .popeye/', async () => {
|
|
87
|
+
await storeWebsiteStrategy(tempDir, mockStrategy, mockMetadata);
|
|
88
|
+
|
|
89
|
+
const loaded = await loadWebsiteStrategy(tempDir);
|
|
90
|
+
expect(loaded).not.toBeNull();
|
|
91
|
+
expect(loaded!.strategy.messaging.headline).toBe('Ship Code 10x Faster');
|
|
92
|
+
expect(loaded!.metadata.inputHash).toBe('abc123');
|
|
93
|
+
expect(loaded!.metadata.version).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns null when no strategy file exists', async () => {
|
|
97
|
+
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'popeye-empty-'));
|
|
98
|
+
await fs.mkdir(path.join(emptyDir, '.popeye'), { recursive: true });
|
|
99
|
+
|
|
100
|
+
const loaded = await loadWebsiteStrategy(emptyDir);
|
|
101
|
+
expect(loaded).toBeNull();
|
|
102
|
+
|
|
103
|
+
await fs.rm(emptyDir, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns null when strategy file has invalid data', async () => {
|
|
107
|
+
const filePath = path.join(tempDir, '.popeye', 'website-strategy.json');
|
|
108
|
+
await fs.writeFile(filePath, '{"strategy": {"invalid": true}}', 'utf-8');
|
|
109
|
+
|
|
110
|
+
const loaded = await loadWebsiteStrategy(tempDir);
|
|
111
|
+
expect(loaded).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('formatStrategyForPlanContext', () => {
|
|
116
|
+
it('formats strategy as plan context without modifying specification', () => {
|
|
117
|
+
const formatted = formatStrategyForPlanContext(mockStrategy);
|
|
118
|
+
|
|
119
|
+
// Should contain key strategy sections
|
|
120
|
+
expect(formatted).toContain('### Target Customer');
|
|
121
|
+
expect(formatted).toContain('DevOps engineers');
|
|
122
|
+
expect(formatted).toContain('### Positioning');
|
|
123
|
+
expect(formatted).toContain('Deploy 10x faster with AI');
|
|
124
|
+
expect(formatted).toContain('### Messaging');
|
|
125
|
+
expect(formatted).toContain('Ship Code 10x Faster');
|
|
126
|
+
expect(formatted).toContain('### SEO Keywords');
|
|
127
|
+
expect(formatted).toContain('CI/CD');
|
|
128
|
+
expect(formatted).toContain('### Site Architecture');
|
|
129
|
+
expect(formatted).toContain('/ (landing)');
|
|
130
|
+
expect(formatted).toContain('/pricing (pricing)');
|
|
131
|
+
expect(formatted).toContain('### Conversion Strategy');
|
|
132
|
+
expect(formatted).toContain('Start Trial');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('includes all strategy pages in site architecture', () => {
|
|
136
|
+
const formatted = formatStrategyForPlanContext(mockStrategy);
|
|
137
|
+
// Should list all pages from siteArchitecture
|
|
138
|
+
for (const page of mockStrategy.siteArchitecture.pages) {
|
|
139
|
+
expect(formatted).toContain(page.path);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('isStrategyStale', () => {
|
|
145
|
+
let tempDir: string;
|
|
146
|
+
|
|
147
|
+
beforeEach(async () => {
|
|
148
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'popeye-stale-'));
|
|
149
|
+
await fs.mkdir(path.join(tempDir, '.popeye'), { recursive: true });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterEach(async () => {
|
|
153
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('detects stale strategy via inputHash comparison', async () => {
|
|
157
|
+
await storeWebsiteStrategy(tempDir, mockStrategy, mockMetadata);
|
|
158
|
+
|
|
159
|
+
// Different input should be stale
|
|
160
|
+
const stale = await isStrategyStale(tempDir, {
|
|
161
|
+
productContext: 'completely different product',
|
|
162
|
+
projectName: 'different-project',
|
|
163
|
+
brandAssets: { logoOutputPath: 'public/brand/logo.svg' },
|
|
164
|
+
});
|
|
165
|
+
expect(stale).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('returns true (stale) when no strategy file exists', async () => {
|
|
169
|
+
const stale = await isStrategyStale(tempDir, {
|
|
170
|
+
productContext: 'some content',
|
|
171
|
+
projectName: 'test',
|
|
172
|
+
brandAssets: { logoOutputPath: 'public/brand/logo.svg' },
|
|
173
|
+
});
|
|
174
|
+
expect(stale).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns cached strategy when hash matches', async () => {
|
|
178
|
+
// Store with a specific hash
|
|
179
|
+
const metadata: StrategyMetadata = {
|
|
180
|
+
...mockMetadata,
|
|
181
|
+
inputHash: 'will-be-computed',
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// First store, then verify we can detect matching input
|
|
185
|
+
await storeWebsiteStrategy(tempDir, mockStrategy, metadata);
|
|
186
|
+
|
|
187
|
+
const loaded = await loadWebsiteStrategy(tempDir);
|
|
188
|
+
expect(loaded).not.toBeNull();
|
|
189
|
+
expect(loaded!.strategy.messaging.headline).toBe('Ship Code 10x Faster');
|
|
190
|
+
});
|
|
191
|
+
});
|