angular-grab-monorepo 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.
- package/.claude/settings.local.json +10 -0
- package/.playwright-mcp/console-2026-03-07T02-49-38-061Z.log +37 -0
- package/.playwright-mcp/console-2026-03-07T02-51-03-493Z.log +26 -0
- package/.playwright-mcp/console-2026-03-07T02-51-25-431Z.log +15 -0
- package/.playwright-mcp/console-2026-03-07T02-52-02-980Z.log +91 -0
- package/.playwright-mcp/page-2026-03-07T02-52-09-791Z.png +0 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/examples/angular-19-app/.editorconfig +17 -0
- package/examples/angular-19-app/.vscode/extensions.json +4 -0
- package/examples/angular-19-app/.vscode/launch.json +20 -0
- package/examples/angular-19-app/.vscode/mcp.json +9 -0
- package/examples/angular-19-app/.vscode/tasks.json +42 -0
- package/examples/angular-19-app/README.md +59 -0
- package/examples/angular-19-app/angular.json +79 -0
- package/examples/angular-19-app/package.json +42 -0
- package/examples/angular-19-app/public/favicon.ico +0 -0
- package/examples/angular-19-app/src/app/app.config.ts +13 -0
- package/examples/angular-19-app/src/app/app.css +37 -0
- package/examples/angular-19-app/src/app/app.html +25 -0
- package/examples/angular-19-app/src/app/app.routes.ts +3 -0
- package/examples/angular-19-app/src/app/app.spec.ts +23 -0
- package/examples/angular-19-app/src/app/app.ts +12 -0
- package/examples/angular-19-app/src/app/button/button.component.ts +25 -0
- package/examples/angular-19-app/src/app/card/card.component.ts +33 -0
- package/examples/angular-19-app/src/app/header/header.component.ts +31 -0
- package/examples/angular-19-app/src/app/popover/popover.component.ts +133 -0
- package/examples/angular-19-app/src/index.html +13 -0
- package/examples/angular-19-app/src/main.ts +6 -0
- package/examples/angular-19-app/src/styles.css +1 -0
- package/examples/angular-19-app/tsconfig.app.json +15 -0
- package/examples/angular-19-app/tsconfig.json +33 -0
- package/examples/angular-19-app/tsconfig.spec.json +15 -0
- package/package.json +22 -0
- package/packages/angular-grab/builders.json +14 -0
- package/packages/angular-grab/package.json +96 -0
- package/packages/angular-grab/src/angular/__tests__/context-builder.test.ts +216 -0
- package/packages/angular-grab/src/angular/angular-grab.service.ts +62 -0
- package/packages/angular-grab/src/angular/index.ts +13 -0
- package/packages/angular-grab/src/angular/provide-angular-grab.ts +22 -0
- package/packages/angular-grab/src/angular/resolvers/component-resolver.ts +71 -0
- package/packages/angular-grab/src/angular/resolvers/context-builder.ts +86 -0
- package/packages/angular-grab/src/angular/resolvers/ng-utils.ts +14 -0
- package/packages/angular-grab/src/angular/resolvers/source-resolver.ts +61 -0
- package/packages/angular-grab/src/builder/__tests__/builder.test.ts +72 -0
- package/packages/angular-grab/src/builder/builders/application/index.ts +13 -0
- package/packages/angular-grab/src/builder/builders/application/schema.json +7 -0
- package/packages/angular-grab/src/builder/builders/dev-server/index.ts +9 -0
- package/packages/angular-grab/src/builder/builders/dev-server/schema.json +7 -0
- package/packages/angular-grab/src/builder/index.ts +3 -0
- package/packages/angular-grab/src/cli/__tests__/cli.test.ts +239 -0
- package/packages/angular-grab/src/cli/commands/init.ts +106 -0
- package/packages/angular-grab/src/cli/index.ts +15 -0
- package/packages/angular-grab/src/cli/utils/detect-project.ts +78 -0
- package/packages/angular-grab/src/cli/utils/modify-angular-json.ts +42 -0
- package/packages/angular-grab/src/cli/utils/modify-app-config.ts +42 -0
- package/packages/angular-grab/src/core/__tests__/generate-snippet.test.ts +149 -0
- package/packages/angular-grab/src/core/__tests__/plugin-registry.test.ts +286 -0
- package/packages/angular-grab/src/core/__tests__/store.test.ts +118 -0
- package/packages/angular-grab/src/core/__tests__/utils.test.ts +85 -0
- package/packages/angular-grab/src/core/clipboard/copy.ts +104 -0
- package/packages/angular-grab/src/core/clipboard/generate-snippet.ts +38 -0
- package/packages/angular-grab/src/core/constants.ts +10 -0
- package/packages/angular-grab/src/core/grab.ts +596 -0
- package/packages/angular-grab/src/core/index.global.ts +13 -0
- package/packages/angular-grab/src/core/index.ts +19 -0
- package/packages/angular-grab/src/core/keyboard/keyboard-handler.ts +163 -0
- package/packages/angular-grab/src/core/overlay/crosshair.ts +107 -0
- package/packages/angular-grab/src/core/overlay/freeze-overlay.ts +239 -0
- package/packages/angular-grab/src/core/overlay/overlay-renderer.ts +180 -0
- package/packages/angular-grab/src/core/overlay/select-feedback.ts +108 -0
- package/packages/angular-grab/src/core/overlay/toast.ts +175 -0
- package/packages/angular-grab/src/core/picker/element-picker.ts +114 -0
- package/packages/angular-grab/src/core/plugins/plugin-registry.ts +83 -0
- package/packages/angular-grab/src/core/store.ts +52 -0
- package/packages/angular-grab/src/core/toolbar/actions-menu.ts +178 -0
- package/packages/angular-grab/src/core/toolbar/comment-popover.ts +235 -0
- package/packages/angular-grab/src/core/toolbar/copy-actions.ts +98 -0
- package/packages/angular-grab/src/core/toolbar/history-popover.ts +245 -0
- package/packages/angular-grab/src/core/toolbar/theme-manager.ts +188 -0
- package/packages/angular-grab/src/core/toolbar/toolbar-icons.ts +29 -0
- package/packages/angular-grab/src/core/toolbar/toolbar-renderer.ts +239 -0
- package/packages/angular-grab/src/core/types.ts +139 -0
- package/packages/angular-grab/src/core/utils.ts +16 -0
- package/packages/angular-grab/src/esbuild-plugin/__tests__/transform.test.ts +174 -0
- package/packages/angular-grab/src/esbuild-plugin/index.ts +3 -0
- package/packages/angular-grab/src/esbuild-plugin/plugin.ts +29 -0
- package/packages/angular-grab/src/esbuild-plugin/scan.ts +105 -0
- package/packages/angular-grab/src/esbuild-plugin/transform.ts +152 -0
- package/packages/angular-grab/src/vite-plugin/__tests__/plugin.test.ts +84 -0
- package/packages/angular-grab/src/vite-plugin/index.ts +19 -0
- package/packages/angular-grab/src/webpack-plugin/__tests__/plugin.test.ts +72 -0
- package/packages/angular-grab/src/webpack-plugin/index.ts +2 -0
- package/packages/angular-grab/src/webpack-plugin/loader.ts +15 -0
- package/packages/angular-grab/src/webpack-plugin/plugin.ts +20 -0
- package/packages/angular-grab/tsconfig.json +15 -0
- package/packages/angular-grab/tsup.config.ts +119 -0
- package/pnpm-workspace.yaml +3 -0
- package/turbo.json +21 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createStore } from '../store';
|
|
3
|
+
import type { AngularGrabOptions } from '../types';
|
|
4
|
+
|
|
5
|
+
function makeOptions(overrides: Partial<AngularGrabOptions> = {}): AngularGrabOptions {
|
|
6
|
+
return {
|
|
7
|
+
activationKey: 'Meta+C',
|
|
8
|
+
activationMode: 'hold',
|
|
9
|
+
keyHoldDuration: 0,
|
|
10
|
+
maxContextLines: 20,
|
|
11
|
+
enabled: true,
|
|
12
|
+
enableInInputs: false,
|
|
13
|
+
devOnly: true,
|
|
14
|
+
showToolbar: true,
|
|
15
|
+
themeMode: 'dark',
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('createStore', () => {
|
|
21
|
+
it('creates state with correct default values', () => {
|
|
22
|
+
const opts = makeOptions();
|
|
23
|
+
const store = createStore(opts);
|
|
24
|
+
|
|
25
|
+
expect(store.state.active).toBe(false);
|
|
26
|
+
expect(store.state.frozen).toBe(false);
|
|
27
|
+
expect(store.state.hoveredElement).toBeNull();
|
|
28
|
+
expect(store.state.options).toBe(opts);
|
|
29
|
+
expect(store.state.toolbar.visible).toBe(true);
|
|
30
|
+
expect(store.state.toolbar.themeMode).toBe('dark');
|
|
31
|
+
expect(store.state.toolbar.history).toEqual([]);
|
|
32
|
+
expect(store.state.toolbar.pendingAction).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('respects showToolbar=false in options', () => {
|
|
36
|
+
const store = createStore(makeOptions({ showToolbar: false }));
|
|
37
|
+
expect(store.state.toolbar.visible).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('respects themeMode in options', () => {
|
|
41
|
+
const store = createStore(makeOptions({ themeMode: 'light' }));
|
|
42
|
+
expect(store.state.toolbar.themeMode).toBe('light');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('notifies listeners on state changes', () => {
|
|
46
|
+
const store = createStore(makeOptions());
|
|
47
|
+
const listener = vi.fn();
|
|
48
|
+
|
|
49
|
+
store.subscribe(listener);
|
|
50
|
+
store.state.active = true;
|
|
51
|
+
|
|
52
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(listener).toHaveBeenCalledWith(store.state, 'active');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not notify listeners when value has not changed', () => {
|
|
57
|
+
const store = createStore(makeOptions());
|
|
58
|
+
const listener = vi.fn();
|
|
59
|
+
|
|
60
|
+
store.subscribe(listener);
|
|
61
|
+
store.state.active = false; // same as default
|
|
62
|
+
|
|
63
|
+
expect(listener).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('subscribe returns an unsubscribe function that works', () => {
|
|
67
|
+
const store = createStore(makeOptions());
|
|
68
|
+
const listener = vi.fn();
|
|
69
|
+
|
|
70
|
+
const unsub = store.subscribe(listener);
|
|
71
|
+
store.state.active = true;
|
|
72
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
73
|
+
|
|
74
|
+
unsub();
|
|
75
|
+
store.state.active = false;
|
|
76
|
+
expect(listener).toHaveBeenCalledTimes(1); // no additional call
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('supports multiple listeners', () => {
|
|
80
|
+
const store = createStore(makeOptions());
|
|
81
|
+
const listener1 = vi.fn();
|
|
82
|
+
const listener2 = vi.fn();
|
|
83
|
+
|
|
84
|
+
store.subscribe(listener1);
|
|
85
|
+
store.subscribe(listener2);
|
|
86
|
+
store.state.frozen = true;
|
|
87
|
+
|
|
88
|
+
expect(listener1).toHaveBeenCalledTimes(1);
|
|
89
|
+
expect(listener2).toHaveBeenCalledTimes(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('unsubscribing one listener does not affect others', () => {
|
|
93
|
+
const store = createStore(makeOptions());
|
|
94
|
+
const listener1 = vi.fn();
|
|
95
|
+
const listener2 = vi.fn();
|
|
96
|
+
|
|
97
|
+
const unsub1 = store.subscribe(listener1);
|
|
98
|
+
store.subscribe(listener2);
|
|
99
|
+
|
|
100
|
+
unsub1();
|
|
101
|
+
store.state.active = true;
|
|
102
|
+
|
|
103
|
+
expect(listener1).not.toHaveBeenCalled();
|
|
104
|
+
expect(listener2).toHaveBeenCalledTimes(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('passes the proxy state and changed key to listeners', () => {
|
|
108
|
+
const store = createStore(makeOptions());
|
|
109
|
+
const listener = vi.fn();
|
|
110
|
+
|
|
111
|
+
store.subscribe(listener);
|
|
112
|
+
store.state.frozen = true;
|
|
113
|
+
|
|
114
|
+
const [receivedState, receivedKey] = listener.mock.calls[0];
|
|
115
|
+
expect(receivedKey).toBe('frozen');
|
|
116
|
+
expect(receivedState.frozen).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { escapeHtml, cleanAngularAttrs } from '../utils';
|
|
4
|
+
|
|
5
|
+
describe('escapeHtml', () => {
|
|
6
|
+
it('escapes ampersands', () => {
|
|
7
|
+
expect(escapeHtml('foo & bar')).toBe('foo & bar');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('escapes angle brackets', () => {
|
|
11
|
+
expect(escapeHtml('<div>')).toBe('<div>');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('passes through double quotes (not special in text nodes)', () => {
|
|
15
|
+
// innerHTML only escapes <, >, & in text content — not quotes
|
|
16
|
+
expect(escapeHtml('"hello"')).toBe('"hello"');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('passes through single quotes (not special in text nodes)', () => {
|
|
20
|
+
expect(escapeHtml("it's")).toBe("it's");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('escapes multiple special characters together', () => {
|
|
24
|
+
expect(escapeHtml('<a href="x">&</a>')).toBe(
|
|
25
|
+
'<a href="x">&</a>',
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns empty string for empty input', () => {
|
|
30
|
+
expect(escapeHtml('')).toBe('');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns plain text unchanged', () => {
|
|
34
|
+
expect(escapeHtml('hello world')).toBe('hello world');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('cleanAngularAttrs', () => {
|
|
39
|
+
it('removes _nghost attributes with values', () => {
|
|
40
|
+
const html = '<div _nghost-abc-123=""></div>';
|
|
41
|
+
expect(cleanAngularAttrs(html)).toBe('<div></div>');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('removes _ngcontent attributes with values', () => {
|
|
45
|
+
const html = '<span _ngcontent-xyz-456=""></span>';
|
|
46
|
+
expect(cleanAngularAttrs(html)).toBe('<span></span>');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('removes _nghost attributes without values', () => {
|
|
50
|
+
const html = '<div _nghost-abc-123></div>';
|
|
51
|
+
expect(cleanAngularAttrs(html)).toBe('<div></div>');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('removes _ngcontent attributes without values', () => {
|
|
55
|
+
const html = '<span _ngcontent-xyz-456></span>';
|
|
56
|
+
expect(cleanAngularAttrs(html)).toBe('<span></span>');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('removes multiple Angular attributes from one tag', () => {
|
|
60
|
+
const html = '<div _nghost-abc-123="" _ngcontent-xyz-456="">text</div>';
|
|
61
|
+
expect(cleanAngularAttrs(html)).toBe('<div>text</div>');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('removes Angular attributes from nested elements', () => {
|
|
65
|
+
const html =
|
|
66
|
+
'<div _nghost-a-1=""><span _ngcontent-b-2="">hi</span></div>';
|
|
67
|
+
expect(cleanAngularAttrs(html)).toBe('<div><span>hi</span></div>');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('preserves non-Angular attributes', () => {
|
|
71
|
+
const html = '<div class="foo" id="bar" _nghost-abc-123="">text</div>';
|
|
72
|
+
expect(cleanAngularAttrs(html)).toBe(
|
|
73
|
+
'<div class="foo" id="bar">text</div>',
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns unchanged HTML when no Angular attributes present', () => {
|
|
78
|
+
const html = '<div class="test">hello</div>';
|
|
79
|
+
expect(cleanAngularAttrs(html)).toBe(html);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('handles empty string', () => {
|
|
83
|
+
expect(cleanAngularAttrs('')).toBe('');
|
|
84
|
+
});
|
|
85
|
+
});
|