@renseiai/agentfactory 0.8.11 → 0.8.13

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.
Files changed (101) hide show
  1. package/dist/src/config/repository-config.d.ts +24 -0
  2. package/dist/src/config/repository-config.d.ts.map +1 -1
  3. package/dist/src/config/repository-config.js +21 -0
  4. package/dist/src/config/repository-config.test.js +202 -0
  5. package/dist/src/governor/decision-engine.d.ts +2 -0
  6. package/dist/src/governor/decision-engine.d.ts.map +1 -1
  7. package/dist/src/governor/decision-engine.js +7 -0
  8. package/dist/src/governor/decision-engine.test.js +63 -0
  9. package/dist/src/governor/governor-types.d.ts +2 -1
  10. package/dist/src/governor/governor-types.d.ts.map +1 -1
  11. package/dist/src/index.d.ts +1 -0
  12. package/dist/src/index.d.ts.map +1 -1
  13. package/dist/src/index.js +1 -0
  14. package/dist/src/merge-queue/conflict-resolver.d.ts +62 -0
  15. package/dist/src/merge-queue/conflict-resolver.d.ts.map +1 -0
  16. package/dist/src/merge-queue/conflict-resolver.js +168 -0
  17. package/dist/src/merge-queue/conflict-resolver.test.d.ts +2 -0
  18. package/dist/src/merge-queue/conflict-resolver.test.d.ts.map +1 -0
  19. package/dist/src/merge-queue/conflict-resolver.test.js +405 -0
  20. package/dist/src/merge-queue/lock-file-regeneration.d.ts +14 -0
  21. package/dist/src/merge-queue/lock-file-regeneration.d.ts.map +1 -0
  22. package/dist/src/merge-queue/lock-file-regeneration.js +82 -0
  23. package/dist/src/merge-queue/lock-file-regeneration.test.d.ts +2 -0
  24. package/dist/src/merge-queue/lock-file-regeneration.test.d.ts.map +1 -0
  25. package/dist/src/merge-queue/lock-file-regeneration.test.js +236 -0
  26. package/dist/src/merge-queue/merge-worker.d.ts +79 -0
  27. package/dist/src/merge-queue/merge-worker.d.ts.map +1 -0
  28. package/dist/src/merge-queue/merge-worker.js +221 -0
  29. package/dist/src/merge-queue/merge-worker.test.d.ts +2 -0
  30. package/dist/src/merge-queue/merge-worker.test.d.ts.map +1 -0
  31. package/dist/src/merge-queue/merge-worker.test.js +883 -0
  32. package/dist/src/merge-queue/strategies/index.d.ts +19 -0
  33. package/dist/src/merge-queue/strategies/index.d.ts.map +1 -0
  34. package/dist/src/merge-queue/strategies/index.js +30 -0
  35. package/dist/src/merge-queue/strategies/merge-commit-strategy.d.ts +14 -0
  36. package/dist/src/merge-queue/strategies/merge-commit-strategy.d.ts.map +1 -0
  37. package/dist/src/merge-queue/strategies/merge-commit-strategy.js +58 -0
  38. package/dist/src/merge-queue/strategies/rebase-strategy.d.ts +14 -0
  39. package/dist/src/merge-queue/strategies/rebase-strategy.d.ts.map +1 -0
  40. package/dist/src/merge-queue/strategies/rebase-strategy.js +62 -0
  41. package/dist/src/merge-queue/strategies/squash-strategy.d.ts +14 -0
  42. package/dist/src/merge-queue/strategies/squash-strategy.d.ts.map +1 -0
  43. package/dist/src/merge-queue/strategies/squash-strategy.js +59 -0
  44. package/dist/src/merge-queue/strategies/strategies.test.d.ts +2 -0
  45. package/dist/src/merge-queue/strategies/strategies.test.d.ts.map +1 -0
  46. package/dist/src/merge-queue/strategies/strategies.test.js +354 -0
  47. package/dist/src/merge-queue/strategies/types.d.ts +62 -0
  48. package/dist/src/merge-queue/strategies/types.d.ts.map +1 -0
  49. package/dist/src/merge-queue/strategies/types.js +7 -0
  50. package/dist/src/orchestrator/parse-work-result.d.ts.map +1 -1
  51. package/dist/src/orchestrator/parse-work-result.js +22 -0
  52. package/dist/src/orchestrator/parse-work-result.test.js +49 -0
  53. package/dist/src/providers/index.d.ts +1 -0
  54. package/dist/src/providers/index.d.ts.map +1 -1
  55. package/dist/src/providers/plugin-types.d.ts +177 -0
  56. package/dist/src/providers/plugin-types.d.ts.map +1 -0
  57. package/dist/src/providers/plugin-types.js +10 -0
  58. package/dist/src/providers/plugin-types.test.d.ts +2 -0
  59. package/dist/src/providers/plugin-types.test.d.ts.map +1 -0
  60. package/dist/src/providers/plugin-types.test.js +810 -0
  61. package/dist/src/registry/index.d.ts +4 -0
  62. package/dist/src/registry/index.d.ts.map +1 -0
  63. package/dist/src/registry/index.js +2 -0
  64. package/dist/src/registry/loader.d.ts +25 -0
  65. package/dist/src/registry/loader.d.ts.map +1 -0
  66. package/dist/src/registry/loader.js +88 -0
  67. package/dist/src/registry/node-type-registry.d.ts +52 -0
  68. package/dist/src/registry/node-type-registry.d.ts.map +1 -0
  69. package/dist/src/registry/node-type-registry.js +130 -0
  70. package/dist/src/registry/types.d.ts +65 -0
  71. package/dist/src/registry/types.d.ts.map +1 -0
  72. package/dist/src/registry/types.js +10 -0
  73. package/dist/src/workflow/expression/ast.d.ts +1 -1
  74. package/dist/src/workflow/expression/ast.d.ts.map +1 -1
  75. package/dist/src/workflow/expression/context.d.ts +4 -0
  76. package/dist/src/workflow/expression/context.d.ts.map +1 -1
  77. package/dist/src/workflow/expression/context.js +5 -1
  78. package/dist/src/workflow/expression/evaluator.d.ts.map +1 -1
  79. package/dist/src/workflow/expression/evaluator.js +24 -1
  80. package/dist/src/workflow/expression/evaluator.test.js +174 -0
  81. package/dist/src/workflow/expression/expression.test.js +140 -1
  82. package/dist/src/workflow/expression/helpers.d.ts +4 -0
  83. package/dist/src/workflow/expression/helpers.d.ts.map +1 -1
  84. package/dist/src/workflow/expression/helpers.js +51 -0
  85. package/dist/src/workflow/expression/index.d.ts +14 -0
  86. package/dist/src/workflow/expression/index.d.ts.map +1 -1
  87. package/dist/src/workflow/expression/index.js +28 -1
  88. package/dist/src/workflow/expression/lexer.d.ts.map +1 -1
  89. package/dist/src/workflow/expression/lexer.js +43 -0
  90. package/dist/src/workflow/expression/parser.js +1 -1
  91. package/dist/src/workflow/index.d.ts +3 -3
  92. package/dist/src/workflow/index.d.ts.map +1 -1
  93. package/dist/src/workflow/index.js +4 -2
  94. package/dist/src/workflow/workflow-loader.d.ts +8 -2
  95. package/dist/src/workflow/workflow-loader.d.ts.map +1 -1
  96. package/dist/src/workflow/workflow-loader.js +21 -2
  97. package/dist/src/workflow/workflow-types.d.ts +781 -12
  98. package/dist/src/workflow/workflow-types.d.ts.map +1 -1
  99. package/dist/src/workflow/workflow-types.js +248 -3
  100. package/dist/src/workflow/workflow-types.test.js +621 -1
  101. package/package.json +3 -2
@@ -0,0 +1,883 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ vi.mock('child_process', () => ({
3
+ exec: vi.fn(),
4
+ }));
5
+ vi.mock('./strategies/index.js', () => ({
6
+ createMergeStrategy: vi.fn(),
7
+ }));
8
+ vi.mock('./conflict-resolver.js', () => ({
9
+ ConflictResolver: vi.fn(),
10
+ }));
11
+ vi.mock('./lock-file-regeneration.js', () => ({
12
+ LockFileRegeneration: vi.fn(),
13
+ }));
14
+ import { exec } from 'child_process';
15
+ import { createMergeStrategy } from './strategies/index.js';
16
+ import { ConflictResolver } from './conflict-resolver.js';
17
+ import { LockFileRegeneration } from './lock-file-regeneration.js';
18
+ import { MergeWorker } from './merge-worker.js';
19
+ const mockExec = vi.mocked(exec);
20
+ const mockCreateMergeStrategy = vi.mocked(createMergeStrategy);
21
+ const MockConflictResolver = vi.mocked(ConflictResolver);
22
+ const MockLockFileRegeneration = vi.mocked(LockFileRegeneration);
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers
25
+ // ---------------------------------------------------------------------------
26
+ function makeConfig(overrides = {}) {
27
+ return {
28
+ repoId: 'repo-1',
29
+ repoPath: '/repo',
30
+ strategy: 'rebase',
31
+ testCommand: 'pnpm test',
32
+ testTimeout: 300_000,
33
+ lockFileRegenerate: true,
34
+ mergiraf: true,
35
+ pollInterval: 1000,
36
+ maxRetries: 2,
37
+ escalation: {
38
+ onConflict: 'reassign',
39
+ onTestFailure: 'notify',
40
+ },
41
+ deleteBranchOnMerge: true,
42
+ packageManager: 'pnpm',
43
+ remote: 'origin',
44
+ targetBranch: 'main',
45
+ ...overrides,
46
+ };
47
+ }
48
+ function makeDeps(overrides = {}) {
49
+ return {
50
+ storage: {
51
+ dequeue: vi.fn().mockResolvedValue(null),
52
+ markCompleted: vi.fn().mockResolvedValue(undefined),
53
+ markFailed: vi.fn().mockResolvedValue(undefined),
54
+ markBlocked: vi.fn().mockResolvedValue(undefined),
55
+ },
56
+ redis: {
57
+ setNX: vi.fn().mockResolvedValue(true),
58
+ del: vi.fn().mockResolvedValue(undefined),
59
+ get: vi.fn().mockResolvedValue(null),
60
+ set: vi.fn().mockResolvedValue(undefined),
61
+ expire: vi.fn().mockResolvedValue(undefined),
62
+ },
63
+ ...overrides,
64
+ };
65
+ }
66
+ function makeEntry(overrides = {}) {
67
+ return {
68
+ prNumber: 42,
69
+ sourceBranch: 'feature/SUP-100',
70
+ targetBranch: 'main',
71
+ issueIdentifier: 'SUP-100',
72
+ prUrl: 'https://github.com/org/repo/pull/42',
73
+ ...overrides,
74
+ };
75
+ }
76
+ function makeMockStrategy() {
77
+ return {
78
+ name: 'rebase',
79
+ prepare: vi.fn().mockResolvedValue({ success: true, headSha: 'abc123' }),
80
+ execute: vi.fn().mockResolvedValue({ status: 'success', mergedSha: 'def456' }),
81
+ finalize: vi.fn().mockResolvedValue(undefined),
82
+ };
83
+ }
84
+ function mockExecSuccess() {
85
+ mockExec.mockImplementation((_cmd, _opts, callback) => {
86
+ const cb = typeof _opts === 'function' ? _opts : callback;
87
+ cb?.(null, { stdout: '', stderr: '' });
88
+ return {};
89
+ });
90
+ }
91
+ function mockExecFailure(errorMsg, stdout = '') {
92
+ mockExec.mockImplementation((_cmd, _opts, callback) => {
93
+ const cb = typeof _opts === 'function' ? _opts : callback;
94
+ const error = Object.assign(new Error(errorMsg), { stdout });
95
+ cb?.(error, { stdout, stderr: '' });
96
+ return {};
97
+ });
98
+ }
99
+ /**
100
+ * Mock exec that routes by command substring.
101
+ */
102
+ function mockExecByCommand(routes) {
103
+ mockExec.mockImplementation((cmd, _opts, callback) => {
104
+ const cb = typeof _opts === 'function' ? _opts : callback;
105
+ const cmdStr = typeof cmd === 'string' ? cmd : '';
106
+ const route = Object.entries(routes).find(([pattern]) => cmdStr.includes(pattern));
107
+ if (route) {
108
+ const [, response] = route;
109
+ if (response.error) {
110
+ cb?.(response.error, { stdout: response.stdout ?? '', stderr: response.stderr ?? '' });
111
+ }
112
+ else {
113
+ cb?.(null, { stdout: response.stdout ?? '', stderr: response.stderr ?? '' });
114
+ }
115
+ }
116
+ else {
117
+ cb?.(null, { stdout: '', stderr: '' });
118
+ }
119
+ return {};
120
+ });
121
+ }
122
+ // ---------------------------------------------------------------------------
123
+ // Tests
124
+ // ---------------------------------------------------------------------------
125
+ describe('MergeWorker', () => {
126
+ beforeEach(() => {
127
+ vi.clearAllMocks();
128
+ vi.useFakeTimers();
129
+ });
130
+ afterEach(() => {
131
+ vi.useRealTimers();
132
+ });
133
+ // -------------------------------------------------------------------------
134
+ // processEntry: successful merge flow
135
+ // -------------------------------------------------------------------------
136
+ describe('processEntry', () => {
137
+ it('successful merge flow: prepare -> execute -> test -> finalize', async () => {
138
+ vi.useRealTimers();
139
+ const config = makeConfig();
140
+ const deps = makeDeps();
141
+ const worker = new MergeWorker(config, deps);
142
+ const entry = makeEntry();
143
+ const mockStrategy = makeMockStrategy();
144
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
145
+ MockConflictResolver.mockImplementation(() => ({
146
+ resolve: vi.fn(),
147
+ }));
148
+ MockLockFileRegeneration.mockImplementation(() => ({
149
+ shouldRegenerate: vi.fn().mockReturnValue(false),
150
+ regenerate: vi.fn(),
151
+ getLockFileName: vi.fn(),
152
+ ensureGitAttributes: vi.fn(),
153
+ }));
154
+ // Test command succeeds
155
+ mockExecSuccess();
156
+ const result = await worker.processEntry(entry);
157
+ expect(result.status).toBe('merged');
158
+ expect(result.prNumber).toBe(42);
159
+ expect(mockStrategy.prepare).toHaveBeenCalledOnce();
160
+ expect(mockStrategy.execute).toHaveBeenCalledOnce();
161
+ expect(mockStrategy.finalize).toHaveBeenCalledOnce();
162
+ // createMergeStrategy called with the configured strategy
163
+ expect(mockCreateMergeStrategy).toHaveBeenCalledWith('rebase');
164
+ });
165
+ it('prepare failure returns error', async () => {
166
+ vi.useRealTimers();
167
+ const config = makeConfig();
168
+ const deps = makeDeps();
169
+ const worker = new MergeWorker(config, deps);
170
+ const entry = makeEntry();
171
+ const mockStrategy = makeMockStrategy();
172
+ mockStrategy.prepare.mockResolvedValue({ success: false, error: 'Could not fetch remote' });
173
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
174
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
175
+ MockLockFileRegeneration.mockImplementation(() => ({
176
+ shouldRegenerate: vi.fn().mockReturnValue(false),
177
+ regenerate: vi.fn(),
178
+ getLockFileName: vi.fn(),
179
+ ensureGitAttributes: vi.fn(),
180
+ }));
181
+ const result = await worker.processEntry(entry);
182
+ expect(result.status).toBe('error');
183
+ expect(result.message).toContain('Prepare failed');
184
+ expect(result.message).toContain('Could not fetch remote');
185
+ expect(mockStrategy.execute).not.toHaveBeenCalled();
186
+ expect(mockStrategy.finalize).not.toHaveBeenCalled();
187
+ });
188
+ it('merge conflict triggers ConflictResolver', async () => {
189
+ vi.useRealTimers();
190
+ const config = makeConfig();
191
+ const deps = makeDeps();
192
+ const worker = new MergeWorker(config, deps);
193
+ const entry = makeEntry();
194
+ const mockStrategy = makeMockStrategy();
195
+ mockStrategy.execute.mockResolvedValue({
196
+ status: 'conflict',
197
+ conflictFiles: ['src/index.ts'],
198
+ conflictDetails: 'Conflict in src/index.ts',
199
+ });
200
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
201
+ const mockResolve = vi.fn().mockResolvedValue({
202
+ status: 'escalated',
203
+ method: 'escalation',
204
+ unresolvedFiles: ['src/index.ts'],
205
+ message: 'Conflict on PR #42',
206
+ });
207
+ MockConflictResolver.mockImplementation(() => ({
208
+ resolve: mockResolve,
209
+ }));
210
+ MockLockFileRegeneration.mockImplementation(() => ({
211
+ shouldRegenerate: vi.fn().mockReturnValue(false),
212
+ regenerate: vi.fn(),
213
+ getLockFileName: vi.fn(),
214
+ ensureGitAttributes: vi.fn(),
215
+ }));
216
+ const result = await worker.processEntry(entry);
217
+ expect(result.status).toBe('conflict');
218
+ expect(result.message).toBe('Conflict on PR #42');
219
+ expect(mockResolve).toHaveBeenCalledOnce();
220
+ // Verify the conflict context passed to the resolver
221
+ expect(mockResolve).toHaveBeenCalledWith(expect.objectContaining({
222
+ repoPath: '/repo',
223
+ sourceBranch: 'feature/SUP-100',
224
+ targetBranch: 'main',
225
+ prNumber: 42,
226
+ issueIdentifier: 'SUP-100',
227
+ conflictFiles: ['src/index.ts'],
228
+ }));
229
+ });
230
+ it('resolved conflict continues to test and finalize', async () => {
231
+ vi.useRealTimers();
232
+ const config = makeConfig();
233
+ const deps = makeDeps();
234
+ const worker = new MergeWorker(config, deps);
235
+ const entry = makeEntry();
236
+ const mockStrategy = makeMockStrategy();
237
+ mockStrategy.execute.mockResolvedValue({
238
+ status: 'conflict',
239
+ conflictFiles: ['src/index.ts'],
240
+ });
241
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
242
+ const mockResolve = vi.fn().mockResolvedValue({
243
+ status: 'resolved',
244
+ method: 'mergiraf',
245
+ resolvedFiles: ['src/index.ts'],
246
+ });
247
+ MockConflictResolver.mockImplementation(() => ({
248
+ resolve: mockResolve,
249
+ }));
250
+ MockLockFileRegeneration.mockImplementation(() => ({
251
+ shouldRegenerate: vi.fn().mockReturnValue(false),
252
+ regenerate: vi.fn(),
253
+ getLockFileName: vi.fn(),
254
+ ensureGitAttributes: vi.fn(),
255
+ }));
256
+ // Test command succeeds
257
+ mockExecSuccess();
258
+ const result = await worker.processEntry(entry);
259
+ // Should continue past conflict resolution to test and finalize
260
+ expect(result.status).toBe('merged');
261
+ expect(mockStrategy.finalize).toHaveBeenCalledOnce();
262
+ });
263
+ it('unresolved conflict returns conflict status', async () => {
264
+ vi.useRealTimers();
265
+ const config = makeConfig();
266
+ const deps = makeDeps();
267
+ const worker = new MergeWorker(config, deps);
268
+ const entry = makeEntry();
269
+ const mockStrategy = makeMockStrategy();
270
+ mockStrategy.execute.mockResolvedValue({
271
+ status: 'conflict',
272
+ conflictFiles: ['src/a.ts', 'src/b.ts'],
273
+ });
274
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
275
+ const mockResolve = vi.fn().mockResolvedValue({
276
+ status: 'parked',
277
+ method: 'escalation',
278
+ unresolvedFiles: ['src/a.ts', 'src/b.ts'],
279
+ message: 'Parked due to conflicts',
280
+ });
281
+ MockConflictResolver.mockImplementation(() => ({
282
+ resolve: mockResolve,
283
+ }));
284
+ MockLockFileRegeneration.mockImplementation(() => ({
285
+ shouldRegenerate: vi.fn().mockReturnValue(false),
286
+ regenerate: vi.fn(),
287
+ getLockFileName: vi.fn(),
288
+ ensureGitAttributes: vi.fn(),
289
+ }));
290
+ const result = await worker.processEntry(entry);
291
+ expect(result.status).toBe('conflict');
292
+ expect(result.message).toBe('Parked due to conflicts');
293
+ // Should NOT proceed to test or finalize
294
+ expect(mockStrategy.finalize).not.toHaveBeenCalled();
295
+ });
296
+ it('lock file regeneration runs when configured', async () => {
297
+ vi.useRealTimers();
298
+ const config = makeConfig({ lockFileRegenerate: true, packageManager: 'pnpm' });
299
+ const deps = makeDeps();
300
+ const worker = new MergeWorker(config, deps);
301
+ const entry = makeEntry();
302
+ const mockStrategy = makeMockStrategy();
303
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
304
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
305
+ const mockShouldRegenerate = vi.fn().mockReturnValue(true);
306
+ const mockRegenerate = vi.fn().mockResolvedValue({ success: true, lockFile: 'pnpm-lock.yaml', packageManager: 'pnpm' });
307
+ MockLockFileRegeneration.mockImplementation(() => ({
308
+ shouldRegenerate: mockShouldRegenerate,
309
+ regenerate: mockRegenerate,
310
+ getLockFileName: vi.fn(),
311
+ ensureGitAttributes: vi.fn(),
312
+ }));
313
+ mockExecSuccess();
314
+ const result = await worker.processEntry(entry);
315
+ expect(result.status).toBe('merged');
316
+ expect(mockShouldRegenerate).toHaveBeenCalledWith('pnpm', true);
317
+ expect(mockRegenerate).toHaveBeenCalledWith('/repo', 'pnpm');
318
+ });
319
+ it('lock file regeneration failure returns error', async () => {
320
+ vi.useRealTimers();
321
+ const config = makeConfig({ lockFileRegenerate: true, packageManager: 'pnpm' });
322
+ const deps = makeDeps();
323
+ const worker = new MergeWorker(config, deps);
324
+ const entry = makeEntry();
325
+ const mockStrategy = makeMockStrategy();
326
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
327
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
328
+ MockLockFileRegeneration.mockImplementation(() => ({
329
+ shouldRegenerate: vi.fn().mockReturnValue(true),
330
+ regenerate: vi.fn().mockResolvedValue({
331
+ success: false,
332
+ lockFile: 'pnpm-lock.yaml',
333
+ packageManager: 'pnpm',
334
+ error: 'pnpm install failed: ENOENT',
335
+ }),
336
+ getLockFileName: vi.fn(),
337
+ ensureGitAttributes: vi.fn(),
338
+ }));
339
+ const result = await worker.processEntry(entry);
340
+ expect(result.status).toBe('error');
341
+ expect(result.message).toContain('Lock file regeneration failed');
342
+ expect(result.message).toContain('pnpm install failed: ENOENT');
343
+ expect(mockStrategy.finalize).not.toHaveBeenCalled();
344
+ });
345
+ it('test failure returns test-failure status', async () => {
346
+ vi.useRealTimers();
347
+ const config = makeConfig();
348
+ const deps = makeDeps();
349
+ const worker = new MergeWorker(config, deps);
350
+ const entry = makeEntry();
351
+ const mockStrategy = makeMockStrategy();
352
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
353
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
354
+ MockLockFileRegeneration.mockImplementation(() => ({
355
+ shouldRegenerate: vi.fn().mockReturnValue(false),
356
+ regenerate: vi.fn(),
357
+ getLockFileName: vi.fn(),
358
+ ensureGitAttributes: vi.fn(),
359
+ }));
360
+ // Test command fails
361
+ mockExecFailure('Test failed: 3 tests failed', 'FAIL src/index.test.ts');
362
+ const result = await worker.processEntry(entry);
363
+ expect(result.status).toBe('test-failure');
364
+ expect(result.message).toContain('FAIL src/index.test.ts');
365
+ expect(mockStrategy.finalize).not.toHaveBeenCalled();
366
+ });
367
+ it('test timeout handled', async () => {
368
+ vi.useRealTimers();
369
+ const config = makeConfig({ testTimeout: 5000 });
370
+ const deps = makeDeps();
371
+ const worker = new MergeWorker(config, deps);
372
+ const entry = makeEntry();
373
+ const mockStrategy = makeMockStrategy();
374
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
375
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
376
+ MockLockFileRegeneration.mockImplementation(() => ({
377
+ shouldRegenerate: vi.fn().mockReturnValue(false),
378
+ regenerate: vi.fn(),
379
+ getLockFileName: vi.fn(),
380
+ ensureGitAttributes: vi.fn(),
381
+ }));
382
+ // Simulate timeout error — exec rejects with a timeout error
383
+ mockExec.mockImplementation((cmd, _opts, callback) => {
384
+ const cb = typeof _opts === 'function' ? _opts : callback;
385
+ const cmdStr = typeof cmd === 'string' ? cmd : '';
386
+ if (cmdStr.includes('pnpm test')) {
387
+ const error = Object.assign(new Error('Command timed out'), { killed: true, signal: 'SIGTERM' });
388
+ cb?.(error, { stdout: '', stderr: '' });
389
+ }
390
+ else {
391
+ cb?.(null, { stdout: '', stderr: '' });
392
+ }
393
+ return {};
394
+ });
395
+ const result = await worker.processEntry(entry);
396
+ expect(result.status).toBe('test-failure');
397
+ expect(result.message).toContain('Command timed out');
398
+ expect(mockStrategy.finalize).not.toHaveBeenCalled();
399
+ });
400
+ it('finalize pushes and merges', async () => {
401
+ vi.useRealTimers();
402
+ const config = makeConfig({ deleteBranchOnMerge: false });
403
+ const deps = makeDeps();
404
+ const worker = new MergeWorker(config, deps);
405
+ const entry = makeEntry();
406
+ const mockStrategy = makeMockStrategy();
407
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
408
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
409
+ MockLockFileRegeneration.mockImplementation(() => ({
410
+ shouldRegenerate: vi.fn().mockReturnValue(false),
411
+ regenerate: vi.fn(),
412
+ getLockFileName: vi.fn(),
413
+ ensureGitAttributes: vi.fn(),
414
+ }));
415
+ mockExecSuccess();
416
+ const result = await worker.processEntry(entry);
417
+ expect(result.status).toBe('merged');
418
+ expect(mockStrategy.finalize).toHaveBeenCalledWith(expect.objectContaining({
419
+ repoPath: '/repo',
420
+ sourceBranch: 'feature/SUP-100',
421
+ targetBranch: 'main',
422
+ remote: 'origin',
423
+ }));
424
+ });
425
+ it('branch deletion when configured', async () => {
426
+ vi.useRealTimers();
427
+ const config = makeConfig({ deleteBranchOnMerge: true });
428
+ const deps = makeDeps();
429
+ const worker = new MergeWorker(config, deps);
430
+ const entry = makeEntry();
431
+ const mockStrategy = makeMockStrategy();
432
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
433
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
434
+ MockLockFileRegeneration.mockImplementation(() => ({
435
+ shouldRegenerate: vi.fn().mockReturnValue(false),
436
+ regenerate: vi.fn(),
437
+ getLockFileName: vi.fn(),
438
+ ensureGitAttributes: vi.fn(),
439
+ }));
440
+ mockExecByCommand({
441
+ 'git push origin --delete feature/SUP-100': { stdout: '' },
442
+ 'pnpm test': { stdout: 'All tests passed' },
443
+ });
444
+ const result = await worker.processEntry(entry);
445
+ expect(result.status).toBe('merged');
446
+ // Verify git push --delete was called
447
+ const deleteCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('git push') && call[0].includes('--delete'));
448
+ expect(deleteCalls).toHaveLength(1);
449
+ expect(deleteCalls[0][0]).toContain('feature/SUP-100');
450
+ });
451
+ it('branch deletion failure is non-fatal', async () => {
452
+ vi.useRealTimers();
453
+ const config = makeConfig({ deleteBranchOnMerge: true });
454
+ const deps = makeDeps();
455
+ const worker = new MergeWorker(config, deps);
456
+ const entry = makeEntry();
457
+ const mockStrategy = makeMockStrategy();
458
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
459
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
460
+ MockLockFileRegeneration.mockImplementation(() => ({
461
+ shouldRegenerate: vi.fn().mockReturnValue(false),
462
+ regenerate: vi.fn(),
463
+ getLockFileName: vi.fn(),
464
+ ensureGitAttributes: vi.fn(),
465
+ }));
466
+ // Branch deletion fails but test passes
467
+ mockExecByCommand({
468
+ 'git push origin --delete': { error: new Error('remote ref does not exist') },
469
+ 'pnpm test': { stdout: 'All tests passed' },
470
+ });
471
+ const result = await worker.processEntry(entry);
472
+ // Should still succeed despite branch deletion failure
473
+ expect(result.status).toBe('merged');
474
+ });
475
+ it('merge strategy error returns error status', async () => {
476
+ vi.useRealTimers();
477
+ const config = makeConfig();
478
+ const deps = makeDeps();
479
+ const worker = new MergeWorker(config, deps);
480
+ const entry = makeEntry();
481
+ const mockStrategy = makeMockStrategy();
482
+ mockStrategy.execute.mockResolvedValue({
483
+ status: 'error',
484
+ error: 'Rebase failed: divergent branches',
485
+ });
486
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
487
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
488
+ MockLockFileRegeneration.mockImplementation(() => ({
489
+ shouldRegenerate: vi.fn().mockReturnValue(false),
490
+ regenerate: vi.fn(),
491
+ getLockFileName: vi.fn(),
492
+ ensureGitAttributes: vi.fn(),
493
+ }));
494
+ const result = await worker.processEntry(entry);
495
+ expect(result.status).toBe('error');
496
+ expect(result.message).toBe('Rebase failed: divergent branches');
497
+ expect(mockStrategy.finalize).not.toHaveBeenCalled();
498
+ });
499
+ it('uses entry.issueIdentifier when provided', async () => {
500
+ vi.useRealTimers();
501
+ const config = makeConfig();
502
+ const deps = makeDeps();
503
+ const worker = new MergeWorker(config, deps);
504
+ const entry = makeEntry({ issueIdentifier: 'SUP-200' });
505
+ const mockStrategy = makeMockStrategy();
506
+ mockStrategy.execute.mockResolvedValue({
507
+ status: 'conflict',
508
+ conflictFiles: ['file.ts'],
509
+ });
510
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
511
+ const mockResolve = vi.fn().mockResolvedValue({
512
+ status: 'escalated',
513
+ method: 'escalation',
514
+ unresolvedFiles: ['file.ts'],
515
+ message: 'Conflict',
516
+ });
517
+ MockConflictResolver.mockImplementation(() => ({ resolve: mockResolve }));
518
+ MockLockFileRegeneration.mockImplementation(() => ({
519
+ shouldRegenerate: vi.fn().mockReturnValue(false),
520
+ regenerate: vi.fn(),
521
+ getLockFileName: vi.fn(),
522
+ ensureGitAttributes: vi.fn(),
523
+ }));
524
+ await worker.processEntry(entry);
525
+ expect(mockResolve).toHaveBeenCalledWith(expect.objectContaining({ issueIdentifier: 'SUP-200' }));
526
+ });
527
+ it('defaults issueIdentifier to PR-{prNumber} when not provided', async () => {
528
+ vi.useRealTimers();
529
+ const config = makeConfig();
530
+ const deps = makeDeps();
531
+ const worker = new MergeWorker(config, deps);
532
+ const entry = { prNumber: 99, sourceBranch: 'feature/test' };
533
+ const mockStrategy = makeMockStrategy();
534
+ mockStrategy.execute.mockResolvedValue({
535
+ status: 'conflict',
536
+ conflictFiles: ['file.ts'],
537
+ });
538
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
539
+ const mockResolve = vi.fn().mockResolvedValue({
540
+ status: 'escalated',
541
+ method: 'escalation',
542
+ unresolvedFiles: ['file.ts'],
543
+ message: 'Conflict',
544
+ });
545
+ MockConflictResolver.mockImplementation(() => ({ resolve: mockResolve }));
546
+ MockLockFileRegeneration.mockImplementation(() => ({
547
+ shouldRegenerate: vi.fn().mockReturnValue(false),
548
+ regenerate: vi.fn(),
549
+ getLockFileName: vi.fn(),
550
+ ensureGitAttributes: vi.fn(),
551
+ }));
552
+ await worker.processEntry(entry);
553
+ expect(mockResolve).toHaveBeenCalledWith(expect.objectContaining({ issueIdentifier: 'PR-99' }));
554
+ });
555
+ it('unexpected exception returns error status', async () => {
556
+ vi.useRealTimers();
557
+ const config = makeConfig();
558
+ const deps = makeDeps();
559
+ const worker = new MergeWorker(config, deps);
560
+ const entry = makeEntry();
561
+ const mockStrategy = makeMockStrategy();
562
+ mockStrategy.prepare.mockRejectedValue(new Error('Unexpected git crash'));
563
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
564
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
565
+ MockLockFileRegeneration.mockImplementation(() => ({
566
+ shouldRegenerate: vi.fn().mockReturnValue(false),
567
+ regenerate: vi.fn(),
568
+ getLockFileName: vi.fn(),
569
+ ensureGitAttributes: vi.fn(),
570
+ }));
571
+ const result = await worker.processEntry(entry);
572
+ expect(result.status).toBe('error');
573
+ expect(result.message).toBe('Unexpected git crash');
574
+ });
575
+ it('skips lock file regeneration when lockFileRegenerate is false', async () => {
576
+ vi.useRealTimers();
577
+ const config = makeConfig({ lockFileRegenerate: false });
578
+ const deps = makeDeps();
579
+ const worker = new MergeWorker(config, deps);
580
+ const entry = makeEntry();
581
+ const mockStrategy = makeMockStrategy();
582
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
583
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
584
+ const mockShouldRegenerate = vi.fn().mockReturnValue(false);
585
+ const mockRegenerate = vi.fn();
586
+ MockLockFileRegeneration.mockImplementation(() => ({
587
+ shouldRegenerate: mockShouldRegenerate,
588
+ regenerate: mockRegenerate,
589
+ getLockFileName: vi.fn(),
590
+ ensureGitAttributes: vi.fn(),
591
+ }));
592
+ mockExecSuccess();
593
+ const result = await worker.processEntry(entry);
594
+ expect(result.status).toBe('merged');
595
+ expect(mockShouldRegenerate).toHaveBeenCalledWith('pnpm', false);
596
+ expect(mockRegenerate).not.toHaveBeenCalled();
597
+ });
598
+ it('uses entry.targetBranch when provided, falls back to config', async () => {
599
+ vi.useRealTimers();
600
+ const config = makeConfig({ targetBranch: 'develop' });
601
+ const deps = makeDeps();
602
+ const worker = new MergeWorker(config, deps);
603
+ const mockStrategy = makeMockStrategy();
604
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
605
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
606
+ MockLockFileRegeneration.mockImplementation(() => ({
607
+ shouldRegenerate: vi.fn().mockReturnValue(false),
608
+ regenerate: vi.fn(),
609
+ getLockFileName: vi.fn(),
610
+ ensureGitAttributes: vi.fn(),
611
+ }));
612
+ mockExecSuccess();
613
+ // With explicit targetBranch in entry
614
+ const entry1 = makeEntry({ targetBranch: 'release' });
615
+ await worker.processEntry(entry1);
616
+ expect(mockStrategy.prepare).toHaveBeenCalledWith(expect.objectContaining({ targetBranch: 'release' }));
617
+ mockStrategy.prepare.mockClear();
618
+ // Without targetBranch in entry — falls back to config
619
+ const entry2 = { prNumber: 50, sourceBranch: 'fix/bug' };
620
+ await worker.processEntry(entry2);
621
+ expect(mockStrategy.prepare).toHaveBeenCalledWith(expect.objectContaining({ targetBranch: 'develop' }));
622
+ });
623
+ });
624
+ // -------------------------------------------------------------------------
625
+ // start/stop
626
+ // -------------------------------------------------------------------------
627
+ describe('start/stop', () => {
628
+ it('acquires Redis lock on start', async () => {
629
+ const config = makeConfig();
630
+ const deps = makeDeps();
631
+ const worker = new MergeWorker(config, deps);
632
+ // Use abort signal to stop immediately after first iteration
633
+ const ac = new AbortController();
634
+ // Make dequeue return null so the loop just polls
635
+ vi.mocked(deps.storage.dequeue).mockResolvedValue(null);
636
+ // Return null for paused check
637
+ vi.mocked(deps.redis.get).mockResolvedValue(null);
638
+ // Start and stop after one iteration
639
+ const startPromise = worker.start(ac.signal);
640
+ // Advance past the first poll interval
641
+ await vi.advanceTimersByTimeAsync(config.pollInterval + 10);
642
+ ac.abort();
643
+ await startPromise;
644
+ expect(deps.redis.setNX).toHaveBeenCalledWith('merge:lock:repo-1', 'worker', 300);
645
+ });
646
+ it('throws if lock already held', async () => {
647
+ const config = makeConfig();
648
+ const deps = makeDeps();
649
+ vi.mocked(deps.redis.setNX).mockResolvedValue(false);
650
+ const worker = new MergeWorker(config, deps);
651
+ await expect(worker.start()).rejects.toThrow('Another merge worker is already running for repo repo-1');
652
+ });
653
+ it('releases lock on stop', async () => {
654
+ const config = makeConfig();
655
+ const deps = makeDeps();
656
+ const worker = new MergeWorker(config, deps);
657
+ vi.mocked(deps.storage.dequeue).mockResolvedValue(null);
658
+ vi.mocked(deps.redis.get).mockResolvedValue(null);
659
+ const ac = new AbortController();
660
+ const startPromise = worker.start(ac.signal);
661
+ await vi.advanceTimersByTimeAsync(config.pollInterval + 10);
662
+ ac.abort();
663
+ await startPromise;
664
+ expect(deps.redis.del).toHaveBeenCalledWith('merge:lock:repo-1');
665
+ });
666
+ it('stop sets running to false', async () => {
667
+ const config = makeConfig();
668
+ const deps = makeDeps();
669
+ const worker = new MergeWorker(config, deps);
670
+ vi.mocked(deps.storage.dequeue).mockResolvedValue(null);
671
+ vi.mocked(deps.redis.get).mockResolvedValue(null);
672
+ const startPromise = worker.start();
673
+ // Let it start running
674
+ await vi.advanceTimersByTimeAsync(50);
675
+ // Stop the worker
676
+ worker.stop();
677
+ // Advance timers to let the loop exit
678
+ await vi.advanceTimersByTimeAsync(config.pollInterval + 100);
679
+ await startPromise;
680
+ // Lock should be released
681
+ expect(deps.redis.del).toHaveBeenCalledWith('merge:lock:repo-1');
682
+ });
683
+ it('heartbeat extends lock TTL', async () => {
684
+ const config = makeConfig();
685
+ const deps = makeDeps();
686
+ const worker = new MergeWorker(config, deps);
687
+ vi.mocked(deps.storage.dequeue).mockResolvedValue(null);
688
+ vi.mocked(deps.redis.get).mockResolvedValue(null);
689
+ const ac = new AbortController();
690
+ const startPromise = worker.start(ac.signal);
691
+ // Advance past the heartbeat interval (60 seconds)
692
+ await vi.advanceTimersByTimeAsync(60_000 + 100);
693
+ // Heartbeat should have called expire to extend the lock
694
+ expect(deps.redis.expire).toHaveBeenCalledWith('merge:lock:repo-1', 300);
695
+ ac.abort();
696
+ await vi.advanceTimersByTimeAsync(config.pollInterval + 10);
697
+ await startPromise;
698
+ });
699
+ it('paused queue causes polling wait', async () => {
700
+ const config = makeConfig({ pollInterval: 100 });
701
+ const deps = makeDeps();
702
+ const worker = new MergeWorker(config, deps);
703
+ // First call: paused, second call: paused, then abort
704
+ let callCount = 0;
705
+ vi.mocked(deps.redis.get).mockImplementation(async (key) => {
706
+ if (key.includes('paused')) {
707
+ callCount++;
708
+ return 'true';
709
+ }
710
+ return null;
711
+ });
712
+ const ac = new AbortController();
713
+ const startPromise = worker.start(ac.signal);
714
+ // Advance to let it check paused state
715
+ await vi.advanceTimersByTimeAsync(config.pollInterval + 50);
716
+ await vi.advanceTimersByTimeAsync(config.pollInterval + 50);
717
+ ac.abort();
718
+ await vi.advanceTimersByTimeAsync(config.pollInterval + 10);
719
+ await startPromise;
720
+ // Should have checked paused state multiple times but never dequeued
721
+ expect(callCount).toBeGreaterThanOrEqual(1);
722
+ expect(deps.storage.dequeue).not.toHaveBeenCalled();
723
+ });
724
+ it('empty queue causes polling wait', async () => {
725
+ const config = makeConfig({ pollInterval: 100 });
726
+ const deps = makeDeps();
727
+ const worker = new MergeWorker(config, deps);
728
+ vi.mocked(deps.storage.dequeue).mockResolvedValue(null);
729
+ vi.mocked(deps.redis.get).mockResolvedValue(null);
730
+ const ac = new AbortController();
731
+ const startPromise = worker.start(ac.signal);
732
+ // Advance past two poll intervals
733
+ await vi.advanceTimersByTimeAsync(config.pollInterval * 2 + 50);
734
+ ac.abort();
735
+ await vi.advanceTimersByTimeAsync(config.pollInterval + 10);
736
+ await startPromise;
737
+ // Should have called dequeue multiple times (polling)
738
+ expect(deps.storage.dequeue).toHaveBeenCalled();
739
+ // No markCompleted/markFailed since queue was empty
740
+ expect(deps.storage.markCompleted).not.toHaveBeenCalled();
741
+ expect(deps.storage.markFailed).not.toHaveBeenCalled();
742
+ });
743
+ it('processes entry and marks completed on success', async () => {
744
+ vi.useRealTimers();
745
+ const config = makeConfig({ pollInterval: 50 });
746
+ const deps = makeDeps();
747
+ const worker = new MergeWorker(config, deps);
748
+ const entry = makeEntry();
749
+ // Return entry on first dequeue, null on second (to stop)
750
+ let dequeueCount = 0;
751
+ vi.mocked(deps.storage.dequeue).mockImplementation(async () => {
752
+ dequeueCount++;
753
+ if (dequeueCount === 1)
754
+ return entry;
755
+ return null;
756
+ });
757
+ vi.mocked(deps.redis.get).mockResolvedValue(null);
758
+ // Set up mocks for processEntry
759
+ const mockStrategy = makeMockStrategy();
760
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
761
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
762
+ MockLockFileRegeneration.mockImplementation(() => ({
763
+ shouldRegenerate: vi.fn().mockReturnValue(false),
764
+ regenerate: vi.fn(),
765
+ getLockFileName: vi.fn(),
766
+ ensureGitAttributes: vi.fn(),
767
+ }));
768
+ mockExecSuccess();
769
+ const ac = new AbortController();
770
+ const startPromise = worker.start(ac.signal);
771
+ // Let the processing happen
772
+ await new Promise(resolve => setTimeout(resolve, 200));
773
+ ac.abort();
774
+ await startPromise;
775
+ expect(deps.storage.markCompleted).toHaveBeenCalledWith('repo-1', 42);
776
+ });
777
+ it('marks blocked on conflict', async () => {
778
+ vi.useRealTimers();
779
+ const config = makeConfig({ pollInterval: 50 });
780
+ const deps = makeDeps();
781
+ const worker = new MergeWorker(config, deps);
782
+ const entry = makeEntry();
783
+ let dequeueCount = 0;
784
+ vi.mocked(deps.storage.dequeue).mockImplementation(async () => {
785
+ dequeueCount++;
786
+ if (dequeueCount === 1)
787
+ return entry;
788
+ return null;
789
+ });
790
+ vi.mocked(deps.redis.get).mockResolvedValue(null);
791
+ const mockStrategy = makeMockStrategy();
792
+ mockStrategy.execute.mockResolvedValue({ status: 'conflict', conflictFiles: ['a.ts'] });
793
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
794
+ MockConflictResolver.mockImplementation(() => ({
795
+ resolve: vi.fn().mockResolvedValue({
796
+ status: 'escalated',
797
+ method: 'escalation',
798
+ message: 'Conflict in a.ts',
799
+ }),
800
+ }));
801
+ MockLockFileRegeneration.mockImplementation(() => ({
802
+ shouldRegenerate: vi.fn().mockReturnValue(false),
803
+ regenerate: vi.fn(),
804
+ getLockFileName: vi.fn(),
805
+ ensureGitAttributes: vi.fn(),
806
+ }));
807
+ const ac = new AbortController();
808
+ const startPromise = worker.start(ac.signal);
809
+ await new Promise(resolve => setTimeout(resolve, 200));
810
+ ac.abort();
811
+ await startPromise;
812
+ expect(deps.storage.markBlocked).toHaveBeenCalledWith('repo-1', 42, 'Conflict in a.ts');
813
+ });
814
+ it('marks failed on test failure with notify escalation', async () => {
815
+ vi.useRealTimers();
816
+ const config = makeConfig({
817
+ pollInterval: 50,
818
+ escalation: { onConflict: 'reassign', onTestFailure: 'notify' },
819
+ });
820
+ const deps = makeDeps();
821
+ const worker = new MergeWorker(config, deps);
822
+ const entry = makeEntry();
823
+ let dequeueCount = 0;
824
+ vi.mocked(deps.storage.dequeue).mockImplementation(async () => {
825
+ dequeueCount++;
826
+ if (dequeueCount === 1)
827
+ return entry;
828
+ return null;
829
+ });
830
+ vi.mocked(deps.redis.get).mockResolvedValue(null);
831
+ const mockStrategy = makeMockStrategy();
832
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
833
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
834
+ MockLockFileRegeneration.mockImplementation(() => ({
835
+ shouldRegenerate: vi.fn().mockReturnValue(false),
836
+ regenerate: vi.fn(),
837
+ getLockFileName: vi.fn(),
838
+ ensureGitAttributes: vi.fn(),
839
+ }));
840
+ mockExecFailure('Tests failed', 'FAIL: 2 tests');
841
+ const ac = new AbortController();
842
+ const startPromise = worker.start(ac.signal);
843
+ await new Promise(resolve => setTimeout(resolve, 200));
844
+ ac.abort();
845
+ await startPromise;
846
+ expect(deps.storage.markFailed).toHaveBeenCalledWith('repo-1', 42, expect.any(String));
847
+ });
848
+ it('marks blocked on test failure with park escalation', async () => {
849
+ vi.useRealTimers();
850
+ const config = makeConfig({
851
+ pollInterval: 50,
852
+ escalation: { onConflict: 'reassign', onTestFailure: 'park' },
853
+ });
854
+ const deps = makeDeps();
855
+ const worker = new MergeWorker(config, deps);
856
+ const entry = makeEntry();
857
+ let dequeueCount = 0;
858
+ vi.mocked(deps.storage.dequeue).mockImplementation(async () => {
859
+ dequeueCount++;
860
+ if (dequeueCount === 1)
861
+ return entry;
862
+ return null;
863
+ });
864
+ vi.mocked(deps.redis.get).mockResolvedValue(null);
865
+ const mockStrategy = makeMockStrategy();
866
+ mockCreateMergeStrategy.mockReturnValue(mockStrategy);
867
+ MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
868
+ MockLockFileRegeneration.mockImplementation(() => ({
869
+ shouldRegenerate: vi.fn().mockReturnValue(false),
870
+ regenerate: vi.fn(),
871
+ getLockFileName: vi.fn(),
872
+ ensureGitAttributes: vi.fn(),
873
+ }));
874
+ mockExecFailure('Tests failed', 'FAIL: 2 tests');
875
+ const ac = new AbortController();
876
+ const startPromise = worker.start(ac.signal);
877
+ await new Promise(resolve => setTimeout(resolve, 200));
878
+ ac.abort();
879
+ await startPromise;
880
+ expect(deps.storage.markBlocked).toHaveBeenCalledWith('repo-1', 42, expect.any(String));
881
+ });
882
+ });
883
+ });