@gallopsystems/agent-skills 1.0.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 +137 -0
- package/package.json +26 -0
- package/plugins/doctl/.claude-plugin/plugin.json +8 -0
- package/plugins/doctl/skills/doctl/SKILL.md +93 -0
- package/plugins/kysely-postgres/.claude-plugin/plugin.json +8 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/SKILL.md +1101 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/aggregations.ts +167 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/ctes.ts +165 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/expressions.ts +272 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/joins.ts +206 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/json-arrays.ts +398 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/mutations.ts +199 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/orderby-pagination.ts +117 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/relations.ts +176 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/select-where.ts +146 -0
- package/plugins/linear/.claude-plugin/plugin.json +8 -0
- package/plugins/linear/skills/linear/SKILL.md +1040 -0
- package/plugins/linear/skills/linear/bin/linear.mjs +1228 -0
- package/plugins/linear/skills/linear/tech-stack.md +273 -0
- package/plugins/nitro-testing/.claude-plugin/plugin.json +8 -0
- package/plugins/nitro-testing/skills/nitro-testing/SKILL.md +497 -0
- package/plugins/nitro-testing/skills/nitro-testing/async-testing.md +270 -0
- package/plugins/nitro-testing/skills/nitro-testing/ci-setup.md +226 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/global-setup.ts +90 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/handler.test.ts +167 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/setup.ts +29 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/test-utils-index.ts +297 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/vitest.config.ts +42 -0
- package/plugins/nitro-testing/skills/nitro-testing/factories.md +278 -0
- package/plugins/nitro-testing/skills/nitro-testing/frontend-testing.md +512 -0
- package/plugins/nitro-testing/skills/nitro-testing/test-utils.md +262 -0
- package/plugins/nitro-testing/skills/nitro-testing/transaction-rollback.md +183 -0
- package/plugins/nitro-testing/skills/nitro-testing/vitest-config.md +236 -0
- package/plugins/nuxt-nitro-api/.claude-plugin/plugin.json +8 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md +260 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/auth-patterns.md +228 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/composables-utils.md +174 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/deep-linking.md +190 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-middleware.ts +32 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-utils.ts +51 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/deep-link-page.vue +61 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/service-util.ts +63 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/sse-endpoint.ts +59 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/validation-endpoint.ts +38 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/fetch-patterns.md +178 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/nitro-tasks.md +243 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/page-structure.md +162 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/server-services.md +238 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/sse.md +221 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/ssr-client.md +166 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/validation.md +131 -0
- package/scripts/link-skills.mjs +252 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Async & Automation Testing
|
|
2
|
+
|
|
3
|
+
> **Example:** [test-utils-index.ts](./examples/test-utils-index.ts)
|
|
4
|
+
|
|
5
|
+
Test Nitro tasks, background jobs, and automation systems that trigger asynchronously.
|
|
6
|
+
|
|
7
|
+
## The Challenge
|
|
8
|
+
|
|
9
|
+
When handlers trigger background tasks, tests need to:
|
|
10
|
+
1. Capture task handlers defined with `defineTask`
|
|
11
|
+
2. Execute tasks when `runTask` is called
|
|
12
|
+
3. Wait for all async operations to complete
|
|
13
|
+
|
|
14
|
+
## Task Handler Registry
|
|
15
|
+
|
|
16
|
+
Capture task handlers at registration time:
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
// Track registered task handlers
|
|
20
|
+
const taskHandlers: Map<string, (opts: { payload: any }) => Promise<any>> = new Map();
|
|
21
|
+
|
|
22
|
+
// Track pending task executions
|
|
23
|
+
let pendingTasks: Promise<any>[] = [];
|
|
24
|
+
|
|
25
|
+
// Stub defineTask to capture handlers
|
|
26
|
+
vi.stubGlobal("defineTask", (config: {
|
|
27
|
+
meta: { name: string };
|
|
28
|
+
run: (opts: { payload: any }) => Promise<any>;
|
|
29
|
+
}) => {
|
|
30
|
+
taskHandlers.set(config.meta.name, config.run);
|
|
31
|
+
return config;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Stub runTask to execute and track
|
|
35
|
+
vi.stubGlobal("runTask", (taskName: string, options: { payload: any }) => {
|
|
36
|
+
const handler = taskHandlers.get(taskName);
|
|
37
|
+
if (!handler) {
|
|
38
|
+
return Promise.reject(new Error(`Task handler not found: ${taskName}`));
|
|
39
|
+
}
|
|
40
|
+
const promise = handler(options);
|
|
41
|
+
pendingTasks.push(promise);
|
|
42
|
+
return promise;
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Waiting for Async Operations
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
/**
|
|
50
|
+
* Wait for all pending task executions to complete.
|
|
51
|
+
* Loops because tasks can spawn other tasks.
|
|
52
|
+
*/
|
|
53
|
+
export async function waitForAutomations(): Promise<void> {
|
|
54
|
+
while (pendingTasks.length > 0) {
|
|
55
|
+
const tasksToWait = [...pendingTasks];
|
|
56
|
+
pendingTasks = [];
|
|
57
|
+
await Promise.allSettled(tasksToWait);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Enabling Real Implementations
|
|
63
|
+
|
|
64
|
+
By default, stub async triggers as no-ops. Tests opt-in to real behavior:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
// Default: no-op stub
|
|
68
|
+
vi.stubGlobal("triggerAutomation", () => {});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Enable automation triggers for tests that need them.
|
|
72
|
+
*/
|
|
73
|
+
export async function enableAutomationTriggers() {
|
|
74
|
+
// Import the task handler to register it
|
|
75
|
+
await import("../tasks/execute-automation");
|
|
76
|
+
|
|
77
|
+
// Import the real trigger function
|
|
78
|
+
const { triggerAutomation: realTrigger } = await import("../utils/automation");
|
|
79
|
+
|
|
80
|
+
// Wrap to track promises
|
|
81
|
+
vi.stubGlobal("triggerAutomation", (...args: Parameters<typeof realTrigger>) => {
|
|
82
|
+
const promise = realTrigger(...args);
|
|
83
|
+
pendingTasks.push(promise);
|
|
84
|
+
return promise;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Usage in Tests
|
|
90
|
+
|
|
91
|
+
### Basic Automation Test
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import {
|
|
95
|
+
describe,
|
|
96
|
+
test,
|
|
97
|
+
expect,
|
|
98
|
+
mockPost,
|
|
99
|
+
enableAutomationTriggers,
|
|
100
|
+
waitForAutomations,
|
|
101
|
+
beforeAll,
|
|
102
|
+
} from "~/server/test-utils";
|
|
103
|
+
import handler from "./index.post";
|
|
104
|
+
|
|
105
|
+
// Enable real automations for this file
|
|
106
|
+
beforeAll(async () => {
|
|
107
|
+
await enableAutomationTriggers();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("Job Creation Automations", () => {
|
|
111
|
+
test("automation creates task when job is created", async ({ factories, db }) => {
|
|
112
|
+
// Set up automation rule
|
|
113
|
+
const template = await factories.jobTemplate();
|
|
114
|
+
await factories.automation({
|
|
115
|
+
jobTemplateId: template.id,
|
|
116
|
+
triggerConfig: { subject: "job", event: "created" },
|
|
117
|
+
actionPayload: { task_type: "Review" },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Create job (triggers automation)
|
|
121
|
+
const project = await factories.project();
|
|
122
|
+
const event = mockPost({}, {
|
|
123
|
+
projectId: project.id,
|
|
124
|
+
jobTemplateId: template.id,
|
|
125
|
+
jobType: "Test",
|
|
126
|
+
});
|
|
127
|
+
const result = await handler(event);
|
|
128
|
+
|
|
129
|
+
// Wait for automation to complete
|
|
130
|
+
await waitForAutomations();
|
|
131
|
+
|
|
132
|
+
// Verify automation created the task
|
|
133
|
+
const tasks = await db
|
|
134
|
+
.selectFrom("task")
|
|
135
|
+
.where("job_id", "=", result.job.id)
|
|
136
|
+
.selectAll()
|
|
137
|
+
.execute();
|
|
138
|
+
|
|
139
|
+
expect(tasks).toHaveLength(1);
|
|
140
|
+
expect(tasks[0].task_type).toBe("Review");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Chained Automations
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
test("completing task triggers follow-up automation", async ({ factories, db }) => {
|
|
149
|
+
const template = await factories.jobTemplate();
|
|
150
|
+
|
|
151
|
+
// Automation: when any task completes, create follow-up
|
|
152
|
+
await factories.automation({
|
|
153
|
+
jobTemplateId: template.id,
|
|
154
|
+
triggerConfig: { subject: "task", event: "completed" },
|
|
155
|
+
actionPayload: { task_type: "Follow-up" },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Create job with initial task
|
|
159
|
+
const project = await factories.project();
|
|
160
|
+
const job = await factories.job({ projectId: project.id, jobTemplateId: template.id });
|
|
161
|
+
const task = await factories.task({ jobId: job.id, status: "Active" });
|
|
162
|
+
|
|
163
|
+
// Complete the task
|
|
164
|
+
const event = mockPatch({}, {
|
|
165
|
+
tasks: [{ id: task.id, status: "Completed" }],
|
|
166
|
+
});
|
|
167
|
+
await taskPatchHandler(event);
|
|
168
|
+
await waitForAutomations();
|
|
169
|
+
|
|
170
|
+
// Should have 2 tasks: original + follow-up
|
|
171
|
+
const tasks = await db
|
|
172
|
+
.selectFrom("task")
|
|
173
|
+
.where("job_id", "=", job.id)
|
|
174
|
+
.selectAll()
|
|
175
|
+
.execute();
|
|
176
|
+
|
|
177
|
+
expect(tasks).toHaveLength(2);
|
|
178
|
+
expect(tasks.some(t => t.task_type === "Follow-up")).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Testing Multiple Async Triggers
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
test("bulk update triggers multiple automations", async ({ factories, db }) => {
|
|
186
|
+
const template = await factories.jobTemplate();
|
|
187
|
+
await factories.automation({
|
|
188
|
+
jobTemplateId: template.id,
|
|
189
|
+
triggerConfig: { subject: "task", event: "completed" },
|
|
190
|
+
actionPayload: { task_type: "Follow-up" },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const job = await factories.job({ jobTemplateId: template.id });
|
|
194
|
+
const tasks = await Promise.all([
|
|
195
|
+
factories.task({ jobId: job.id, status: "Active" }),
|
|
196
|
+
factories.task({ jobId: job.id, status: "Active" }),
|
|
197
|
+
factories.task({ jobId: job.id, status: "Active" }),
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
// Complete all 3 tasks in one patch
|
|
201
|
+
const event = mockPatch({}, {
|
|
202
|
+
tasks: tasks.map(t => ({ id: t.id, status: "Completed" })),
|
|
203
|
+
});
|
|
204
|
+
await handler(event);
|
|
205
|
+
await waitForAutomations();
|
|
206
|
+
|
|
207
|
+
// Should have 6 tasks: 3 original + 3 follow-ups
|
|
208
|
+
const allTasks = await db
|
|
209
|
+
.selectFrom("task")
|
|
210
|
+
.where("job_id", "=", job.id)
|
|
211
|
+
.selectAll()
|
|
212
|
+
.execute();
|
|
213
|
+
|
|
214
|
+
expect(allTasks).toHaveLength(6);
|
|
215
|
+
expect(allTasks.filter(t => t.task_type === "Follow-up")).toHaveLength(3);
|
|
216
|
+
});
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Testing Without Automations
|
|
220
|
+
|
|
221
|
+
Most tests don't need real automations - the stub is a no-op:
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
// No beforeAll(enableAutomationTriggers) - uses stub
|
|
225
|
+
describe("Basic CRUD", () => {
|
|
226
|
+
test("creates job without triggering automations", async ({ factories }) => {
|
|
227
|
+
const project = await factories.project();
|
|
228
|
+
const event = mockPost({}, { projectId: project.id, jobType: "Test" });
|
|
229
|
+
|
|
230
|
+
const result = await handler(event);
|
|
231
|
+
|
|
232
|
+
expect(result.job.id).toBeDefined();
|
|
233
|
+
// No automations ran - triggerAutomation is a no-op
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Verifying Execution Records
|
|
239
|
+
|
|
240
|
+
If your system logs automation executions:
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
test("records automation execution", async ({ factories, db }) => {
|
|
244
|
+
const template = await factories.jobTemplate();
|
|
245
|
+
await factories.automation({
|
|
246
|
+
jobTemplateId: template.id,
|
|
247
|
+
triggerConfig: { subject: "job", event: "created" },
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const job = await factories.job({ jobTemplateId: template.id });
|
|
251
|
+
await waitForAutomations();
|
|
252
|
+
|
|
253
|
+
const execution = await db
|
|
254
|
+
.selectFrom("automation_execution")
|
|
255
|
+
.where("job_id", "=", job.id)
|
|
256
|
+
.selectAll()
|
|
257
|
+
.executeTakeFirst();
|
|
258
|
+
|
|
259
|
+
expect(execution?.status).toBe("completed");
|
|
260
|
+
expect(execution?.affected_entities).toBeDefined();
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Key Patterns
|
|
265
|
+
|
|
266
|
+
1. **Opt-in real behavior** - Default to stubs, `enableAutomationTriggers()` for tests that need it
|
|
267
|
+
2. **Track all promises** - Both the trigger and the tasks it spawns
|
|
268
|
+
3. **Wait in a loop** - Tasks can spawn more tasks
|
|
269
|
+
4. **Use `Promise.allSettled`** - Don't fail fast, let all settle
|
|
270
|
+
5. **Verify final state** - Check database after `waitForAutomations()`
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# CI/CD Setup
|
|
2
|
+
|
|
3
|
+
Configure GitHub Actions to run tests with a real PostgreSQL database.
|
|
4
|
+
|
|
5
|
+
## GitHub Actions Workflow
|
|
6
|
+
|
|
7
|
+
```yaml
|
|
8
|
+
# .github/workflows/ci.yml
|
|
9
|
+
name: CI
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
push:
|
|
13
|
+
branches: [main]
|
|
14
|
+
pull_request:
|
|
15
|
+
branches: [main]
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
test:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
|
|
21
|
+
services:
|
|
22
|
+
postgres:
|
|
23
|
+
image: postgres:15
|
|
24
|
+
env:
|
|
25
|
+
POSTGRES_USER: postgres
|
|
26
|
+
POSTGRES_PASSWORD: postgres
|
|
27
|
+
POSTGRES_DB: myapp-test
|
|
28
|
+
ports:
|
|
29
|
+
- 5432:5432
|
|
30
|
+
options: >-
|
|
31
|
+
--health-cmd pg_isready
|
|
32
|
+
--health-interval 10s
|
|
33
|
+
--health-timeout 5s
|
|
34
|
+
--health-retries 5
|
|
35
|
+
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v4
|
|
38
|
+
|
|
39
|
+
- name: Setup Node.js
|
|
40
|
+
uses: actions/setup-node@v4
|
|
41
|
+
with:
|
|
42
|
+
node-version: "22"
|
|
43
|
+
cache: "yarn"
|
|
44
|
+
|
|
45
|
+
- name: Install dependencies
|
|
46
|
+
run: yarn install --frozen-lockfile
|
|
47
|
+
|
|
48
|
+
- name: Run tests
|
|
49
|
+
run: yarn test --coverage
|
|
50
|
+
env:
|
|
51
|
+
TEST_POSTGRESQL_CONNECTION_STRING: postgresql://postgres:postgres@localhost:5432/myapp-test
|
|
52
|
+
|
|
53
|
+
- name: Upload coverage
|
|
54
|
+
uses: actions/upload-artifact@v4
|
|
55
|
+
with:
|
|
56
|
+
name: coverage-report
|
|
57
|
+
path: coverage/
|
|
58
|
+
retention-days: 7
|
|
59
|
+
|
|
60
|
+
typecheck:
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
steps:
|
|
63
|
+
- uses: actions/checkout@v4
|
|
64
|
+
- uses: actions/setup-node@v4
|
|
65
|
+
with:
|
|
66
|
+
node-version: "22"
|
|
67
|
+
cache: "yarn"
|
|
68
|
+
- run: yarn install --frozen-lockfile
|
|
69
|
+
- run: yarn typecheck
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Coverage Report on PRs
|
|
73
|
+
|
|
74
|
+
Add coverage reporting to pull requests:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
# .github/workflows/ci.yml (add to test job steps)
|
|
78
|
+
- name: Coverage Report
|
|
79
|
+
if: github.event_name == 'pull_request'
|
|
80
|
+
uses: davelosert/vitest-coverage-report-action@v2
|
|
81
|
+
with:
|
|
82
|
+
vite-config-path: vitest.config.ts
|
|
83
|
+
json-summary-path: coverage/coverage-summary.json
|
|
84
|
+
json-final-path: coverage/coverage-final.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Key Configuration Points
|
|
88
|
+
|
|
89
|
+
### PostgreSQL Service
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
services:
|
|
93
|
+
postgres:
|
|
94
|
+
image: postgres:15 # Match your production version
|
|
95
|
+
env:
|
|
96
|
+
POSTGRES_USER: postgres
|
|
97
|
+
POSTGRES_PASSWORD: postgres
|
|
98
|
+
POSTGRES_DB: myapp-test # Test database name
|
|
99
|
+
ports:
|
|
100
|
+
- 5432:5432
|
|
101
|
+
options: >-
|
|
102
|
+
--health-cmd pg_isready
|
|
103
|
+
--health-interval 10s
|
|
104
|
+
--health-timeout 5s
|
|
105
|
+
--health-retries 5
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The health check ensures PostgreSQL is ready before tests run.
|
|
109
|
+
|
|
110
|
+
### Connection String
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
env:
|
|
114
|
+
TEST_POSTGRESQL_CONNECTION_STRING: postgresql://postgres:postgres@localhost:5432/myapp-test
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This environment variable is read by `global-setup.ts` and `setup.ts`.
|
|
118
|
+
|
|
119
|
+
### Node.js Version
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
- uses: actions/setup-node@v4
|
|
123
|
+
with:
|
|
124
|
+
node-version: "22" # Match your project
|
|
125
|
+
cache: "yarn" # or "npm"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Parallel Test Jobs
|
|
129
|
+
|
|
130
|
+
For faster CI, run tests in parallel (if you have many):
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
jobs:
|
|
134
|
+
test:
|
|
135
|
+
runs-on: ubuntu-latest
|
|
136
|
+
strategy:
|
|
137
|
+
matrix:
|
|
138
|
+
shard: [1, 2, 3, 4]
|
|
139
|
+
|
|
140
|
+
services:
|
|
141
|
+
postgres:
|
|
142
|
+
# ... same config
|
|
143
|
+
|
|
144
|
+
steps:
|
|
145
|
+
# ... setup steps
|
|
146
|
+
|
|
147
|
+
- name: Run tests (shard ${{ matrix.shard }})
|
|
148
|
+
run: yarn test --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
|
149
|
+
env:
|
|
150
|
+
TEST_POSTGRESQL_CONNECTION_STRING: postgresql://postgres:postgres@localhost:5432/myapp-test
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Caching Dependencies
|
|
154
|
+
|
|
155
|
+
The `cache: "yarn"` option caches `node_modules` based on `yarn.lock`:
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
- uses: actions/setup-node@v4
|
|
159
|
+
with:
|
|
160
|
+
node-version: "22"
|
|
161
|
+
cache: "yarn" # Automatically caches based on yarn.lock
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
For npm:
|
|
165
|
+
```yaml
|
|
166
|
+
cache: "npm" # Caches based on package-lock.json
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Branch Protection
|
|
170
|
+
|
|
171
|
+
Configure branch protection rules in GitHub:
|
|
172
|
+
|
|
173
|
+
1. Go to **Settings → Branches → Add rule**
|
|
174
|
+
2. Branch name pattern: `main`
|
|
175
|
+
3. Enable:
|
|
176
|
+
- ✅ Require status checks to pass before merging
|
|
177
|
+
- ✅ Require branches to be up to date before merging
|
|
178
|
+
4. Add required status checks:
|
|
179
|
+
- `test`
|
|
180
|
+
- `typecheck`
|
|
181
|
+
|
|
182
|
+
## Local CI Simulation
|
|
183
|
+
|
|
184
|
+
Test the CI workflow locally using [act](https://github.com/nektos/act):
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
# Install act
|
|
188
|
+
brew install act
|
|
189
|
+
|
|
190
|
+
# Run workflow
|
|
191
|
+
act push
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Or use Docker directly:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
# Start test database
|
|
198
|
+
docker run -d --name test-db \
|
|
199
|
+
-e POSTGRES_USER=postgres \
|
|
200
|
+
-e POSTGRES_PASSWORD=postgres \
|
|
201
|
+
-e POSTGRES_DB=myapp-test \
|
|
202
|
+
-p 5432:5432 \
|
|
203
|
+
postgres:15
|
|
204
|
+
|
|
205
|
+
# Run tests
|
|
206
|
+
TEST_POSTGRESQL_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/myapp-test \
|
|
207
|
+
yarn test
|
|
208
|
+
|
|
209
|
+
# Cleanup
|
|
210
|
+
docker stop test-db && docker rm test-db
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Secrets and Environment Variables
|
|
214
|
+
|
|
215
|
+
For production-like test databases or external services:
|
|
216
|
+
|
|
217
|
+
```yaml
|
|
218
|
+
steps:
|
|
219
|
+
- name: Run tests
|
|
220
|
+
run: yarn test
|
|
221
|
+
env:
|
|
222
|
+
TEST_POSTGRESQL_CONNECTION_STRING: ${{ secrets.TEST_DATABASE_URL }}
|
|
223
|
+
STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Store secrets in **Settings → Secrets and variables → Actions**.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vitest Global Setup
|
|
3
|
+
*
|
|
4
|
+
* Runs ONCE before all tests to reset and migrate the test database.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Kysely, PostgresDialect, Migrator, FileMigrationProvider } from "kysely";
|
|
8
|
+
import { Pool } from "pg";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { promises as fs } from "fs";
|
|
11
|
+
|
|
12
|
+
function getTestConnectionString(): string {
|
|
13
|
+
if (process.env.TEST_POSTGRESQL_CONNECTION_STRING) {
|
|
14
|
+
return process.env.TEST_POSTGRESQL_CONNECTION_STRING;
|
|
15
|
+
}
|
|
16
|
+
return "postgresql://localhost/myapp-test";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function setup() {
|
|
20
|
+
const connectionString = getTestConnectionString();
|
|
21
|
+
const pool = new Pool({ connectionString });
|
|
22
|
+
|
|
23
|
+
// Check database exists
|
|
24
|
+
try {
|
|
25
|
+
await pool.query("SELECT 1");
|
|
26
|
+
} catch (err: unknown) {
|
|
27
|
+
const pgError = err as { code?: string };
|
|
28
|
+
if (pgError.code === "3D000") {
|
|
29
|
+
console.error(`
|
|
30
|
+
╭─────────────────────────────────────────────────────╮
|
|
31
|
+
│ Error: Test database does not exist. │
|
|
32
|
+
│ │
|
|
33
|
+
│ Run: createdb myapp-test │
|
|
34
|
+
│ │
|
|
35
|
+
│ Then re-run your tests. │
|
|
36
|
+
╰─────────────────────────────────────────────────────╯
|
|
37
|
+
`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Drop all tables and custom types for clean slate
|
|
44
|
+
await pool.query(`
|
|
45
|
+
DO $$ DECLARE
|
|
46
|
+
r RECORD;
|
|
47
|
+
BEGIN
|
|
48
|
+
-- Drop all tables
|
|
49
|
+
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
|
|
50
|
+
EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
|
|
51
|
+
END LOOP;
|
|
52
|
+
-- Drop all custom enum types
|
|
53
|
+
FOR r IN (SELECT typname FROM pg_type t
|
|
54
|
+
JOIN pg_namespace n ON t.typnamespace = n.oid
|
|
55
|
+
WHERE n.nspname = 'public' AND t.typtype = 'e') LOOP
|
|
56
|
+
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
|
|
57
|
+
END LOOP;
|
|
58
|
+
END $$;
|
|
59
|
+
`);
|
|
60
|
+
|
|
61
|
+
await pool.end();
|
|
62
|
+
|
|
63
|
+
// Run migrations from scratch
|
|
64
|
+
const db = new Kysely({
|
|
65
|
+
dialect: new PostgresDialect({
|
|
66
|
+
pool: new Pool({ connectionString }),
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const migrator = new Migrator({
|
|
71
|
+
db,
|
|
72
|
+
provider: new FileMigrationProvider({
|
|
73
|
+
fs,
|
|
74
|
+
path,
|
|
75
|
+
migrationFolder: path.resolve(__dirname, "../db/migrations"),
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const { error, results } = await migrator.migrateToLatest();
|
|
80
|
+
|
|
81
|
+
if (error) {
|
|
82
|
+
console.error("Migration failed:", error);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const applied = results?.filter((r) => r.status === "Success") ?? [];
|
|
87
|
+
console.log(`Test DB ready: ${applied.length} migrations applied`);
|
|
88
|
+
|
|
89
|
+
await db.destroy();
|
|
90
|
+
}
|