angular-grab 0.1.1 → 0.1.3

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 (138) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.playwright-mcp/console-2026-03-07T02-49-38-061Z.log +37 -0
  3. package/.playwright-mcp/console-2026-03-07T02-51-03-493Z.log +26 -0
  4. package/.playwright-mcp/console-2026-03-07T02-51-25-431Z.log +15 -0
  5. package/.playwright-mcp/console-2026-03-07T02-52-02-980Z.log +199 -0
  6. package/.playwright-mcp/page-2026-03-07T02-52-09-791Z.png +0 -0
  7. package/README.md +215 -0
  8. package/examples/angular-19-app/.editorconfig +17 -0
  9. package/examples/angular-19-app/.vscode/extensions.json +4 -0
  10. package/examples/angular-19-app/.vscode/launch.json +20 -0
  11. package/examples/angular-19-app/.vscode/mcp.json +9 -0
  12. package/examples/angular-19-app/.vscode/tasks.json +42 -0
  13. package/examples/angular-19-app/README.md +59 -0
  14. package/examples/angular-19-app/angular.json +79 -0
  15. package/examples/angular-19-app/package.json +42 -0
  16. package/examples/angular-19-app/public/favicon.ico +0 -0
  17. package/examples/angular-19-app/src/app/app.config.ts +13 -0
  18. package/examples/angular-19-app/src/app/app.css +37 -0
  19. package/examples/angular-19-app/src/app/app.html +25 -0
  20. package/examples/angular-19-app/src/app/app.routes.ts +3 -0
  21. package/examples/angular-19-app/src/app/app.spec.ts +23 -0
  22. package/examples/angular-19-app/src/app/app.ts +12 -0
  23. package/examples/angular-19-app/src/app/button/button.component.ts +25 -0
  24. package/examples/angular-19-app/src/app/card/card.component.ts +33 -0
  25. package/examples/angular-19-app/src/app/header/header.component.ts +31 -0
  26. package/examples/angular-19-app/src/app/popover/popover.component.ts +133 -0
  27. package/examples/angular-19-app/src/index.html +13 -0
  28. package/examples/angular-19-app/src/main.ts +6 -0
  29. package/examples/angular-19-app/src/styles.css +1 -0
  30. package/examples/angular-19-app/tsconfig.app.json +15 -0
  31. package/examples/angular-19-app/tsconfig.json +33 -0
  32. package/examples/angular-19-app/tsconfig.spec.json +15 -0
  33. package/package.json +14 -111
  34. package/packages/angular-grab/package.json +96 -0
  35. package/packages/angular-grab/src/angular/__tests__/context-builder.test.ts +216 -0
  36. package/packages/angular-grab/src/angular/angular-grab.service.ts +62 -0
  37. package/packages/angular-grab/src/angular/index.ts +13 -0
  38. package/packages/angular-grab/src/angular/provide-angular-grab.ts +22 -0
  39. package/packages/angular-grab/src/angular/resolvers/component-resolver.ts +71 -0
  40. package/packages/angular-grab/src/angular/resolvers/context-builder.ts +86 -0
  41. package/packages/angular-grab/src/angular/resolvers/ng-utils.ts +14 -0
  42. package/packages/angular-grab/src/angular/resolvers/source-resolver.ts +61 -0
  43. package/packages/angular-grab/src/builder/__tests__/builder.test.ts +72 -0
  44. package/packages/angular-grab/src/builder/builders/application/index.ts +13 -0
  45. package/packages/angular-grab/src/builder/builders/dev-server/index.ts +9 -0
  46. package/packages/angular-grab/src/builder/index.ts +3 -0
  47. package/packages/angular-grab/src/cli/__tests__/cli.test.ts +239 -0
  48. package/packages/angular-grab/src/cli/commands/init.ts +106 -0
  49. package/packages/angular-grab/src/cli/index.ts +15 -0
  50. package/packages/angular-grab/src/cli/utils/detect-project.ts +78 -0
  51. package/packages/angular-grab/src/cli/utils/modify-angular-json.ts +42 -0
  52. package/packages/angular-grab/src/cli/utils/modify-app-config.ts +42 -0
  53. package/packages/angular-grab/src/core/__tests__/generate-snippet.test.ts +149 -0
  54. package/packages/angular-grab/src/core/__tests__/plugin-registry.test.ts +286 -0
  55. package/packages/angular-grab/src/core/__tests__/store.test.ts +118 -0
  56. package/packages/angular-grab/src/core/__tests__/utils.test.ts +85 -0
  57. package/packages/angular-grab/src/core/clipboard/copy.ts +104 -0
  58. package/packages/angular-grab/src/core/clipboard/generate-snippet.ts +38 -0
  59. package/packages/angular-grab/src/core/constants.ts +10 -0
  60. package/packages/angular-grab/src/core/grab.ts +596 -0
  61. package/packages/angular-grab/src/core/index.global.ts +13 -0
  62. package/packages/angular-grab/src/core/index.ts +19 -0
  63. package/packages/angular-grab/src/core/keyboard/keyboard-handler.ts +163 -0
  64. package/packages/angular-grab/src/core/overlay/crosshair.ts +107 -0
  65. package/packages/angular-grab/src/core/overlay/freeze-overlay.ts +239 -0
  66. package/packages/angular-grab/src/core/overlay/overlay-renderer.ts +180 -0
  67. package/packages/angular-grab/src/core/overlay/select-feedback.ts +108 -0
  68. package/packages/angular-grab/src/core/overlay/toast.ts +175 -0
  69. package/packages/angular-grab/src/core/picker/element-picker.ts +114 -0
  70. package/packages/angular-grab/src/core/plugins/plugin-registry.ts +83 -0
  71. package/packages/angular-grab/src/core/store.ts +52 -0
  72. package/packages/angular-grab/src/core/toolbar/actions-menu.ts +178 -0
  73. package/packages/angular-grab/src/core/toolbar/comment-popover.ts +235 -0
  74. package/packages/angular-grab/src/core/toolbar/copy-actions.ts +98 -0
  75. package/packages/angular-grab/src/core/toolbar/history-popover.ts +245 -0
  76. package/packages/angular-grab/src/core/toolbar/theme-manager.ts +188 -0
  77. package/packages/angular-grab/src/core/toolbar/toolbar-icons.ts +29 -0
  78. package/packages/angular-grab/src/core/toolbar/toolbar-renderer.ts +239 -0
  79. package/packages/angular-grab/src/core/types.ts +139 -0
  80. package/packages/angular-grab/src/core/utils.ts +16 -0
  81. package/packages/angular-grab/src/esbuild-plugin/__tests__/transform.test.ts +174 -0
  82. package/packages/angular-grab/src/esbuild-plugin/index.ts +3 -0
  83. package/packages/angular-grab/src/esbuild-plugin/plugin.ts +29 -0
  84. package/packages/angular-grab/src/esbuild-plugin/scan.ts +105 -0
  85. package/packages/angular-grab/src/esbuild-plugin/transform.ts +152 -0
  86. package/packages/angular-grab/src/vite-plugin/__tests__/plugin.test.ts +84 -0
  87. package/packages/angular-grab/src/vite-plugin/index.ts +19 -0
  88. package/packages/angular-grab/src/webpack-plugin/__tests__/plugin.test.ts +72 -0
  89. package/packages/angular-grab/src/webpack-plugin/index.ts +2 -0
  90. package/packages/angular-grab/src/webpack-plugin/loader.ts +15 -0
  91. package/packages/angular-grab/src/webpack-plugin/plugin.ts +20 -0
  92. package/packages/angular-grab/tsconfig.json +15 -0
  93. package/packages/angular-grab/tsup.config.ts +119 -0
  94. package/pnpm-workspace.yaml +3 -0
  95. package/turbo.json +21 -0
  96. package/dist/angular/index.d.ts +0 -151
  97. package/dist/angular/index.js +0 -2811
  98. package/dist/angular/index.js.map +0 -1
  99. package/dist/builder/builders/application/index.js +0 -143
  100. package/dist/builder/builders/application/index.js.map +0 -1
  101. package/dist/builder/builders/dev-server/index.js +0 -139
  102. package/dist/builder/builders/dev-server/index.js.map +0 -1
  103. package/dist/builder/index.js +0 -2
  104. package/dist/builder/index.js.map +0 -1
  105. package/dist/builder/package.json +0 -1
  106. package/dist/cli/index.js +0 -223
  107. package/dist/cli/index.js.map +0 -1
  108. package/dist/core/index.cjs +0 -2589
  109. package/dist/core/index.cjs.map +0 -1
  110. package/dist/core/index.d.cts +0 -139
  111. package/dist/core/index.d.ts +0 -139
  112. package/dist/core/index.global.js +0 -542
  113. package/dist/core/index.js +0 -2560
  114. package/dist/core/index.js.map +0 -1
  115. package/dist/esbuild-plugin/index.cjs +0 -239
  116. package/dist/esbuild-plugin/index.cjs.map +0 -1
  117. package/dist/esbuild-plugin/index.d.cts +0 -26
  118. package/dist/esbuild-plugin/index.d.ts +0 -26
  119. package/dist/esbuild-plugin/index.js +0 -200
  120. package/dist/esbuild-plugin/index.js.map +0 -1
  121. package/dist/vite-plugin/index.d.ts +0 -7
  122. package/dist/vite-plugin/index.js +0 -128
  123. package/dist/vite-plugin/index.js.map +0 -1
  124. package/dist/webpack-plugin/index.cjs +0 -54
  125. package/dist/webpack-plugin/index.cjs.map +0 -1
  126. package/dist/webpack-plugin/index.d.cts +0 -5
  127. package/dist/webpack-plugin/index.d.ts +0 -5
  128. package/dist/webpack-plugin/index.js +0 -23
  129. package/dist/webpack-plugin/index.js.map +0 -1
  130. package/dist/webpack-plugin/loader.cjs +0 -155
  131. package/dist/webpack-plugin/loader.cjs.map +0 -1
  132. package/dist/webpack-plugin/loader.d.cts +0 -3
  133. package/dist/webpack-plugin/loader.d.ts +0 -3
  134. package/dist/webpack-plugin/loader.js +0 -122
  135. package/dist/webpack-plugin/loader.js.map +0 -1
  136. /package/{builders.json → packages/angular-grab/builders.json} +0 -0
  137. /package/{dist → packages/angular-grab/src}/builder/builders/application/schema.json +0 -0
  138. /package/{dist → packages/angular-grab/src}/builder/builders/dev-server/schema.json +0 -0
@@ -0,0 +1,78 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+
4
+ export interface ProjectInfo {
5
+ angularJsonPath: string;
6
+ projectRoot: string;
7
+ projectName: string;
8
+ sourceRoot: string;
9
+ builderType: 'application' | 'browser-esbuild' | 'browser';
10
+ packageManager: 'pnpm' | 'yarn' | 'npm';
11
+ }
12
+
13
+ export function findAngularJson(startDir: string = process.cwd()): string | null {
14
+ let dir = startDir;
15
+ while (true) {
16
+ const candidate = join(dir, 'angular.json');
17
+ if (existsSync(candidate)) return candidate;
18
+
19
+ const parent = dirname(dir);
20
+ if (parent === dir) return null;
21
+ dir = parent;
22
+ }
23
+ }
24
+
25
+ export function detectProject(angularJsonPath: string): ProjectInfo {
26
+ const projectRoot = dirname(angularJsonPath);
27
+ const raw = readFileSync(angularJsonPath, 'utf8');
28
+ const angularJson = JSON.parse(raw);
29
+
30
+ const projects = angularJson.projects || {};
31
+ const projectNames = Object.keys(projects);
32
+ if (projectNames.length === 0) {
33
+ throw new Error('No projects found in angular.json');
34
+ }
35
+
36
+ const projectName = projectNames[0];
37
+ const project = projects[projectName];
38
+ if (!project) {
39
+ throw new Error(`Project "${projectName}" not found in angular.json`);
40
+ }
41
+
42
+ const sourceRoot = project.sourceRoot || 'src';
43
+ const targets = project.architect || project.targets;
44
+ const buildTarget = targets?.build;
45
+ if (!buildTarget) {
46
+ throw new Error(`No build target found for project "${projectName}"`);
47
+ }
48
+
49
+ const builder: string = buildTarget.builder || '';
50
+ let builderType: ProjectInfo['builderType'];
51
+
52
+ if (builder.includes(':application') || builder.includes('angular-grab:application')) {
53
+ builderType = 'application';
54
+ } else if (builder.includes(':browser-esbuild')) {
55
+ builderType = 'browser-esbuild';
56
+ } else if (builder.includes(':browser')) {
57
+ builderType = 'browser';
58
+ } else {
59
+ builderType = 'application';
60
+ }
61
+
62
+ const packageManager = detectPackageManager(projectRoot);
63
+
64
+ return {
65
+ angularJsonPath,
66
+ projectRoot,
67
+ projectName,
68
+ sourceRoot,
69
+ builderType,
70
+ packageManager,
71
+ };
72
+ }
73
+
74
+ function detectPackageManager(root: string): 'pnpm' | 'yarn' | 'npm' {
75
+ if (existsSync(join(root, 'pnpm-lock.yaml'))) return 'pnpm';
76
+ if (existsSync(join(root, 'yarn.lock'))) return 'yarn';
77
+ return 'npm';
78
+ }
@@ -0,0 +1,42 @@
1
+ import { readFileSync, writeFileSync } from 'fs';
2
+
3
+ export function modifyAngularJson(angularJsonPath: string, projectName: string): boolean {
4
+ const raw = readFileSync(angularJsonPath, 'utf8');
5
+ const angularJson = JSON.parse(raw);
6
+
7
+ const project = angularJson.projects?.[projectName];
8
+ const targets = project?.architect || project?.targets;
9
+ if (!targets) return false;
10
+
11
+ let modified = false;
12
+
13
+ const buildTarget = targets.build;
14
+ if (buildTarget?.builder) {
15
+ const currentBuilder: string = buildTarget.builder;
16
+ if (
17
+ currentBuilder === '@angular/build:application' ||
18
+ currentBuilder === '@angular-devkit/build-angular:application'
19
+ ) {
20
+ buildTarget.builder = 'angular-grab:application';
21
+ modified = true;
22
+ }
23
+ }
24
+
25
+ const serveTarget = targets.serve;
26
+ if (serveTarget?.builder) {
27
+ const currentBuilder: string = serveTarget.builder;
28
+ if (
29
+ currentBuilder === '@angular/build:dev-server' ||
30
+ currentBuilder === '@angular-devkit/build-angular:dev-server'
31
+ ) {
32
+ serveTarget.builder = 'angular-grab:dev-server';
33
+ modified = true;
34
+ }
35
+ }
36
+
37
+ if (modified) {
38
+ writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2) + '\n', 'utf8');
39
+ }
40
+
41
+ return modified;
42
+ }
@@ -0,0 +1,42 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ export function modifyAppConfig(projectRoot: string, sourceRoot: string): boolean {
5
+ const configPath = join(projectRoot, sourceRoot, 'app', 'app.config.ts');
6
+ if (!existsSync(configPath)) return false;
7
+
8
+ let content = readFileSync(configPath, 'utf8');
9
+
10
+ // Skip if already has provideAngularGrab
11
+ if (content.includes('provideAngularGrab')) return false;
12
+
13
+ // Add import for provideAngularGrab
14
+ const importStatement = "import { provideAngularGrab } from 'angular-grab/angular';";
15
+
16
+ // Find the last import block end by matching `from '...'` or `from "..."` lines
17
+ const lines = content.split('\n');
18
+ let lastImportEndIndex = -1;
19
+ for (let i = 0; i < lines.length; i++) {
20
+ const trimmed = lines[i].trimStart();
21
+ if (trimmed.startsWith('import ') || /\}\s*from\s+['"]/.test(trimmed) || /from\s+['"]/.test(trimmed)) {
22
+ lastImportEndIndex = i;
23
+ }
24
+ }
25
+
26
+ if (lastImportEndIndex >= 0) {
27
+ lines.splice(lastImportEndIndex + 1, 0, importStatement);
28
+ content = lines.join('\n');
29
+ } else {
30
+ content = importStatement + '\n' + content;
31
+ }
32
+
33
+ // Try to add provideAngularGrab() to the providers array
34
+ const providersMatch = content.match(/providers\s*:\s*\[/);
35
+ if (providersMatch && providersMatch.index != null) {
36
+ const insertPos = providersMatch.index + providersMatch[0].length;
37
+ content = content.slice(0, insertPos) + '\n provideAngularGrab(),' + content.slice(insertPos);
38
+ }
39
+
40
+ writeFileSync(configPath, content, 'utf8');
41
+ return true;
42
+ }
@@ -0,0 +1,149 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest';
3
+ import { generateSnippet } from '../clipboard/generate-snippet';
4
+ import type { ElementContext, ComponentStackEntry } from '../types';
5
+
6
+ function makeContext(overrides: Partial<ElementContext> = {}): ElementContext {
7
+ return {
8
+ element: document.createElement('div'),
9
+ html: '<div>hello</div>',
10
+ componentName: null,
11
+ filePath: null,
12
+ line: null,
13
+ column: null,
14
+ componentStack: [],
15
+ selector: 'div',
16
+ cssClasses: [],
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe('generateSnippet', () => {
22
+ it('generates HTML-only snippet when no component info', () => {
23
+ const ctx = makeContext({ html: '<button>Click</button>' });
24
+ const result = generateSnippet(ctx, 20);
25
+ expect(result).toBe('<button>Click</button>');
26
+ });
27
+
28
+ it('includes component name and file path', () => {
29
+ const ctx = makeContext({
30
+ html: '<div>test</div>',
31
+ componentName: 'AppComponent',
32
+ filePath: 'src/app/app.component.ts',
33
+ line: 10,
34
+ column: 5,
35
+ });
36
+ const result = generateSnippet(ctx, 20);
37
+
38
+ expect(result).toContain('<div>test</div>');
39
+ expect(result).toContain('in AppComponent');
40
+ expect(result).toContain('at src/app/app.component.ts:10:5');
41
+ });
42
+
43
+ it('includes component name without file path', () => {
44
+ const ctx = makeContext({ componentName: 'MyComponent' });
45
+ const result = generateSnippet(ctx, 20);
46
+
47
+ expect(result).toContain('in MyComponent');
48
+ expect(result).not.toContain('at');
49
+ });
50
+
51
+ it('includes file path without component name', () => {
52
+ const ctx = makeContext({
53
+ filePath: 'src/app.ts',
54
+ line: 5,
55
+ });
56
+ const result = generateSnippet(ctx, 20);
57
+
58
+ expect(result).toContain('at src/app.ts:5');
59
+ expect(result).not.toContain('in ');
60
+ });
61
+
62
+ it('includes component stack trace', () => {
63
+ const stack: ComponentStackEntry[] = [
64
+ { name: 'ChildComponent', filePath: 'src/child.ts', line: 3, column: 1 },
65
+ { name: 'ParentComponent', filePath: 'src/parent.ts', line: 10, column: null },
66
+ { name: 'AppComponent', filePath: 'src/app.ts', line: null, column: null },
67
+ ];
68
+ const ctx = makeContext({
69
+ html: '<span>hi</span>',
70
+ componentStack: stack,
71
+ });
72
+ const result = generateSnippet(ctx, 20);
73
+
74
+ expect(result).toContain('in ChildComponent at src/child.ts:3:1');
75
+ expect(result).toContain('in ParentComponent at src/parent.ts:10');
76
+ expect(result).toContain('in AppComponent at src/app.ts');
77
+ });
78
+
79
+ it('prefers component stack over componentName/filePath', () => {
80
+ const stack: ComponentStackEntry[] = [
81
+ { name: 'Inner', filePath: 'inner.ts', line: 1, column: null },
82
+ ];
83
+ const ctx = makeContext({
84
+ componentName: 'Outer',
85
+ filePath: 'outer.ts',
86
+ line: 99,
87
+ column: null,
88
+ componentStack: stack,
89
+ });
90
+ const result = generateSnippet(ctx, 20);
91
+
92
+ expect(result).toContain('in Inner at inner.ts:1');
93
+ expect(result).not.toContain('Outer');
94
+ });
95
+
96
+ it('respects maxLines truncation', () => {
97
+ const multilineHtml = Array.from({ length: 10 }, (_, i) => ` <line${i}/>`).join('\n');
98
+ const ctx = makeContext({ html: multilineHtml });
99
+ const result = generateSnippet(ctx, 3);
100
+
101
+ const lines = result.split('\n');
102
+ // 3 HTML lines + " ..." truncation line
103
+ expect(lines.length).toBe(4);
104
+ expect(lines[3]).toBe(' ...');
105
+ });
106
+
107
+ it('does not truncate when HTML is within maxLines', () => {
108
+ const html = '<div>\n <span>hi</span>\n</div>';
109
+ const ctx = makeContext({ html });
110
+ const result = generateSnippet(ctx, 10);
111
+
112
+ expect(result).not.toContain('...');
113
+ expect(result).toBe(html);
114
+ });
115
+
116
+ it('cleans Angular attributes from HTML', () => {
117
+ const html = '<div _nghost-abc-123="">content</div>';
118
+ const ctx = makeContext({ html });
119
+ const result = generateSnippet(ctx, 20);
120
+
121
+ expect(result).toBe('<div>content</div>');
122
+ expect(result).not.toContain('_nghost');
123
+ });
124
+
125
+ it('handles null fields gracefully', () => {
126
+ const ctx = makeContext({
127
+ componentName: null,
128
+ filePath: null,
129
+ line: null,
130
+ column: null,
131
+ componentStack: [],
132
+ });
133
+ const result = generateSnippet(ctx, 20);
134
+
135
+ // Should just be the HTML, no location info
136
+ expect(result).toBe('<div>hello</div>');
137
+ });
138
+
139
+ it('handles stack entry with null filePath', () => {
140
+ const stack: ComponentStackEntry[] = [
141
+ { name: 'Comp', filePath: null, line: null, column: null },
142
+ ];
143
+ const ctx = makeContext({ componentStack: stack });
144
+ const result = generateSnippet(ctx, 20);
145
+
146
+ expect(result).toContain('in Comp');
147
+ expect(result).not.toContain('at');
148
+ });
149
+ });
@@ -0,0 +1,286 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createPluginRegistry } from '../plugins/plugin-registry';
3
+ import type { Plugin, AngularGrabAPI, ElementContext } from '../types';
4
+
5
+ function makeMockApi(): AngularGrabAPI {
6
+ return {
7
+ activate: vi.fn(),
8
+ deactivate: vi.fn(),
9
+ toggle: vi.fn(),
10
+ isActive: vi.fn(() => false),
11
+ setOptions: vi.fn(),
12
+ registerPlugin: vi.fn(),
13
+ unregisterPlugin: vi.fn(),
14
+ setComponentResolver: vi.fn(),
15
+ setSourceResolver: vi.fn(),
16
+ showToolbar: vi.fn(),
17
+ hideToolbar: vi.fn(),
18
+ setThemeMode: vi.fn(),
19
+ getHistory: vi.fn(() => []),
20
+ clearHistory: vi.fn(),
21
+ dispose: vi.fn(),
22
+ };
23
+ }
24
+
25
+ function makeContext(overrides: Partial<ElementContext> = {}): ElementContext {
26
+ return {
27
+ element: null as any,
28
+ html: '<div>test</div>',
29
+ componentName: null,
30
+ filePath: null,
31
+ line: null,
32
+ column: null,
33
+ componentStack: [],
34
+ selector: 'div',
35
+ cssClasses: [],
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ describe('createPluginRegistry', () => {
41
+ it('registers a plugin', () => {
42
+ const registry = createPluginRegistry();
43
+ const api = makeMockApi();
44
+ const plugin: Plugin = { name: 'test-plugin' };
45
+
46
+ registry.register(plugin, api);
47
+
48
+ expect(registry.getPlugins()).toHaveLength(1);
49
+ expect(registry.getPlugins()[0].name).toBe('test-plugin');
50
+ });
51
+
52
+ it('calls setup on register and stores cleanup', () => {
53
+ const registry = createPluginRegistry();
54
+ const api = makeMockApi();
55
+ const cleanup = vi.fn();
56
+ const plugin: Plugin = {
57
+ name: 'with-setup',
58
+ setup: vi.fn(() => cleanup),
59
+ };
60
+
61
+ registry.register(plugin, api);
62
+
63
+ expect(plugin.setup).toHaveBeenCalledWith(api);
64
+ });
65
+
66
+ it('unregisters a plugin', () => {
67
+ const registry = createPluginRegistry();
68
+ const api = makeMockApi();
69
+ const plugin: Plugin = { name: 'removable' };
70
+
71
+ registry.register(plugin, api);
72
+ expect(registry.getPlugins()).toHaveLength(1);
73
+
74
+ registry.unregister('removable');
75
+ expect(registry.getPlugins()).toHaveLength(0);
76
+ });
77
+
78
+ it('calls cleanup on unregister', () => {
79
+ const registry = createPluginRegistry();
80
+ const api = makeMockApi();
81
+ const cleanup = vi.fn();
82
+ const plugin: Plugin = {
83
+ name: 'cleanup-test',
84
+ setup: () => cleanup,
85
+ };
86
+
87
+ registry.register(plugin, api);
88
+ registry.unregister('cleanup-test');
89
+
90
+ expect(cleanup).toHaveBeenCalledTimes(1);
91
+ });
92
+
93
+ it('re-registers a plugin by unregistering the old one first', () => {
94
+ const registry = createPluginRegistry();
95
+ const api = makeMockApi();
96
+ const cleanup = vi.fn();
97
+ const plugin1: Plugin = {
98
+ name: 'dup',
99
+ setup: () => cleanup,
100
+ };
101
+ const plugin2: Plugin = { name: 'dup' };
102
+
103
+ registry.register(plugin1, api);
104
+ registry.register(plugin2, api);
105
+
106
+ expect(cleanup).toHaveBeenCalledTimes(1);
107
+ expect(registry.getPlugins()).toHaveLength(1);
108
+ });
109
+
110
+ it('unregister is a no-op for unknown plugins', () => {
111
+ const registry = createPluginRegistry();
112
+ // Should not throw
113
+ registry.unregister('nonexistent');
114
+ expect(registry.getPlugins()).toHaveLength(0);
115
+ });
116
+
117
+ describe('callHook', () => {
118
+ it('calls registered plugin hooks', () => {
119
+ const registry = createPluginRegistry();
120
+ const api = makeMockApi();
121
+ const onActivate = vi.fn();
122
+ const plugin: Plugin = {
123
+ name: 'hook-test',
124
+ hooks: { onActivate },
125
+ };
126
+
127
+ registry.register(plugin, api);
128
+ registry.callHook('onActivate');
129
+
130
+ expect(onActivate).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ it('calls hooks on all registered plugins', () => {
134
+ const registry = createPluginRegistry();
135
+ const api = makeMockApi();
136
+ const onActivate1 = vi.fn();
137
+ const onActivate2 = vi.fn();
138
+
139
+ registry.register({ name: 'p1', hooks: { onActivate: onActivate1 } }, api);
140
+ registry.register({ name: 'p2', hooks: { onActivate: onActivate2 } }, api);
141
+ registry.callHook('onActivate');
142
+
143
+ expect(onActivate1).toHaveBeenCalledTimes(1);
144
+ expect(onActivate2).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it('passes arguments to hooks', () => {
148
+ const registry = createPluginRegistry();
149
+ const api = makeMockApi();
150
+ const onElementSelect = vi.fn();
151
+ const ctx = makeContext();
152
+
153
+ registry.register({ name: 'args-test', hooks: { onElementSelect } }, api);
154
+ registry.callHook('onElementSelect', ctx);
155
+
156
+ expect(onElementSelect).toHaveBeenCalledWith(ctx);
157
+ });
158
+
159
+ it('handles plugins with no hooks gracefully', () => {
160
+ const registry = createPluginRegistry();
161
+ const api = makeMockApi();
162
+
163
+ registry.register({ name: 'no-hooks' }, api);
164
+
165
+ // Should not throw
166
+ expect(() => registry.callHook('onActivate')).not.toThrow();
167
+ });
168
+
169
+ it('handles plugins without the specific hook', () => {
170
+ const registry = createPluginRegistry();
171
+ const api = makeMockApi();
172
+
173
+ registry.register(
174
+ { name: 'partial', hooks: { onDeactivate: vi.fn() } },
175
+ api,
176
+ );
177
+
178
+ // Should not throw when calling a hook the plugin doesn't have
179
+ expect(() => registry.callHook('onActivate')).not.toThrow();
180
+ });
181
+
182
+ it('catches and warns on hook errors without stopping other plugins', () => {
183
+ const registry = createPluginRegistry();
184
+ const api = makeMockApi();
185
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
186
+ const onActivate2 = vi.fn();
187
+
188
+ registry.register({
189
+ name: 'bad',
190
+ hooks: { onActivate: () => { throw new Error('boom'); } },
191
+ }, api);
192
+ registry.register({ name: 'good', hooks: { onActivate: onActivate2 } }, api);
193
+
194
+ registry.callHook('onActivate');
195
+
196
+ expect(warnSpy).toHaveBeenCalledTimes(1);
197
+ expect(onActivate2).toHaveBeenCalledTimes(1);
198
+ warnSpy.mockRestore();
199
+ });
200
+ });
201
+
202
+ describe('callTransformHook', () => {
203
+ it('chains transforms across plugins', () => {
204
+ const registry = createPluginRegistry();
205
+ const api = makeMockApi();
206
+ const ctx = makeContext();
207
+
208
+ registry.register({
209
+ name: 'upper',
210
+ hooks: { transformCopyContent: (text: string) => text.toUpperCase() },
211
+ }, api);
212
+ registry.register({
213
+ name: 'prefix',
214
+ hooks: { transformCopyContent: (text: string) => `PREFIX:${text}` },
215
+ }, api);
216
+
217
+ const result = registry.callTransformHook('hello', ctx);
218
+ expect(result).toBe('PREFIX:HELLO');
219
+ });
220
+
221
+ it('returns original text when no plugins have transform', () => {
222
+ const registry = createPluginRegistry();
223
+ const api = makeMockApi();
224
+ const ctx = makeContext();
225
+
226
+ registry.register({ name: 'no-transform' }, api);
227
+
228
+ const result = registry.callTransformHook('original', ctx);
229
+ expect(result).toBe('original');
230
+ });
231
+
232
+ it('catches transform errors and continues', () => {
233
+ const registry = createPluginRegistry();
234
+ const api = makeMockApi();
235
+ const ctx = makeContext();
236
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
237
+
238
+ registry.register({
239
+ name: 'bad-transform',
240
+ hooks: { transformCopyContent: () => { throw new Error('fail'); } },
241
+ }, api);
242
+ registry.register({
243
+ name: 'good-transform',
244
+ hooks: { transformCopyContent: (text: string) => text + '-suffix' },
245
+ }, api);
246
+
247
+ // The bad transform throws, so the input to the good transform is still the original
248
+ const result = registry.callTransformHook('input', ctx);
249
+ expect(result).toBe('input-suffix');
250
+ expect(warnSpy).toHaveBeenCalledTimes(1);
251
+ warnSpy.mockRestore();
252
+ });
253
+ });
254
+
255
+ describe('dispose', () => {
256
+ it('calls all cleanups and clears plugins', () => {
257
+ const registry = createPluginRegistry();
258
+ const api = makeMockApi();
259
+ const cleanup1 = vi.fn();
260
+ const cleanup2 = vi.fn();
261
+
262
+ registry.register({ name: 'p1', setup: () => cleanup1 }, api);
263
+ registry.register({ name: 'p2', setup: () => cleanup2 }, api);
264
+
265
+ registry.dispose();
266
+
267
+ expect(cleanup1).toHaveBeenCalledTimes(1);
268
+ expect(cleanup2).toHaveBeenCalledTimes(1);
269
+ expect(registry.getPlugins()).toHaveLength(0);
270
+ });
271
+
272
+ it('ignores errors during cleanup', () => {
273
+ const registry = createPluginRegistry();
274
+ const api = makeMockApi();
275
+
276
+ registry.register({
277
+ name: 'err',
278
+ setup: () => () => { throw new Error('cleanup fail'); },
279
+ }, api);
280
+
281
+ // Should not throw
282
+ expect(() => registry.dispose()).not.toThrow();
283
+ expect(registry.getPlugins()).toHaveLength(0);
284
+ });
285
+ });
286
+ });