@flowcodex/core 0.3.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 +9 -0
- package/dist/index-LbxYtxxS.d.ts +560 -0
- package/dist/index.d.ts +995 -0
- package/dist/index.js +3840 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +551 -0
- package/dist/kernel/index.js.map +1 -0
- package/package.json +39 -0
- package/src/agent/agent-loop.ts +254 -0
- package/src/agent/context.ts +99 -0
- package/src/agent/conversation-state.ts +44 -0
- package/src/agent/provider-runner.ts +241 -0
- package/src/agent/system-prompt-builder.ts +193 -0
- package/src/execution/compactor.ts +256 -0
- package/src/execution/index.ts +7 -0
- package/src/execution/output-serializer.ts +90 -0
- package/src/execution/schema-validator.ts +124 -0
- package/src/execution/tool-executor.ts +276 -0
- package/src/execution/tool-registry.ts +104 -0
- package/src/index.ts +215 -0
- package/src/infrastructure/catalog-parser.ts +218 -0
- package/src/infrastructure/index.ts +16 -0
- package/src/infrastructure/path-resolver.ts +123 -0
- package/src/infrastructure/provider-factory.ts +116 -0
- package/src/infrastructure/provider-presets.ts +19 -0
- package/src/infrastructure/retry-policy.ts +50 -0
- package/src/infrastructure/secret-scrubber.ts +67 -0
- package/src/infrastructure/token-counter.ts +156 -0
- package/src/infrastructure/tracer.ts +23 -0
- package/src/kernel/container.ts +166 -0
- package/src/kernel/events.ts +323 -0
- package/src/kernel/index.ts +18 -0
- package/src/kernel/pipeline.ts +152 -0
- package/src/kernel/run-controller.ts +85 -0
- package/src/kernel/tokens.ts +21 -0
- package/src/security/index.ts +13 -0
- package/src/security/permission-policy.ts +273 -0
- package/src/session/audit-log.ts +201 -0
- package/src/session/auth-service.ts +178 -0
- package/src/session/index.ts +26 -0
- package/src/session/secret-vault.ts +183 -0
- package/src/session/session-store.ts +339 -0
- package/src/session/types.ts +100 -0
- package/src/types/blocks.ts +56 -0
- package/src/types/context.ts +54 -0
- package/src/types/errors.ts +359 -0
- package/src/types/index.ts +34 -0
- package/src/types/provider.ts +58 -0
- package/src/types/tool.ts +39 -0
- package/src/utils/error.ts +3 -0
- package/src/utils/fs.ts +185 -0
- package/src/utils/image-resize.ts +76 -0
- package/src/utils/ssrf-guard.ts +133 -0
- package/src/utils/ulid.ts +72 -0
- package/src/utils/version-check.ts +59 -0
- package/tests/agent-loop.test.ts +490 -0
- package/tests/audit-log.test.ts +199 -0
- package/tests/auth-service.test.ts +170 -0
- package/tests/blocks.test.ts +79 -0
- package/tests/catalog-parser.test.ts +174 -0
- package/tests/compactor.test.ts +180 -0
- package/tests/container.test.ts +224 -0
- package/tests/conversation-state.test.ts +75 -0
- package/tests/errors.test.ts +429 -0
- package/tests/events-v021.test.ts +60 -0
- package/tests/events-v022.test.ts +75 -0
- package/tests/events.test.ts +340 -0
- package/tests/fixtures/large-image.png +0 -0
- package/tests/fixtures/small-image.png +0 -0
- package/tests/fs-utils.test.ts +164 -0
- package/tests/image-resize.test.ts +51 -0
- package/tests/output-serializer.test.ts +79 -0
- package/tests/path-resolver.test.ts +91 -0
- package/tests/permission-policy.test.ts +174 -0
- package/tests/pipeline.test.ts +193 -0
- package/tests/provider-factory.test.ts +245 -0
- package/tests/provider-runner.test.ts +535 -0
- package/tests/retry-policy.test.ts +104 -0
- package/tests/run-controller.test.ts +115 -0
- package/tests/sanity.test.ts +26 -0
- package/tests/schema-validator.test.ts +109 -0
- package/tests/secret-scrubber.test.ts +133 -0
- package/tests/secret-vault.test.ts +130 -0
- package/tests/session-store.test.ts +429 -0
- package/tests/ssrf-guard.test.ts +112 -0
- package/tests/system-prompt-builder.test.ts +116 -0
- package/tests/token-counter.test.ts +163 -0
- package/tests/tokens.test.ts +42 -0
- package/tests/tool-executor.test.ts +452 -0
- package/tests/tool-registry.test.ts +143 -0
- package/tests/tracer.test.ts +32 -0
- package/tests/ulid.test.ts +53 -0
- package/tests/version-check.test.ts +57 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +16 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { promises as fsp } from 'node:fs';
|
|
5
|
+
import { DefaultPathResolver, safeResolve, safeResolveReal } from '../src/infrastructure/path-resolver.js';
|
|
6
|
+
|
|
7
|
+
const TMP = path.join(os.tmpdir(), `fcx-test-${Date.now()}`);
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await fsp.mkdir(TMP, { recursive: true });
|
|
11
|
+
await fsp.mkdir(path.join(TMP, 'subdir'), { recursive: true });
|
|
12
|
+
await fsp.writeFile(path.join(TMP, 'file.txt'), 'hello');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await fsp.rm(TMP, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('DefaultPathResolver', () => {
|
|
20
|
+
it('detects project root from a directory with a marker', () => {
|
|
21
|
+
const resolver = new DefaultPathResolver({ projectRoot: TMP, cwd: TMP });
|
|
22
|
+
expect(resolver.projectRoot).toBe(TMP);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('resolve joins cwd with input', () => {
|
|
26
|
+
const resolver = new DefaultPathResolver({ projectRoot: TMP, cwd: TMP });
|
|
27
|
+
const resolved = resolver.resolve('file.txt');
|
|
28
|
+
expect(resolved).toContain('file.txt');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('isInsideRoot returns true for paths inside project', () => {
|
|
32
|
+
const resolver = new DefaultPathResolver({ projectRoot: TMP, cwd: TMP });
|
|
33
|
+
expect(resolver.isInsideRoot(path.join(TMP, 'file.txt'))).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('isInsideRoot returns false for paths outside', () => {
|
|
37
|
+
const resolver = new DefaultPathResolver({ projectRoot: TMP, cwd: TMP });
|
|
38
|
+
expect(resolver.isInsideRoot('/etc/passwd')).toBe(false);
|
|
39
|
+
expect(resolver.isInsideRoot(path.join(os.homedir(), 'secret'))).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('ensureInsideRoot throws for outside path', () => {
|
|
43
|
+
const resolver = new DefaultPathResolver({ projectRoot: TMP, cwd: TMP });
|
|
44
|
+
expect(() => resolver.ensureInsideRoot('/etc/passwd')).toThrow(/outside the project root/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('ensureInsideRoot returns path for inside path', () => {
|
|
48
|
+
const resolver = new DefaultPathResolver({ projectRoot: TMP, cwd: TMP });
|
|
49
|
+
const inside = path.join(TMP, 'file.txt');
|
|
50
|
+
expect(resolver.ensureInsideRoot(inside)).toBe(inside);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('safeResolve', () => {
|
|
55
|
+
it('resolves a relative path inside root', async () => {
|
|
56
|
+
const result = await safeResolve('file.txt', TMP, TMP);
|
|
57
|
+
expect(result).toBe(path.join(TMP, 'file.txt'));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('resolves a subdirectory path', async () => {
|
|
61
|
+
const result = await safeResolve('subdir/file.txt', TMP, TMP);
|
|
62
|
+
expect(result).toBe(path.join(TMP, 'subdir', 'file.txt'));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('throws for path escape via ..', async () => {
|
|
66
|
+
await expect(safeResolve('../etc/passwd', TMP, TMP)).rejects.toThrow(/outside the project root/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('throws for absolute path outside root', async () => {
|
|
70
|
+
await expect(safeResolve('/etc/passwd', TMP, TMP)).rejects.toThrow(/outside the project root/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('safeResolveReal', () => {
|
|
75
|
+
it('resolves existing file inside root', async () => {
|
|
76
|
+
const result = await safeResolveReal('file.txt', TMP, TMP);
|
|
77
|
+
expect(result).toBe(path.join(TMP, 'file.txt'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('resolves non-existing path inside root (walks to ancestor)', async () => {
|
|
81
|
+
const result = await safeResolveReal('subdir/newfile.txt', TMP, TMP);
|
|
82
|
+
expect(result).toBe(path.join(TMP, 'subdir', 'newfile.txt'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('throws for symlink escape', async () => {
|
|
86
|
+
const linkPath = path.join(TMP, 'evil-link');
|
|
87
|
+
const target = os.tmpdir();
|
|
88
|
+
await fsp.symlink(target, linkPath, 'dir');
|
|
89
|
+
await expect(safeResolveReal('evil-link/file.txt', TMP, TMP)).rejects.toThrow(/outside|escape/i);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fsp } from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { DefaultPermissionPolicy, matchGlob } from '../src/security/permission-policy.js';
|
|
6
|
+
import type { Tool } from '../src/types/tool.js';
|
|
7
|
+
import { EventBus } from '../src/kernel/events.js';
|
|
8
|
+
|
|
9
|
+
function makeTool(overrides: Partial<Tool> = {}): Tool {
|
|
10
|
+
return {
|
|
11
|
+
name: 'bash',
|
|
12
|
+
description: 'run',
|
|
13
|
+
inputSchema: { type: 'object' },
|
|
14
|
+
permission: 'confirm',
|
|
15
|
+
mutating: true,
|
|
16
|
+
riskTier: 'destructive',
|
|
17
|
+
subjectKey: 'command',
|
|
18
|
+
capabilities: ['shell.arbitrary'],
|
|
19
|
+
async execute() {
|
|
20
|
+
return 'ok';
|
|
21
|
+
},
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('matchGlob', () => {
|
|
27
|
+
it('matches literal', () => {
|
|
28
|
+
expect(matchGlob('pnpm install', 'pnpm install')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
it('matches trailing *', () => {
|
|
31
|
+
expect(matchGlob('pnpm *', 'pnpm install')).toBe(true);
|
|
32
|
+
expect(matchGlob('pnpm *', 'npm install')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
it('matches ** across separators', () => {
|
|
35
|
+
expect(matchGlob('src/**', 'src/foo/bar.ts')).toBe(true);
|
|
36
|
+
expect(matchGlob('src/**', 'lib/foo.ts')).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it('matches bare * against anything non-empty', () => {
|
|
39
|
+
expect(matchGlob('*', 'anything')).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('DefaultPermissionPolicy', () => {
|
|
44
|
+
let homeDir: string;
|
|
45
|
+
let projectRoot: string;
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
homeDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'fc-trust-home-'));
|
|
49
|
+
projectRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'fc-trust-proj-'));
|
|
50
|
+
});
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await fsp.rm(homeDir, { recursive: true, force: true });
|
|
53
|
+
await fsp.rm(projectRoot, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
async function newPolicy(): Promise<DefaultPermissionPolicy> {
|
|
57
|
+
const p = new DefaultPermissionPolicy({ homeDir, projectRoot });
|
|
58
|
+
await p.load();
|
|
59
|
+
return p;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('returns tool default permission when no rules match', async () => {
|
|
63
|
+
const p = await newPolicy();
|
|
64
|
+
const d = await p.evaluate(makeTool({ permission: 'auto', capabilities: undefined }), { command: 'ls' });
|
|
65
|
+
expect(d.permission).toBe('auto');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('deny rule beats allow rule and default', async () => {
|
|
69
|
+
const p = await newPolicy();
|
|
70
|
+
await p.deny({ tool: 'bash', pattern: 'rm *' });
|
|
71
|
+
await p.allowOnce({ tool: 'bash', pattern: 'rm *' });
|
|
72
|
+
const d = await p.evaluate(makeTool(), { command: 'rm -rf x' });
|
|
73
|
+
expect(d.permission).toBe('deny');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('session denyOnce beats trust allow', async () => {
|
|
77
|
+
const p = await newPolicy();
|
|
78
|
+
await p.trust({ tool: 'bash', pattern: 'pnpm *' });
|
|
79
|
+
p.denyOnce({ tool: 'bash', pattern: 'pnpm *' });
|
|
80
|
+
const d = await p.evaluate(makeTool(), { command: 'pnpm install' });
|
|
81
|
+
expect(d.permission).toBe('deny');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('trust allow downgrades confirm to auto', async () => {
|
|
85
|
+
const p = await newPolicy();
|
|
86
|
+
await p.trust({ tool: 'bash', pattern: 'pnpm *' });
|
|
87
|
+
const d = await p.evaluate(makeTool(), { command: 'pnpm install' });
|
|
88
|
+
expect(d.permission).toBe('auto');
|
|
89
|
+
expect(d.source).toBe('trust');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('session allowOnce downgrades confirm to auto for this session', async () => {
|
|
93
|
+
const p = await newPolicy();
|
|
94
|
+
p.allowOnce({ tool: 'bash', pattern: 'pnpm *' });
|
|
95
|
+
const d = await p.evaluate(makeTool(), { command: 'pnpm install' });
|
|
96
|
+
expect(d.permission).toBe('auto');
|
|
97
|
+
expect(d.source).toBe('user');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('YOLO downgrades confirm to auto for non-destructive tools', async () => {
|
|
101
|
+
const p = await newPolicy();
|
|
102
|
+
p.setYolo(true);
|
|
103
|
+
const tool = makeTool({ permission: 'confirm', riskTier: 'standard' });
|
|
104
|
+
const d = await p.evaluate(tool, { path: 'src/x.ts' });
|
|
105
|
+
expect(d.permission).toBe('auto');
|
|
106
|
+
expect(d.source).toBe('yolo');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('YOLO downgrades ALL confirms to auto, including destructive', async () => {
|
|
110
|
+
const p = await newPolicy();
|
|
111
|
+
p.setYolo(true);
|
|
112
|
+
const d = await p.evaluate(makeTool(), { command: 'rm -rf x' });
|
|
113
|
+
expect(d.permission).toBe('auto');
|
|
114
|
+
expect(d.source).toBe('yolo');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('YOLO never bypasses explicit deny rules', async () => {
|
|
118
|
+
const p = await newPolicy();
|
|
119
|
+
await p.deny({ tool: 'bash', pattern: 'rm *' });
|
|
120
|
+
p.setYolo(true);
|
|
121
|
+
const d = await p.evaluate(makeTool(), { command: 'rm -rf x' });
|
|
122
|
+
expect(d.permission).toBe('deny');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('dangerous-cap auto tool escalates to confirm when YOLO off', async () => {
|
|
126
|
+
const p = await newPolicy();
|
|
127
|
+
const tool = makeTool({ name: 'nettool', permission: 'auto', subjectKey: 'url', capabilities: ['net.outbound'], riskTier: 'safe' });
|
|
128
|
+
const d = await p.evaluate(tool, { url: 'https://x' });
|
|
129
|
+
expect(d.permission).toBe('confirm');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('persists trust rules to home trust.json and reloads', async () => {
|
|
133
|
+
const p = await newPolicy();
|
|
134
|
+
await p.trust({ tool: 'bash', pattern: 'git *' });
|
|
135
|
+
const raw = await fsp.readFile(path.join(homeDir, 'trust.json'), 'utf8');
|
|
136
|
+
const parsed = JSON.parse(raw);
|
|
137
|
+
expect(parsed.allow).toContainEqual({ tool: 'bash', pattern: 'git *' });
|
|
138
|
+
|
|
139
|
+
const p2 = new DefaultPermissionPolicy({ homeDir, projectRoot });
|
|
140
|
+
await p2.load();
|
|
141
|
+
const d = await p2.evaluate(makeTool(), { command: 'git status' });
|
|
142
|
+
expect(d.permission).toBe('auto');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('emits permission.persisted event', async () => {
|
|
146
|
+
const events = new EventBus();
|
|
147
|
+
const p = new DefaultPermissionPolicy({ homeDir, projectRoot, events });
|
|
148
|
+
await p.load();
|
|
149
|
+
const seen: unknown[] = [];
|
|
150
|
+
events.on('permission.persisted', (e) => seen.push(e));
|
|
151
|
+
await p.trust({ tool: 'bash', pattern: 'ls *' });
|
|
152
|
+
expect(seen).toEqual([{ action: 'allow', tool: 'bash', pattern: 'ls *', scope: 'trust' }]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('subject undefined (no subjectKey) only matches * rules', async () => {
|
|
156
|
+
const p = await newPolicy();
|
|
157
|
+
await p.trust({ tool: 'patch', pattern: '*' });
|
|
158
|
+
const tool = makeTool({ name: 'patch', permission: 'confirm', subjectKey: undefined, capabilities: undefined });
|
|
159
|
+
const d = await p.evaluate(tool, { diff: '...' });
|
|
160
|
+
expect(d.permission).toBe('auto');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('project-local trust file merges (cannot weaken home deny)', async () => {
|
|
164
|
+
await fsp.mkdir(path.join(projectRoot, '.flowcodex'), { recursive: true });
|
|
165
|
+
await fsp.writeFile(
|
|
166
|
+
path.join(projectRoot, '.flowcodex', 'trust.json'),
|
|
167
|
+
JSON.stringify({ allow: [{ tool: 'bash', pattern: 'rm *' }], deny: [], yolo: false }),
|
|
168
|
+
);
|
|
169
|
+
const p = await newPolicy();
|
|
170
|
+
await p.deny({ tool: 'bash', pattern: 'rm *' });
|
|
171
|
+
const d = await p.evaluate(makeTool(), { command: 'rm -rf x' });
|
|
172
|
+
expect(d.permission).toBe('deny');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Pipeline, type Middleware } from '../src/kernel/pipeline.js';
|
|
3
|
+
|
|
4
|
+
const mw = <T>(name: string, fn: (v: T) => T): Middleware<T> => ({
|
|
5
|
+
name,
|
|
6
|
+
handler: async (v, next) => next(fn(v)),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('Pipeline', () => {
|
|
10
|
+
describe('basic execution', () => {
|
|
11
|
+
it('runs an empty pipeline returning input', async () => {
|
|
12
|
+
const p = new Pipeline<number>();
|
|
13
|
+
expect(await p.run(42)).toBe(42);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('runs a single middleware', async () => {
|
|
17
|
+
const p = new Pipeline<number>();
|
|
18
|
+
p.use(mw('double', (v) => v * 2));
|
|
19
|
+
expect(await p.run(5)).toBe(10);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('runs middleware in order', async () => {
|
|
23
|
+
const p = new Pipeline<number>();
|
|
24
|
+
p.use(mw('add1', (v) => v + 1));
|
|
25
|
+
p.use(mw('mul2', (v) => v * 2));
|
|
26
|
+
expect(await p.run(3)).toBe(8);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('middleware can short-circuit by not calling next', async () => {
|
|
30
|
+
const p = new Pipeline<string>();
|
|
31
|
+
p.use({
|
|
32
|
+
name: 'stop',
|
|
33
|
+
handler: async () => 'stopped',
|
|
34
|
+
});
|
|
35
|
+
p.use(mw('never', () => 'should-not-run'));
|
|
36
|
+
expect(await p.run('input')).toBe('stopped');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('duplicate name', () => {
|
|
41
|
+
it('throws on duplicate name', () => {
|
|
42
|
+
const p = new Pipeline<number>();
|
|
43
|
+
p.use(mw('a', (v) => v));
|
|
44
|
+
expect(() => p.use(mw('a', (v) => v))).toThrow(/already registered/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('prepend', () => {
|
|
49
|
+
it('prepends middleware before existing', async () => {
|
|
50
|
+
const p = new Pipeline<number>();
|
|
51
|
+
p.use(mw('second', (v) => v + 100));
|
|
52
|
+
p.prepend(mw('first', (v) => v + 1));
|
|
53
|
+
expect(await p.run(0)).toBe(101);
|
|
54
|
+
expect(p.list()).toEqual(['first', 'second']);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('insertAt', () => {
|
|
59
|
+
it('inserts at a specific index', async () => {
|
|
60
|
+
const p = new Pipeline<number>();
|
|
61
|
+
p.use(mw('a', (v) => v + 1));
|
|
62
|
+
p.use(mw('c', (v) => v + 3));
|
|
63
|
+
p.insertAt(1, mw('b', (v) => v + 2));
|
|
64
|
+
expect(p.list()).toEqual(['a', 'b', 'c']);
|
|
65
|
+
expect(await p.run(0)).toBe(6);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('clamps out-of-range index', () => {
|
|
69
|
+
const p = new Pipeline<number>();
|
|
70
|
+
p.use(mw('a', (v) => v));
|
|
71
|
+
p.insertAt(999, mw('b', (v) => v));
|
|
72
|
+
expect(p.list()).toEqual(['a', 'b']);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('insertBefore / insertAfter', () => {
|
|
77
|
+
it('inserts before target', () => {
|
|
78
|
+
const p = new Pipeline<number>();
|
|
79
|
+
p.use(mw('a', (v) => v));
|
|
80
|
+
p.use(mw('c', (v) => v));
|
|
81
|
+
p.insertBefore('c', mw('b', (v) => v));
|
|
82
|
+
expect(p.list()).toEqual(['a', 'b', 'c']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('inserts after target', () => {
|
|
86
|
+
const p = new Pipeline<number>();
|
|
87
|
+
p.use(mw('a', (v) => v));
|
|
88
|
+
p.use(mw('c', (v) => v));
|
|
89
|
+
p.insertAfter('a', mw('b', (v) => v));
|
|
90
|
+
expect(p.list()).toEqual(['a', 'b', 'c']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('throws when target not found (non-optional)', () => {
|
|
94
|
+
const p = new Pipeline<number>();
|
|
95
|
+
expect(() => p.insertBefore('missing', mw('x', (v) => v))).toThrow(/not found/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('silently no-ops when optional and target missing', () => {
|
|
99
|
+
const p = new Pipeline<number>();
|
|
100
|
+
p.insertBefore('missing', mw('x', (v) => v), { optional: true });
|
|
101
|
+
expect(p.list()).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('replace', () => {
|
|
106
|
+
it('replaces a middleware by name', async () => {
|
|
107
|
+
const p = new Pipeline<number>();
|
|
108
|
+
p.use(mw('a', (v) => v + 1));
|
|
109
|
+
p.replace('a', mw('a', (v) => v + 100));
|
|
110
|
+
expect(await p.run(0)).toBe(100);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('remove', () => {
|
|
115
|
+
it('removes a middleware by name', () => {
|
|
116
|
+
const p = new Pipeline<number>();
|
|
117
|
+
p.use(mw('a', (v) => v));
|
|
118
|
+
p.use(mw('b', (v) => v));
|
|
119
|
+
p.remove('a');
|
|
120
|
+
expect(p.list()).toEqual(['b']);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('size', () => {
|
|
125
|
+
it('returns chain length', () => {
|
|
126
|
+
const p = new Pipeline<number>();
|
|
127
|
+
p.use(mw('a', (v) => v));
|
|
128
|
+
p.use(mw('b', (v) => v));
|
|
129
|
+
expect(p.size()).toBe(2);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('error handler', () => {
|
|
134
|
+
it('swallows error when handler returns swallow', async () => {
|
|
135
|
+
const p = new Pipeline<number>();
|
|
136
|
+
p.use(mw('safe', (v) => v + 1));
|
|
137
|
+
p.use({
|
|
138
|
+
name: 'crash',
|
|
139
|
+
handler: async () => { throw new Error('boom'); },
|
|
140
|
+
});
|
|
141
|
+
p.use(mw('after', (v) => v + 100));
|
|
142
|
+
p.setErrorHandler(() => 'swallow');
|
|
143
|
+
const result = await p.run(0);
|
|
144
|
+
expect(result).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rethrows when handler returns rethrow', async () => {
|
|
148
|
+
const p = new Pipeline<number>();
|
|
149
|
+
p.use({
|
|
150
|
+
name: 'crash',
|
|
151
|
+
handler: async () => { throw new Error('boom'); },
|
|
152
|
+
});
|
|
153
|
+
p.setErrorHandler(() => 'rethrow');
|
|
154
|
+
await expect(p.run(0)).rejects.toThrow('boom');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('error handler receives middleware name and error', async () => {
|
|
158
|
+
const p = new Pipeline<number>();
|
|
159
|
+
p.use({
|
|
160
|
+
name: 'crash',
|
|
161
|
+
handler: async () => { throw new Error('boom'); },
|
|
162
|
+
});
|
|
163
|
+
let receivedName = '';
|
|
164
|
+
let receivedErr: unknown = null;
|
|
165
|
+
p.setErrorHandler((ev) => {
|
|
166
|
+
receivedName = ev.middleware;
|
|
167
|
+
receivedErr = ev.err;
|
|
168
|
+
return 'swallow';
|
|
169
|
+
});
|
|
170
|
+
await p.run(0);
|
|
171
|
+
expect(receivedName).toBe('crash');
|
|
172
|
+
expect(receivedErr).toBeInstanceOf(Error);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('asReadonly', () => {
|
|
177
|
+
it('returns a frozen view with live chain', () => {
|
|
178
|
+
const p = new Pipeline<number>();
|
|
179
|
+
p.use(mw('a', (v) => v));
|
|
180
|
+
const ro = p.asReadonly();
|
|
181
|
+
p.use(mw('b', (v) => v));
|
|
182
|
+
expect(ro.size).toBe(2);
|
|
183
|
+
expect(ro.list()).toEqual(['a', 'b']);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('readonly run delegates to pipeline', async () => {
|
|
187
|
+
const p = new Pipeline<number>();
|
|
188
|
+
p.use(mw('double', (v) => v * 2));
|
|
189
|
+
const ro = p.asReadonly();
|
|
190
|
+
expect(await ro.run(5)).toBe(10);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|