context_drop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +337 -0
  4. data/Rakefile +20 -0
  5. data/SPEC.md +532 -0
  6. data/app/assets/javascripts/context_drop/context_drop.js +3508 -0
  7. data/app/assets/stylesheets/context_drop/application.css +2 -0
  8. data/app/controllers/context_drop/application_controller.rb +114 -0
  9. data/app/controllers/context_drop/attachments_controller.rb +31 -0
  10. data/app/controllers/context_drop/configs_controller.rb +20 -0
  11. data/app/controllers/context_drop/dashboard/feedbacks_controller.rb +107 -0
  12. data/app/controllers/context_drop/feedbacks_controller.rb +208 -0
  13. data/app/controllers/context_drop/health_controller.rb +9 -0
  14. data/app/controllers/context_drop/mcp_controller.rb +67 -0
  15. data/app/helpers/context_drop/widget_helper.rb +62 -0
  16. data/app/javascript/context_drop/__tests__/widget.test.ts +319 -0
  17. data/app/javascript/context_drop/api-client.ts +87 -0
  18. data/app/javascript/context_drop/element-picker.ts +1004 -0
  19. data/app/javascript/context_drop/icons.ts +35 -0
  20. data/app/javascript/context_drop/index.ts +11 -0
  21. data/app/javascript/context_drop/sanitizer.ts +454 -0
  22. data/app/javascript/context_drop/screenshot.ts +214 -0
  23. data/app/javascript/context_drop/styles.ts +690 -0
  24. data/app/javascript/context_drop/types.ts +110 -0
  25. data/app/javascript/context_drop/widget.ts +1120 -0
  26. data/app/models/context_drop/application_record.rb +7 -0
  27. data/app/models/context_drop/attachment.rb +30 -0
  28. data/app/models/context_drop/element_reference.rb +47 -0
  29. data/app/models/context_drop/feedback.rb +94 -0
  30. data/app/services/context_drop/metadata_sanitizer.rb +215 -0
  31. data/app/services/context_drop/upload_validator.rb +217 -0
  32. data/app/views/context_drop/dashboard/feedbacks/index.html.erb +86 -0
  33. data/app/views/context_drop/dashboard/feedbacks/show.html.erb +121 -0
  34. data/app/views/layouts/context_drop/application.html.erb +215 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20261109000001_create_context_drop_feedbacks.rb +23 -0
  37. data/db/migrate/20261109000002_create_context_drop_element_references.rb +29 -0
  38. data/db/migrate/20261109000003_create_context_drop_attachments.rb +24 -0
  39. data/lib/context_drop/base64_util.rb +15 -0
  40. data/lib/context_drop/configuration.rb +155 -0
  41. data/lib/context_drop/engine.rb +32 -0
  42. data/lib/context_drop/mcp/dispatcher.rb +197 -0
  43. data/lib/context_drop/mcp/prompts/investigate_feedback.rb +83 -0
  44. data/lib/context_drop/mcp/server.rb +25 -0
  45. data/lib/context_drop/mcp/stdio_runner.rb +62 -0
  46. data/lib/context_drop/mcp/tools/get_feedback.rb +133 -0
  47. data/lib/context_drop/mcp/tools/get_feedback_attachment.rb +71 -0
  48. data/lib/context_drop/mcp/tools/list_feedbacks.rb +127 -0
  49. data/lib/context_drop/mcp/tools/update_feedback_status.rb +72 -0
  50. data/lib/context_drop/version.rb +5 -0
  51. data/lib/context_drop.rb +32 -0
  52. data/lib/generators/context_drop/install/install_generator.rb +52 -0
  53. data/lib/generators/context_drop/install/templates/initializer.rb.tt +55 -0
  54. data/lib/tasks/context_drop_tasks.rake +14 -0
  55. metadata +246 -0
@@ -0,0 +1,319 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ captureElementSnapshot,
5
+ computeDomPath,
6
+ computeSelector,
7
+ extractSanitizedAttributes,
8
+ extractSanitizedText,
9
+ isElementExcluded,
10
+ isSelectableElement,
11
+ resolveAncestorChain,
12
+ resolveCandidateStack
13
+ } from "../sanitizer.ts";
14
+ import { ElementPicker } from "../element-picker.ts";
15
+ import { ContextDropWidgetElement } from "../widget.ts";
16
+
17
+ interface MockNodeInit {
18
+ tagName?: string;
19
+ attributes?: Record<string, string>;
20
+ children?: Array<MockElement | MockTextNode>;
21
+ rect?: { x: number; y: number; width: number; height: number };
22
+ }
23
+
24
+ class MockTextNode {
25
+ public readonly nodeType: number = 3;
26
+ public nodeValue: string;
27
+
28
+ constructor(text: string) {
29
+ this.nodeValue = text;
30
+ }
31
+ }
32
+
33
+ class MockElement {
34
+ public readonly nodeType: number = 1;
35
+ public tagName: string;
36
+ public parentElement: MockElement | null = null;
37
+ public childNodes: Array<MockElement | MockTextNode> = [];
38
+ private attributes: Map<string, string> = new Map();
39
+ private rect: { x: number; y: number; width: number; height: number };
40
+
41
+ constructor(init: MockNodeInit = {}) {
42
+ this.tagName = (init.tagName || "DIV").toUpperCase();
43
+ this.rect = init.rect || { x: 24, y: 48, width: 180, height: 42 };
44
+ if (init.attributes) {
45
+ for (const [k, v] of Object.entries(init.attributes)) {
46
+ this.attributes.set(k, v);
47
+ }
48
+ }
49
+ if (init.children) {
50
+ for (const child of init.children) {
51
+ if (child instanceof MockElement) {
52
+ child.parentElement = this;
53
+ }
54
+ this.childNodes.push(child);
55
+ }
56
+ }
57
+ }
58
+
59
+ public hasAttribute(name: string): boolean {
60
+ return this.attributes.has(name);
61
+ }
62
+
63
+ public getAttribute(name: string): string | null {
64
+ return this.attributes.has(name) ? (this.attributes.get(name) ?? null) : null;
65
+ }
66
+
67
+ public getAttributeNames(): string[] {
68
+ return Array.from(this.attributes.keys());
69
+ }
70
+
71
+ public getBoundingClientRect(): { x: number; y: number; left: number; top: number; width: number; height: number } {
72
+ return {
73
+ ...this.rect,
74
+ left: this.rect.x,
75
+ top: this.rect.y
76
+ };
77
+ }
78
+
79
+ public matches(selector: string): boolean {
80
+ if (selector.startsWith(".")) {
81
+ const cls = (this.getAttribute("class") || "").split(/\s+/);
82
+ return cls.includes(selector.slice(1));
83
+ }
84
+ if (selector.startsWith("#")) {
85
+ return this.getAttribute("id") === selector.slice(1);
86
+ }
87
+ return false;
88
+ }
89
+ }
90
+
91
+ describe("ContextDrop Frontend DOM Sanitizer & Snapshot Capture", () => {
92
+ it("excludes elements inside data-feedback-ignore or data-feedback-sensitive containers recursively", () => {
93
+ const ssnSpan = new MockElement({
94
+ tagName: "SPAN",
95
+ attributes: { id: "ssn" },
96
+ children: [new MockTextNode("123-45-6789")]
97
+ });
98
+ const ignoredContainer = new MockElement({
99
+ tagName: "DIV",
100
+ attributes: { "data-feedback-ignore": "true" },
101
+ children: [ssnSpan]
102
+ });
103
+
104
+ assert.equal(isElementExcluded(ignoredContainer as unknown as Element), true);
105
+ assert.equal(isElementExcluded(ssnSpan as unknown as Element), true);
106
+ assert.equal(captureElementSnapshot(ssnSpan as unknown as Element), null);
107
+ });
108
+
109
+ it("excludes password inputs, hidden inputs, and credit-card autocomplete inputs", () => {
110
+ const passwordInput = new MockElement({
111
+ tagName: "INPUT",
112
+ attributes: { type: "password", value: "s3cret!" }
113
+ });
114
+ const ccInput = new MockElement({
115
+ tagName: "INPUT",
116
+ attributes: { type: "text", autocomplete: "cc-number" }
117
+ });
118
+
119
+ assert.equal(isElementExcluded(passwordInput as unknown as Element), true);
120
+ assert.equal(isElementExcluded(ccInput as unknown as Element), true);
121
+ });
122
+
123
+ it("omits sensitive descendant text and password values when selecting a parent container", () => {
124
+ const label = new MockElement({
125
+ tagName: "LABEL",
126
+ children: [new MockTextNode("Email Address")]
127
+ });
128
+ const passwordInput = new MockElement({
129
+ tagName: "INPUT",
130
+ attributes: { type: "password", value: "s3cret!" }
131
+ });
132
+ const sensitiveChild = new MockElement({
133
+ tagName: "DIV",
134
+ attributes: { "data-feedback-sensitive": "true" },
135
+ children: [new MockTextNode("Secret API Token 999")]
136
+ });
137
+ const form = new MockElement({
138
+ tagName: "FORM",
139
+ attributes: { id: "profile-form", class: "settings-card" },
140
+ children: [label, passwordInput, sensitiveChild]
141
+ });
142
+
143
+ const text = extractSanitizedText(form as unknown as Element);
144
+ assert.equal(text, "Email Address");
145
+ });
146
+
147
+ it("strips value, event handlers, and javascript: URLs while preserving allowlisted attributes", () => {
148
+ const btn = new MockElement({
149
+ tagName: "BUTTON",
150
+ attributes: {
151
+ id: "checkout-button",
152
+ class: "btn primary",
153
+ "data-testid": "pay-now-btn",
154
+ "aria-label": "Pay Now",
155
+ "aria-hidden": "false",
156
+ value: "should_not_be_captured",
157
+ onclick: "malicious()",
158
+ href: "javascript:alert(1)"
159
+ }
160
+ });
161
+
162
+ const attrs = extractSanitizedAttributes(btn as unknown as Element);
163
+ assert.deepEqual(attrs, {
164
+ id: "checkout-button",
165
+ class: "btn primary",
166
+ "data-testid": "pay-now-btn",
167
+ "aria-label": "Pay Now"
168
+ });
169
+ });
170
+
171
+ it("computes best selector, structural dom_path, and point-in-time snapshot", () => {
172
+ const button = new MockElement({
173
+ tagName: "BUTTON",
174
+ attributes: { id: "checkout-button", class: "btn primary" },
175
+ children: [new MockTextNode("Complete Purchase")],
176
+ rect: { x: 100, y: 250, width: 200, height: 48 }
177
+ });
178
+ const main = new MockElement({
179
+ tagName: "MAIN",
180
+ attributes: { class: "checkout-layout" },
181
+ children: [button]
182
+ });
183
+ new MockElement({
184
+ tagName: "BODY",
185
+ children: [main]
186
+ });
187
+
188
+ assert.equal(computeSelector(button as unknown as Element), "#checkout-button");
189
+ assert.equal(
190
+ computeDomPath(button as unknown as Element),
191
+ "body > main.checkout-layout > button#checkout-button"
192
+ );
193
+
194
+ const snapshot = captureElementSnapshot(button as unknown as Element, [], "ref_123");
195
+ assert.ok(snapshot);
196
+ assert.equal(snapshot.client_ref, "ref_123");
197
+ assert.equal(snapshot.tag_name, "button");
198
+ assert.equal(snapshot.selector, "#checkout-button");
199
+ assert.equal(snapshot.text_content, "Complete Purchase");
200
+ assert.deepEqual(snapshot.bounding_rect, { x: 100, y: 250, width: 200, height: 48 });
201
+ assert.equal("outerHTML" in snapshot, false);
202
+ });
203
+
204
+ it("resolves cascading ancestor chain and overlapping candidate stack while filtering sensitive elements", () => {
205
+ const iconSpan = new MockElement({
206
+ tagName: "SPAN",
207
+ attributes: { class: "icon-label" },
208
+ children: [new MockTextNode("Pay $49.00")],
209
+ rect: { x: 112, y: 260, width: 88, height: 20 }
210
+ });
211
+ const button = new MockElement({
212
+ tagName: "BUTTON",
213
+ attributes: { id: "pay-btn", class: "btn-cta" },
214
+ children: [iconSpan],
215
+ rect: { x: 100, y: 250, width: 200, height: 44 }
216
+ });
217
+ const card = new MockElement({
218
+ tagName: "DIV",
219
+ attributes: { class: "pricing-card featured" },
220
+ children: [button],
221
+ rect: { x: 80, y: 180, width: 320, height: 240 }
222
+ });
223
+ const overlappingRibbon = new MockElement({
224
+ tagName: "DIV",
225
+ attributes: { class: "floating-promo-badge" },
226
+ children: [new MockTextNode("Limited Offer")],
227
+ rect: { x: 95, y: 245, width: 140, height: 28 }
228
+ });
229
+ const excludedOverlay = new MockElement({
230
+ tagName: "DIV",
231
+ attributes: { "data-feedback-ignore": "true", class: "ignored-overlay" },
232
+ rect: { x: 0, y: 0, width: 500, height: 500 }
233
+ });
234
+ const section = new MockElement({
235
+ tagName: "SECTION",
236
+ attributes: { id: "checkout-section" },
237
+ children: [card, overlappingRibbon, excludedOverlay],
238
+ rect: { x: 40, y: 120, width: 640, height: 400 }
239
+ });
240
+ const body = new MockElement({
241
+ tagName: "BODY",
242
+ children: [section],
243
+ rect: { x: 0, y: 0, width: 1280, height: 800 }
244
+ });
245
+
246
+ assert.equal(isSelectableElement(body as unknown as Element), false);
247
+ assert.equal(isSelectableElement(excludedOverlay as unknown as Element), false);
248
+ assert.equal(isSelectableElement(button as unknown as Element), true);
249
+
250
+ const ancestors = resolveAncestorChain(button as unknown as Element);
251
+ assert.equal(ancestors.length, 3);
252
+ assert.deepEqual(
253
+ ancestors.map((el) => computeSelector(el)),
254
+ ["#checkout-section", "div.pricing-card.featured", "#pay-btn"]
255
+ );
256
+
257
+ const stack = resolveCandidateStack(
258
+ button as unknown as Element,
259
+ [
260
+ excludedOverlay as unknown as Element,
261
+ button as unknown as Element,
262
+ overlappingRibbon as unknown as Element,
263
+ card as unknown as Element
264
+ ]
265
+ );
266
+
267
+ assert.deepEqual(
268
+ stack.map((item) => ({
269
+ selector: item.selector,
270
+ relation: item.relation
271
+ })),
272
+ [
273
+ { selector: "#pay-btn", relation: "target" },
274
+ { selector: "div.floating-promo-badge", relation: "overlap" },
275
+ { selector: "span.icon-label", relation: "child" },
276
+ { selector: "div.pricing-card.featured", relation: "ancestor" },
277
+ { selector: "#checkout-section", relation: "ancestor" }
278
+ ]
279
+ );
280
+ });
281
+
282
+ it("manages pinned element references, compact bar minimization, and element picker lifecycle", () => {
283
+ const widget = new ContextDropWidgetElement();
284
+ assert.equal(widget.getElementReferences().length, 0);
285
+
286
+ const btn = new MockElement({
287
+ tagName: "BUTTON",
288
+ attributes: { id: "submit-order" },
289
+ children: [new MockTextNode("Place Order")]
290
+ });
291
+ const snap = captureElementSnapshot(btn as unknown as Element, [], "ref_pin_1");
292
+ assert.ok(snap);
293
+
294
+ const added = widget.addElementReference(snap, btn as unknown as Element);
295
+ assert.equal(added, true);
296
+ assert.equal(widget.getElementReferences().length, 1);
297
+ assert.equal(widget.getElementReferences()[0].selector, "#submit-order");
298
+
299
+ widget.minimizeToCompactBar();
300
+ widget.expandFromCompactBar();
301
+
302
+ widget.removeElementReference("ref_pin_1");
303
+ assert.equal(widget.getElementReferences().length, 0);
304
+
305
+ let selectedRef: string | null = null;
306
+ const picker = new ElementPicker({
307
+ onSelect: (s) => {
308
+ selectedRef = s.selector;
309
+ },
310
+ onExit: () => {},
311
+ canSelectMore: () => true,
312
+ getExcludedSelectors: () => []
313
+ });
314
+
315
+ assert.equal(picker.isActive(), false);
316
+ assert.equal(picker.selectElementDirectly(btn as unknown as Element), true);
317
+ assert.equal(selectedRef, "#submit-order");
318
+ });
319
+ });
@@ -0,0 +1,87 @@
1
+ import type {
2
+ FeedbackSubmissionPayload,
3
+ FeedbackSubmissionResponse,
4
+ WidgetConfig
5
+ } from "./types.ts";
6
+
7
+ export class ContextDropApiClient {
8
+ private baseUrl: string;
9
+
10
+ constructor(baseUrl: string = "/context_drop") {
11
+ this.baseUrl = baseUrl.replace(/\/+$/, "") || "/context_drop";
12
+ }
13
+
14
+ public getCsrfToken(): string | null {
15
+ if (typeof document === "undefined") return null;
16
+ const meta = document.querySelector('meta[name="csrf-token"]');
17
+ return meta ? meta.getAttribute("content") : null;
18
+ }
19
+
20
+ public async fetchConfig(): Promise<WidgetConfig> {
21
+ const response = await fetch(`${this.baseUrl}/config`, {
22
+ method: "GET",
23
+ credentials: "same-origin",
24
+ headers: {
25
+ Accept: "application/json"
26
+ }
27
+ });
28
+
29
+ if (!response.ok) {
30
+ throw new Error(`Failed to fetch ContextDrop config (${response.status})`);
31
+ }
32
+
33
+ return (await response.json()) as WidgetConfig;
34
+ }
35
+
36
+ public async uploadAttachment(file: Blob, filename: string): Promise<{ signed_blob_id: string }> {
37
+ const formData = new FormData();
38
+ formData.append("file", file, filename);
39
+
40
+ const headers: Record<string, string> = {
41
+ Accept: "application/json"
42
+ };
43
+ const csrf = this.getCsrfToken();
44
+ if (csrf) {
45
+ headers["X-CSRF-Token"] = csrf;
46
+ }
47
+
48
+ const response = await fetch(`${this.baseUrl}/attachments`, {
49
+ method: "POST",
50
+ credentials: "same-origin",
51
+ headers,
52
+ body: formData
53
+ });
54
+
55
+ if (!response.ok) {
56
+ const errBody = await response.json().catch(() => ({}));
57
+ throw new Error((errBody as { error?: string }).error || `Upload failed (${response.status})`);
58
+ }
59
+
60
+ return (await response.json()) as { signed_blob_id: string };
61
+ }
62
+
63
+ public async submitFeedback(payload: FeedbackSubmissionPayload): Promise<FeedbackSubmissionResponse> {
64
+ const headers: Record<string, string> = {
65
+ "Content-Type": "application/json",
66
+ Accept: "application/json"
67
+ };
68
+ const csrf = this.getCsrfToken();
69
+ if (csrf) {
70
+ headers["X-CSRF-Token"] = csrf;
71
+ }
72
+
73
+ const response = await fetch(`${this.baseUrl}/feedback`, {
74
+ method: "POST",
75
+ credentials: "same-origin",
76
+ headers,
77
+ body: JSON.stringify({ feedback: payload })
78
+ });
79
+
80
+ if (!response.ok) {
81
+ const errBody = await response.json().catch(() => ({}));
82
+ throw new Error((errBody as { error?: string }).error || `Submission failed (${response.status})`);
83
+ }
84
+
85
+ return (await response.json()) as FeedbackSubmissionResponse;
86
+ }
87
+ }