popeye-cli 1.7.0 → 1.8.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 +102 -5
- package/cheatsheet.md +407 -0
- package/dist/cli/commands/db.d.ts +10 -0
- package/dist/cli/commands/db.d.ts.map +1 -0
- package/dist/cli/commands/db.js +240 -0
- package/dist/cli/commands/db.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +18 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +255 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/index.d.ts +2 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +2 -0
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +96 -0
- package/dist/cli/interactive.js.map +1 -1
- package/dist/generators/admin-wizard.d.ts +25 -0
- package/dist/generators/admin-wizard.d.ts.map +1 -0
- package/dist/generators/admin-wizard.js +123 -0
- package/dist/generators/admin-wizard.js.map +1 -0
- package/dist/generators/all.d.ts.map +1 -1
- package/dist/generators/all.js +10 -3
- package/dist/generators/all.js.map +1 -1
- package/dist/generators/database.d.ts +58 -0
- package/dist/generators/database.d.ts.map +1 -0
- package/dist/generators/database.js +229 -0
- package/dist/generators/database.js.map +1 -0
- package/dist/generators/fullstack.d.ts.map +1 -1
- package/dist/generators/fullstack.js +23 -7
- package/dist/generators/fullstack.js.map +1 -1
- package/dist/generators/index.d.ts +2 -0
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +2 -0
- package/dist/generators/index.js.map +1 -1
- package/dist/generators/templates/admin-wizard-python.d.ts +32 -0
- package/dist/generators/templates/admin-wizard-python.d.ts.map +1 -0
- package/dist/generators/templates/admin-wizard-python.js +425 -0
- package/dist/generators/templates/admin-wizard-python.js.map +1 -0
- package/dist/generators/templates/admin-wizard-react.d.ts +48 -0
- package/dist/generators/templates/admin-wizard-react.d.ts.map +1 -0
- package/dist/generators/templates/admin-wizard-react.js +554 -0
- package/dist/generators/templates/admin-wizard-react.js.map +1 -0
- package/dist/generators/templates/database-docker.d.ts +23 -0
- package/dist/generators/templates/database-docker.d.ts.map +1 -0
- package/dist/generators/templates/database-docker.js +221 -0
- package/dist/generators/templates/database-docker.js.map +1 -0
- package/dist/generators/templates/database-python.d.ts +54 -0
- package/dist/generators/templates/database-python.d.ts.map +1 -0
- package/dist/generators/templates/database-python.js +723 -0
- package/dist/generators/templates/database-python.js.map +1 -0
- package/dist/generators/templates/database-typescript.d.ts +34 -0
- package/dist/generators/templates/database-typescript.d.ts.map +1 -0
- package/dist/generators/templates/database-typescript.js +232 -0
- package/dist/generators/templates/database-typescript.js.map +1 -0
- package/dist/generators/templates/fullstack.d.ts.map +1 -1
- package/dist/generators/templates/fullstack.js +29 -0
- package/dist/generators/templates/fullstack.js.map +1 -1
- package/dist/generators/templates/index.d.ts +5 -0
- package/dist/generators/templates/index.d.ts.map +1 -1
- package/dist/generators/templates/index.js +5 -0
- package/dist/generators/templates/index.js.map +1 -1
- package/dist/state/index.d.ts +10 -0
- package/dist/state/index.d.ts.map +1 -1
- package/dist/state/index.js +21 -0
- package/dist/state/index.js.map +1 -1
- package/dist/types/database-runtime.d.ts +86 -0
- package/dist/types/database-runtime.d.ts.map +1 -0
- package/dist/types/database-runtime.js +61 -0
- package/dist/types/database-runtime.js.map +1 -0
- package/dist/types/database.d.ts +85 -0
- package/dist/types/database.d.ts.map +1 -0
- package/dist/types/database.js +71 -0
- package/dist/types/database.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +4 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/workflow.d.ts +21 -0
- package/dist/types/workflow.d.ts.map +1 -1
- package/dist/types/workflow.js +2 -0
- package/dist/types/workflow.js.map +1 -1
- package/dist/workflow/db-setup-runner.d.ts +63 -0
- package/dist/workflow/db-setup-runner.d.ts.map +1 -0
- package/dist/workflow/db-setup-runner.js +336 -0
- package/dist/workflow/db-setup-runner.js.map +1 -0
- package/dist/workflow/db-state-machine.d.ts +30 -0
- package/dist/workflow/db-state-machine.d.ts.map +1 -0
- package/dist/workflow/db-state-machine.js +51 -0
- package/dist/workflow/db-state-machine.js.map +1 -0
- package/dist/workflow/index.d.ts +2 -0
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +2 -0
- package/dist/workflow/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/db.ts +281 -0
- package/src/cli/commands/doctor.ts +273 -0
- package/src/cli/commands/index.ts +2 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/interactive.ts +102 -0
- package/src/generators/admin-wizard.ts +146 -0
- package/src/generators/all.ts +10 -3
- package/src/generators/database.ts +286 -0
- package/src/generators/fullstack.ts +26 -9
- package/src/generators/index.ts +12 -0
- package/src/generators/templates/admin-wizard-python.ts +431 -0
- package/src/generators/templates/admin-wizard-react.ts +560 -0
- package/src/generators/templates/database-docker.ts +227 -0
- package/src/generators/templates/database-python.ts +734 -0
- package/src/generators/templates/database-typescript.ts +238 -0
- package/src/generators/templates/fullstack.ts +29 -0
- package/src/generators/templates/index.ts +5 -0
- package/src/state/index.ts +28 -0
- package/src/types/database-runtime.ts +69 -0
- package/src/types/database.ts +84 -0
- package/src/types/index.ts +29 -0
- package/src/types/workflow.ts +5 -0
- package/src/workflow/db-setup-runner.ts +391 -0
- package/src/workflow/db-state-machine.ts +58 -0
- package/src/workflow/index.ts +2 -0
- package/tests/generators/admin-wizard-orchestrator.test.ts +64 -0
- package/tests/generators/admin-wizard-templates.test.ts +366 -0
- package/tests/generators/cross-phase-integration.test.ts +383 -0
- package/tests/generators/database.test.ts +456 -0
- package/tests/generators/fe-be-db-integration.test.ts +613 -0
- package/tests/types/database-runtime.test.ts +158 -0
- package/tests/types/database.test.ts +187 -0
- package/tests/workflow/db-setup-runner.test.ts +211 -0
- package/tests/workflow/db-state-machine.test.ts +117 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for database runtime types and schemas
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
SetupStepResultSchema,
|
|
8
|
+
SetupResultSchema,
|
|
9
|
+
ReadinessCheckSchema,
|
|
10
|
+
ReadinessResultSchema,
|
|
11
|
+
} from '../../src/types/database-runtime.js';
|
|
12
|
+
|
|
13
|
+
describe('SetupStepResultSchema', () => {
|
|
14
|
+
it('should accept a valid successful step result', () => {
|
|
15
|
+
const result = SetupStepResultSchema.safeParse({
|
|
16
|
+
step: 'check_connection',
|
|
17
|
+
success: true,
|
|
18
|
+
message: 'Database connection verified',
|
|
19
|
+
durationMs: 150,
|
|
20
|
+
});
|
|
21
|
+
expect(result.success).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should accept a failed step result with error', () => {
|
|
25
|
+
const result = SetupStepResultSchema.safeParse({
|
|
26
|
+
step: 'apply_migrations',
|
|
27
|
+
success: false,
|
|
28
|
+
message: 'Migration failed',
|
|
29
|
+
durationMs: 3000,
|
|
30
|
+
error: 'alembic upgrade head returned exit code 1',
|
|
31
|
+
});
|
|
32
|
+
expect(result.success).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should reject invalid step names', () => {
|
|
36
|
+
const result = SetupStepResultSchema.safeParse({
|
|
37
|
+
step: 'invalid_step',
|
|
38
|
+
success: true,
|
|
39
|
+
message: 'ok',
|
|
40
|
+
durationMs: 0,
|
|
41
|
+
});
|
|
42
|
+
expect(result.success).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should reject missing required fields', () => {
|
|
46
|
+
const result = SetupStepResultSchema.safeParse({
|
|
47
|
+
step: 'check_connection',
|
|
48
|
+
success: true,
|
|
49
|
+
});
|
|
50
|
+
expect(result.success).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('SetupResultSchema', () => {
|
|
55
|
+
it('should accept a successful pipeline result', () => {
|
|
56
|
+
const result = SetupResultSchema.safeParse({
|
|
57
|
+
success: true,
|
|
58
|
+
steps: [
|
|
59
|
+
{ step: 'check_connection', success: true, message: 'ok', durationMs: 100 },
|
|
60
|
+
{ step: 'apply_migrations', success: true, message: 'ok', durationMs: 200 },
|
|
61
|
+
],
|
|
62
|
+
totalDurationMs: 300,
|
|
63
|
+
finalStatus: 'ready',
|
|
64
|
+
});
|
|
65
|
+
expect(result.success).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should accept a failed pipeline result with error', () => {
|
|
69
|
+
const result = SetupResultSchema.safeParse({
|
|
70
|
+
success: false,
|
|
71
|
+
steps: [
|
|
72
|
+
{ step: 'check_connection', success: false, message: 'fail', durationMs: 50, error: 'timeout' },
|
|
73
|
+
],
|
|
74
|
+
totalDurationMs: 50,
|
|
75
|
+
finalStatus: 'error',
|
|
76
|
+
error: 'timeout',
|
|
77
|
+
});
|
|
78
|
+
expect(result.success).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should accept empty steps array', () => {
|
|
82
|
+
const result = SetupResultSchema.safeParse({
|
|
83
|
+
success: false,
|
|
84
|
+
steps: [],
|
|
85
|
+
totalDurationMs: 0,
|
|
86
|
+
finalStatus: 'error',
|
|
87
|
+
});
|
|
88
|
+
expect(result.success).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should reject invalid finalStatus', () => {
|
|
92
|
+
const result = SetupResultSchema.safeParse({
|
|
93
|
+
success: true,
|
|
94
|
+
steps: [],
|
|
95
|
+
totalDurationMs: 0,
|
|
96
|
+
finalStatus: 'complete',
|
|
97
|
+
});
|
|
98
|
+
expect(result.success).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('ReadinessCheckSchema', () => {
|
|
103
|
+
it('should accept all severity levels', () => {
|
|
104
|
+
for (const severity of ['critical', 'warning', 'info'] as const) {
|
|
105
|
+
const result = ReadinessCheckSchema.safeParse({
|
|
106
|
+
name: 'Test Check',
|
|
107
|
+
passed: true,
|
|
108
|
+
message: 'Check passed',
|
|
109
|
+
severity,
|
|
110
|
+
});
|
|
111
|
+
expect(result.success).toBe(true);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should reject invalid severity', () => {
|
|
116
|
+
const result = ReadinessCheckSchema.safeParse({
|
|
117
|
+
name: 'Test',
|
|
118
|
+
passed: true,
|
|
119
|
+
message: 'ok',
|
|
120
|
+
severity: 'error',
|
|
121
|
+
});
|
|
122
|
+
expect(result.success).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('ReadinessResultSchema', () => {
|
|
127
|
+
it('should accept a healthy result with checks', () => {
|
|
128
|
+
const result = ReadinessResultSchema.safeParse({
|
|
129
|
+
healthy: true,
|
|
130
|
+
checks: [
|
|
131
|
+
{ name: 'DB Connection', passed: true, message: 'Connected', severity: 'critical' },
|
|
132
|
+
{ name: 'pgvector', passed: true, message: 'Available', severity: 'warning' },
|
|
133
|
+
],
|
|
134
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
135
|
+
});
|
|
136
|
+
expect(result.success).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should accept an unhealthy result', () => {
|
|
140
|
+
const result = ReadinessResultSchema.safeParse({
|
|
141
|
+
healthy: false,
|
|
142
|
+
checks: [
|
|
143
|
+
{ name: 'DB Connection', passed: false, message: 'Cannot connect', severity: 'critical' },
|
|
144
|
+
],
|
|
145
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
146
|
+
});
|
|
147
|
+
expect(result.success).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should accept empty checks array', () => {
|
|
151
|
+
const result = ReadinessResultSchema.safeParse({
|
|
152
|
+
healthy: true,
|
|
153
|
+
checks: [],
|
|
154
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
155
|
+
});
|
|
156
|
+
expect(result.success).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for database types and schemas
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
DbStatusSchema,
|
|
8
|
+
DbModeSchema,
|
|
9
|
+
DbProviderSchema,
|
|
10
|
+
BackendOrmSchema,
|
|
11
|
+
DbSetupStepSchema,
|
|
12
|
+
DbConfigSchema,
|
|
13
|
+
DEFAULT_DB_CONFIG,
|
|
14
|
+
} from '../../src/types/database.js';
|
|
15
|
+
|
|
16
|
+
describe('DbStatusSchema', () => {
|
|
17
|
+
it('should accept all valid status values', () => {
|
|
18
|
+
const validStatuses = ['unconfigured', 'configured', 'applying', 'ready', 'error'];
|
|
19
|
+
for (const status of validStatuses) {
|
|
20
|
+
expect(DbStatusSchema.safeParse(status).success).toBe(true);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should reject invalid status values', () => {
|
|
25
|
+
expect(DbStatusSchema.safeParse('pending').success).toBe(false);
|
|
26
|
+
expect(DbStatusSchema.safeParse('active').success).toBe(false);
|
|
27
|
+
expect(DbStatusSchema.safeParse('').success).toBe(false);
|
|
28
|
+
expect(DbStatusSchema.safeParse(123).success).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('DbModeSchema', () => {
|
|
33
|
+
it('should accept valid modes', () => {
|
|
34
|
+
expect(DbModeSchema.safeParse('local_docker').success).toBe(true);
|
|
35
|
+
expect(DbModeSchema.safeParse('managed').success).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should reject unconfigured and unknown values', () => {
|
|
39
|
+
expect(DbModeSchema.safeParse('unconfigured').success).toBe(false);
|
|
40
|
+
expect(DbModeSchema.safeParse('cloud').success).toBe(false);
|
|
41
|
+
expect(DbModeSchema.safeParse('').success).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('DbProviderSchema', () => {
|
|
46
|
+
it('should accept valid providers', () => {
|
|
47
|
+
expect(DbProviderSchema.safeParse('neon').success).toBe(true);
|
|
48
|
+
expect(DbProviderSchema.safeParse('supabase').success).toBe(true);
|
|
49
|
+
expect(DbProviderSchema.safeParse('other').success).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should reject unknown providers', () => {
|
|
53
|
+
expect(DbProviderSchema.safeParse('aws').success).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('BackendOrmSchema', () => {
|
|
58
|
+
it('should accept valid ORM values', () => {
|
|
59
|
+
expect(BackendOrmSchema.safeParse('sqlalchemy').success).toBe(true);
|
|
60
|
+
expect(BackendOrmSchema.safeParse('prisma').success).toBe(true);
|
|
61
|
+
expect(BackendOrmSchema.safeParse('drizzle').success).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should reject unknown ORM values', () => {
|
|
65
|
+
expect(BackendOrmSchema.safeParse('typeorm').success).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('DbConfigSchema', () => {
|
|
70
|
+
it('should accept a full config with all fields', () => {
|
|
71
|
+
const config = {
|
|
72
|
+
designed: true,
|
|
73
|
+
mode: 'local_docker',
|
|
74
|
+
vectorRequired: true,
|
|
75
|
+
status: 'ready',
|
|
76
|
+
lastError: undefined,
|
|
77
|
+
migrationsApplied: 3,
|
|
78
|
+
readinessCheckedAt: '2024-01-01T00:00:00Z',
|
|
79
|
+
};
|
|
80
|
+
const result = DbConfigSchema.safeParse(config);
|
|
81
|
+
expect(result.success).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should accept a minimal config without optional mode', () => {
|
|
85
|
+
const config = {
|
|
86
|
+
designed: true,
|
|
87
|
+
vectorRequired: true,
|
|
88
|
+
status: 'unconfigured',
|
|
89
|
+
migrationsApplied: 0,
|
|
90
|
+
};
|
|
91
|
+
const result = DbConfigSchema.safeParse(config);
|
|
92
|
+
expect(result.success).toBe(true);
|
|
93
|
+
if (result.success) {
|
|
94
|
+
expect(result.data.mode).toBeUndefined();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should allow mode set with status unconfigured (valid during transitions)', () => {
|
|
99
|
+
const config = {
|
|
100
|
+
designed: true,
|
|
101
|
+
mode: 'managed',
|
|
102
|
+
vectorRequired: false,
|
|
103
|
+
status: 'unconfigured',
|
|
104
|
+
migrationsApplied: 0,
|
|
105
|
+
};
|
|
106
|
+
const result = DbConfigSchema.safeParse(config);
|
|
107
|
+
expect(result.success).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should reject config with invalid status', () => {
|
|
111
|
+
const config = {
|
|
112
|
+
designed: true,
|
|
113
|
+
vectorRequired: true,
|
|
114
|
+
status: 'broken',
|
|
115
|
+
migrationsApplied: 0,
|
|
116
|
+
};
|
|
117
|
+
expect(DbConfigSchema.safeParse(config).success).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should reject config missing required fields', () => {
|
|
121
|
+
const config = { designed: true };
|
|
122
|
+
expect(DbConfigSchema.safeParse(config).success).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('DEFAULT_DB_CONFIG', () => {
|
|
127
|
+
it('should have correct initial values', () => {
|
|
128
|
+
expect(DEFAULT_DB_CONFIG.designed).toBe(true);
|
|
129
|
+
expect(DEFAULT_DB_CONFIG.status).toBe('unconfigured');
|
|
130
|
+
expect(DEFAULT_DB_CONFIG.vectorRequired).toBe(true);
|
|
131
|
+
expect(DEFAULT_DB_CONFIG.migrationsApplied).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should not have mode set (absent until user configures)', () => {
|
|
135
|
+
expect(DEFAULT_DB_CONFIG.mode).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should parse through DbConfigSchema successfully', () => {
|
|
139
|
+
const result = DbConfigSchema.safeParse(DEFAULT_DB_CONFIG);
|
|
140
|
+
expect(result.success).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('DbSetupStepSchema', () => {
|
|
145
|
+
it('should accept all setup steps', () => {
|
|
146
|
+
const steps = [
|
|
147
|
+
'check_connection',
|
|
148
|
+
'ensure_extensions',
|
|
149
|
+
'apply_migrations',
|
|
150
|
+
'seed_minimal',
|
|
151
|
+
'readiness_tests',
|
|
152
|
+
'mark_ready',
|
|
153
|
+
];
|
|
154
|
+
for (const step of steps) {
|
|
155
|
+
expect(DbSetupStepSchema.safeParse(step).success).toBe(true);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('ProjectStateSchema backward compatibility', () => {
|
|
161
|
+
it('should parse existing state without dbConfig field', async () => {
|
|
162
|
+
// Import ProjectStateSchema to verify backward compat
|
|
163
|
+
const { ProjectStateSchema } = await import('../../src/types/workflow.js');
|
|
164
|
+
|
|
165
|
+
const existingState = {
|
|
166
|
+
id: 'test-id',
|
|
167
|
+
name: 'test-project',
|
|
168
|
+
idea: 'Build something',
|
|
169
|
+
language: 'fullstack',
|
|
170
|
+
openaiModel: 'gpt-4o',
|
|
171
|
+
phase: 'plan',
|
|
172
|
+
status: 'pending',
|
|
173
|
+
milestones: [],
|
|
174
|
+
currentMilestone: null,
|
|
175
|
+
currentTask: null,
|
|
176
|
+
consensusHistory: [],
|
|
177
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
178
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const result = ProjectStateSchema.safeParse(existingState);
|
|
182
|
+
expect(result.success).toBe(true);
|
|
183
|
+
if (result.success) {
|
|
184
|
+
expect(result.data.dbConfig).toBeUndefined();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for database setup pipeline runner
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
readEnvFile,
|
|
10
|
+
parseMigrationPrereqs,
|
|
11
|
+
getPackageName,
|
|
12
|
+
resolveBackendDir,
|
|
13
|
+
computePostPipelineStatus,
|
|
14
|
+
} from '../../src/workflow/db-setup-runner.js';
|
|
15
|
+
import type { SetupResult } from '../../src/types/database-runtime.js';
|
|
16
|
+
|
|
17
|
+
// Mock fs for controlled tests
|
|
18
|
+
vi.mock('node:fs', async () => {
|
|
19
|
+
const actual = await vi.importActual('node:fs');
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
promises: {
|
|
23
|
+
...actual.promises,
|
|
24
|
+
readFile: vi.fn(),
|
|
25
|
+
readdir: vi.fn(),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.clearAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('readEnvFile', () => {
|
|
35
|
+
it('should parse key=value pairs from .env content', async () => {
|
|
36
|
+
vi.mocked(fs.readFile).mockResolvedValue(
|
|
37
|
+
'DEBUG=true\nDATABASE_URL=postgresql://localhost/db\nPOSTGRES_USER=postgres\n'
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const result = await readEnvFile('/some/path/.env');
|
|
41
|
+
expect(result).toEqual({
|
|
42
|
+
DEBUG: 'true',
|
|
43
|
+
DATABASE_URL: 'postgresql://localhost/db',
|
|
44
|
+
POSTGRES_USER: 'postgres',
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should skip comments and empty lines', async () => {
|
|
49
|
+
vi.mocked(fs.readFile).mockResolvedValue(
|
|
50
|
+
'# This is a comment\n\nDATABASE_URL=postgres://host/db\n# Another comment\n'
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const result = await readEnvFile('/some/path/.env');
|
|
54
|
+
expect(result).toEqual({
|
|
55
|
+
DATABASE_URL: 'postgres://host/db',
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should strip surrounding quotes from values', async () => {
|
|
60
|
+
vi.mocked(fs.readFile).mockResolvedValue(
|
|
61
|
+
'FOO="bar"\nBAZ=\'qux\'\n'
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const result = await readEnvFile('/some/path/.env');
|
|
65
|
+
expect(result).toEqual({
|
|
66
|
+
FOO: 'bar',
|
|
67
|
+
BAZ: 'qux',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should return empty object when file does not exist', async () => {
|
|
72
|
+
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
|
73
|
+
|
|
74
|
+
const result = await readEnvFile('/nonexistent/.env');
|
|
75
|
+
expect(result).toEqual({});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should handle values containing equals signs', async () => {
|
|
79
|
+
vi.mocked(fs.readFile).mockResolvedValue(
|
|
80
|
+
'DATABASE_URL=postgresql://user:pass@host/db?sslmode=require\n'
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const result = await readEnvFile('/some/.env');
|
|
84
|
+
expect(result.DATABASE_URL).toBe('postgresql://user:pass@host/db?sslmode=require');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('parseMigrationPrereqs', () => {
|
|
89
|
+
it('should extract extension names from migration comments', async () => {
|
|
90
|
+
vi.mocked(fs.readdir).mockResolvedValue(['001_initial.py'] as any);
|
|
91
|
+
vi.mocked(fs.readFile).mockResolvedValue(
|
|
92
|
+
'"""\nInitial migration.\n\n# popeye:requires_extension=vector\n"""\nfrom alembic import op\n'
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const result = await parseMigrationPrereqs('/project/apps/backend/migrations');
|
|
96
|
+
expect(result).toEqual(['vector']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should handle multiple extensions across multiple files', async () => {
|
|
100
|
+
vi.mocked(fs.readdir).mockResolvedValue(['001_initial.py', '002_search.py'] as any);
|
|
101
|
+
vi.mocked(fs.readFile)
|
|
102
|
+
.mockResolvedValueOnce('# popeye:requires_extension=vector\n')
|
|
103
|
+
.mockResolvedValueOnce('# popeye:requires_extension=pg_trgm\n');
|
|
104
|
+
|
|
105
|
+
const result = await parseMigrationPrereqs('/project/apps/backend/migrations');
|
|
106
|
+
expect(result).toEqual(['vector', 'pg_trgm']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should not duplicate extension names', async () => {
|
|
110
|
+
vi.mocked(fs.readdir).mockResolvedValue(['001_initial.py', '002_more.py'] as any);
|
|
111
|
+
vi.mocked(fs.readFile)
|
|
112
|
+
.mockResolvedValueOnce('# popeye:requires_extension=vector\n')
|
|
113
|
+
.mockResolvedValueOnce('# popeye:requires_extension=vector\n');
|
|
114
|
+
|
|
115
|
+
const result = await parseMigrationPrereqs('/project/apps/backend/migrations');
|
|
116
|
+
expect(result).toEqual(['vector']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should return empty array when no extensions required', async () => {
|
|
120
|
+
vi.mocked(fs.readdir).mockResolvedValue(['001_initial.py'] as any);
|
|
121
|
+
vi.mocked(fs.readFile).mockResolvedValue('from alembic import op\n');
|
|
122
|
+
|
|
123
|
+
const result = await parseMigrationPrereqs('/project/apps/backend/migrations');
|
|
124
|
+
expect(result).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should return empty array when directory does not exist', async () => {
|
|
128
|
+
vi.mocked(fs.readdir).mockRejectedValue(new Error('ENOENT'));
|
|
129
|
+
|
|
130
|
+
const result = await parseMigrationPrereqs('/nonexistent/migrations');
|
|
131
|
+
expect(result).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should skip non-.py files', async () => {
|
|
135
|
+
vi.mocked(fs.readdir).mockResolvedValue(['README.md', '001_initial.py'] as any);
|
|
136
|
+
vi.mocked(fs.readFile).mockResolvedValue('# popeye:requires_extension=vector\n');
|
|
137
|
+
|
|
138
|
+
const result = await parseMigrationPrereqs('/project/migrations');
|
|
139
|
+
// readFile called only once (for .py file, not .md)
|
|
140
|
+
expect(fs.readFile).toHaveBeenCalledTimes(1);
|
|
141
|
+
expect(result).toEqual(['vector']);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('getPackageName', () => {
|
|
146
|
+
it('should derive snake_case name from project state', async () => {
|
|
147
|
+
vi.mocked(fs.readFile).mockResolvedValue(
|
|
148
|
+
JSON.stringify({ name: 'my-cool-project' })
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const result = await getPackageName('/project');
|
|
152
|
+
expect(result).toBe('my_cool_project');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should strip non-alphanumeric characters', async () => {
|
|
156
|
+
vi.mocked(fs.readFile).mockResolvedValue(
|
|
157
|
+
JSON.stringify({ name: 'My-Project!@#' })
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const result = await getPackageName('/project');
|
|
161
|
+
expect(result).toBe('my_project');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should return "backend" as fallback when state is unreadable', async () => {
|
|
165
|
+
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
|
166
|
+
|
|
167
|
+
const result = await getPackageName('/nonexistent');
|
|
168
|
+
expect(result).toBe('backend');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('resolveBackendDir', () => {
|
|
173
|
+
it('should return apps/backend path', () => {
|
|
174
|
+
expect(resolveBackendDir('/project')).toBe(path.join('/project', 'apps', 'backend'));
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('computePostPipelineStatus', () => {
|
|
179
|
+
it('should transition configured -> applying -> ready on success', () => {
|
|
180
|
+
const result: SetupResult = {
|
|
181
|
+
success: true,
|
|
182
|
+
steps: [],
|
|
183
|
+
totalDurationMs: 100,
|
|
184
|
+
finalStatus: 'ready',
|
|
185
|
+
};
|
|
186
|
+
expect(computePostPipelineStatus('configured', result)).toBe('ready');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should transition configured -> applying -> error on failure', () => {
|
|
190
|
+
const result: SetupResult = {
|
|
191
|
+
success: false,
|
|
192
|
+
steps: [],
|
|
193
|
+
totalDurationMs: 50,
|
|
194
|
+
finalStatus: 'error',
|
|
195
|
+
error: 'Connection refused',
|
|
196
|
+
};
|
|
197
|
+
expect(computePostPipelineStatus('configured', result)).toBe('error');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should throw when starting from unconfigured', () => {
|
|
201
|
+
const result: SetupResult = {
|
|
202
|
+
success: true,
|
|
203
|
+
steps: [],
|
|
204
|
+
totalDurationMs: 0,
|
|
205
|
+
finalStatus: 'ready',
|
|
206
|
+
};
|
|
207
|
+
expect(() => computePostPipelineStatus('unconfigured', result)).toThrow(
|
|
208
|
+
/Invalid DB status transition/
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for database lifecycle state machine
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
canTransition,
|
|
8
|
+
transitionDbStatus,
|
|
9
|
+
getAvailableTransitions,
|
|
10
|
+
} from '../../src/workflow/db-state-machine.js';
|
|
11
|
+
|
|
12
|
+
describe('canTransition', () => {
|
|
13
|
+
it('should allow unconfigured -> configured', () => {
|
|
14
|
+
expect(canTransition('unconfigured', 'configured')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should allow configured -> applying', () => {
|
|
18
|
+
expect(canTransition('configured', 'applying')).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should allow configured -> unconfigured (reset)', () => {
|
|
22
|
+
expect(canTransition('configured', 'unconfigured')).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should allow applying -> ready', () => {
|
|
26
|
+
expect(canTransition('applying', 'ready')).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should allow applying -> error', () => {
|
|
30
|
+
expect(canTransition('applying', 'error')).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should allow ready -> configured (reconfigure)', () => {
|
|
34
|
+
expect(canTransition('ready', 'configured')).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should allow ready -> unconfigured (reset)', () => {
|
|
38
|
+
expect(canTransition('ready', 'unconfigured')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should allow error -> configured (retry)', () => {
|
|
42
|
+
expect(canTransition('error', 'configured')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should allow error -> unconfigured (reset)', () => {
|
|
46
|
+
expect(canTransition('error', 'unconfigured')).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should reject unconfigured -> ready (skip steps)', () => {
|
|
50
|
+
expect(canTransition('unconfigured', 'ready')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should reject unconfigured -> applying (must configure first)', () => {
|
|
54
|
+
expect(canTransition('unconfigured', 'applying')).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should reject ready -> applying (must reconfigure first)', () => {
|
|
58
|
+
expect(canTransition('ready', 'applying')).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should reject error -> ready (must reconfigure and reapply)', () => {
|
|
62
|
+
expect(canTransition('error', 'ready')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should reject self-transitions', () => {
|
|
66
|
+
expect(canTransition('unconfigured', 'unconfigured')).toBe(false);
|
|
67
|
+
expect(canTransition('ready', 'ready')).toBe(false);
|
|
68
|
+
expect(canTransition('error', 'error')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('transitionDbStatus', () => {
|
|
73
|
+
it('should return the target status on valid transition', () => {
|
|
74
|
+
expect(transitionDbStatus('unconfigured', 'configured')).toBe('configured');
|
|
75
|
+
expect(transitionDbStatus('configured', 'applying')).toBe('applying');
|
|
76
|
+
expect(transitionDbStatus('applying', 'ready')).toBe('ready');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should throw on invalid transition', () => {
|
|
80
|
+
expect(() => transitionDbStatus('unconfigured', 'ready')).toThrow(
|
|
81
|
+
/Invalid DB status transition/
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should include available transitions in error message', () => {
|
|
86
|
+
expect(() => transitionDbStatus('unconfigured', 'ready')).toThrow(
|
|
87
|
+
/configured/
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('getAvailableTransitions', () => {
|
|
93
|
+
it('should return [configured] for unconfigured', () => {
|
|
94
|
+
const transitions = getAvailableTransitions('unconfigured');
|
|
95
|
+
expect(transitions).toEqual(['configured']);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should return [applying, unconfigured] for configured', () => {
|
|
99
|
+
const transitions = getAvailableTransitions('configured');
|
|
100
|
+
expect(transitions).toEqual(['applying', 'unconfigured']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should return [ready, error] for applying', () => {
|
|
104
|
+
const transitions = getAvailableTransitions('applying');
|
|
105
|
+
expect(transitions).toEqual(['ready', 'error']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should return [configured, unconfigured] for ready', () => {
|
|
109
|
+
const transitions = getAvailableTransitions('ready');
|
|
110
|
+
expect(transitions).toEqual(['configured', 'unconfigured']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should return [configured, unconfigured] for error', () => {
|
|
114
|
+
const transitions = getAvailableTransitions('error');
|
|
115
|
+
expect(transitions).toEqual(['configured', 'unconfigured']);
|
|
116
|
+
});
|
|
117
|
+
});
|