@loopcheck/qa-sdk 1.0.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 (124) hide show
  1. package/ARCHITECTURE.md +59 -0
  2. package/README.md +131 -0
  3. package/dist/config/normalize.d.ts +6 -0
  4. package/dist/config/normalize.d.ts.map +1 -0
  5. package/dist/config/normalize.js +88 -0
  6. package/dist/config/normalize.js.map +1 -0
  7. package/dist/core/QAClient.d.ts +53 -0
  8. package/dist/core/QAClient.d.ts.map +1 -0
  9. package/dist/core/QAClient.js +318 -0
  10. package/dist/core/QAClient.js.map +1 -0
  11. package/dist/core/QAEventBus.d.ts +10 -0
  12. package/dist/core/QAEventBus.d.ts.map +1 -0
  13. package/dist/core/QAEventBus.js +27 -0
  14. package/dist/core/QAEventBus.js.map +1 -0
  15. package/dist/debug/Debugger.d.ts +4 -0
  16. package/dist/debug/Debugger.d.ts.map +1 -0
  17. package/dist/debug/Debugger.js +57 -0
  18. package/dist/debug/Debugger.js.map +1 -0
  19. package/dist/identity/AccessibilityResolver.d.ts +3 -0
  20. package/dist/identity/AccessibilityResolver.d.ts.map +1 -0
  21. package/dist/identity/AccessibilityResolver.js +76 -0
  22. package/dist/identity/AccessibilityResolver.js.map +1 -0
  23. package/dist/identity/DuplicateDetector.d.ts +9 -0
  24. package/dist/identity/DuplicateDetector.d.ts.map +1 -0
  25. package/dist/identity/DuplicateDetector.js +41 -0
  26. package/dist/identity/DuplicateDetector.js.map +1 -0
  27. package/dist/identity/ElementResolver.d.ts +5 -0
  28. package/dist/identity/ElementResolver.d.ts.map +1 -0
  29. package/dist/identity/ElementResolver.js +123 -0
  30. package/dist/identity/ElementResolver.js.map +1 -0
  31. package/dist/identity/FingerprintGenerator.d.ts +19 -0
  32. package/dist/identity/FingerprintGenerator.d.ts.map +1 -0
  33. package/dist/identity/FingerprintGenerator.js +49 -0
  34. package/dist/identity/FingerprintGenerator.js.map +1 -0
  35. package/dist/identity/IdentityValidator.d.ts +6 -0
  36. package/dist/identity/IdentityValidator.d.ts.map +1 -0
  37. package/dist/identity/IdentityValidator.js +28 -0
  38. package/dist/identity/IdentityValidator.js.map +1 -0
  39. package/dist/index.d.ts +10 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +9 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/instrumentation/DOMObserver.d.ts +13 -0
  44. package/dist/instrumentation/DOMObserver.d.ts.map +1 -0
  45. package/dist/instrumentation/DOMObserver.js +45 -0
  46. package/dist/instrumentation/DOMObserver.js.map +1 -0
  47. package/dist/instrumentation/ElementRegistry.d.ts +15 -0
  48. package/dist/instrumentation/ElementRegistry.d.ts.map +1 -0
  49. package/dist/instrumentation/ElementRegistry.js +53 -0
  50. package/dist/instrumentation/ElementRegistry.js.map +1 -0
  51. package/dist/instrumentation/EventDelegator.d.ts +31 -0
  52. package/dist/instrumentation/EventDelegator.d.ts.map +1 -0
  53. package/dist/instrumentation/EventDelegator.js +238 -0
  54. package/dist/instrumentation/EventDelegator.js.map +1 -0
  55. package/dist/instrumentation/FormHarvest.d.ts +33 -0
  56. package/dist/instrumentation/FormHarvest.d.ts.map +1 -0
  57. package/dist/instrumentation/FormHarvest.js +179 -0
  58. package/dist/instrumentation/FormHarvest.js.map +1 -0
  59. package/dist/instrumentation/NavigationObserver.d.ts +11 -0
  60. package/dist/instrumentation/NavigationObserver.d.ts.map +1 -0
  61. package/dist/instrumentation/NavigationObserver.js +52 -0
  62. package/dist/instrumentation/NavigationObserver.js.map +1 -0
  63. package/dist/instrumentation/compiler-contract.d.ts +11 -0
  64. package/dist/instrumentation/compiler-contract.d.ts.map +1 -0
  65. package/dist/instrumentation/compiler-contract.js +11 -0
  66. package/dist/instrumentation/compiler-contract.js.map +1 -0
  67. package/dist/privacy/Redactor.d.ts +4 -0
  68. package/dist/privacy/Redactor.d.ts.map +1 -0
  69. package/dist/privacy/Redactor.js +50 -0
  70. package/dist/privacy/Redactor.js.map +1 -0
  71. package/dist/react/QAButton.d.ts +595 -0
  72. package/dist/react/QAButton.d.ts.map +1 -0
  73. package/dist/react/QAButton.js +22 -0
  74. package/dist/react/QAButton.js.map +1 -0
  75. package/dist/react/QAProvider.d.ts +23 -0
  76. package/dist/react/QAProvider.d.ts.map +1 -0
  77. package/dist/react/QAProvider.js +41 -0
  78. package/dist/react/QAProvider.js.map +1 -0
  79. package/dist/react/index.d.ts +4 -0
  80. package/dist/react/index.d.ts.map +1 -0
  81. package/dist/react/index.js +5 -0
  82. package/dist/react/index.js.map +1 -0
  83. package/dist/react/useQAElement.d.ts +3 -0
  84. package/dist/react/useQAElement.d.ts.map +1 -0
  85. package/dist/react/useQAElement.js +23 -0
  86. package/dist/react/useQAElement.js.map +1 -0
  87. package/dist/recorder/EventNormalizer.d.ts +32 -0
  88. package/dist/recorder/EventNormalizer.d.ts.map +1 -0
  89. package/dist/recorder/EventNormalizer.js +113 -0
  90. package/dist/recorder/EventNormalizer.js.map +1 -0
  91. package/dist/recorder.iife.js +14 -0
  92. package/dist/transport/BatchQueue.d.ts +26 -0
  93. package/dist/transport/BatchQueue.d.ts.map +1 -0
  94. package/dist/transport/BatchQueue.js +110 -0
  95. package/dist/transport/BatchQueue.js.map +1 -0
  96. package/dist/transport/HTTPTransport.d.ts +36 -0
  97. package/dist/transport/HTTPTransport.d.ts.map +1 -0
  98. package/dist/transport/HTTPTransport.js +121 -0
  99. package/dist/transport/HTTPTransport.js.map +1 -0
  100. package/dist/transport/RetryQueue.d.ts +10 -0
  101. package/dist/transport/RetryQueue.d.ts.map +1 -0
  102. package/dist/transport/RetryQueue.js +60 -0
  103. package/dist/transport/RetryQueue.js.map +1 -0
  104. package/dist/types/index.d.ts +147 -0
  105. package/dist/types/index.d.ts.map +1 -0
  106. package/dist/types/index.js +24 -0
  107. package/dist/types/index.js.map +1 -0
  108. package/dist/utils/dom.d.ts +24 -0
  109. package/dist/utils/dom.d.ts.map +1 -0
  110. package/dist/utils/dom.js +152 -0
  111. package/dist/utils/dom.js.map +1 -0
  112. package/dist/utils/env.d.ts +2 -0
  113. package/dist/utils/env.d.ts.map +1 -0
  114. package/dist/utils/env.js +6 -0
  115. package/dist/utils/env.js.map +1 -0
  116. package/dist/utils/id.d.ts +13 -0
  117. package/dist/utils/id.d.ts.map +1 -0
  118. package/dist/utils/id.js +74 -0
  119. package/dist/utils/id.js.map +1 -0
  120. package/dist/utils/safe.d.ts +5 -0
  121. package/dist/utils/safe.d.ts.map +1 -0
  122. package/dist/utils/safe.js +28 -0
  123. package/dist/utils/safe.js.map +1 -0
  124. package/package.json +79 -0
@@ -0,0 +1,59 @@
1
+ # QA SDK architecture
2
+
3
+ ```
4
+ CUSTOMER APP
5
+
6
+
7
+ ┌──────────────┐
8
+ │ QA SDK │
9
+ │ │
10
+ │ Element ID │
11
+ │ Recording │
12
+ │ Metadata │
13
+ └──────┬───────┘
14
+
15
+
16
+ Recording API → Automation IR → Source Resolver → ID injection PR
17
+ ```
18
+
19
+ ## Package layout
20
+
21
+ | Module | Responsibility |
22
+ |---|---|
23
+ | `core` | `QAClient`, event bus, lifecycle |
24
+ | `identity` | explicit IDs, a11y, fingerprints, duplicates |
25
+ | `instrumentation` | delegated events, MutationObserver, SPA navigation |
26
+ | `recorder` | session + semantic normalization |
27
+ | `privacy` | redaction / sensitive-field detection |
28
+ | `transport` | HTTP, batching, bounded offline retry |
29
+ | `react` | `QAProvider`, `useQA`, `useQAElement` |
30
+
31
+ The runtime is framework-agnostic. React is an adapter. Vue/Angular can call `new QAClient(config)` later.
32
+
33
+ ## Identity resolution order
34
+
35
+ 1. explicit `data-qa-id`
36
+ 2. SDK-stamped metadata (`useQAElement`)
37
+ 3. accessibility name/role
38
+ 4. semantic HTML
39
+ 5. stable attributes (`name`, `type`, non-React `id`)
40
+ 6. `data-qa-source` (compiler-injected)
41
+ 7. deterministic fingerprint
42
+
43
+ ## Compiler boundary
44
+
45
+ A future `@loopcheck/qa-sdk-instrumentation` plugin may inject:
46
+
47
+ ```html
48
+ <button data-qa-source="src/checkout/Checkout.tsx:42">
49
+ ```
50
+
51
+ The runtime only **reads** that attribute. It does not depend on source maps or React internals.
52
+
53
+ ## Conflicts with this repo
54
+
55
+ - Loopcheck already uses pnpm workspaces + Turbo + `@loopcheck/*`. The SDK follows that; it is **not** `@company/qa-sdk`.
56
+ - The API uses Jest; this package uses Vitest + Playwright because it is a browser SDK.
57
+ - The Loopcheck web app is the **platform**, not the customer app. The example lives under `packages/qa-sdk/example` so the dashboard is not instrumented by default.
58
+ - No privileged env vars are read. Recording ingest is `POST /v1/sdk/events`. Publishable-key session create is `POST /v1/sdk/sessions`.
59
+ - Durable keys are `lc_pk_…` (`PUBLISHABLE`). One-shot extension tokens remain `lc_pub_…` (`SESSION`) and are revoked when that recording stops.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # @loopcheck/qa-sdk
2
+
3
+ Browser instrumentation for Loopcheck. The SDK answers two questions:
4
+
5
+ 1. **What did the user interact with?**
6
+ 2. **How do we identify that element stably?**
7
+
8
+ It does **not** generate Playwright tests — that belongs to the Loopcheck automation engine.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pnpm add @loopcheck/qa-sdk
14
+ ```
15
+
16
+ Peer dependency: `react` >= 18 (only if you use `@loopcheck/qa-sdk/react`).
17
+
18
+ ## Quick start (publishable key)
19
+
20
+ 1. In Loopcheck → **Project settings** → **Publishable keys**, create a key (`lc_pk_…`).
21
+ 2. Wrap your app. The SDK stays **inert** until you call `startRecording()` (safe behind a login wall).
22
+
23
+ ```tsx
24
+ "use client";
25
+
26
+ import { QAProvider, useQA } from "@loopcheck/qa-sdk/react";
27
+
28
+ export function Providers({ children }: { children: React.ReactNode }) {
29
+ return (
30
+ <QAProvider
31
+ config={{
32
+ apiUrl: "https://api.loopcheck.dev", // your Loopcheck API origin
33
+ publishableKey: process.env.NEXT_PUBLIC_LOOPCHECK_KEY!,
34
+ // default: inert until startRecording()
35
+ startOn: "manual",
36
+ }}
37
+ >
38
+ {children}
39
+ </QAProvider>
40
+ );
41
+ }
42
+
43
+ function RecordToolbar() {
44
+ const { startRecording, stopRecording, isRecording } = useQA();
45
+ return (
46
+ <button
47
+ type="button"
48
+ onClick={() => void (isRecording ? stopRecording() : startRecording())}
49
+ >
50
+ {isRecording ? "Stop" : "Record"}
51
+ </button>
52
+ );
53
+ }
54
+ ```
55
+
56
+ `startRecording()`:
57
+
58
+ 1. `POST /v1/sdk/sessions` with the publishable key (no Loopcheck login cookie).
59
+ 2. Attaches listeners and posts events to `/v1/sdk/events`.
60
+
61
+ ### Auth gate
62
+
63
+ Keep the provider mounted for all visitors. Call `startRecording()` only after the user is logged into **your** app (or from an internal QA toolbar). Until then the SDK does not attach listeners or make network calls.
64
+
65
+ To arm from the URL instead:
66
+
67
+ ```tsx
68
+ <QAProvider
69
+ config={{
70
+ apiUrl: "https://api.loopcheck.dev",
71
+ publishableKey: process.env.NEXT_PUBLIC_LOOPCHECK_KEY!,
72
+ startOn: "query", // starts when ?lc_record=1
73
+ }}
74
+ >
75
+ ```
76
+
77
+ ## Configuration
78
+
79
+ | Field | Default | Purpose |
80
+ |---|---|---|
81
+ | `publishableKey` | — | Durable browser key (`lc_pk_…`) from project settings |
82
+ | `apiUrl` | `""` | Loopcheck API origin |
83
+ | `projectId` | resolved on session create | Optional; filled from the key response |
84
+ | `sessionId` | — | Pre-minted session (extension / dashboard flow) |
85
+ | `publicClientToken` | — | Alias / legacy session token (`lc_pub_…`) |
86
+ | `startOn` | `manual` | `manual` \| `query` (`?lc_record=1`) |
87
+ | `enabled` | `true` | Master switch |
88
+ | `mode` | inert for key-only installs | `disabled` \| `observe` \| `record` |
89
+ | `debug` | `false` | Inspector overlay (Alt+Shift+hover) |
90
+ | `recordInputValues` | `false` | Values are `[REDACTED]` unless true **and** not sensitive |
91
+ | `transport` | HTTP | Replaceable `QATransport` |
92
+
93
+ All config is passed explicitly. The SDK never reads `DATABASE_URL`, `SESSION_SECRET`, or other server secrets.
94
+
95
+ ## React helpers
96
+
97
+ ```tsx
98
+ import { QAProvider, useQA, useQAElement, QAButton } from "@loopcheck/qa-sdk/react";
99
+
100
+ function CheckoutButton() {
101
+ const ref = useQAElement("checkout.continue");
102
+ return <button ref={ref}>Continue</button>;
103
+ }
104
+ ```
105
+
106
+ Or stamp the DOM yourself:
107
+
108
+ ```html
109
+ <button data-qa-id="checkout.continue">Continue</button>
110
+ ```
111
+
112
+ ## Privacy
113
+
114
+ Always redacted: passwords, `data-qa-sensitive`, card/CVV-like fields, and `redactSelectors`. Fetch uses `credentials: "omit"`. Cookies and authorization headers are never collected.
115
+
116
+ ## Extension / dashboard recording
117
+
118
+ The Chrome extension and Loopcheck record UI still mint one-shot `lc_pub_…` session tokens via `recordings.create`. That path is unchanged. Prefer publishable keys for embedding the SDK in a customer app.
119
+
120
+ ## Troubleshooting
121
+
122
+ | Symptom | Fix |
123
+ |---|---|
124
+ | Empty recordings | Call `startRecording()` after login, or set `startOn: "query"` with `?lc_record=1` |
125
+ | `401` on session create | Check the publishable key and optional origin allowlist |
126
+ | Hydration warnings | Keep `QAProvider` in a Client Component |
127
+ | Network errors in console | Swallowed by default; use `onError` |
128
+
129
+ ## License
130
+
131
+ Proprietary — UNLICENSED. Not open source.
@@ -0,0 +1,6 @@
1
+ import { type QAConfig, type QAMode, type QAStartOn, type ResolvedQAConfig } from '../types';
2
+ export declare function findPrivilegedConfigKeys(config: QAConfig): string[];
3
+ export declare function normalizeConfig(input: QAConfig): ResolvedQAConfig;
4
+ export declare function effectiveMode(config: ResolvedQAConfig): QAMode;
5
+ export declare function shouldArmFromQuery(startOn: QAStartOn): boolean;
6
+ //# sourceMappingURL=normalize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize.d.ts","sourceRoot":"","sources":["../../src/config/normalize.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,QAAQ,EACb,KAAK,MAAM,EACX,KAAK,SAAS,EACd,KAAK,gBAAgB,EACtB,MAAM,UAAU,CAAC;AAYlB,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,QAAQ,GAAG,MAAM,EAAE,CAOnE;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,QAAQ,GAAG,gBAAgB,CAgDjE;AAcD,wBAAgB,aAAa,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAK9D;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,SAAS,GAAG,OAAO,CAS9D"}
@@ -0,0 +1,88 @@
1
+ import { DEFAULT_INTERACTIVE_SELECTORS, } from '../types';
2
+ const FORBIDDEN_CONFIG_KEYS = new Set([
3
+ 'DATABASE_URL',
4
+ 'SECRET_KEY',
5
+ 'AUTH_SECRET',
6
+ 'PRIVATE_API_KEY',
7
+ 'GITHUB_APP_PRIVATE_KEY',
8
+ 'ENCRYPTION_MASTER_KEY',
9
+ 'SESSION_SECRET',
10
+ ]);
11
+ export function findPrivilegedConfigKeys(config) {
12
+ return Object.keys(config).filter((key) => {
13
+ if (key === 'publicClientToken' || key === 'publishableKey') {
14
+ return false;
15
+ }
16
+ return FORBIDDEN_CONFIG_KEYS.has(key) || /secret|private.?key|password/i.test(key);
17
+ });
18
+ }
19
+ export function normalizeConfig(input) {
20
+ const enabled = input.enabled !== false;
21
+ const environment = input.environment ?? 'development';
22
+ const publishableKey = input.publishableKey ?? undefined;
23
+ const publicClientToken = input.publicClientToken ?? publishableKey;
24
+ const startOn = input.startOn ?? 'manual';
25
+ const hasSession = Boolean(input.sessionId);
26
+ const keyedWithoutSession = Boolean(publicClientToken) && !hasSession;
27
+ // Publishable-key installs stay inert until startRecording() / ?lc_record=1.
28
+ // Extension / dashboard bootstraps pass sessionId + mode: 'record'.
29
+ let defaultMode;
30
+ if (keyedWithoutSession && input.mode === undefined) {
31
+ defaultMode = 'disabled';
32
+ }
33
+ else if (environment.toLowerCase() === 'production') {
34
+ defaultMode = 'disabled';
35
+ }
36
+ else {
37
+ defaultMode = 'observe';
38
+ }
39
+ const mode = !enabled ? 'disabled' : (input.mode ?? defaultMode);
40
+ const extraSelectors = (input.interactiveSelectors ?? []).filter((selector) => typeof selector === 'string' && selector.trim().length > 0);
41
+ return {
42
+ projectId: input.projectId ?? '',
43
+ sessionId: input.sessionId,
44
+ environment,
45
+ apiUrl: input.apiUrl ?? '',
46
+ publishableKey,
47
+ publicClientToken,
48
+ enabled,
49
+ mode,
50
+ startOn,
51
+ debug: Boolean(input.debug),
52
+ interactiveSelectors: Array.from(new Set([...DEFAULT_INTERACTIVE_SELECTORS, ...extraSelectors])),
53
+ redactSelectors: (input.redactSelectors ?? []).filter(Boolean),
54
+ allowUnstableIds: Boolean(input.allowUnstableIds),
55
+ recordHover: Boolean(input.recordHover),
56
+ recordInputValues: Boolean(input.recordInputValues),
57
+ flushIntervalMs: clampNumber(input.flushIntervalMs, 250, 5000, 500),
58
+ maxBatchSize: clampNumber(input.maxBatchSize, 1, 100, 25),
59
+ maxQueueSize: clampNumber(input.maxQueueSize, 20, 500, 200),
60
+ maxStorageBytes: clampNumber(input.maxStorageBytes, 4_096, 64_000, 32_000),
61
+ transport: input.transport,
62
+ onError: input.onError,
63
+ };
64
+ }
65
+ function clampNumber(value, min, max, fallback) {
66
+ if (typeof value !== 'number' || Number.isNaN(value)) {
67
+ return fallback;
68
+ }
69
+ return Math.min(max, Math.max(min, value));
70
+ }
71
+ export function effectiveMode(config) {
72
+ if (!config.enabled) {
73
+ return 'disabled';
74
+ }
75
+ return config.mode;
76
+ }
77
+ export function shouldArmFromQuery(startOn) {
78
+ if (startOn !== 'query' || typeof window === 'undefined') {
79
+ return false;
80
+ }
81
+ try {
82
+ return new URLSearchParams(window.location.search).get('lc_record') === '1';
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ //# sourceMappingURL=normalize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize.js","sourceRoot":"","sources":["../../src/config/normalize.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ import type { QAClientEventMap, QAConfig, QAElementResolution, QAListener, QARecording, ResolvedQAConfig } from '../types';
2
+ export interface QASnapshot {
3
+ isRecording: boolean;
4
+ isPaused: boolean;
5
+ mode: ResolvedQAConfig['mode'];
6
+ }
7
+ export declare class QAClient {
8
+ readonly config: ResolvedQAConfig;
9
+ private readonly bus;
10
+ private readonly subscribers;
11
+ private session;
12
+ private registry;
13
+ private observer;
14
+ private delegator;
15
+ private navigation;
16
+ private queue;
17
+ private normalizer;
18
+ private started;
19
+ private snapshot;
20
+ private prefillTimer;
21
+ private prefillRaf;
22
+ private sessionBootstrap;
23
+ private queryArmed;
24
+ constructor(input: QAConfig);
25
+ start(): void;
26
+ /**
27
+ * Call after mount for publishable-key installs with `startOn: "query"`.
28
+ * Arms when the URL contains `?lc_record=1`.
29
+ */
30
+ maybeArmFromQuery(): void;
31
+ shutdown(): Promise<void>;
32
+ startRecording(): Promise<void>;
33
+ /** Synchronous arming once a session (or local transport) is ready. */
34
+ private beginRecording;
35
+ stopRecording(): QARecording | null;
36
+ pauseRecording(): void;
37
+ resumeRecording(): void;
38
+ get isRecording(): boolean;
39
+ get isPaused(): boolean;
40
+ resolveElement(element: Element): QAElementResolution;
41
+ on<K extends keyof QAClientEventMap>(event: K, listener: QAListener<QAClientEventMap[K]>): () => void;
42
+ subscribe: (onStoreChange: () => void) => (() => void);
43
+ getSnapshot: () => QASnapshot;
44
+ static getServerSnapshot: () => QASnapshot;
45
+ private attachInstrumentation;
46
+ private ensureSession;
47
+ private schedulePrefillHarvest;
48
+ private clearPrefillHarvest;
49
+ private emitEvent;
50
+ private setSnapshot;
51
+ private report;
52
+ }
53
+ //# sourceMappingURL=QAClient.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"QAClient.d.ts","sourceRoot":"","sources":["../../src/core/QAClient.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,QAAQ,EACR,mBAAmB,EAEnB,UAAU,EACV,WAAW,EACX,gBAAgB,EACjB,MAAM,UAAU,CAAC;AAqBlB,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,OAAO,CAAC;IACrB,QAAQ,EAAE,OAAO,CAAC;IAClB,IAAI,EAAE,gBAAgB,CAAC,MAAM,CAAC,CAAC;CAChC;AAQD,qBAAa,QAAQ;IACnB,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAsC;IAC1D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAyB;IACrD,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,QAAQ,CAAgC;IAChD,OAAO,CAAC,QAAQ,CAA4B;IAC5C,OAAO,CAAC,SAAS,CAA+B;IAChD,OAAO,CAAC,UAAU,CAAmC;IACrD,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,UAAU,CAAgC;IAClD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAA6B;IAC7C,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,UAAU,CAAqB;IACvC,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,UAAU,CAAS;gBAEf,KAAK,EAAE,QAAQ;IAgB3B,KAAK,IAAI,IAAI;IAYb;;;OAGG;IACH,iBAAiB,IAAI,IAAI;IAQnB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAiBzB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAYrC,uEAAuE;IACvE,OAAO,CAAC,cAAc;IAiCtB,aAAa,IAAI,WAAW,GAAG,IAAI;IAqBnC,cAAc,IAAI,IAAI;IAQtB,eAAe,IAAI,IAAI;IAQvB,IAAI,WAAW,IAAI,OAAO,CAEzB;IAED,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,mBAAmB;IAOrD,EAAE,CAAC,CAAC,SAAS,MAAM,gBAAgB,EACjC,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,UAAU,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,GACxC,MAAM,IAAI;IAIb,SAAS,GAAI,eAAe,MAAM,IAAI,KAAG,CAAC,MAAM,IAAI,CAAC,CAKnD;IAEF,WAAW,QAAO,UAAU,CAAkB;IAE9C,MAAM,CAAC,iBAAiB,QAAO,UAAU,CAAkB;IAE3D,OAAO,CAAC,qBAAqB;YAsDf,aAAa;IAyC3B,OAAO,CAAC,sBAAsB;IAkB9B,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,SAAS;IASjB,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,MAAM;CAOf"}
@@ -0,0 +1,318 @@
1
+ import { effectiveMode, findPrivilegedConfigKeys, normalizeConfig, shouldArmFromQuery, } from '../config/normalize';
2
+ import { QAEventBus } from './QAEventBus';
3
+ import { ElementRegistry } from '../instrumentation/ElementRegistry';
4
+ import { DOMObserver } from '../instrumentation/DOMObserver';
5
+ import { EventDelegator } from '../instrumentation/EventDelegator';
6
+ import { NavigationObserver } from '../instrumentation/NavigationObserver';
7
+ import { EventNormalizer, RecordingSession, buildEvent } from '../recorder/EventNormalizer';
8
+ import { BatchQueue } from '../transport/BatchQueue';
9
+ import { createSdkSession } from '../transport/HTTPTransport';
10
+ import { isBrowser } from '../utils/id';
11
+ import { asQAError, swallow } from '../utils/safe';
12
+ import { attachDebugger, detachDebugger } from '../debug/Debugger';
13
+ import { resolveElement } from '../identity/ElementResolver';
14
+ import { PREFILL_HARVEST_MS } from '../instrumentation/FormHarvest';
15
+ const IDLE_SNAPSHOT = {
16
+ isRecording: false,
17
+ isPaused: false,
18
+ mode: 'disabled',
19
+ };
20
+ export class QAClient {
21
+ config;
22
+ bus = new QAEventBus();
23
+ subscribers = new Set();
24
+ session = null;
25
+ registry = null;
26
+ observer = null;
27
+ delegator = null;
28
+ navigation = null;
29
+ queue = null;
30
+ normalizer = null;
31
+ started = false;
32
+ snapshot = IDLE_SNAPSHOT;
33
+ prefillTimer;
34
+ prefillRaf;
35
+ sessionBootstrap = null;
36
+ queryArmed = false;
37
+ constructor(input) {
38
+ const privileged = findPrivilegedConfigKeys(input);
39
+ this.config = normalizeConfig(input);
40
+ if (privileged.length > 0) {
41
+ this.report({
42
+ name: 'QAConfigError',
43
+ message: `Ignored privileged config keys: ${privileged.join(', ')}`,
44
+ });
45
+ }
46
+ this.snapshot = {
47
+ isRecording: false,
48
+ isPaused: false,
49
+ mode: effectiveMode(this.config),
50
+ };
51
+ }
52
+ start() {
53
+ swallow(() => {
54
+ if (this.started || !isBrowser() || effectiveMode(this.config) === 'disabled') {
55
+ return;
56
+ }
57
+ this.attachInstrumentation();
58
+ if (this.config.mode === 'record') {
59
+ void this.beginRecording();
60
+ }
61
+ }, (error) => this.report(error));
62
+ }
63
+ /**
64
+ * Call after mount for publishable-key installs with `startOn: "query"`.
65
+ * Arms when the URL contains `?lc_record=1`.
66
+ */
67
+ maybeArmFromQuery() {
68
+ if (this.queryArmed || !shouldArmFromQuery(this.config.startOn)) {
69
+ return;
70
+ }
71
+ this.queryArmed = true;
72
+ void this.startRecording();
73
+ }
74
+ async shutdown() {
75
+ this.clearPrefillHarvest();
76
+ this.normalizer?.dispose();
77
+ this.delegator?.stop();
78
+ this.observer?.stop();
79
+ this.navigation?.stop();
80
+ detachDebugger();
81
+ await this.queue?.shutdown();
82
+ this.started = false;
83
+ this.registry = null;
84
+ this.observer = null;
85
+ this.delegator = null;
86
+ this.navigation = null;
87
+ this.queue = null;
88
+ this.normalizer = null;
89
+ }
90
+ async startRecording() {
91
+ try {
92
+ if (!isBrowser() || this.config.enabled === false) {
93
+ return;
94
+ }
95
+ await this.ensureSession();
96
+ this.beginRecording();
97
+ }
98
+ catch (error) {
99
+ this.report(asQAError(error));
100
+ }
101
+ }
102
+ /** Synchronous arming once a session (or local transport) is ready. */
103
+ beginRecording() {
104
+ swallow(() => {
105
+ if (!isBrowser() || this.config.enabled === false) {
106
+ return;
107
+ }
108
+ this.config.mode = 'record';
109
+ this.config.enabled = true;
110
+ if (!this.started) {
111
+ this.attachInstrumentation();
112
+ }
113
+ if (this.session && !this.session.recording.endedAt) {
114
+ this.session.resume();
115
+ this.setSnapshot({ isRecording: true, isPaused: false, mode: 'record' });
116
+ this.bus.emit('recording:resume', null);
117
+ return;
118
+ }
119
+ this.session = new RecordingSession(window.location.href);
120
+ this.setSnapshot({ isRecording: true, isPaused: false, mode: 'record' });
121
+ this.bus.emit('recording:start', this.session.recording);
122
+ this.emitEvent(buildEvent('navigation', {
123
+ page: {
124
+ url: window.location.href,
125
+ pathname: window.location.pathname || '/',
126
+ title: document.title || undefined,
127
+ },
128
+ }));
129
+ this.schedulePrefillHarvest();
130
+ }, (error) => this.report(error));
131
+ }
132
+ stopRecording() {
133
+ let result = null;
134
+ swallow(() => {
135
+ this.clearPrefillHarvest();
136
+ this.delegator?.harvestPrefills('prefilled');
137
+ this.normalizer?.flushInput();
138
+ if (!this.session) {
139
+ return;
140
+ }
141
+ result = this.session.stop();
142
+ this.session = null;
143
+ this.setSnapshot({
144
+ isRecording: false,
145
+ isPaused: false,
146
+ mode: this.config.mode === 'record' ? 'observe' : this.config.mode,
147
+ });
148
+ this.bus.emit('recording:stop', result);
149
+ }, (error) => this.report(error));
150
+ return result;
151
+ }
152
+ pauseRecording() {
153
+ swallow(() => {
154
+ this.session?.pause();
155
+ this.setSnapshot({ isPaused: true });
156
+ this.bus.emit('recording:pause', null);
157
+ }, (error) => this.report(error));
158
+ }
159
+ resumeRecording() {
160
+ swallow(() => {
161
+ this.session?.resume();
162
+ this.setSnapshot({ isPaused: false });
163
+ this.bus.emit('recording:resume', null);
164
+ }, (error) => this.report(error));
165
+ }
166
+ get isRecording() {
167
+ return this.snapshot.isRecording;
168
+ }
169
+ get isPaused() {
170
+ return this.snapshot.isPaused;
171
+ }
172
+ resolveElement(element) {
173
+ if (!this.registry) {
174
+ return resolveElement(element, { allowUnstableIds: this.config.allowUnstableIds });
175
+ }
176
+ return this.registry.resolve(element);
177
+ }
178
+ on(event, listener) {
179
+ return this.bus.on(event, listener);
180
+ }
181
+ subscribe = (onStoreChange) => {
182
+ this.subscribers.add(onStoreChange);
183
+ return () => {
184
+ this.subscribers.delete(onStoreChange);
185
+ };
186
+ };
187
+ getSnapshot = () => this.snapshot;
188
+ static getServerSnapshot = () => IDLE_SNAPSHOT;
189
+ attachInstrumentation() {
190
+ if (this.started || !isBrowser()) {
191
+ return;
192
+ }
193
+ this.started = true;
194
+ this.registry = new ElementRegistry(this.config, (hits) => {
195
+ for (const hit of hits) {
196
+ this.bus.emit('duplicate-id', hit);
197
+ if (this.config.debug || this.config.environment !== 'production') {
198
+ console.warn(`Duplicate QA ID detected:\n${hit.qaId}\nElements:\n${hit.sources.map((source, index) => `${index + 1}. ${source}`).join('\n')}`);
199
+ }
200
+ }
201
+ }, (resolution) => this.bus.emit('element:resolved', resolution));
202
+ this.observer = new DOMObserver(this.config, this.registry);
203
+ this.observer.start(document);
204
+ this.navigation = new NavigationObserver((url, pathname) => {
205
+ this.normalizer?.flushInput();
206
+ this.emitEvent(buildEvent('navigation', {
207
+ page: { url, pathname, title: document.title || undefined },
208
+ }));
209
+ void this.queue?.flush();
210
+ });
211
+ this.navigation.start();
212
+ this.normalizer = new EventNormalizer((partial) => {
213
+ this.emitEvent(buildEvent(partial.type, {
214
+ target: partial.target,
215
+ metadata: partial.metadata,
216
+ page: partial.page,
217
+ }));
218
+ });
219
+ this.delegator = new EventDelegator(this.config, this.registry, this.normalizer, () => this.isRecording && !this.isPaused);
220
+ this.delegator.start();
221
+ this.queue = new BatchQueue(this.config);
222
+ this.queue.start();
223
+ if (this.config.debug) {
224
+ attachDebugger(this);
225
+ }
226
+ }
227
+ async ensureSession() {
228
+ if (this.config.sessionId) {
229
+ return;
230
+ }
231
+ const key = this.config.publishableKey ?? this.config.publicClientToken;
232
+ if (!key) {
233
+ // Local / memory transport path — no remote session required.
234
+ return;
235
+ }
236
+ if (!this.config.apiUrl) {
237
+ throw new Error('apiUrl is required to create a recording session');
238
+ }
239
+ if (!this.sessionBootstrap) {
240
+ this.sessionBootstrap = (async () => {
241
+ const created = await createSdkSession({
242
+ apiUrl: this.config.apiUrl,
243
+ publishableKey: key,
244
+ initialUrl: window.location.href,
245
+ projectId: this.config.projectId || undefined,
246
+ });
247
+ this.config.sessionId = created.sessionId;
248
+ this.config.projectId = created.projectId;
249
+ if (created.apiUrl) {
250
+ this.config.apiUrl = created.apiUrl;
251
+ }
252
+ this.queue?.updateSession({
253
+ sessionId: created.sessionId,
254
+ projectId: created.projectId,
255
+ apiUrl: this.config.apiUrl,
256
+ publicClientToken: key,
257
+ });
258
+ })();
259
+ }
260
+ try {
261
+ await this.sessionBootstrap;
262
+ }
263
+ catch (error) {
264
+ this.sessionBootstrap = null;
265
+ throw error;
266
+ }
267
+ }
268
+ schedulePrefillHarvest() {
269
+ this.clearPrefillHarvest();
270
+ const run = () => {
271
+ this.prefillTimer = setTimeout(() => {
272
+ this.prefillTimer = undefined;
273
+ this.delegator?.harvestPrefills('prefilled');
274
+ }, PREFILL_HARVEST_MS);
275
+ };
276
+ if (typeof requestAnimationFrame === 'function') {
277
+ this.prefillRaf = requestAnimationFrame(() => {
278
+ this.prefillRaf = undefined;
279
+ run();
280
+ });
281
+ }
282
+ else {
283
+ run();
284
+ }
285
+ }
286
+ clearPrefillHarvest() {
287
+ if (this.prefillRaf !== undefined && typeof cancelAnimationFrame === 'function') {
288
+ cancelAnimationFrame(this.prefillRaf);
289
+ this.prefillRaf = undefined;
290
+ }
291
+ if (this.prefillTimer) {
292
+ clearTimeout(this.prefillTimer);
293
+ this.prefillTimer = undefined;
294
+ }
295
+ }
296
+ emitEvent(event) {
297
+ if (!this.snapshot.isRecording || this.snapshot.isPaused) {
298
+ return;
299
+ }
300
+ this.session?.push(event);
301
+ this.bus.emit('event', event);
302
+ this.queue?.enqueue(event);
303
+ }
304
+ setSnapshot(patch) {
305
+ this.snapshot = { ...this.snapshot, ...patch };
306
+ for (const subscriber of this.subscribers) {
307
+ subscriber();
308
+ }
309
+ }
310
+ report(error) {
311
+ this.config.onError?.(error);
312
+ this.bus.emit('error', error);
313
+ if (this.config.debug) {
314
+ console.warn('[loopcheck-qa]', error.message);
315
+ }
316
+ }
317
+ }
318
+ //# sourceMappingURL=QAClient.js.map