codetac 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.
- package/LICENSE +21 -0
- package/package.json +60 -0
- package/readme.md +172 -0
- package/src/action-view.mjs +104 -0
- package/src/ai.mjs +504 -0
- package/src/boundaries.mjs +483 -0
- package/src/browser/bar.js +593 -0
- package/src/cli.mjs +556 -0
- package/src/detect.mjs +198 -0
- package/src/diagnose.mjs +121 -0
- package/src/digest.mjs +317 -0
- package/src/home.mjs +15 -0
- package/src/origins.mjs +182 -0
- package/src/page.mjs +220 -0
- package/src/panel.mjs +800 -0
- package/src/recording.mjs +124 -0
- package/src/redact.mjs +35 -0
- package/src/register.mjs +94 -0
- package/src/runtime.mjs +353 -0
- package/src/sentences.mjs +209 -0
- package/src/store.mjs +376 -0
- package/src/transform.mjs +255 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
// CodeTAC page script, injected by the observed Node server into HTML pages.
|
|
2
|
+
// It records user actions (click or submit): the element, the component and
|
|
3
|
+
// the handler, the requests that followed (each marked with the action id),
|
|
4
|
+
// and what changed on screen. It does not trace browser functions one by one.
|
|
5
|
+
// Everything the page does is left as it was: the originals are always called.
|
|
6
|
+
(() => {
|
|
7
|
+
'use strict';
|
|
8
|
+
if (window.__codetacPage) return;
|
|
9
|
+
window.__codetacPage = true;
|
|
10
|
+
const config = window.__CODETAC_CONFIG__ || {};
|
|
11
|
+
const ENDPOINT = '/__codetac/events';
|
|
12
|
+
const HEADER = 'x-codetac-action';
|
|
13
|
+
const COOKIE = 'codetac_action';
|
|
14
|
+
const QUIET_MS = 600;
|
|
15
|
+
const MAX_MS = 15000;
|
|
16
|
+
const originalFetch = window.fetch;
|
|
17
|
+
const now = () => performance.now();
|
|
18
|
+
let host = null; // the bar's element, excluded from everything observed
|
|
19
|
+
let current = null; // newest open action: new requests and screen changes go to it
|
|
20
|
+
const open = new Set();
|
|
21
|
+
const recorded = []; // actions sent from this page, for the bar
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Stacks (only positions in scripts, resolved later by the panel)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Numeric cache-busting parameters (Vite's ?t=, Next's ?v=) look like phone
|
|
27
|
+
// numbers to the redaction; the scripts are served without them too.
|
|
28
|
+
function withoutCacheBusters(url) {
|
|
29
|
+
return url.replace(/([?&])[\w-]+=\d{8,}(?=&|$)/g, '$1').replace(/\?&+/, '?').replace(/&&+/g, '&').replace(/[?&]$/, '');
|
|
30
|
+
}
|
|
31
|
+
function parseStack(stack) {
|
|
32
|
+
const frames = [];
|
|
33
|
+
for (const line of String(stack || '').split('\n')) {
|
|
34
|
+
let match = line.match(/^\s*at (?:async )?(.*?) \((.*):(\d+):(\d+)\)\s*$/) || line.match(/^\s*at (?:async )?()(.*):(\d+):(\d+)\s*$/)
|
|
35
|
+
|| line.match(/^(.*?)@(.*):(\d+):(\d+)$/);
|
|
36
|
+
if (!match) continue;
|
|
37
|
+
const url = withoutCacheBusters(match[2]);
|
|
38
|
+
if (url.includes('/__codetac/') || !/^(https?|webpack-internal):/.test(url)) continue;
|
|
39
|
+
// "HTMLButtonElement.saveNote", "Object.onClick": the receiver is not part of the name.
|
|
40
|
+
const fn = (match[1] || '').replace(/^(?:[\w$]+\.)+(?=[\w$]+$)/, '') || undefined;
|
|
41
|
+
frames.push({ fn, url, line: Number(match[3]), column: Number(match[4]) });
|
|
42
|
+
if (frames.length >= 16) break;
|
|
43
|
+
}
|
|
44
|
+
return frames;
|
|
45
|
+
}
|
|
46
|
+
function stackHere() {
|
|
47
|
+
const limit = Error.stackTraceLimit;
|
|
48
|
+
try { Error.stackTraceLimit = 40; } catch {}
|
|
49
|
+
const stack = new Error().stack;
|
|
50
|
+
try { Error.stackTraceLimit = limit; } catch {}
|
|
51
|
+
return parseStack(stack);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Element, component and handler
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
const INTERACTIVE = 'a[href],button,input,select,textarea,label,summary,[role=button],[role=link],[role=menuitem],[role=tab],[role=checkbox],[role=switch],[role=option],[onclick],[contenteditable=""],[contenteditable=true]';
|
|
58
|
+
const clean = (value, max = 80) => typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : undefined;
|
|
59
|
+
function describeElement(element) {
|
|
60
|
+
const tag = element.tagName.toLowerCase();
|
|
61
|
+
const type = element.getAttribute('type') || undefined;
|
|
62
|
+
// Never the value of a field: only what identifies it on screen.
|
|
63
|
+
const isField = tag === 'input' || tag === 'textarea' || tag === 'select';
|
|
64
|
+
const label = element.getAttribute('aria-label') || element.getAttribute('title')
|
|
65
|
+
|| (isField ? element.getAttribute('placeholder') || element.labels?.[0]?.textContent : undefined);
|
|
66
|
+
let text = isField || tag === 'form' ? undefined : element.innerText || element.textContent;
|
|
67
|
+
if (isField && (type === 'submit' || type === 'button')) text = element.value;
|
|
68
|
+
return { tag, type, role: element.getAttribute('role') || undefined, text: clean(text), label: clean(label),
|
|
69
|
+
name: element.getAttribute('name') || undefined, id: element.id || undefined,
|
|
70
|
+
href: tag === 'a' ? element.getAttribute('href') || undefined : undefined };
|
|
71
|
+
}
|
|
72
|
+
function reactKey(node, prefix) {
|
|
73
|
+
for (const key in node) if (key.startsWith(prefix)) return node[key];
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
function componentName(fiber) {
|
|
77
|
+
const type = fiber && (fiber.type || fiber.elementType);
|
|
78
|
+
if (!type) return fiber && typeof fiber.name === 'string' ? fiber.name : undefined;
|
|
79
|
+
if (typeof type === 'function') return type.displayName || type.name || undefined;
|
|
80
|
+
if (typeof type === 'object') {
|
|
81
|
+
return type.displayName || (type.render && (type.render.displayName || type.render.name))
|
|
82
|
+
|| (type.type && (type.type.displayName || type.type.name)) || undefined;
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
function isComposite(fiber) {
|
|
87
|
+
return fiber && (typeof fiber.type === 'function' || (fiber.type && typeof fiber.type === 'object' && (fiber.type.render || fiber.type.type)));
|
|
88
|
+
}
|
|
89
|
+
function creationFrames(fiber) {
|
|
90
|
+
// React 19 (development): the stack where the element was created, i.e.
|
|
91
|
+
// the JSX line inside its component. React 18: _debugSource.
|
|
92
|
+
// Only the frames above React's marker belong to the owner's render.
|
|
93
|
+
if (fiber && fiber._debugStack) {
|
|
94
|
+
const stack = String(fiber._debugStack.stack || '');
|
|
95
|
+
const bottom = stack.search(/react[-_]stack[-_]bottom[-_]frame/);
|
|
96
|
+
return parseStack(bottom >= 0 ? stack.slice(0, stack.lastIndexOf('\n', bottom)) : stack).slice(0, 8);
|
|
97
|
+
}
|
|
98
|
+
const source = fiber && fiber._debugSource;
|
|
99
|
+
if (source && source.fileName) return [{ file: source.fileName, line: source.lineNumber, column: source.columnNumber }];
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
// React 19 form actions (<form action={fn}>, <button formAction={fn}>) count as submit handlers.
|
|
103
|
+
const HANDLERS = { click: ['onClick', 'onClickCapture', 'onMouseDown', 'onMouseUp', 'onPointerDown', 'onPointerUp'], submit: ['onSubmit', 'onSubmitCapture', 'action', 'formAction'] };
|
|
104
|
+
function reactTrigger(target, kind) {
|
|
105
|
+
for (let element = target; element && element !== document.documentElement; element = element.parentElement) {
|
|
106
|
+
const fiber = reactKey(element, '__reactFiber$');
|
|
107
|
+
if (!fiber) continue;
|
|
108
|
+
const props = fiber.memoizedProps || {};
|
|
109
|
+
const prop = HANDLERS[kind].find(name => typeof props[name] === 'function');
|
|
110
|
+
if (!prop) continue;
|
|
111
|
+
let owner = fiber._debugOwner;
|
|
112
|
+
if (!owner) { owner = fiber.return; while (owner && !isComposite(owner)) owner = owner.return; }
|
|
113
|
+
// An inline function takes the name of the prop it was written in.
|
|
114
|
+
const name = handlerName(props[prop]);
|
|
115
|
+
return { element, handler: { name: name === prop ? '(função escrita no próprio elemento)' : name, prop, source: 'react' },
|
|
116
|
+
component: componentOf(fiber, owner) };
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
// The component that rendered the element, and the components that rendered
|
|
121
|
+
// it in turn (each with where it was written), so that the panel can name the
|
|
122
|
+
// first one from the project when the element comes from a library.
|
|
123
|
+
function componentOf(fiber, owner) {
|
|
124
|
+
const owners = [];
|
|
125
|
+
for (let item = owner; item && owners.length < 6; item = item._debugOwner || null) {
|
|
126
|
+
const name = componentName(item);
|
|
127
|
+
if (name) owners.push({ name, frames: creationFrames(item) });
|
|
128
|
+
}
|
|
129
|
+
return { name: componentName(owner), frames: creationFrames(fiber), owners: owners.slice(1) };
|
|
130
|
+
}
|
|
131
|
+
function handlerName(fn) {
|
|
132
|
+
const name = String(fn && fn.name || '').replace(/^bound /, '');
|
|
133
|
+
return name || '(função anónima)';
|
|
134
|
+
}
|
|
135
|
+
// Listeners added with addEventListener are remembered by element and type,
|
|
136
|
+
// only their names, so a non-React page can still name the handler.
|
|
137
|
+
const listeners = new WeakMap();
|
|
138
|
+
const addEventListener = EventTarget.prototype.addEventListener;
|
|
139
|
+
EventTarget.prototype.addEventListener = function (type, listener, options) {
|
|
140
|
+
try {
|
|
141
|
+
if (listener && (type === 'click' || type === 'submit') && this instanceof Element) {
|
|
142
|
+
const list = listeners.get(this) || [];
|
|
143
|
+
if (list.length < 20) list.push({ type, name: handlerName(typeof listener === 'function' ? listener : listener.handleEvent) });
|
|
144
|
+
listeners.set(this, list);
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
return addEventListener.call(this, type, listener, options);
|
|
148
|
+
};
|
|
149
|
+
function domTrigger(target, kind) {
|
|
150
|
+
for (let element = target; element && element !== document.documentElement; element = element.parentElement) {
|
|
151
|
+
// Page-wide listeners (on <body>, or React's own on its root container)
|
|
152
|
+
// are not the handler of the element that was clicked.
|
|
153
|
+
if (element === document.body || element._reactRootContainer || reactKey(element, '__reactContainer$')) break;
|
|
154
|
+
const inline = kind === 'click' ? element.onclick : element.onsubmit;
|
|
155
|
+
if (typeof inline === 'function') return { element, handler: { name: element.hasAttribute('on' + kind) ? '(atributo on' + kind + ')' : handlerName(inline), source: 'dom' } };
|
|
156
|
+
const found = (listeners.get(element) || []).find(item => item.type === kind);
|
|
157
|
+
if (found) return { element, handler: { name: found.name, source: 'dom' } };
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Actions
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
const randomId = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
166
|
+
function newAction(id, segment, trigger) {
|
|
167
|
+
const action = { id, segment, trigger, page: location.pathname + location.search, startedAt: Date.now(), start: now(), last: now(), requests: [], pending: 0, next: (segment - 1) * 1000 + 1,
|
|
168
|
+
screen: { added: 0, removed: 0, text: 0, attributes: 0, title: false, stateChanged: new Map(), mounted: new Map(), unmounted: new Map() },
|
|
169
|
+
navigations: [], element: null, timer: null, closed: false };
|
|
170
|
+
open.add(action);
|
|
171
|
+
current = action;
|
|
172
|
+
schedule(action);
|
|
173
|
+
return action;
|
|
174
|
+
}
|
|
175
|
+
function touch(action) { action.last = now(); schedule(action); }
|
|
176
|
+
function schedule(action) {
|
|
177
|
+
clearTimeout(action.timer);
|
|
178
|
+
action.timer = setTimeout(() => check(action), QUIET_MS);
|
|
179
|
+
}
|
|
180
|
+
function check(action) {
|
|
181
|
+
if (action.closed) return;
|
|
182
|
+
if (now() - action.start > MAX_MS) return close(action, 'tempo máximo');
|
|
183
|
+
if (action.pending > 0 || now() - action.last < QUIET_MS) return schedule(action);
|
|
184
|
+
close(action, 'sem atividade');
|
|
185
|
+
}
|
|
186
|
+
function listOf(map) {
|
|
187
|
+
return [...map.values()].slice(0, 40).map(item => ({ name: item.name, count: item.count, frames: item.frames }));
|
|
188
|
+
}
|
|
189
|
+
function payload(action, closedBy) {
|
|
190
|
+
const s = action.screen;
|
|
191
|
+
return { actionId: action.id, segment: action.segment, origin: location.origin, page: action.page,
|
|
192
|
+
startedAt: action.startedAt, durationMs: Math.round(now() - action.start), closedBy, trigger: action.trigger,
|
|
193
|
+
requests: action.requests.map(({ n, kind, method, url, sameOrigin, host: domain, startMs, durationMs, status, error, frames }) =>
|
|
194
|
+
({ n, kind, method, url, sameOrigin, host: domain, startMs, durationMs, status, error, frames })),
|
|
195
|
+
screen: { added: s.added, removed: s.removed, text: s.text, attributes: s.attributes, title: s.title,
|
|
196
|
+
stateChanged: listOf(s.stateChanged), mounted: listOf(s.mounted), unmounted: listOf(s.unmounted) },
|
|
197
|
+
navigations: action.navigations, scripts: pageScripts() };
|
|
198
|
+
}
|
|
199
|
+
// Scripts of this page, so that positions inside code evaluated from
|
|
200
|
+
// strings (webpack "eval" modes) can be found by the panel.
|
|
201
|
+
function pageScripts() {
|
|
202
|
+
const urls = new Set();
|
|
203
|
+
try {
|
|
204
|
+
for (const script of document.scripts) if (script.src) urls.add(script.src);
|
|
205
|
+
for (const entry of performance.getEntriesByType('resource')) if (entry.initiatorType === 'script') urls.add(entry.name);
|
|
206
|
+
} catch {}
|
|
207
|
+
return [...urls].filter(url => url.startsWith(location.origin + '/') && !url.includes('/__codetac/')).map(withoutCacheBusters).slice(0, 80);
|
|
208
|
+
}
|
|
209
|
+
function close(action, closedBy, leaving) {
|
|
210
|
+
if (action.closed) return;
|
|
211
|
+
action.closed = true;
|
|
212
|
+
clearTimeout(action.timer);
|
|
213
|
+
open.delete(action);
|
|
214
|
+
if (current === action) current = null;
|
|
215
|
+
if (!leaving) clearCookie();
|
|
216
|
+
const body = JSON.stringify(payload(action, closedBy));
|
|
217
|
+
try {
|
|
218
|
+
if (leaving && navigator.sendBeacon) navigator.sendBeacon(ENDPOINT, new Blob([body], { type: 'text/plain' }));
|
|
219
|
+
else originalFetch.call(window, ENDPOINT, { method: 'POST', body, keepalive: body.length < 60000, headers: { 'content-type': 'text/plain' } }).catch(() => {});
|
|
220
|
+
} catch {}
|
|
221
|
+
if (action.trigger || action.requests.length) {
|
|
222
|
+
recorded.push({ id: action.id, label: labelOf(action), requests: action.requests.length, closedBy });
|
|
223
|
+
if (recorded.length > 30) recorded.shift();
|
|
224
|
+
updateBar(true);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function labelOf(action) {
|
|
228
|
+
const element = action.trigger && action.trigger.element;
|
|
229
|
+
if (!element) return action.continues ? action.continues + ' (continuação)' : 'continuação após navegação';
|
|
230
|
+
const kinds = { a: 'ligação', button: 'botão', input: 'campo', select: 'lista', textarea: 'texto', label: 'etiqueta', summary: 'secção', form: 'formulário' };
|
|
231
|
+
const kind = element.role === 'button' || (element.tag === 'input' && /submit|button/.test(element.type || '')) ? 'botão' : kinds[element.tag] || element.tag;
|
|
232
|
+
const text = element.text || element.label || element.name || element.id;
|
|
233
|
+
return kind + (text ? ' «' + text + '»' : '');
|
|
234
|
+
}
|
|
235
|
+
// Navigations that load a new document carry the action in a short cookie;
|
|
236
|
+
// the new page continues the same action until it is quiet.
|
|
237
|
+
function setCookie(id) { document.cookie = COOKIE + '=' + id + '; path=/; max-age=15; samesite=lax'; }
|
|
238
|
+
function clearCookie() { if (document.cookie.includes(COOKIE + '=')) document.cookie = COOKIE + '=; path=/; max-age=0; samesite=lax'; }
|
|
239
|
+
function continued() {
|
|
240
|
+
const match = document.cookie.match(new RegExp('(?:^|;\\s*)' + COOKIE + '=([A-Za-z0-9_-]{6,40})'));
|
|
241
|
+
if (!match) return;
|
|
242
|
+
clearCookie();
|
|
243
|
+
let segment = 2;
|
|
244
|
+
try { segment = Number(sessionStorage.getItem('codetac:segment:' + match[1]) || 1) + 1; sessionStorage.setItem('codetac:segment:' + match[1], String(segment)); } catch {}
|
|
245
|
+
const action = newAction(match[1], segment, null);
|
|
246
|
+
action.navigations.push({ kind: 'documento', to: location.pathname + location.search, atMs: 0 });
|
|
247
|
+
try { action.continues = sessionStorage.getItem('codetac:label:' + match[1]); } catch {}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Clicking into a text field only focuses it; typing is not an action.
|
|
251
|
+
const TEXT_INPUT = /^(text|email|password|search|tel|url|number|date|datetime-local|month|week|time|color)?$/;
|
|
252
|
+
function onlyFocus(element) {
|
|
253
|
+
const tag = element.tagName;
|
|
254
|
+
return tag === 'TEXTAREA' || tag === 'SELECT' || element.isContentEditable
|
|
255
|
+
|| (tag === 'INPUT' && TEXT_INPUT.test((element.getAttribute('type') || '').toLowerCase()));
|
|
256
|
+
}
|
|
257
|
+
// One gesture can produce several events: a click on a submit button is
|
|
258
|
+
// followed by the form's submit, and a click on a label by a click on its
|
|
259
|
+
// field. They join the action of the first event instead of opening another.
|
|
260
|
+
function sameGesture(event, kind, target) {
|
|
261
|
+
const action = current;
|
|
262
|
+
if (!action || action.closed || !action.element || now() - action.start > 1000) return null;
|
|
263
|
+
if (kind === 'submit') {
|
|
264
|
+
const submitter = event.submitter;
|
|
265
|
+
return submitter && (submitter === action.element || submitter.contains(action.element) || action.element.contains(submitter)) ? action : null;
|
|
266
|
+
}
|
|
267
|
+
if (kind === 'click' && action.element.tagName === 'LABEL' && action.element.control === target && now() - action.start < 100) return action;
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
function onUserEvent(event) {
|
|
271
|
+
const target = event.target;
|
|
272
|
+
if (!(target instanceof Element) || (host && (target === host || host.contains(target)))) return;
|
|
273
|
+
const kind = event.type === 'submit' ? 'submit' : event.type === 'change' ? 'change' : 'click';
|
|
274
|
+
if (kind === 'change' && !(target.tagName === 'SELECT' || (target.tagName === 'INPUT' && /^(file|range)$/i.test(target.type)))) return;
|
|
275
|
+
const joined = sameGesture(event, kind, target);
|
|
276
|
+
if (joined) {
|
|
277
|
+
if (kind === 'submit') {
|
|
278
|
+
const found = reactTrigger(target, 'submit') || domTrigger(target, 'submit');
|
|
279
|
+
if (found && !joined.trigger.handler) joined.trigger.handler = found.handler;
|
|
280
|
+
else if (found) joined.trigger.submit = { handler: found.handler };
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const lookup = kind === 'change' ? 'click' : kind;
|
|
285
|
+
const found = kind === 'change' ? null : reactTrigger(target, lookup) || domTrigger(target, lookup);
|
|
286
|
+
// The element is the interactive one that was clicked; a handler found on
|
|
287
|
+
// an ancestor (event delegation) does not replace it.
|
|
288
|
+
const element = kind === 'click' ? target.closest(INTERACTIVE) || (found && found.element) : target;
|
|
289
|
+
if (!element || (kind === 'click' && onlyFocus(element) && !(found && found.element === element))) return;
|
|
290
|
+
const trigger = { event: kind, element: describeElement(element), handler: found ? found.handler : undefined,
|
|
291
|
+
component: found ? found.component : undefined };
|
|
292
|
+
if (kind === 'change') {
|
|
293
|
+
const fiber = reactKey(element, '__reactFiber$');
|
|
294
|
+
const props = (fiber && fiber.memoizedProps) || {};
|
|
295
|
+
if (typeof props.onChange === 'function') trigger.handler = { name: handlerName(props.onChange) === 'onChange' ? '(função escrita no próprio elemento)' : handlerName(props.onChange), prop: 'onChange', source: 'react' };
|
|
296
|
+
}
|
|
297
|
+
if (!trigger.component) {
|
|
298
|
+
const fiber = reactKey(element, '__reactFiber$');
|
|
299
|
+
if (fiber) {
|
|
300
|
+
let owner = fiber._debugOwner;
|
|
301
|
+
if (!owner) { owner = fiber.return; while (owner && !isComposite(owner)) owner = owner.return; }
|
|
302
|
+
trigger.component = componentOf(fiber, owner);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const action = newAction(randomId(), 1, trigger);
|
|
306
|
+
action.element = element;
|
|
307
|
+
try {
|
|
308
|
+
sessionStorage.setItem('codetac:segment:' + action.id, '1');
|
|
309
|
+
sessionStorage.setItem('codetac:label:' + action.id, labelOf(action));
|
|
310
|
+
} catch {}
|
|
311
|
+
setCookie(action.id);
|
|
312
|
+
}
|
|
313
|
+
addEventListener.call(window, 'click', onUserEvent, true);
|
|
314
|
+
addEventListener.call(window, 'submit', onUserEvent, true);
|
|
315
|
+
addEventListener.call(window, 'change', onUserEvent, true);
|
|
316
|
+
addEventListener.call(window, 'pagehide', () => {
|
|
317
|
+
for (const action of [...open]) {
|
|
318
|
+
action.navigations.push({ kind: 'saída da página', to: location.pathname, atMs: Math.round(now() - action.start) });
|
|
319
|
+
close(action, 'navegação', true);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Requests
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
function track(kind, method, rawUrl) {
|
|
327
|
+
const action = current;
|
|
328
|
+
if (!action || action.closed) return null;
|
|
329
|
+
let url;
|
|
330
|
+
try { url = new URL(rawUrl, location.href); } catch { return null; }
|
|
331
|
+
if (url.pathname.startsWith('/__codetac/') && url.origin === location.origin) return null;
|
|
332
|
+
const sameOrigin = url.origin === location.origin;
|
|
333
|
+
const entry = { n: action.next++, kind, method: String(method || 'GET').toUpperCase(), url: url.href, sameOrigin,
|
|
334
|
+
host: sameOrigin ? undefined : url.host, startMs: Math.round(now() - action.start), frames: stackHere(), started: now() };
|
|
335
|
+
action.requests.push(entry);
|
|
336
|
+
action.pending++;
|
|
337
|
+
touch(action);
|
|
338
|
+
return { action, entry, header: sameOrigin ? action.id + '.' + entry.n : null };
|
|
339
|
+
}
|
|
340
|
+
function done(tracked, status, error) {
|
|
341
|
+
if (!tracked) return;
|
|
342
|
+
tracked.entry.durationMs = Math.round(now() - tracked.entry.started);
|
|
343
|
+
tracked.entry.status = status;
|
|
344
|
+
tracked.entry.error = error || undefined;
|
|
345
|
+
tracked.action.pending--;
|
|
346
|
+
touch(tracked.action);
|
|
347
|
+
}
|
|
348
|
+
if (typeof originalFetch === 'function') {
|
|
349
|
+
window.fetch = function fetch(input, init) {
|
|
350
|
+
let tracked = null;
|
|
351
|
+
try {
|
|
352
|
+
const isRequest = typeof Request !== 'undefined' && input instanceof Request;
|
|
353
|
+
tracked = track('fetch', (init && init.method) || (isRequest ? input.method : 'GET'), isRequest ? input.url : String(input));
|
|
354
|
+
if (tracked && tracked.header) {
|
|
355
|
+
const headers = new Headers((init && init.headers) || (isRequest ? input.headers : undefined));
|
|
356
|
+
headers.set(HEADER, tracked.header);
|
|
357
|
+
init = Object.assign({}, init, { headers });
|
|
358
|
+
}
|
|
359
|
+
} catch { tracked = null; }
|
|
360
|
+
const result = originalFetch.call(this, input, init);
|
|
361
|
+
if (tracked) result.then(response => done(tracked, response.status, false), () => done(tracked, undefined, true));
|
|
362
|
+
return result;
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const xhrOpen = XMLHttpRequest.prototype.open;
|
|
366
|
+
const xhrSend = XMLHttpRequest.prototype.send;
|
|
367
|
+
XMLHttpRequest.prototype.open = function (method, url) {
|
|
368
|
+
this.__codetac = { method, url };
|
|
369
|
+
return xhrOpen.apply(this, arguments);
|
|
370
|
+
};
|
|
371
|
+
XMLHttpRequest.prototype.send = function () {
|
|
372
|
+
const info = this.__codetac;
|
|
373
|
+
let tracked = null;
|
|
374
|
+
try {
|
|
375
|
+
tracked = info ? track('xhr', info.method, info.url) : null;
|
|
376
|
+
if (tracked && tracked.header) this.setRequestHeader(HEADER, tracked.header);
|
|
377
|
+
if (tracked) addEventListener.call(this, 'loadend', () => done(tracked, this.status || undefined, !this.status));
|
|
378
|
+
} catch {}
|
|
379
|
+
return xhrSend.apply(this, arguments);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Screen: DOM changes, React state changes, navigation
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
function outsideBar(node) { return !host || (node !== host && !host.contains(node)); }
|
|
386
|
+
const observer = new MutationObserver(records => {
|
|
387
|
+
const action = current;
|
|
388
|
+
if (!action || action.closed) return;
|
|
389
|
+
let counted = false;
|
|
390
|
+
for (const record of records) {
|
|
391
|
+
if (!outsideBar(record.target)) continue;
|
|
392
|
+
counted = true;
|
|
393
|
+
if (record.type === 'childList') {
|
|
394
|
+
action.screen.added += record.addedNodes.length;
|
|
395
|
+
action.screen.removed += record.removedNodes.length;
|
|
396
|
+
if (record.target.nodeName === 'TITLE') action.screen.title = true;
|
|
397
|
+
} else if (record.type === 'characterData') action.screen.text++;
|
|
398
|
+
else action.screen.attributes++;
|
|
399
|
+
}
|
|
400
|
+
if (counted) touch(action);
|
|
401
|
+
});
|
|
402
|
+
observer.observe(document, { childList: true, subtree: true, characterData: true, attributes: true });
|
|
403
|
+
|
|
404
|
+
// React reports each commit to the DevTools hook. An existing hook (the
|
|
405
|
+
// extension, React Refresh) is kept and chained; otherwise a minimal one is
|
|
406
|
+
// defined, with the same shape React Refresh uses.
|
|
407
|
+
// A component is placed where it is written: the elements it returns are
|
|
408
|
+
// created inside its own render, so their position is its own file. (Where
|
|
409
|
+
// it is used may be a framework's router.)
|
|
410
|
+
function count(map, fiber) {
|
|
411
|
+
const name = componentName(fiber);
|
|
412
|
+
if (!name) return;
|
|
413
|
+
const item = map.get(name);
|
|
414
|
+
if (item) item.count++;
|
|
415
|
+
else if (map.size < 40) map.set(name, { name, count: 1, frames: (fiber.child && fiber.child._debugOwner === fiber && creationFrames(fiber.child)) || creationFrames(fiber) });
|
|
416
|
+
}
|
|
417
|
+
// A state change is a useState/useReducer value (or a class state object)
|
|
418
|
+
// that differs from the previous commit, not a mere re-render.
|
|
419
|
+
function changedState(fiber, previous) {
|
|
420
|
+
if (fiber.tag === 1) return fiber.memoizedState !== previous.memoizedState;
|
|
421
|
+
let hook = fiber.memoizedState;
|
|
422
|
+
let old = previous.memoizedState;
|
|
423
|
+
for (let guard = 0; hook && old && typeof hook === 'object' && guard < 200; guard++) {
|
|
424
|
+
if (hook.queue && typeof hook.queue.dispatch === 'function' && !Object.is(hook.memoizedState, old.memoizedState)) return true;
|
|
425
|
+
hook = hook.next;
|
|
426
|
+
old = old.next;
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
// Only subtrees touched by this commit are visited: an untouched subtree
|
|
431
|
+
// keeps the very same child fiber as before (the check React DevTools uses).
|
|
432
|
+
function onCommit(root) {
|
|
433
|
+
const action = current;
|
|
434
|
+
if (!action || action.closed || !root || !root.current) return;
|
|
435
|
+
let seen = 0;
|
|
436
|
+
const stack = [root.current];
|
|
437
|
+
while (stack.length && seen < 20000) {
|
|
438
|
+
const fiber = stack.pop();
|
|
439
|
+
seen++;
|
|
440
|
+
const previous = fiber.alternate;
|
|
441
|
+
if (isComposite(fiber)) {
|
|
442
|
+
if (!previous) count(action.screen.mounted, fiber);
|
|
443
|
+
else if (changedState(fiber, previous)) count(action.screen.stateChanged, fiber);
|
|
444
|
+
}
|
|
445
|
+
if (!previous || fiber.child !== previous.child) {
|
|
446
|
+
for (let child = fiber.child; child; child = child.sibling) stack.push(child);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
touch(action);
|
|
450
|
+
}
|
|
451
|
+
function onUnmount(fiber) {
|
|
452
|
+
const action = current;
|
|
453
|
+
if (action && !action.closed && isComposite(fiber)) count(action.screen.unmounted, fiber);
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
let hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
457
|
+
if (!hook) {
|
|
458
|
+
let nextId = 1;
|
|
459
|
+
hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { renderers: new Map(), supportsFiber: true,
|
|
460
|
+
inject(renderer) { const id = nextId++; this.renderers.set(id, renderer); return id; },
|
|
461
|
+
onScheduleFiberRoot() {}, onCommitFiberRoot() {}, onCommitFiberUnmount() {}, onPostCommitFiberRoot() {}, checkDCE() {} };
|
|
462
|
+
}
|
|
463
|
+
const commit = hook.onCommitFiberRoot;
|
|
464
|
+
hook.onCommitFiberRoot = function (id, root) {
|
|
465
|
+
try { onCommit(root); } catch {}
|
|
466
|
+
return typeof commit === 'function' ? commit.apply(this, arguments) : undefined;
|
|
467
|
+
};
|
|
468
|
+
const unmount = hook.onCommitFiberUnmount;
|
|
469
|
+
hook.onCommitFiberUnmount = function (id, fiber) {
|
|
470
|
+
try { onUnmount(fiber); } catch {}
|
|
471
|
+
return typeof unmount === 'function' ? unmount.apply(this, arguments) : undefined;
|
|
472
|
+
};
|
|
473
|
+
} catch {}
|
|
474
|
+
|
|
475
|
+
for (const name of ['pushState', 'replaceState']) {
|
|
476
|
+
const original = history[name];
|
|
477
|
+
history[name] = function () {
|
|
478
|
+
const result = original.apply(this, arguments);
|
|
479
|
+
try {
|
|
480
|
+
const action = current;
|
|
481
|
+
if (action && !action.closed) {
|
|
482
|
+
action.navigations.push({ kind: name === 'pushState' ? 'endereço mudou' : 'endereço substituído', to: location.pathname + location.search, atMs: Math.round(now() - action.start) });
|
|
483
|
+
touch(action);
|
|
484
|
+
}
|
|
485
|
+
} catch {}
|
|
486
|
+
return result;
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
addEventListener.call(window, 'popstate', () => {
|
|
490
|
+
if (!current || current.closed) return;
|
|
491
|
+
// A link to a fragment (#secção, or a hash router) also fires popstate:
|
|
492
|
+
// it is not the back/forward buttons. Only short, plain fragments are
|
|
493
|
+
// shown (an access token can travel in the fragment).
|
|
494
|
+
// The same when the code of the click changes location.hash: the back
|
|
495
|
+
// button makes no click in the page, so a popstate right after one is
|
|
496
|
+
// the page's own doing.
|
|
497
|
+
const anchor = current.element && current.element.closest && current.element.closest('a[href]');
|
|
498
|
+
const byPage = now() - current.start < 1000;
|
|
499
|
+
const hash = byPage && /^#[\w\/-]{1,40}$/.test(location.hash) ? location.hash : '';
|
|
500
|
+
const kind = !byPage ? 'recuar/avançar' : anchor && anchor.hash ? 'ligação interna' : 'endereço mudou';
|
|
501
|
+
current.navigations.push({ kind, to: location.pathname + location.search + hash, atMs: Math.round(now() - current.start) });
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// Bar: a small closed shadow root, fixed in a corner, outside the page's layout.
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
let shadow = null;
|
|
508
|
+
let shown = null;
|
|
509
|
+
function mountBar() {
|
|
510
|
+
if (host || !document.documentElement) return;
|
|
511
|
+
host = document.createElement('codetac-bar');
|
|
512
|
+
host.setAttribute('style', 'all:initial;position:fixed;right:12px;bottom:12px;z-index:2147483647;display:block;');
|
|
513
|
+
shadow = host.attachShadow({ mode: 'closed' });
|
|
514
|
+
shadow.innerHTML = '<style>' +
|
|
515
|
+
':host{all:initial}*{box-sizing:border-box;font:12px/1.3 system-ui,-apple-system,"Segoe UI",sans-serif}' +
|
|
516
|
+
'.pill{display:flex;align-items:center;gap:6px;padding:5px 10px;border-radius:999px;background:rgba(24,24,27,.82);color:#f4f4f5;border:0;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.25);max-width:280px}' +
|
|
517
|
+
'.pill:hover{background:rgba(24,24,27,.95)}.dot{width:8px;height:8px;border-radius:50%;background:#71717a;flex:none}.dot.on{background:#22c55e}' +
|
|
518
|
+
'.flash .dot{animation:f 1s ease-out}@keyframes f{0%{transform:scale(1.8);background:#4ade80}100%{transform:scale(1)}}' +
|
|
519
|
+
'.label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
520
|
+
'.sheet{position:fixed;right:12px;bottom:48px;width:min(760px,calc(100vw - 24px));height:min(640px,calc(100vh - 72px));background:#fff;border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden}' +
|
|
521
|
+
'.top{display:flex;gap:6px;align-items:center;padding:6px 8px;background:#18181b;color:#f4f4f5}.top b{margin-right:auto}' +
|
|
522
|
+
'.top button,.top a{all:unset;cursor:pointer;padding:2px 8px;border-radius:5px;color:#e4e4e7}.top button:hover,.top a:hover{background:#3f3f46}' +
|
|
523
|
+
'iframe{border:0;flex:1;width:100%}.msg{padding:16px;color:#27272a}</style>' +
|
|
524
|
+
'<button class="pill" part="pill" title="CodeTAC: carregue para ver o dossiê da última ação"><span class="dot"></span><span class="label">CodeTAC</span></button>';
|
|
525
|
+
shadow.querySelector('.pill').addEventListener('click', () => toggleSheet());
|
|
526
|
+
document.documentElement.appendChild(host);
|
|
527
|
+
updateBar(false);
|
|
528
|
+
}
|
|
529
|
+
function updateBar(flash) {
|
|
530
|
+
if (!shadow) return;
|
|
531
|
+
const pill = shadow.querySelector('.pill');
|
|
532
|
+
const last = recorded[recorded.length - 1];
|
|
533
|
+
shadow.querySelector('.dot').classList.toggle('on', Boolean(last));
|
|
534
|
+
shadow.querySelector('.label').textContent = last ? 'Gravada: ' + last.label : 'CodeTAC';
|
|
535
|
+
if (flash) { pill.classList.remove('flash'); void pill.offsetWidth; pill.classList.add('flash'); }
|
|
536
|
+
if (shown && flash) showAction(recorded.length - 1);
|
|
537
|
+
}
|
|
538
|
+
function toggleSheet() {
|
|
539
|
+
if (shown) { shown.remove(); shown = null; return; }
|
|
540
|
+
if (recorded.length) { showAction(recorded.length - 1); return; }
|
|
541
|
+
// Nothing recorded on this page yet: say so instead of doing nothing.
|
|
542
|
+
const panel = String(config.panel || 'http://127.0.0.1:4000').replace(/\/$/, '');
|
|
543
|
+
shown = document.createElement('div');
|
|
544
|
+
shown.className = 'sheet';
|
|
545
|
+
shown.style.height = 'auto';
|
|
546
|
+
shown.innerHTML = '<div class="top"><b>CodeTAC</b><a target="_blank" rel="noopener">painel ↗</a><button data-close title="Fechar">✕</button></div>' +
|
|
547
|
+
'<div class="msg">Ainda não há ações gravadas nesta página. Carregue num botão, envie um formulário ou siga uma ligação da aplicação: ' +
|
|
548
|
+
'a ação aparece aqui. As ações de outras páginas estão no painel.</div>';
|
|
549
|
+
shown.querySelector('a').href = panel + '/';
|
|
550
|
+
shown.querySelector('[data-close]').addEventListener('click', () => { shown.remove(); shown = null; });
|
|
551
|
+
shadow.appendChild(shown);
|
|
552
|
+
}
|
|
553
|
+
function showAction(index) {
|
|
554
|
+
const item = recorded[index];
|
|
555
|
+
if (!item) return;
|
|
556
|
+
const panel = String(config.panel || 'http://127.0.0.1:4000').replace(/\/$/, '');
|
|
557
|
+
const url = panel + '/?embed=1&action=' + encodeURIComponent(item.id);
|
|
558
|
+
if (!shown) {
|
|
559
|
+
shown = document.createElement('div');
|
|
560
|
+
shown.className = 'sheet';
|
|
561
|
+
shadow.appendChild(shown);
|
|
562
|
+
}
|
|
563
|
+
shown.innerHTML = '<div class="top"><b></b><button data-go="-1" title="Ação anterior">◀</button><button data-go="1" title="Ação seguinte">▶</button>' +
|
|
564
|
+
'<a target="_blank" rel="noopener" title="Abrir no painel">painel ↗</a><button data-close title="Fechar">✕</button></div>' +
|
|
565
|
+
'<iframe title="Dossiê da ação"></iframe>';
|
|
566
|
+
shown.querySelector('b').textContent = (index + 1) + '/' + recorded.length + ' · ' + item.label;
|
|
567
|
+
shown.querySelector('a').href = panel + '/?action=' + encodeURIComponent(item.id);
|
|
568
|
+
shown.querySelector('iframe').src = url;
|
|
569
|
+
shown.querySelector('[data-close]').addEventListener('click', () => { shown.remove(); shown = null; });
|
|
570
|
+
for (const button of shown.querySelectorAll('[data-go]')) {
|
|
571
|
+
button.addEventListener('click', () => showAction(Math.max(0, Math.min(recorded.length - 1, index + Number(button.dataset.go)))));
|
|
572
|
+
}
|
|
573
|
+
// The panel runs separately; without it the frame would stay blank.
|
|
574
|
+
originalFetch.call(window, panel + '/api/ping', { mode: 'no-cors', cache: 'no-store' }).catch(() => {
|
|
575
|
+
if (!shown) return;
|
|
576
|
+
const frame = shown.querySelector('iframe');
|
|
577
|
+
const message = document.createElement('div');
|
|
578
|
+
message.className = 'msg';
|
|
579
|
+
message.textContent = 'O painel do CodeTAC não está a correr. Na pasta do CodeTAC: npm run panel';
|
|
580
|
+
frame.replaceWith(message);
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
// Mounted after the page has loaded, so it never takes part in hydration.
|
|
584
|
+
function whenLoaded() {
|
|
585
|
+
const mount = () => setTimeout(mountBar, 300);
|
|
586
|
+
if (document.readyState === 'complete') mount();
|
|
587
|
+
else addEventListener.call(window, 'load', mount, { once: true });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
continued();
|
|
591
|
+
whenLoaded();
|
|
592
|
+
window.__codetac = { recorded, config };
|
|
593
|
+
})();
|