@mutineerjs/mutineer 0.1.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/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/admin/assets/index-B7nXq-e7.js +32 -0
- package/dist/admin/assets/index-B7nXq-e7.js.map +1 -0
- package/dist/admin/assets/index-BDQLkBUE.js +32 -0
- package/dist/admin/assets/index-BDQLkBUE.js.map +1 -0
- package/dist/admin/assets/index-DVkP-Tc7.css +1 -0
- package/dist/admin/index.html +13 -0
- package/dist/admin/server/admin.d.ts +6 -0
- package/dist/admin/server/admin.js +234 -0
- package/dist/bin/mutate-vitest.d.ts +2 -0
- package/dist/bin/mutate-vitest.js +90 -0
- package/dist/bin/mutineer.d.ts +2 -0
- package/dist/bin/mutineer.js +46 -0
- package/dist/core/__tests__/module.spec.d.ts +1 -0
- package/dist/core/__tests__/module.spec.js +6 -0
- package/dist/core/module.d.ts +11 -0
- package/dist/core/module.js +14 -0
- package/dist/core/sfc.d.ts +12 -0
- package/dist/core/sfc.js +54 -0
- package/dist/core/types.d.ts +6 -0
- package/dist/core/types.js +1 -0
- package/dist/core/variant-utils.d.ts +30 -0
- package/dist/core/variant-utils.js +54 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/mutators/__tests__/registry.spec.d.ts +1 -0
- package/dist/mutators/__tests__/registry.spec.js +43 -0
- package/dist/mutators/__tests__/utils.spec.d.ts +1 -0
- package/dist/mutators/__tests__/utils.spec.js +15 -0
- package/dist/mutators/registry.d.ts +37 -0
- package/dist/mutators/registry.js +101 -0
- package/dist/mutators/types.d.ts +39 -0
- package/dist/mutators/types.js +7 -0
- package/dist/mutators/utils.d.ts +37 -0
- package/dist/mutators/utils.js +151 -0
- package/dist/plugin/viteMutate.d.ts +15 -0
- package/dist/plugin/viteMutate.js +52 -0
- package/dist/plugin/vitest.setup.d.ts +47 -0
- package/dist/plugin/vitest.setup.js +118 -0
- package/dist/plugin/withVitest.d.ts +13 -0
- package/dist/plugin/withVitest.js +30 -0
- package/dist/runner/__tests__/discover.spec.d.ts +1 -0
- package/dist/runner/__tests__/discover.spec.js +59 -0
- package/dist/runner/__tests__/orchestrator.spec.d.ts +1 -0
- package/dist/runner/__tests__/orchestrator.spec.js +55 -0
- package/dist/runner/adapters/__tests__/jest.spec.d.ts +1 -0
- package/dist/runner/adapters/__tests__/jest.spec.js +88 -0
- package/dist/runner/adapters/__tests__/vitest-worker-runtime.spec.d.ts +1 -0
- package/dist/runner/adapters/__tests__/vitest-worker-runtime.spec.js +59 -0
- package/dist/runner/adapters/__tests__/vitest.spec.d.ts +1 -0
- package/dist/runner/adapters/__tests__/vitest.spec.js +118 -0
- package/dist/runner/adapters/index.d.ts +10 -0
- package/dist/runner/adapters/index.js +9 -0
- package/dist/runner/adapters/jest/__tests__/index.spec.d.ts +1 -0
- package/dist/runner/adapters/jest/__tests__/index.spec.js +88 -0
- package/dist/runner/adapters/jest/index.d.ts +24 -0
- package/dist/runner/adapters/jest/index.js +216 -0
- package/dist/runner/adapters/jest/worker-runtime.d.ts +37 -0
- package/dist/runner/adapters/jest/worker-runtime.js +171 -0
- package/dist/runner/adapters/jest-worker-runtime.d.ts +37 -0
- package/dist/runner/adapters/jest-worker-runtime.js +171 -0
- package/dist/runner/adapters/jest.d.ts +24 -0
- package/dist/runner/adapters/jest.js +216 -0
- package/dist/runner/adapters/types.d.ts +89 -0
- package/dist/runner/adapters/types.js +8 -0
- package/dist/runner/adapters/vitest/__tests__/index.spec.d.ts +1 -0
- package/dist/runner/adapters/vitest/__tests__/index.spec.js +118 -0
- package/dist/runner/adapters/vitest/__tests__/worker-runtime.spec.d.ts +1 -0
- package/dist/runner/adapters/vitest/__tests__/worker-runtime.spec.js +59 -0
- package/dist/runner/adapters/vitest/index.d.ts +33 -0
- package/dist/runner/adapters/vitest/index.js +267 -0
- package/dist/runner/adapters/vitest/worker-runtime.d.ts +25 -0
- package/dist/runner/adapters/vitest/worker-runtime.js +118 -0
- package/dist/runner/adapters/vitest-worker-runtime.d.ts +25 -0
- package/dist/runner/adapters/vitest-worker-runtime.js +118 -0
- package/dist/runner/adapters/vitest.d.ts +33 -0
- package/dist/runner/adapters/vitest.js +267 -0
- package/dist/runner/args.d.ts +50 -0
- package/dist/runner/args.js +123 -0
- package/dist/runner/cache.d.ts +38 -0
- package/dist/runner/cache.js +118 -0
- package/dist/runner/changed.d.ts +22 -0
- package/dist/runner/changed.js +210 -0
- package/dist/runner/cleanup.d.ts +4 -0
- package/dist/runner/cleanup.js +21 -0
- package/dist/runner/config.d.ts +13 -0
- package/dist/runner/config.js +94 -0
- package/dist/runner/discover.d.ts +7 -0
- package/dist/runner/discover.js +258 -0
- package/dist/runner/jest/__tests__/adapter.spec.d.ts +1 -0
- package/dist/runner/jest/__tests__/adapter.spec.js +110 -0
- package/dist/runner/jest/adapter.d.ts +24 -0
- package/dist/runner/jest/adapter.js +191 -0
- package/dist/runner/jest/index.d.ts +8 -0
- package/dist/runner/jest/index.js +7 -0
- package/dist/runner/jest/pool.d.ts +47 -0
- package/dist/runner/jest/pool.js +307 -0
- package/dist/runner/jest/resolver.cjs +61 -0
- package/dist/runner/jest/resolver.d.cts +11 -0
- package/dist/runner/jest/worker-runtime.d.ts +30 -0
- package/dist/runner/jest/worker-runtime.js +98 -0
- package/dist/runner/jest/worker.d.mts +1 -0
- package/dist/runner/jest/worker.mjs +55 -0
- package/dist/runner/orchestrator.d.ts +13 -0
- package/dist/runner/orchestrator.js +387 -0
- package/dist/runner/pool/__tests__/index.spec.d.ts +1 -0
- package/dist/runner/pool/__tests__/index.spec.js +83 -0
- package/dist/runner/pool/__tests__/pool-plugin.spec.d.ts +1 -0
- package/dist/runner/pool/__tests__/pool-plugin.spec.js +59 -0
- package/dist/runner/pool/__tests__/pool-redirect-loader.spec.d.ts +1 -0
- package/dist/runner/pool/__tests__/pool-redirect-loader.spec.js +78 -0
- package/dist/runner/pool/index.d.ts +8 -0
- package/dist/runner/pool/index.js +9 -0
- package/dist/runner/pool/jest/pool.d.ts +52 -0
- package/dist/runner/pool/jest/pool.js +309 -0
- package/dist/runner/pool/jest/worker.d.mts +1 -0
- package/dist/runner/pool/jest/worker.mjs +60 -0
- package/dist/runner/pool/jest-pool.d.ts +52 -0
- package/dist/runner/pool/jest-pool.js +309 -0
- package/dist/runner/pool/jest-worker.d.mts +1 -0
- package/dist/runner/pool/jest-worker.mjs +60 -0
- package/dist/runner/pool/plugin.d.ts +18 -0
- package/dist/runner/pool/plugin.js +60 -0
- package/dist/runner/pool/pool-plugin.d.ts +18 -0
- package/dist/runner/pool/pool-plugin.js +60 -0
- package/dist/runner/pool/pool-redirect-loader.d.ts +19 -0
- package/dist/runner/pool/pool-redirect-loader.js +116 -0
- package/dist/runner/pool/pool-redirect-loader.mjs +146 -0
- package/dist/runner/pool/redirect-loader.d.ts +19 -0
- package/dist/runner/pool/redirect-loader.js +116 -0
- package/dist/runner/pool/vitest/pool.d.ts +70 -0
- package/dist/runner/pool/vitest/pool.js +376 -0
- package/dist/runner/pool/vitest/worker.d.mts +15 -0
- package/dist/runner/pool/vitest/worker.mjs +96 -0
- package/dist/runner/pool/vitest-worker.d.mts +15 -0
- package/dist/runner/pool/vitest-worker.mjs +96 -0
- package/dist/runner/shared/index.d.ts +9 -0
- package/dist/runner/shared/index.js +8 -0
- package/dist/runner/shared/mutant-paths.d.ts +15 -0
- package/dist/runner/shared/mutant-paths.js +30 -0
- package/dist/runner/shared/redirect-state.d.ts +45 -0
- package/dist/runner/shared/redirect-state.js +50 -0
- package/dist/runner/shared-module-redirect.d.ts +56 -0
- package/dist/runner/shared-module-redirect.js +84 -0
- package/dist/runner/types.d.ts +88 -0
- package/dist/runner/types.js +8 -0
- package/dist/runner/variants.d.ts +21 -0
- package/dist/runner/variants.js +66 -0
- package/dist/runner/vitest/__tests__/adapter.spec.d.ts +1 -0
- package/dist/runner/vitest/__tests__/adapter.spec.js +131 -0
- package/dist/runner/vitest/__tests__/plugin.spec.d.ts +1 -0
- package/dist/runner/vitest/__tests__/plugin.spec.js +65 -0
- package/dist/runner/vitest/__tests__/pool.spec.d.ts +1 -0
- package/dist/runner/vitest/__tests__/pool.spec.js +106 -0
- package/dist/runner/vitest/__tests__/redirect-loader.spec.d.ts +1 -0
- package/dist/runner/vitest/__tests__/redirect-loader.spec.js +87 -0
- package/dist/runner/vitest/__tests__/worker-runtime.spec.d.ts +1 -0
- package/dist/runner/vitest/__tests__/worker-runtime.spec.js +75 -0
- package/dist/runner/vitest/adapter.d.ts +33 -0
- package/dist/runner/vitest/adapter.js +277 -0
- package/dist/runner/vitest/index.d.ts +11 -0
- package/dist/runner/vitest/index.js +10 -0
- package/dist/runner/vitest/plugin.d.ts +12 -0
- package/dist/runner/vitest/plugin.js +49 -0
- package/dist/runner/vitest/pool.d.ts +65 -0
- package/dist/runner/vitest/pool.js +376 -0
- package/dist/runner/vitest/redirect-loader.d.ts +30 -0
- package/dist/runner/vitest/redirect-loader.js +123 -0
- package/dist/runner/vitest/worker-runtime.d.ts +16 -0
- package/dist/runner/vitest/worker-runtime.js +105 -0
- package/dist/runner/vitest/worker.d.mts +15 -0
- package/dist/runner/vitest/worker.mjs +92 -0
- package/dist/types/api.d.ts +20 -0
- package/dist/types/api.js +1 -0
- package/dist/types/config.d.ts +48 -0
- package/dist/types/config.js +1 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.js +11 -0
- package/dist/types/mutant.d.ts +44 -0
- package/dist/types/mutant.js +7 -0
- package/dist/utils/PoolSpinner.d.ts +5 -0
- package/dist/utils/PoolSpinner.js +6 -0
- package/dist/utils/ProgressBar.d.ts +11 -0
- package/dist/utils/ProgressBar.js +9 -0
- package/dist/utils/__tests__/coverage.spec.d.ts +1 -0
- package/dist/utils/__tests__/coverage.spec.js +91 -0
- package/dist/utils/__tests__/progress.spec.d.ts +1 -0
- package/dist/utils/__tests__/progress.spec.js +50 -0
- package/dist/utils/__tests__/summary.spec.d.ts +1 -0
- package/dist/utils/__tests__/summary.spec.js +54 -0
- package/dist/utils/coverage.d.ts +57 -0
- package/dist/utils/coverage.js +204 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/logger.js +18 -0
- package/dist/utils/progress.d.ts +25 -0
- package/dist/utils/progress.js +90 -0
- package/dist/utils/summary.d.ts +12 -0
- package/dist/utils/summary.js +107 -0
- package/package.json +59 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { createJestAdapter } from '../adapter.js';
|
|
6
|
+
// Mock JestPool to avoid real processes
|
|
7
|
+
let poolInstance = null;
|
|
8
|
+
let poolCtorOpts = null;
|
|
9
|
+
vi.mock('../pool.js', () => {
|
|
10
|
+
class MockPool {
|
|
11
|
+
constructor(opts) {
|
|
12
|
+
this.init = vi.fn();
|
|
13
|
+
this.run = vi.fn();
|
|
14
|
+
this.shutdown = vi.fn();
|
|
15
|
+
poolCtorOpts = opts;
|
|
16
|
+
poolInstance = this;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return { JestPool: MockPool };
|
|
20
|
+
});
|
|
21
|
+
// Mock runCLI from jest
|
|
22
|
+
const runCLIMock = vi.fn();
|
|
23
|
+
vi.mock('@jest/core', () => ({
|
|
24
|
+
runCLI: runCLIMock,
|
|
25
|
+
}));
|
|
26
|
+
function makeAdapter(opts = {}) {
|
|
27
|
+
return createJestAdapter({
|
|
28
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
29
|
+
concurrency: opts.concurrency ?? 2,
|
|
30
|
+
timeoutMs: opts.timeoutMs ?? 1000,
|
|
31
|
+
config: opts.config ?? { jestConfig: undefined },
|
|
32
|
+
cliArgs: opts.cliArgs ?? ['--changed'],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
describe('Jest adapter', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.useRealTimers();
|
|
41
|
+
});
|
|
42
|
+
it('initializes pool with override concurrency', async () => {
|
|
43
|
+
const adapter = makeAdapter();
|
|
44
|
+
await adapter.init(4);
|
|
45
|
+
expect(poolInstance?.init).toHaveBeenCalledTimes(1);
|
|
46
|
+
expect(poolCtorOpts.concurrency).toBe(4);
|
|
47
|
+
});
|
|
48
|
+
it('runs baseline via runCLI with coverage when requested', async () => {
|
|
49
|
+
const adapter = makeAdapter({
|
|
50
|
+
config: { jestConfig: 'jest.config.ts' },
|
|
51
|
+
});
|
|
52
|
+
runCLIMock.mockResolvedValueOnce({ results: { success: true } });
|
|
53
|
+
const ok = await adapter.runBaseline(['test-a'], {
|
|
54
|
+
collectCoverage: true,
|
|
55
|
+
perTestCoverage: false,
|
|
56
|
+
});
|
|
57
|
+
expect(ok).toBe(true);
|
|
58
|
+
expect(runCLIMock).toHaveBeenCalledTimes(1);
|
|
59
|
+
const args = runCLIMock.mock.calls[0][0];
|
|
60
|
+
expect(args.collectCoverage).toBe(true);
|
|
61
|
+
expect(args.coverageProvider).toBe('v8');
|
|
62
|
+
expect(args.config).toBe('jest.config.ts');
|
|
63
|
+
});
|
|
64
|
+
it('maps pool result to mutant status', async () => {
|
|
65
|
+
const adapter = makeAdapter();
|
|
66
|
+
await adapter.init();
|
|
67
|
+
poolInstance.run.mockResolvedValueOnce({ killed: true, durationMs: 10 });
|
|
68
|
+
const res = await adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
|
|
69
|
+
expect(res).toEqual({ status: 'killed', durationMs: 10 });
|
|
70
|
+
});
|
|
71
|
+
it('maps pool errors to error status', async () => {
|
|
72
|
+
const adapter = makeAdapter();
|
|
73
|
+
await adapter.init();
|
|
74
|
+
poolInstance.run.mockResolvedValueOnce({
|
|
75
|
+
killed: false,
|
|
76
|
+
durationMs: 12,
|
|
77
|
+
error: 'crash',
|
|
78
|
+
});
|
|
79
|
+
const res = await adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
|
|
80
|
+
expect(res).toEqual({ status: 'error', durationMs: 12, error: 'crash' });
|
|
81
|
+
});
|
|
82
|
+
it('maps killed with failure messages to killed status', async () => {
|
|
83
|
+
const adapter = makeAdapter();
|
|
84
|
+
await adapter.init();
|
|
85
|
+
poolInstance.run.mockResolvedValueOnce({
|
|
86
|
+
killed: true,
|
|
87
|
+
durationMs: 12,
|
|
88
|
+
error: 'expect(received).toBe(expected)',
|
|
89
|
+
});
|
|
90
|
+
const res = await adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
|
|
91
|
+
expect(res).toEqual({ status: 'killed', durationMs: 12 });
|
|
92
|
+
});
|
|
93
|
+
it('detects coverage config from jest config file', async () => {
|
|
94
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-jest-'));
|
|
95
|
+
const cfgPath = path.join(tmp, 'jest.config.ts');
|
|
96
|
+
await fs.writeFile(cfgPath, 'module.exports = { collectCoverage: true }');
|
|
97
|
+
try {
|
|
98
|
+
const adapter = makeAdapter({
|
|
99
|
+
cwd: tmp,
|
|
100
|
+
config: { jestConfig: 'jest.config.ts' },
|
|
101
|
+
});
|
|
102
|
+
const coverage = await adapter.detectCoverageConfig();
|
|
103
|
+
expect(coverage.coverageEnabled).toBe(true);
|
|
104
|
+
expect(coverage.perTestEnabled).toBe(false);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jest Test Runner Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the TestRunnerAdapter interface for Jest using runCLI.
|
|
5
|
+
* Baseline runs are executed directly via runCLI; mutant runs are delegated
|
|
6
|
+
* to a pool of long-lived worker processes that also use runCLI with a
|
|
7
|
+
* redirect resolver to swap in mutated code.
|
|
8
|
+
*/
|
|
9
|
+
import type { TestRunnerAdapter, TestRunnerAdapterOptions, MutantPayload, MutantRunResult, BaselineOptions, CoverageConfig } from '../types.js';
|
|
10
|
+
export declare class JestAdapter implements TestRunnerAdapter {
|
|
11
|
+
readonly name = "jest";
|
|
12
|
+
private readonly options;
|
|
13
|
+
private jestConfigPath?;
|
|
14
|
+
private pool;
|
|
15
|
+
private readonly requireFromCwd;
|
|
16
|
+
constructor(options: TestRunnerAdapterOptions);
|
|
17
|
+
init(concurrencyOverride?: number): Promise<void>;
|
|
18
|
+
runBaseline(tests: readonly string[], options: BaselineOptions): Promise<boolean>;
|
|
19
|
+
runMutant(mutant: MutantPayload, tests: readonly string[]): Promise<MutantRunResult>;
|
|
20
|
+
shutdown(): Promise<void>;
|
|
21
|
+
hasCoverageProvider(): boolean;
|
|
22
|
+
detectCoverageConfig(): Promise<CoverageConfig>;
|
|
23
|
+
}
|
|
24
|
+
export declare function createJestAdapter(options: TestRunnerAdapterOptions): JestAdapter;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jest Test Runner Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the TestRunnerAdapter interface for Jest using runCLI.
|
|
5
|
+
* Baseline runs are executed directly via runCLI; mutant runs are delegated
|
|
6
|
+
* to a pool of long-lived worker processes that also use runCLI with a
|
|
7
|
+
* redirect resolver to swap in mutated code.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
// import os from 'node:os'
|
|
13
|
+
import { JestPool } from './pool.js';
|
|
14
|
+
import { createLogger } from '../../utils/logger.js';
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const log = createLogger('jest-adapter');
|
|
17
|
+
/**
|
|
18
|
+
* Strip mutineer-specific CLI args that shouldn't be passed to Jest.
|
|
19
|
+
*/
|
|
20
|
+
function stripMutineerArgs(args) {
|
|
21
|
+
const out = [];
|
|
22
|
+
const consumeNext = new Set([
|
|
23
|
+
'--concurrency',
|
|
24
|
+
'--progress',
|
|
25
|
+
'--min-kill-percent',
|
|
26
|
+
'--config',
|
|
27
|
+
'-c',
|
|
28
|
+
'--coverage-file',
|
|
29
|
+
'--runner',
|
|
30
|
+
]);
|
|
31
|
+
const dropExact = new Set([
|
|
32
|
+
'-m',
|
|
33
|
+
'--mutate',
|
|
34
|
+
'--changed',
|
|
35
|
+
'--changed-with-deps',
|
|
36
|
+
'--only-covered-lines',
|
|
37
|
+
'--per-test-coverage',
|
|
38
|
+
'--perTestCoverage',
|
|
39
|
+
]);
|
|
40
|
+
for (let i = 0; i < args.length; i++) {
|
|
41
|
+
const a = args[i];
|
|
42
|
+
if (dropExact.has(a))
|
|
43
|
+
continue;
|
|
44
|
+
if (consumeNext.has(a)) {
|
|
45
|
+
i++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (a.startsWith('--min-kill-percent='))
|
|
49
|
+
continue;
|
|
50
|
+
if (a.startsWith('--config=') || a.startsWith('-c='))
|
|
51
|
+
continue;
|
|
52
|
+
out.push(a);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
async function loadRunCLI(requireFromCwd) {
|
|
57
|
+
try {
|
|
58
|
+
return requireFromCwd('@jest/core');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return import('@jest/core');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build Jest runCLI options for the given mode.
|
|
66
|
+
*/
|
|
67
|
+
function buildJestCliOptions(tests, mode, jestConfigPath) {
|
|
68
|
+
const opts = {
|
|
69
|
+
_: [...tests],
|
|
70
|
+
$0: 'mutineer',
|
|
71
|
+
runInBand: true,
|
|
72
|
+
runTestsByPath: true,
|
|
73
|
+
watch: false,
|
|
74
|
+
passWithNoTests: true,
|
|
75
|
+
testPathPattern: [...tests],
|
|
76
|
+
};
|
|
77
|
+
if (jestConfigPath) {
|
|
78
|
+
opts.config = jestConfigPath;
|
|
79
|
+
}
|
|
80
|
+
if (mode === 'baseline-with-coverage') {
|
|
81
|
+
opts.coverage = true;
|
|
82
|
+
opts.collectCoverage = true;
|
|
83
|
+
opts.coverageProvider = 'v8';
|
|
84
|
+
}
|
|
85
|
+
return opts;
|
|
86
|
+
}
|
|
87
|
+
export class JestAdapter {
|
|
88
|
+
constructor(options) {
|
|
89
|
+
this.name = 'jest';
|
|
90
|
+
this.pool = null;
|
|
91
|
+
this.options = options;
|
|
92
|
+
this.jestConfigPath = options.config.jestConfig;
|
|
93
|
+
stripMutineerArgs(options.cliArgs);
|
|
94
|
+
this.requireFromCwd = createRequire(path.join(options.cwd, 'package.json'));
|
|
95
|
+
}
|
|
96
|
+
async init(concurrencyOverride) {
|
|
97
|
+
const workerCount = Math.max(1, concurrencyOverride ?? this.options.concurrency);
|
|
98
|
+
this.pool = new JestPool({
|
|
99
|
+
cwd: this.options.cwd,
|
|
100
|
+
concurrency: workerCount,
|
|
101
|
+
jestConfig: this.options.config.jestConfig,
|
|
102
|
+
timeoutMs: this.options.timeoutMs,
|
|
103
|
+
});
|
|
104
|
+
await this.pool.init();
|
|
105
|
+
}
|
|
106
|
+
async runBaseline(tests, options) {
|
|
107
|
+
const mode = options.collectCoverage
|
|
108
|
+
? 'baseline-with-coverage'
|
|
109
|
+
: 'baseline';
|
|
110
|
+
const cliOptions = buildJestCliOptions(tests, mode, this.jestConfigPath);
|
|
111
|
+
try {
|
|
112
|
+
const { runCLI } = await loadRunCLI(this.requireFromCwd);
|
|
113
|
+
const { results } = await runCLI(cliOptions, [this.options.cwd]);
|
|
114
|
+
return results.success;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
log.debug('Failed to run Jest baseline: ' +
|
|
118
|
+
(err instanceof Error ? err.message : String(err)));
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async runMutant(mutant, tests) {
|
|
123
|
+
if (!this.pool) {
|
|
124
|
+
throw new Error('JestAdapter not initialized. Call init() first.');
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const result = await this.pool.run(mutant, [...tests]);
|
|
128
|
+
if (result.error === 'timeout') {
|
|
129
|
+
return {
|
|
130
|
+
status: 'timeout',
|
|
131
|
+
durationMs: result.durationMs,
|
|
132
|
+
error: result.error,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (result.error && !result.killed) {
|
|
136
|
+
return {
|
|
137
|
+
status: 'error',
|
|
138
|
+
durationMs: result.durationMs,
|
|
139
|
+
error: result.error,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
status: result.killed ? 'killed' : 'escaped',
|
|
144
|
+
durationMs: result.durationMs,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
return {
|
|
149
|
+
status: 'error',
|
|
150
|
+
durationMs: 0,
|
|
151
|
+
error: err instanceof Error ? err.message : String(err),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async shutdown() {
|
|
156
|
+
if (this.pool) {
|
|
157
|
+
await this.pool.shutdown();
|
|
158
|
+
this.pool = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
hasCoverageProvider() {
|
|
162
|
+
try {
|
|
163
|
+
require.resolve('jest/package.json', { paths: [this.options.cwd] });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async detectCoverageConfig() {
|
|
171
|
+
const configPath = this.options.config.jestConfig;
|
|
172
|
+
if (!configPath) {
|
|
173
|
+
return { perTestEnabled: false, coverageEnabled: false };
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const abs = path.isAbsolute(configPath)
|
|
177
|
+
? configPath
|
|
178
|
+
: path.join(this.options.cwd, configPath);
|
|
179
|
+
const content = await fs.readFile(abs, 'utf8');
|
|
180
|
+
const coverageEnabled = /collectCoverage\s*:\s*true/.test(content) ||
|
|
181
|
+
/coverageProvider\s*:/.test(content);
|
|
182
|
+
return { perTestEnabled: false, coverageEnabled };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return { perTestEnabled: false, coverageEnabled: false };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function createJestAdapter(options) {
|
|
190
|
+
return new JestAdapter(options);
|
|
191
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jest Test Runner
|
|
3
|
+
*
|
|
4
|
+
* Complete Jest test runner implementation including adapter, pool, and worker runtime.
|
|
5
|
+
*/
|
|
6
|
+
export { JestAdapter, createJestAdapter } from './adapter.js';
|
|
7
|
+
export { JestPool, runWithJestPool, type JestPoolOptions } from './pool.js';
|
|
8
|
+
export type { MutantPayload, MutantRunResult, MutantRunSummary, } from '../../types/mutant.js';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { MutantPayload, MutantRunResult, MutantRunSummary } from '../../types/mutant.js';
|
|
3
|
+
declare class JestWorker extends EventEmitter {
|
|
4
|
+
private readonly cwd;
|
|
5
|
+
private readonly jestConfig?;
|
|
6
|
+
readonly id: string;
|
|
7
|
+
private process;
|
|
8
|
+
private pendingTask;
|
|
9
|
+
private ready;
|
|
10
|
+
private shuttingDown;
|
|
11
|
+
constructor(id: string, cwd: string, jestConfig?: string | undefined);
|
|
12
|
+
start(): Promise<void>;
|
|
13
|
+
private handleMessage;
|
|
14
|
+
private handleExit;
|
|
15
|
+
isReady(): boolean;
|
|
16
|
+
isBusy(): boolean;
|
|
17
|
+
run(mutant: MutantPayload, tests: string[], timeoutMs?: number): Promise<MutantRunSummary>;
|
|
18
|
+
shutdown(): Promise<void>;
|
|
19
|
+
kill(): void;
|
|
20
|
+
}
|
|
21
|
+
export interface JestPoolOptions {
|
|
22
|
+
cwd: string;
|
|
23
|
+
concurrency: number;
|
|
24
|
+
jestConfig?: string;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
createWorker?: (id: string, opts: {
|
|
27
|
+
cwd: string;
|
|
28
|
+
jestConfig?: string;
|
|
29
|
+
}) => JestWorker;
|
|
30
|
+
}
|
|
31
|
+
export declare class JestPool {
|
|
32
|
+
private workers;
|
|
33
|
+
private availableWorkers;
|
|
34
|
+
private waitingTasks;
|
|
35
|
+
private readonly options;
|
|
36
|
+
private initialized;
|
|
37
|
+
private shuttingDown;
|
|
38
|
+
constructor(options: JestPoolOptions);
|
|
39
|
+
init(): Promise<void>;
|
|
40
|
+
private handleWorkerExit;
|
|
41
|
+
private acquireWorker;
|
|
42
|
+
private releaseWorker;
|
|
43
|
+
run(mutant: MutantPayload, tests: string[]): Promise<MutantRunSummary>;
|
|
44
|
+
shutdown(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
export declare function runWithJestPool(pool: JestPool, mutant: MutantPayload, tests: readonly string[]): Promise<MutantRunResult>;
|
|
47
|
+
export type { MutantPayload, MutantRunResult, MutantRunSummary, } from '../../types/mutant.js';
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
import { createLogger, DEBUG } from '../../utils/logger.js';
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const workerLog = createLogger('JestWorker');
|
|
9
|
+
const poolLog = createLogger('JestPool');
|
|
10
|
+
class JestWorker extends EventEmitter {
|
|
11
|
+
constructor(id, cwd, jestConfig) {
|
|
12
|
+
super();
|
|
13
|
+
this.cwd = cwd;
|
|
14
|
+
this.jestConfig = jestConfig;
|
|
15
|
+
this.process = null;
|
|
16
|
+
this.pendingTask = null;
|
|
17
|
+
this.ready = false;
|
|
18
|
+
this.shuttingDown = false;
|
|
19
|
+
this.id = id;
|
|
20
|
+
}
|
|
21
|
+
async start() {
|
|
22
|
+
const workerJs = path.join(__dirname, 'worker.js');
|
|
23
|
+
const workerMts = path.join(__dirname, 'worker.mjs');
|
|
24
|
+
const workerTs = path.join(__dirname, 'worker.mts');
|
|
25
|
+
const workerScript = fs.existsSync(workerJs)
|
|
26
|
+
? workerJs
|
|
27
|
+
: fs.existsSync(workerMts)
|
|
28
|
+
? workerMts
|
|
29
|
+
: workerTs;
|
|
30
|
+
const env = {
|
|
31
|
+
...process.env,
|
|
32
|
+
MUTINEER_WORKER_ID: this.id,
|
|
33
|
+
MUTINEER_CWD: this.cwd,
|
|
34
|
+
...(this.jestConfig ? { MUTINEER_JEST_CONFIG: this.jestConfig } : {}),
|
|
35
|
+
...(DEBUG ? { MUTINEER_DEBUG: '1' } : {}),
|
|
36
|
+
};
|
|
37
|
+
workerLog.debug(`[${this.id}] Starting Jest worker process`);
|
|
38
|
+
this.process = fork(workerScript, [], {
|
|
39
|
+
cwd: this.cwd,
|
|
40
|
+
env,
|
|
41
|
+
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
|
42
|
+
execArgv: [
|
|
43
|
+
'--experimental-strip-types',
|
|
44
|
+
'--experimental-transform-types',
|
|
45
|
+
'--no-warnings',
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
this.process.stderr?.on('data', (data) => {
|
|
49
|
+
if (DEBUG) {
|
|
50
|
+
process.stderr.write(`[jest-worker-${this.id}] ${data}`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
this.process.on('message', (msg) => this.handleMessage(msg));
|
|
54
|
+
this.process.on('error', (err) => {
|
|
55
|
+
workerLog.debug(`[${this.id}] Process error: ${err.message}`);
|
|
56
|
+
this.handleExit(1);
|
|
57
|
+
});
|
|
58
|
+
this.process.on('exit', (code) => {
|
|
59
|
+
workerLog.debug(`[${this.id}] Process exited with code ${code}`);
|
|
60
|
+
this.handleExit(code ?? 1);
|
|
61
|
+
});
|
|
62
|
+
await new Promise((resolve, reject) => {
|
|
63
|
+
const timeoutMs = 60_000;
|
|
64
|
+
const timeout = setTimeout(() => {
|
|
65
|
+
reject(new Error(`Worker ${this.id} did not become ready in time (${timeoutMs}ms)`));
|
|
66
|
+
}, timeoutMs);
|
|
67
|
+
this.once('ready', () => {
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
resolve();
|
|
70
|
+
});
|
|
71
|
+
this.once('error', (err) => {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
reject(err);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
handleMessage(raw) {
|
|
78
|
+
const msg = raw;
|
|
79
|
+
if (msg.type === 'ready') {
|
|
80
|
+
this.ready = true;
|
|
81
|
+
this.emit('ready');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (msg.type === 'result') {
|
|
85
|
+
if (this.pendingTask) {
|
|
86
|
+
const { resolve, timeoutHandle } = this.pendingTask;
|
|
87
|
+
if (timeoutHandle)
|
|
88
|
+
clearTimeout(timeoutHandle);
|
|
89
|
+
this.pendingTask = null;
|
|
90
|
+
resolve({
|
|
91
|
+
killed: msg.killed ?? true,
|
|
92
|
+
durationMs: msg.durationMs ?? 0,
|
|
93
|
+
error: msg.error,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (msg.type === 'shutdown') {
|
|
99
|
+
this.emit('shutdown');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
handleExit(code) {
|
|
104
|
+
this.ready = false;
|
|
105
|
+
if (this.pendingTask && !this.shuttingDown) {
|
|
106
|
+
const { reject, timeoutHandle } = this.pendingTask;
|
|
107
|
+
if (timeoutHandle)
|
|
108
|
+
clearTimeout(timeoutHandle);
|
|
109
|
+
this.pendingTask = null;
|
|
110
|
+
reject(new Error(`Worker exited unexpectedly with code ${code}`));
|
|
111
|
+
}
|
|
112
|
+
this.emit('exit', code);
|
|
113
|
+
}
|
|
114
|
+
isReady() {
|
|
115
|
+
return this.ready && this.process !== null && !this.shuttingDown;
|
|
116
|
+
}
|
|
117
|
+
isBusy() {
|
|
118
|
+
return this.pendingTask !== null;
|
|
119
|
+
}
|
|
120
|
+
async run(mutant, tests, timeoutMs = 10_000) {
|
|
121
|
+
if (!this.isReady()) {
|
|
122
|
+
throw new Error(`Worker ${this.id} is not ready`);
|
|
123
|
+
}
|
|
124
|
+
if (this.isBusy()) {
|
|
125
|
+
throw new Error(`Worker ${this.id} is busy`);
|
|
126
|
+
}
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const timeoutHandle = setTimeout(() => {
|
|
129
|
+
if (this.pendingTask) {
|
|
130
|
+
this.pendingTask = null;
|
|
131
|
+
this.kill();
|
|
132
|
+
resolve({ killed: true, durationMs: timeoutMs, error: 'timeout' });
|
|
133
|
+
}
|
|
134
|
+
}, timeoutMs);
|
|
135
|
+
this.pendingTask = { resolve, reject, timeoutHandle };
|
|
136
|
+
this.process.send?.({ type: 'run', mutant, tests });
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async shutdown() {
|
|
140
|
+
if (!this.process || this.shuttingDown)
|
|
141
|
+
return;
|
|
142
|
+
this.shuttingDown = true;
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
const timeout = setTimeout(() => {
|
|
145
|
+
this.kill();
|
|
146
|
+
resolve();
|
|
147
|
+
}, 5000);
|
|
148
|
+
this.once('shutdown', () => {
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
resolve();
|
|
151
|
+
});
|
|
152
|
+
this.process.send?.({ type: 'shutdown' });
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
kill() {
|
|
156
|
+
if (this.process) {
|
|
157
|
+
try {
|
|
158
|
+
this.process.kill('SIGKILL');
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// ignore
|
|
162
|
+
}
|
|
163
|
+
this.process = null;
|
|
164
|
+
}
|
|
165
|
+
this.ready = false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export class JestPool {
|
|
169
|
+
constructor(options) {
|
|
170
|
+
this.workers = [];
|
|
171
|
+
this.availableWorkers = [];
|
|
172
|
+
this.waitingTasks = [];
|
|
173
|
+
this.initialized = false;
|
|
174
|
+
this.shuttingDown = false;
|
|
175
|
+
this.options = {
|
|
176
|
+
cwd: options.cwd,
|
|
177
|
+
concurrency: options.concurrency,
|
|
178
|
+
jestConfig: options.jestConfig,
|
|
179
|
+
timeoutMs: options.timeoutMs ?? 10_000,
|
|
180
|
+
createWorker: options.createWorker,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
async init() {
|
|
184
|
+
if (this.initialized)
|
|
185
|
+
return;
|
|
186
|
+
const startPromises = [];
|
|
187
|
+
for (let i = 0; i < this.options.concurrency; i++) {
|
|
188
|
+
const worker = this.options.createWorker?.(`w${i}`, {
|
|
189
|
+
cwd: this.options.cwd,
|
|
190
|
+
jestConfig: this.options.jestConfig,
|
|
191
|
+
}) ?? new JestWorker(`w${i}`, this.options.cwd, this.options.jestConfig);
|
|
192
|
+
worker.on('exit', () => {
|
|
193
|
+
if (!this.shuttingDown) {
|
|
194
|
+
this.handleWorkerExit(worker);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
this.workers.push(worker);
|
|
198
|
+
startPromises.push(worker.start().then(() => {
|
|
199
|
+
this.availableWorkers.push(worker);
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
await Promise.all(startPromises);
|
|
203
|
+
this.initialized = true;
|
|
204
|
+
}
|
|
205
|
+
handleWorkerExit(worker) {
|
|
206
|
+
const availIdx = this.availableWorkers.indexOf(worker);
|
|
207
|
+
if (availIdx >= 0) {
|
|
208
|
+
this.availableWorkers.splice(availIdx, 1);
|
|
209
|
+
}
|
|
210
|
+
const newWorker = this.options.createWorker?.(worker.id, {
|
|
211
|
+
cwd: this.options.cwd,
|
|
212
|
+
jestConfig: this.options.jestConfig,
|
|
213
|
+
}) ?? new JestWorker(worker.id, this.options.cwd, this.options.jestConfig);
|
|
214
|
+
const idx = this.workers.indexOf(worker);
|
|
215
|
+
if (idx >= 0) {
|
|
216
|
+
this.workers[idx] = newWorker;
|
|
217
|
+
}
|
|
218
|
+
newWorker.on('exit', () => {
|
|
219
|
+
if (!this.shuttingDown) {
|
|
220
|
+
this.handleWorkerExit(newWorker);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
newWorker
|
|
224
|
+
.start()
|
|
225
|
+
.then(() => {
|
|
226
|
+
this.releaseWorker(newWorker);
|
|
227
|
+
})
|
|
228
|
+
.catch((err) => {
|
|
229
|
+
poolLog.debug(`Failed to restart worker ${newWorker.id}: ${err}`);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
async acquireWorker() {
|
|
233
|
+
const worker = this.availableWorkers.shift();
|
|
234
|
+
if (worker) {
|
|
235
|
+
return worker;
|
|
236
|
+
}
|
|
237
|
+
return new Promise((resolve) => {
|
|
238
|
+
this.waitingTasks.push(resolve);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
releaseWorker(worker) {
|
|
242
|
+
const waiting = this.waitingTasks.shift();
|
|
243
|
+
if (waiting) {
|
|
244
|
+
waiting(worker);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (worker.isReady()) {
|
|
248
|
+
this.availableWorkers.push(worker);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async run(mutant, tests) {
|
|
252
|
+
if (!this.initialized) {
|
|
253
|
+
throw new Error('Pool not initialized. Call init() first.');
|
|
254
|
+
}
|
|
255
|
+
if (this.shuttingDown) {
|
|
256
|
+
throw new Error('Pool is shutting down');
|
|
257
|
+
}
|
|
258
|
+
const worker = await this.acquireWorker();
|
|
259
|
+
try {
|
|
260
|
+
const result = await worker.run(mutant, tests, this.options.timeoutMs);
|
|
261
|
+
poolLog.debug(`worker ${worker.id} returned killed=${result.killed} error=${result.error ?? 'none'} duration=${result.durationMs}`);
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
this.releaseWorker(worker);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async shutdown() {
|
|
269
|
+
if (this.shuttingDown)
|
|
270
|
+
return;
|
|
271
|
+
this.shuttingDown = true;
|
|
272
|
+
await Promise.all(this.workers.map((w) => w.shutdown()));
|
|
273
|
+
this.workers = [];
|
|
274
|
+
this.availableWorkers = [];
|
|
275
|
+
this.initialized = false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
export async function runWithJestPool(pool, mutant, tests) {
|
|
279
|
+
try {
|
|
280
|
+
const result = await pool.run(mutant, [...tests]);
|
|
281
|
+
if (result.error === 'timeout') {
|
|
282
|
+
return {
|
|
283
|
+
status: 'timeout',
|
|
284
|
+
durationMs: result.durationMs,
|
|
285
|
+
error: result.error,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (result.error && !result.killed) {
|
|
289
|
+
return {
|
|
290
|
+
status: 'error',
|
|
291
|
+
durationMs: result.durationMs,
|
|
292
|
+
error: result.error,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
status: result.killed ? 'killed' : 'escaped',
|
|
297
|
+
durationMs: result.durationMs,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
return {
|
|
302
|
+
status: 'error',
|
|
303
|
+
durationMs: 0,
|
|
304
|
+
error: err instanceof Error ? err.message : String(err),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|