@studio-foundation/runner 0.3.0-beta.1 → 0.3.0-beta.5

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 (78) hide show
  1. package/package.json +6 -3
  2. package/ARCHITECTURE.md +0 -53
  3. package/configs/agents/analyst.agent.yaml +0 -31
  4. package/configs/agents/code-generator.agent.yaml +0 -31
  5. package/configs/agents/generic.agent.yaml +0 -23
  6. package/src/__tests__/script-executor.test.ts +0 -180
  7. package/src/index.ts +0 -68
  8. package/src/integrations/integration-loader.test.ts +0 -88
  9. package/src/integrations/integration-loader.ts +0 -68
  10. package/src/middleware/anonymization.ts +0 -38
  11. package/src/plugins/index.ts +0 -4
  12. package/src/plugins/mcp-client.test.ts +0 -148
  13. package/src/plugins/mcp-client.ts +0 -128
  14. package/src/plugins/oauth-provider.test.ts +0 -167
  15. package/src/plugins/oauth-provider.ts +0 -175
  16. package/src/plugins/plugin-loader.test.ts +0 -114
  17. package/src/plugins/plugin-loader.ts +0 -90
  18. package/src/prompt-builder.test.ts +0 -167
  19. package/src/prompt-builder.ts +0 -332
  20. package/src/providers/anthropic.test.ts +0 -101
  21. package/src/providers/anthropic.ts +0 -135
  22. package/src/providers/mock.ts +0 -57
  23. package/src/providers/ollama.test.ts +0 -166
  24. package/src/providers/ollama.ts +0 -152
  25. package/src/providers/openai-responses.ts +0 -212
  26. package/src/providers/openai.test.ts +0 -67
  27. package/src/providers/openai.ts +0 -139
  28. package/src/providers/provider.ts +0 -54
  29. package/src/providers/registry.ts +0 -77
  30. package/src/runner.test.ts +0 -343
  31. package/src/runner.ts +0 -396
  32. package/src/script-executor.ts +0 -107
  33. package/src/tools/builtin/git.ts +0 -311
  34. package/src/tools/builtin/patch.ts +0 -257
  35. package/src/tools/builtin/repo-manager.ts +0 -142
  36. package/src/tools/builtin/search.ts +0 -108
  37. package/src/tools/builtin/shell.ts +0 -82
  38. package/src/tools/builtin/studio-run.ts +0 -73
  39. package/src/tools/builtin/web-search.test.ts +0 -122
  40. package/src/tools/builtin/web-search.ts +0 -101
  41. package/src/tools/errors.test.ts +0 -12
  42. package/src/tools/errors.ts +0 -6
  43. package/src/tools/plugin-loader.test.ts +0 -130
  44. package/src/tools/plugin-loader.ts +0 -203
  45. package/src/tools/skills/README.md +0 -49
  46. package/src/tools/skills/skill-loader.test.ts +0 -106
  47. package/src/tools/skills/skill-loader.ts +0 -62
  48. package/src/tools/tool-executor.test.ts +0 -88
  49. package/src/tools/tool-executor.ts +0 -84
  50. package/src/tools/tool-registry.ts +0 -130
  51. package/src/tools/yaml-executor.ts +0 -120
  52. package/src/utils/race-signal.test.ts +0 -50
  53. package/src/utils/race-signal.ts +0 -17
  54. package/templates/integrations/linear.integration.yaml +0 -35
  55. package/templates/integrations/slack.integration.yaml +0 -22
  56. package/templates/integrations/webhook.integration.yaml +0 -17
  57. package/templates/tools/git.tool.yaml +0 -80
  58. package/templates/tools/repo-manager.tool.yaml +0 -64
  59. package/templates/tools/search.tool.yaml +0 -22
  60. package/templates/tools/shell.tool.yaml +0 -19
  61. package/templates/tools/web-search.tool.yaml +0 -24
  62. package/tests/anonymization-middleware.test.ts +0 -61
  63. package/tests/anthropic.test.ts +0 -87
  64. package/tests/apply-patch.test.ts +0 -355
  65. package/tests/fixtures/tools/test-builtin.tool.yaml +0 -14
  66. package/tests/fixtures/tools/test-shell.tool.yaml +0 -19
  67. package/tests/mock-provider.test.ts +0 -104
  68. package/tests/openai.test.ts +0 -72
  69. package/tests/plugin-loader.test.ts +0 -54
  70. package/tests/prompt-builder.test.ts +0 -468
  71. package/tests/runner-anonymization.test.ts +0 -89
  72. package/tests/runner.test.ts +0 -885
  73. package/tests/studio-run.test.ts +0 -94
  74. package/tests/tool-executor.test.ts +0 -115
  75. package/tests/tool-registry.test.ts +0 -84
  76. package/tests/yaml-executor.test.ts +0 -76
  77. package/tsconfig.json +0 -20
  78. package/vitest.config.ts +0 -7
@@ -1,130 +0,0 @@
1
- /**
2
- * Tool registry - manages tool definitions and execution
3
- */
4
-
5
- import { ToolDefinition } from '@studio-foundation/contracts';
6
-
7
- /** Normalize tool name: dots → hyphens so both conventions work */
8
- export function normalizeToolName(name: string): string {
9
- return name.replace(/\./g, '-');
10
- }
11
-
12
- export interface ToolResult {
13
- success: boolean;
14
- output: unknown;
15
- error?: string;
16
- }
17
-
18
- export interface Tool {
19
- name: string;
20
- description: string;
21
- parameters: Record<string, unknown>;
22
- execute(args: Record<string, unknown>): Promise<ToolResult>;
23
- }
24
-
25
- export class ToolRegistry {
26
- private tools: Map<string, Tool> = new Map();
27
- private toolToPlugin: Map<string, string> = new Map(); // normalized name → plugin name
28
- private pluginSnippets: Map<string, string> = new Map(); // plugin name → snippet
29
-
30
- /**
31
- * Register a single tool (no plugin metadata).
32
- */
33
- register(tool: Tool): void {
34
- this.tools.set(tool.name, tool);
35
- }
36
-
37
- /**
38
- * Register all tools belonging to a plugin.
39
- * If promptSnippet is provided it will be returned by getActiveSnippets()
40
- * whenever any tool from this plugin is in the registry.
41
- */
42
- registerPlugin(pluginName: string, tools: Tool[], promptSnippet?: string): void {
43
- for (const tool of tools) {
44
- this.register(tool);
45
- this.toolToPlugin.set(normalizeToolName(tool.name), pluginName);
46
- }
47
- if (promptSnippet) {
48
- this.pluginSnippets.set(pluginName, promptSnippet);
49
- }
50
- }
51
-
52
- /**
53
- * Get tool by name
54
- */
55
- get(name: string): Tool | undefined {
56
- return this.tools.get(name);
57
- }
58
-
59
- /**
60
- * Check if tool exists
61
- */
62
- has(name: string): boolean {
63
- return this.tools.has(name);
64
- }
65
-
66
- /**
67
- * List all tools
68
- */
69
- list(): Tool[] {
70
- return Array.from(this.tools.values());
71
- }
72
-
73
- /**
74
- * Convert tools to LLM tool definitions format
75
- */
76
- toToolDefinitions(): ToolDefinition[] {
77
- return this.list().map(tool => ({
78
- name: tool.name,
79
- description: tool.description,
80
- parameters: tool.parameters
81
- }));
82
- }
83
-
84
- /**
85
- * Return prompt snippets for all plugins that have at least one tool
86
- * currently in this registry.
87
- */
88
- getActiveSnippets(): string[] {
89
- const activePlugins = new Set<string>();
90
- for (const toolName of this.tools.keys()) {
91
- const plugin = this.toolToPlugin.get(normalizeToolName(toolName));
92
- if (plugin) activePlugins.add(plugin);
93
- }
94
- return Array.from(activePlugins)
95
- .map(p => this.pluginSnippets.get(p))
96
- .filter((s): s is string => s !== undefined);
97
- }
98
-
99
- /**
100
- * Create a new registry filtered to specific tool names.
101
- * Normalizes dots to hyphens so both "repo_manager.write_file"
102
- * and "repo_manager-write_file" match the registered name.
103
- * Plugin snippet metadata is carried over for included tools.
104
- */
105
- filter(allowedTools: string[]): ToolRegistry {
106
- const filtered = new ToolRegistry();
107
- for (const toolName of allowedTools) {
108
- const tool = this.tools.get(toolName)
109
- ?? this.tools.get(normalizeToolName(toolName));
110
- if (tool) {
111
- filtered.register(tool);
112
- // Carry over plugin metadata so getActiveSnippets() works on filtered registry
113
- const pluginName = this.toolToPlugin.get(normalizeToolName(tool.name));
114
- if (pluginName) {
115
- filtered.toolToPlugin.set(normalizeToolName(tool.name), pluginName);
116
- const snippet = this.pluginSnippets.get(pluginName);
117
- if (snippet) filtered.pluginSnippets.set(pluginName, snippet);
118
- }
119
- }
120
- }
121
- return filtered;
122
- }
123
-
124
- /**
125
- * Create a full copy of this registry (all tools + plugin metadata).
126
- */
127
- clone(): ToolRegistry {
128
- return this.filter(Array.from(this.tools.keys()));
129
- }
130
- }
@@ -1,120 +0,0 @@
1
- // runner/src/tools/yaml-executor.ts
2
- import { execFile } from 'node:child_process';
3
- import { promisify } from 'node:util';
4
- import type { ParseOutputFormat } from '@studio-foundation/contracts';
5
-
6
- const execFileAsync = promisify(execFile);
7
-
8
- /**
9
- * Render a shell command template with parameter substitution.
10
- *
11
- * Supports:
12
- * {{param}} → stringify value (empty string if undefined)
13
- * {{#if param}}...{{/if}} → include block only when param is truthy
14
- * {{#if param}}...{{else}}...{{/if}} → if/else block
15
- * {{param | join 'sep'}} → join array with separator
16
- * {{param | json}} → JSON.stringify(value)
17
- */
18
- export function renderTemplate(
19
- template: string,
20
- params: Record<string, unknown>
21
- ): string {
22
- let result = template;
23
-
24
- // {{#if param}}...{{else}}...{{/if}} blocks (with else)
25
- result = result.replace(
26
- /\{\{#if (\w+)\}\}([\s\S]*?)\{\{else\}\}([\s\S]*?)\{\{\/if\}\}/g,
27
- (_, key: string, trueBranch: string, falseBranch: string) =>
28
- params[key] ? trueBranch : falseBranch
29
- );
30
-
31
- // {{#if param}}...{{/if}} blocks (without else)
32
- result = result.replace(
33
- /\{\{#if (\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
34
- (_, key: string, inner: string) => (params[key] ? inner : '')
35
- );
36
-
37
- // {{param | join 'sep'}} filter
38
- result = result.replace(
39
- /\{\{(\w+)\s*\|\s*join\s+'([^']*)'\}\}/g,
40
- (_, key: string, sep: string) => {
41
- const value = params[key];
42
- return Array.isArray(value) ? value.join(sep) : String(value ?? '');
43
- }
44
- );
45
-
46
- // {{param | join "sep"}} filter (double quotes variant)
47
- result = result.replace(
48
- /\{\{(\w+)\s*\|\s*join\s+"([^"]*)"\}\}/g,
49
- (_, key: string, sep: string) => {
50
- const value = params[key];
51
- return Array.isArray(value) ? value.join(sep) : String(value ?? '');
52
- }
53
- );
54
-
55
- // {{param | json}} filter
56
- result = result.replace(
57
- /\{\{(\w+)\s*\|\s*json\}\}/g,
58
- (_, key: string) => JSON.stringify(params[key] ?? null)
59
- );
60
-
61
- // Plain {{param}} substitution
62
- result = result.replace(
63
- /\{\{(\w+)\}\}/g,
64
- (_, key: string) => (params[key] === undefined ? '' : String(params[key]))
65
- );
66
-
67
- return result;
68
- }
69
-
70
- export interface ShellResult {
71
- success: boolean;
72
- output: unknown;
73
- error?: string;
74
- }
75
-
76
- /**
77
- * Execute a rendered shell command and parse the output.
78
- */
79
- export async function executeShellCommand(
80
- command: string,
81
- parseOutput: ParseOutputFormat = 'text',
82
- workingDir: string,
83
- timeoutMs: number = 30_000,
84
- env?: Record<string, string>
85
- ): Promise<ShellResult> {
86
- try {
87
- const { stdout } = await execFileAsync('sh', ['-c', command], {
88
- cwd: workingDir,
89
- timeout: timeoutMs,
90
- maxBuffer: 10 * 1024 * 1024,
91
- ...(env ? { env: { ...process.env, ...env } } : {}),
92
- });
93
-
94
- const raw = stdout.trim();
95
-
96
- if (parseOutput === 'json') {
97
- try {
98
- return { success: true, output: JSON.parse(raw) };
99
- } catch {
100
- return {
101
- success: false,
102
- output: undefined,
103
- error: `Failed to parse JSON output: ${raw.slice(0, 200)}`,
104
- };
105
- }
106
- }
107
-
108
- return { success: true, output: raw };
109
- } catch (err: unknown) {
110
- const e = err as { message?: string; stderr?: string; stdout?: string };
111
- // When claude -p exits non-zero, the JSON result is still in stdout.
112
- // Try to parse it so the runner gets structured error info instead of "Command failed".
113
- if (parseOutput === 'json' && e.stdout?.trim()) {
114
- try {
115
- return { success: false, output: JSON.parse(e.stdout.trim()), error: e.stderr?.trim() || undefined };
116
- } catch { /* fall through to default error */ }
117
- }
118
- return { success: false, output: undefined, error: e.stderr?.trim() || e.message || 'Command failed' };
119
- }
120
- }
@@ -1,50 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { raceSignal } from './race-signal.js';
3
-
4
- describe('raceSignal', () => {
5
- it('resolves normally when no signal provided', async () => {
6
- const result = await raceSignal(Promise.resolve(42));
7
- expect(result).toBe(42);
8
- });
9
-
10
- it('resolves normally when signal is not aborted', async () => {
11
- const controller = new AbortController();
12
- const result = await raceSignal(Promise.resolve('hello'), controller.signal);
13
- expect(result).toBe('hello');
14
- });
15
-
16
- it('rejects immediately if signal is already aborted', async () => {
17
- const controller = new AbortController();
18
- controller.abort();
19
- await expect(raceSignal(new Promise(() => {}), controller.signal))
20
- .rejects.toThrow('Aborted');
21
- });
22
-
23
- it('rejects when signal fires after creation', async () => {
24
- const controller = new AbortController();
25
- // A promise that never resolves on its own
26
- const hanging = new Promise<never>(() => {});
27
- const raced = raceSignal(hanging, controller.signal);
28
- controller.abort();
29
- await expect(raced).rejects.toThrow('Aborted');
30
- });
31
-
32
- it('resolves if promise resolves before signal fires', async () => {
33
- const controller = new AbortController();
34
- const result = await raceSignal(Promise.resolve(99), controller.signal);
35
- // Abort after the fact — should not cause rejection
36
- controller.abort();
37
- expect(result).toBe(99);
38
- });
39
-
40
- it('thrown error name is AbortError', async () => {
41
- const controller = new AbortController();
42
- controller.abort();
43
- try {
44
- await raceSignal(new Promise(() => {}), controller.signal);
45
- } catch (e) {
46
- expect(e).toBeInstanceOf(DOMException);
47
- expect((e as DOMException).name).toBe('AbortError');
48
- }
49
- });
50
- });
@@ -1,17 +0,0 @@
1
- /**
2
- * Race a promise against an AbortSignal.
3
- * Rejects with DOMException('Aborted', 'AbortError') if the signal fires first.
4
- */
5
- export function raceSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
6
- if (!signal) return promise;
7
- if (signal.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
8
-
9
- return new Promise((resolve, reject) => {
10
- const onAbort = () => reject(new DOMException('Aborted', 'AbortError'));
11
- signal.addEventListener('abort', onAbort, { once: true });
12
-
13
- promise
14
- .then((v) => { signal.removeEventListener('abort', onAbort); resolve(v); })
15
- .catch((e) => { signal.removeEventListener('abort', onAbort); reject(e); });
16
- });
17
- }
@@ -1,35 +0,0 @@
1
- name: linear
2
- version: 1
3
- description: "Linear webhook trigger + issue status sync"
4
-
5
- config:
6
- required:
7
- - LINEAR_API_KEY
8
- - LINEAR_WEBHOOK_SECRET
9
- optional:
10
- autoTrigger: false
11
-
12
- webhook:
13
- hmac:
14
- header: linear-signature
15
- secret_env: LINEAR_WEBHOOK_SECRET
16
- handler: linear-webhook
17
-
18
- on_failure:
19
- handler: linear-failure
20
-
21
- events:
22
- consumes:
23
- - linear.issue.in_progress
24
- emits:
25
- - pipeline.complete
26
- - pipeline.failed
27
-
28
- test:
29
- type: http
30
- endpoint: https://api.linear.app/graphql
31
- method: POST
32
- auth: bearer:${LINEAR_API_KEY}
33
- body: '{"query":"{ viewer { id name } }"}'
34
- expect:
35
- status: 200
@@ -1,22 +0,0 @@
1
- name: slack
2
- version: 1
3
- description: "Slack notifications for pipeline events"
4
-
5
- config:
6
- required:
7
- - SLACK_BOT_TOKEN
8
- optional:
9
- channel: "#studio-runs"
10
-
11
- events:
12
- emits:
13
- - pipeline.complete
14
- - pipeline.failed
15
-
16
- test:
17
- type: http
18
- endpoint: https://slack.com/api/auth.test
19
- method: POST
20
- auth: bearer:${SLACK_BOT_TOKEN}
21
- expect:
22
- status: 200
@@ -1,17 +0,0 @@
1
- name: webhook
2
- version: 1
3
- description: "Generic HTTP webhook notifications for pipeline events"
4
-
5
- config:
6
- optional:
7
- url: ""
8
- events: "pipeline.complete,pipeline.failed"
9
- secret: ""
10
-
11
- events:
12
- emits:
13
- - pipeline.complete
14
- - pipeline.failed
15
- - pipeline.start
16
- - stage.complete
17
- - stage.failed
@@ -1,80 +0,0 @@
1
- name: git
2
- description: Git version control operations
3
- version: 1
4
-
5
- commands:
6
- - name: git-status
7
- description: Show working tree status
8
- parameters: {}
9
- execute:
10
- type: shell
11
- command: git status --porcelain
12
- parse_output: text
13
-
14
- - name: git-diff
15
- description: Show changes in the working tree
16
- parameters:
17
- staged:
18
- type: boolean
19
- required: false
20
- description: Show staged changes instead of unstaged
21
- file:
22
- type: string
23
- required: false
24
- description: Restrict diff to this file path
25
- execute:
26
- type: shell
27
- command: |
28
- git diff {{#if staged}}--cached{{/if}} {{#if file}}{{file}}{{/if}}
29
- parse_output: text
30
-
31
- - name: git-checkout
32
- description: Checkout an existing branch or create a new one
33
- parameters:
34
- branch:
35
- type: string
36
- required: true
37
- description: Branch name to checkout or create
38
- create:
39
- type: boolean
40
- required: false
41
- description: Create the branch if it does not exist
42
- execute:
43
- type: shell
44
- command: |
45
- git checkout {{#if create}}-b{{/if}} {{branch}}
46
- parse_output: text
47
-
48
- - name: git-commit
49
- description: Stage all changes and commit with a message
50
- parameters:
51
- message:
52
- type: string
53
- required: true
54
- description: Commit message (use conventional commits format)
55
- execute:
56
- type: shell
57
- command: |
58
- git add -A && git commit -m "{{message}}"
59
- parse_output: text
60
-
61
- - name: git-push
62
- description: Push the current branch to origin
63
- parameters:
64
- set_upstream:
65
- type: boolean
66
- required: false
67
- description: Set upstream tracking reference (-u flag)
68
- execute:
69
- type: shell
70
- command: |
71
- git push {{#if set_upstream}}-u{{/if}} origin HEAD
72
- parse_output: text
73
-
74
- prompt_snippet: |
75
- You have access to git tools. Always create a feature branch before making changes.
76
- Never commit directly to main or master.
77
- Use conventional commit messages: <type>(<scope>): <description>
78
-
79
- constraints:
80
- requires_binaries: [git]
@@ -1,64 +0,0 @@
1
- name: repo_manager
2
- description: Read and write files in the workspace
3
- version: 1
4
-
5
- commands:
6
- - name: repo_manager-read_file
7
- description: Read a file from the workspace
8
- parameters:
9
- path:
10
- type: string
11
- required: true
12
- description: File path relative to the workspace root
13
- execute:
14
- type: builtin
15
- parse_output: json
16
-
17
- - name: repo_manager-write_file
18
- description: Write or create a file in the workspace
19
- parameters:
20
- path:
21
- type: string
22
- required: true
23
- description: File path relative to the workspace root
24
- content:
25
- type: string
26
- required: true
27
- description: Full content to write to the file
28
- execute:
29
- type: builtin
30
- parse_output: json
31
-
32
- - name: repo_manager-list_files
33
- description: List files in the workspace
34
- parameters:
35
- path:
36
- type: string
37
- required: false
38
- description: Directory to list (default is workspace root)
39
- recursive:
40
- type: boolean
41
- required: false
42
- description: Whether to list recursively
43
- execute:
44
- type: builtin
45
- parse_output: json
46
-
47
- - name: repo_manager-apply_patch
48
- description: Apply a unified diff patch to a file
49
- parameters:
50
- path:
51
- type: string
52
- required: true
53
- description: File path to patch
54
- patch:
55
- type: string
56
- required: true
57
- description: Unified diff patch content
58
- execute:
59
- type: builtin
60
- parse_output: json
61
-
62
- prompt_snippet: |
63
- You have access to file management tools. Read files before modifying them.
64
- Use repo_manager-write_file to create or update files — provide the full file content.
@@ -1,22 +0,0 @@
1
- name: search
2
- description: Search the codebase by content or file pattern
3
- version: 1
4
-
5
- commands:
6
- - name: search-search_codebase
7
- description: Search files by content pattern (uses ripgrep)
8
- parameters:
9
- pattern:
10
- type: string
11
- required: true
12
- description: Regex or literal pattern to search for
13
- file_pattern:
14
- type: string
15
- required: false
16
- description: Glob pattern to restrict which files are searched (e.g. "*.ts")
17
- execute:
18
- type: builtin
19
- parse_output: json
20
-
21
- prompt_snippet: |
22
- You have access to a codebase search tool. Use it to find relevant code before making changes.
@@ -1,19 +0,0 @@
1
- name: shell
2
- description: Execute shell commands in the workspace
3
- version: 1
4
-
5
- commands:
6
- - name: shell-run_command
7
- description: Run a shell command in the workspace directory
8
- parameters:
9
- command:
10
- type: string
11
- required: true
12
- description: Shell command to execute
13
- execute:
14
- type: builtin
15
- parse_output: text
16
-
17
- prompt_snippet: |
18
- You have access to a shell tool. Use it to run build, test, or inspection commands.
19
- Avoid destructive commands. Prefer targeted commands over broad ones.
@@ -1,24 +0,0 @@
1
- name: web-search
2
- description: Search the web using Tavily (requires TAVILY_API_KEY)
3
- version: 1
4
-
5
- commands:
6
- - name: web_search-search
7
- description: Search the web and return relevant results
8
- parameters:
9
- query:
10
- type: string
11
- required: true
12
- description: The search query
13
- max_results:
14
- type: number
15
- required: false
16
- description: "Maximum number of results to return (default: 5)"
17
- execute:
18
- type: builtin
19
- parse_output: json
20
-
21
- prompt_snippet: |
22
- You have access to a web search tool. Use it to find up-to-date information from the web.
23
- Search in the language most relevant to the query (English for technical docs, French for local content).
24
- Prefer specific, targeted queries over broad ones for better results.
@@ -1,61 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { AnonymizationMiddleware } from '../src/middleware/anonymization.js';
3
-
4
- describe('AnonymizationMiddleware', () => {
5
- it('anonymizes text containing PII', () => {
6
- const mw = new AnonymizationMiddleware();
7
- const result = mw.anonymize('Contact mc@acme.com');
8
- expect(result).not.toContain('mc@acme.com');
9
- expect(result).toContain('EMAIL_1');
10
- });
11
-
12
- it('deanonymizes using accumulated keymap', () => {
13
- const mw = new AnonymizationMiddleware();
14
- const anon = mw.anonymize('Contact mc@acme.com');
15
- const restored = mw.deanonymize(anon);
16
- expect(restored).toBe('Contact mc@acme.com');
17
- });
18
-
19
- it('is consistent across multiple calls (same email = same token)', () => {
20
- const mw = new AnonymizationMiddleware();
21
- const first = mw.anonymize('Email mc@acme.com here');
22
- const second = mw.anonymize('Also mc@acme.com again');
23
- expect(first).toContain('EMAIL_1');
24
- expect(second).toContain('EMAIL_1');
25
- // Verify deanonymize restores both
26
- expect(mw.deanonymize(first)).toBe('Email mc@acme.com here');
27
- expect(mw.deanonymize(second)).toBe('Also mc@acme.com again');
28
- });
29
-
30
- it('accumulates keymap across calls', () => {
31
- const mw = new AnonymizationMiddleware();
32
- mw.anonymize('Email mc@acme.com');
33
- mw.anonymize('Phone 514-555-1234');
34
- const keymap = mw.getKeymap();
35
- expect(keymap['EMAIL_1']).toBe('mc@acme.com');
36
- expect(keymap['PHONE_1']).toBe('514-555-1234');
37
- });
38
-
39
- it('handles JSON round-trip', () => {
40
- const mw = new AnonymizationMiddleware();
41
- const obj = { email: 'mc@acme.com', message: 'hello' };
42
- const anonStr = mw.anonymize(JSON.stringify(obj));
43
- const restored = JSON.parse(mw.deanonymize(anonStr));
44
- expect(restored.email).toBe('mc@acme.com');
45
- });
46
-
47
- it('passes through text with no PII unchanged', () => {
48
- const mw = new AnonymizationMiddleware();
49
- const text = 'Calculate 2 + 2 = 4';
50
- expect(mw.anonymize(text)).toBe(text);
51
- expect(mw.deanonymize(text)).toBe(text);
52
- });
53
-
54
- it('new email in second call gets EMAIL_2 (not EMAIL_1 again)', () => {
55
- const mw = new AnonymizationMiddleware();
56
- mw.anonymize('Email mc@acme.com');
57
- const second = mw.anonymize('Other other@example.com');
58
- expect(second).toContain('EMAIL_2');
59
- expect(mw.getKeymap()['EMAIL_2']).toBe('other@example.com');
60
- });
61
- });