browser-assistant 0.0.0-stage → 2.8.1

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.
@@ -0,0 +1,617 @@
1
+ // Browser Assistant automation authorization policy.
2
+ // Site-specific adapters take precedence; ordinary websites use semantic detection.
3
+ // Runs begin from the user's Start control and remain tied to their starting origin.
4
+ (function () {
5
+ const targets = [
6
+ {
7
+ id: 'local-practice',
8
+ origins: ['http://127.0.0.1:8000'],
9
+ paths: ['/practice'],
10
+ marker: '[data-browser-assistant-automation="authorized"]',
11
+ adapter: 'practice-fixture',
12
+ pageTypes: ['practice'],
13
+ capabilities: { detect: true, read: true, fill: true, select: true, click: true, navigate: true, validate: true, submit: true },
14
+ },
15
+ {
16
+ id: 'ccbp-learning-practice',
17
+ origins: ['https://learning.ccbp.in'],
18
+ paths: [],
19
+ adapter: 'semantic-mcq',
20
+ pageTypes: ['practice', 'quiz'],
21
+ capabilities: { detect: true, read: true, fill: true, select: true, click: true, navigate: true, validate: true, submit: true },
22
+ },
23
+ // Example for an explicitly authorized remote QA app:
24
+ // {
25
+ // id: 'my-staging-qa',
26
+ // origins: ['https://staging.example.com'],
27
+ // paths: ['/qa/'], // path prefix; omit to allow all paths on that exact origin
28
+ // marker: '[data-browser-assistant-automation="authorized"]',
29
+ // adapter: 'semantic-mcq',
30
+ // pageTypes: ['practice', 'quiz', 'form'],
31
+ // capabilities: { detect: true, read: true, fill: true, select: true, click: true, navigate: false, validate: true, submit: false },
32
+ // },
33
+ ];
34
+ const adapters = new Map();
35
+ function registerAdapter(adapter) {
36
+ if (!adapter || typeof adapter.id !== 'string' || typeof adapter.getQuestion !== 'function') {
37
+ throw new TypeError('Adapter requires an id and getQuestion(page) function.');
38
+ }
39
+ adapters.set(adapter.id, adapter);
40
+ }
41
+ function resolveTarget(location, doc) {
42
+ let url;
43
+ try {
44
+ url = new URL(location.href);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ if (!['http:', 'https:'].includes(url.protocol))
50
+ return null;
51
+ const origin = url.origin;
52
+ return targets.find((target) => target.origins.includes(origin)
53
+ && (!target.paths?.length || target.paths.some((path) => location.pathname === path || location.pathname.startsWith(path)))
54
+ && (!target.marker || doc.querySelector(target.marker))) || {
55
+ id: `website:${origin}`,
56
+ origins: [origin],
57
+ adapter: 'semantic-mcq',
58
+ pageTypes: ['practice', 'quiz', 'code'],
59
+ capabilities: { detect: true, read: true, fill: true, select: true, click: true, navigate: true, validate: true, submit: true },
60
+ };
61
+ }
62
+ function determineMode(context) {
63
+ if (!context?.authorizationVerified)
64
+ return 'ASSIST';
65
+ if (context.target?.restricted)
66
+ return 'DISABLED';
67
+ if (!context.target?.authorized || !context.page?.authorized)
68
+ return 'ASSIST';
69
+ return 'FULL';
70
+ }
71
+ function canExecute(action, context) {
72
+ return determineMode(context) === 'FULL' && context.target.capabilities?.[action] === true;
73
+ }
74
+ registerAdapter({
75
+ id: 'practice-fixture',
76
+ getQuestion(doc) {
77
+ const question = doc.querySelector('[data-practice-question-text]');
78
+ const optionNodes = [...doc.querySelectorAll('[data-practice-option]')];
79
+ if (!question || optionNodes.length < 2)
80
+ return null;
81
+ const options = optionNodes.map((node, index) => ({
82
+ index, text: (node.textContent || '').replace(/\s+/g, ' ').trim(), node,
83
+ input: node.querySelector('input:not(:disabled)'),
84
+ }));
85
+ if (options.some((option) => !option.input) || !question.textContent.trim())
86
+ return null;
87
+ return { text: question.textContent.replace(/\s+/g, ' ').trim(), options, form: question.closest('form') || doc.querySelector('form') };
88
+ },
89
+ getSubmit(doc, question) { return question.form?.querySelector('[data-practice-submit]:not(:disabled)') || null; },
90
+ isComplete(doc) { return Boolean(doc.querySelector('[data-practice-complete]')); },
91
+ });
92
+ // Semantic fallback for authorized pages that use ordinary radio forms.
93
+ registerAdapter({
94
+ id: 'semantic-mcq',
95
+ getQuestion(doc) {
96
+ const visible = (element) => element.getClientRects().length > 0;
97
+ const textOf = (element) => {
98
+ if (!element)
99
+ return '';
100
+ const clone = element.cloneNode(true);
101
+ const editors = element.querySelectorAll('.ace_editor,.CodeMirror,.monaco-editor');
102
+ [...clone.querySelectorAll('.ace_editor,.CodeMirror,.monaco-editor')].forEach((block, index) => {
103
+ const source = typeof browserAssistantCachedMcqSnippet === 'function'
104
+ ? browserAssistantCachedMcqSnippet(editors[index]) : undefined;
105
+ if (typeof source === 'string')
106
+ block.textContent = '\n' + source + '\n';
107
+ });
108
+ clone.querySelectorAll('.ace_gutter,.line-numbers,.line-numbers-rows,.linenumber,.margin-view-overlays,script,style').forEach(node => node.remove());
109
+ return (clone.textContent || '').replace(/\s+/g, ' ').trim();
110
+ };
111
+ const optionIdentity = element => {
112
+ if (!element)
113
+ return '';
114
+ const clone = element.cloneNode(true);
115
+ // Full source is checked separately before selection. A viewport
116
+ // repaint or editor warning must not look like a new option.
117
+ clone.querySelectorAll('.ace_editor,.CodeMirror,.monaco-editor').forEach(node => {
118
+ const marker = doc.createElement('span');
119
+ marker.textContent = ' [code editor] ';
120
+ node.replaceWith(marker);
121
+ });
122
+ return textOf(clone);
123
+ };
124
+ const labelledText = (element) => (element?.getAttribute('aria-labelledby') || '')
125
+ .split(/\s+/).filter(Boolean).map((id) => doc.getElementById(id)).filter(Boolean).map(textOf).join(' ');
126
+ const nativeRegion = doc.querySelector('#generic-question-container');
127
+ const nativeLabels = [...(nativeRegion?.querySelectorAll('.drag-and-drop-option-label') || [])].filter(visible);
128
+ const nativePrompt = nativeRegion?.querySelector('#generic-question-wrapper-id');
129
+ if (nativeLabels.length >= 2 && nativeLabels.length <= 30 && nativePrompt
130
+ && nativeLabels.every(label => label.parentElement.querySelector('.drag-icon-container[draggable="true"]'))) {
131
+ const options = nativeLabels.map((label, index) => ({ index, text: textOf(label), node: label.parentElement }));
132
+ if (new Set(options.map(option => option.text)).size === options.length)
133
+ return {
134
+ type: 'ordering', dragKind: 'native-ccbp', text: textOf(nativePrompt), container: nativeRegion, options,
135
+ };
136
+ }
137
+ const dragSelector = '[data-rbd-draggable-id],[data-rfd-draggable-id]';
138
+ const rows = [...doc.querySelectorAll(dragSelector)].filter(visible);
139
+ if (rows.length >= 2 && rows.length <= 30) {
140
+ const scope = rows[0].closest('[data-rbd-droppable-id],[data-rfd-droppable-id]');
141
+ if (scope && rows.every(row => scope.contains(row))) {
142
+ for (let region = scope.parentElement; region && region !== doc.body; region = region.parentElement) {
143
+ const clone = region.cloneNode(true);
144
+ clone.querySelectorAll(`${dragSelector},button,[role="button"],script,style`).forEach(node => node.remove());
145
+ const prompt = textOf(clone);
146
+ if (/rearrange|correct order|order the|sort the/i.test(prompt) && prompt.length < 7000)
147
+ return {
148
+ type: 'ordering', text: prompt, container: region, options: rows.map((row, index) => ({
149
+ index, text: textOf(row), node: row,
150
+ id: row.getAttribute('data-rbd-draggable-id') || row.getAttribute('data-rfd-draggable-id'),
151
+ })),
152
+ };
153
+ }
154
+ }
155
+ }
156
+ const allControls = [...doc.querySelectorAll('input[type="radio"]:not(:disabled),input[type="checkbox"]:not(:disabled),[role="radio"]:not([aria-disabled="true"]),[role="checkbox"]:not([aria-disabled="true"])')]
157
+ .filter((control) => visible(control) || (control.matches('input[type="radio"],input[type="checkbox"]') && [...(control.labels || [])].some(visible)));
158
+ const groups = new Map();
159
+ for (const control of allControls) {
160
+ // Radio buttons with the same name are one question even when
161
+ // each option lives in a different React/CSS wrapper.
162
+ const isCheckbox = control.matches('input[type="checkbox"],[role="checkbox"]');
163
+ const key = isCheckbox
164
+ ? `checkbox:${control.form?.id || '__page__'}`
165
+ : control.matches('input[type="radio"]')
166
+ ? `native:${control.form?.id || ''}:${control.name || control.closest('form')?.id || ''}`
167
+ : `aria:${control.closest('[role="radiogroup"]') || control.parentElement}`;
168
+ if (!groups.has(key))
169
+ groups.set(key, []);
170
+ groups.get(key).push(control);
171
+ }
172
+ for (const group of groups.values()) {
173
+ if (group.length < 2)
174
+ continue;
175
+ const container = group[0].closest('fieldset,[role="radiogroup"],form')
176
+ || group[0].parentElement?.parentElement?.parentElement
177
+ || group[0].parentElement;
178
+ if (!container)
179
+ continue;
180
+ const options = group.map((control, index) => {
181
+ const input = control.matches('input[type="radio"],input[type="checkbox"]') ? control : control.querySelector('input[type="radio"],input[type="checkbox"]');
182
+ const label = input?.labels?.[0]
183
+ || (input?.id ? doc.querySelector(`[for="${CSS.escape(input.id)}"]`) : null)
184
+ || (control.matches('label') ? control : null);
185
+ const optionControl = label && input ? label : control;
186
+ const optionText = textOf(label) || textOf(control) || control.getAttribute('aria-label') || control.getAttribute('value') || '';
187
+ return {
188
+ index, input, control: optionControl, node: label || control,
189
+ text: optionText.replace(/\s+/g, ' ').trim(),
190
+ identityText: optionIdentity(label || control) || optionText.replace(/\s+/g, ' ').trim(),
191
+ isSelected: () => Boolean(input?.checked || optionControl.getAttribute('aria-checked') === 'true'),
192
+ };
193
+ });
194
+ if (options.some((option) => !option.text))
195
+ continue;
196
+ const fieldset = container.closest('fieldset') || group[0].closest('fieldset');
197
+ const radioGroup = group[0].closest('[role="radiogroup"]');
198
+ const optionScope = fieldset || radioGroup || group[0].parentElement?.parentElement || container;
199
+ const heading = fieldset?.querySelector('legend')
200
+ || radioGroup?.querySelector('h1,h2,h3,h4,[role="heading"]')
201
+ || container.querySelector?.('h1,h2,h3,h4,[role="heading"]');
202
+ let nearbyPrompt = '';
203
+ let branch = optionScope;
204
+ for (let ancestor = optionScope; ancestor && ancestor !== doc.body && !nearbyPrompt; ancestor = ancestor.parentElement) {
205
+ for (let sibling = branch.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
206
+ const prose = sibling.cloneNode(true);
207
+ prose.querySelectorAll('pre,code,.ace_editor,.CodeMirror,.monaco-editor').forEach(node => node.remove());
208
+ const candidate = sibling.matches('pre,code,.ace_editor,.CodeMirror,.monaco-editor') ? '' : textOf(prose);
209
+ if (candidate.length >= 20 && candidate.length <= 1200 && !sibling.querySelector('input[type="radio"],[role="radio"]')) {
210
+ nearbyPrompt = candidate;
211
+ break;
212
+ }
213
+ }
214
+ branch = ancestor;
215
+ if (ancestor === doc.body)
216
+ break;
217
+ }
218
+ const text = textOf(heading) || labelledText(radioGroup) || labelledText(container)
219
+ || radioGroup?.getAttribute('aria-label') || container.getAttribute('aria-label') || nearbyPrompt;
220
+ if (!text.trim())
221
+ continue;
222
+ // Coding MCQs often render the code separately from the prose
223
+ // question. Preserve that visible context before asking the AI.
224
+ const questionRegion = optionScope.closest('[data-question],article,section,main') || optionScope.parentElement;
225
+ const promptRegion = questionRegion?.querySelector('#generic-question-wrapper-id,[data-question-text]')
226
+ || doc.querySelector('#generic-question-wrapper-id');
227
+ const sourceRegion = promptRegion || questionRegion;
228
+ const snippets = [...(sourceRegion?.querySelectorAll('pre,code,.ace_editor,.CodeMirror,.monaco-editor') || [])]
229
+ .filter(node => visible(node) && !node.parentElement?.closest('pre,code,.ace_editor,.CodeMirror,.monaco-editor'));
230
+ const codeText = node => {
231
+ const lines = [...node.querySelectorAll('.ace_line,.view-line,.CodeMirror-line')];
232
+ if (lines.length)
233
+ return lines.map(line => line.textContent || '').join('\n');
234
+ const clone = node.cloneNode(true);
235
+ clone.querySelectorAll('.ace_gutter,.line-numbers,.linenumber,script,style').forEach(item => item.remove());
236
+ return (clone.textContent || '').trim();
237
+ };
238
+ let questionText;
239
+ if (promptRegion && !promptRegion.querySelector('input[type="radio"],[role="radio"]')) {
240
+ // Preserve filenames, prose and ALL snippets together; the
241
+ // last code block alone is not the question's full context.
242
+ const clone = promptRegion.cloneNode(true);
243
+ for (const block of clone.querySelectorAll('pre,.ace_editor,.CodeMirror,.monaco-editor')) {
244
+ const plain = doc.createElement('pre');
245
+ plain.textContent = '\n' + codeText(block) + '\n';
246
+ block.replaceWith(plain);
247
+ }
248
+ clone.querySelectorAll('button,input,textarea,script,style,.ace_gutter').forEach(node => node.remove());
249
+ questionText = (clone.textContent || '').trim();
250
+ }
251
+ else {
252
+ questionText = [text.trim(), ...snippets.map(codeText)].join('\n');
253
+ }
254
+ questionText = questionText.slice(0, 12000);
255
+ const identityRegion = promptRegion?.cloneNode(true);
256
+ identityRegion?.querySelectorAll('pre,code,.ace_editor,.CodeMirror,.monaco-editor,button,input,textarea,script,style').forEach(node => node.remove());
257
+ const identityText = identityRegion ? textOf(identityRegion) : text.trim();
258
+ return {
259
+ text: questionText, identityText: identityText || text.trim(), options, visualRegion: sourceRegion,
260
+ selectionType: group[0].matches('input[type="checkbox"],[role="checkbox"]') ? 'multiple' : 'single',
261
+ form: container.matches('form') ? container : container.closest('form'), container: optionScope,
262
+ };
263
+ }
264
+ // Letter-badge cards (A, B, C...) do not always expose native
265
+ // radios. Require a consecutive sibling group and an explicit
266
+ // question region so palette numbers/navigation are never options.
267
+ const cardGroups = new Map();
268
+ for (const badge of doc.querySelectorAll('span,div,b,strong')) {
269
+ if (!visible(badge) || badge.children.length || !/^[A-H]$/.test(textOf(badge)))
270
+ continue;
271
+ const card = badge.parentElement;
272
+ if (!card || !visible(card) || card.closest('#assistant-root,nav,aside')
273
+ || card.querySelector('input') || textOf(card).length <= 1)
274
+ continue;
275
+ const group = card.parentElement;
276
+ if (!cardGroups.has(group))
277
+ cardGroups.set(group, []);
278
+ cardGroups.get(group).push({ badge, card });
279
+ }
280
+ const candidates = [];
281
+ for (const [group, cards] of cardGroups) {
282
+ if (cards.length < 2 || cards.length > 8
283
+ || !cards.every(({ badge }, index) => textOf(badge) === String.fromCharCode(65 + index)))
284
+ continue;
285
+ const region = group.closest('main,article,section,[data-question]') || group.parentElement;
286
+ const heading = region?.querySelector('[data-question-text],h1,h2,h3,[role="heading"]');
287
+ const preceding = cards[0].card.previousElementSibling || group.previousElementSibling;
288
+ const prompt = textOf(heading) || textOf(preceding);
289
+ if (prompt.length < 10 || prompt.length > 2000)
290
+ continue;
291
+ candidates.push({ text: prompt, selectionType: 'single', container: group, visualRegion: region,
292
+ options: cards.map(({ badge, card }, index) => {
293
+ const copy = card.cloneNode(true);
294
+ const savedMarkers = element => {
295
+ // React layouts may nest the autosave indicator in
296
+ // the answer wrapper instead of a third direct child.
297
+ const markers = [...element.querySelectorAll('span,small,em,div')]
298
+ .filter(node => !node.children.length && /^(?:saved|saving(?:\.{3}|…)?)$/i.test(textOf(node)));
299
+ const remaining = element.cloneNode(true);
300
+ for (const node of [...remaining.querySelectorAll('*')].reverse()) {
301
+ if ((!node.children.length && /^(?:saved|saving(?:\.{3}|…)?)$/i.test(textOf(node))) || textOf(node) === textOf(badge))
302
+ node.remove();
303
+ }
304
+ // An answer whose literal text is "Saved" is not
305
+ // itself a selection indicator.
306
+ return textOf(remaining) ? markers : [];
307
+ };
308
+ for (const marker of savedMarkers(copy))
309
+ marker.remove();
310
+ const letter = [...copy.children].find(node => textOf(node) === textOf(badge));
311
+ letter?.remove();
312
+ return { index, text: textOf(copy), node: card, control: card,
313
+ getSaveState: () => savedMarkers(card).some(node => visible(node) && /^saving/i.test(textOf(node))) ? 'saving'
314
+ : savedMarkers(card).some(node => visible(node) && /^saved$/i.test(textOf(node))) ? 'saved' : null,
315
+ isSelected: () => card.getAttribute('aria-checked') === 'true'
316
+ || card.getAttribute('aria-pressed') === 'true'
317
+ || card.getAttribute('data-selected') === 'true'
318
+ || /(?:^|\s)(?:selected|active|is-selected)(?:\s|$)/.test(card.className || '')
319
+ || savedMarkers(card).some(node => visible(node) && /^saved$/i.test(textOf(node))) };
320
+ }),
321
+ });
322
+ }
323
+ if (candidates.length === 1 && candidates[0].options.every(option => option.text))
324
+ return candidates[0];
325
+ // Short-answer practice questions have no option controls.
326
+ const fields = [...doc.querySelectorAll('textarea:not(:disabled):not([readonly]),input[type="text"]:not(:disabled):not([readonly]),input[type="number"]:not(:disabled):not([readonly])')]
327
+ .filter(field => visible(field) && /answer/i.test(`${field.getAttribute('placeholder') || ''} ${field.getAttribute('aria-label') || ''}`)
328
+ && !field.closest('.monaco-editor,.ace_editor,.CodeMirror'));
329
+ if (fields.length === 1) {
330
+ const input = fields[0];
331
+ for (let region = input.parentElement; region && region !== doc.body; region = region.parentElement) {
332
+ const submits = [...region.querySelectorAll('button,[role="button"],input[type="submit"]')]
333
+ .filter(button => visible(button) && /^submit(?: answer)?$/i.test(textOf(button) || button.getAttribute('value') || button.getAttribute('aria-label') || ''));
334
+ const submit = submits.length === 1 ? submits[0] : null;
335
+ if (!submit)
336
+ continue;
337
+ // CCBP's question body is separate from its changing skip
338
+ // countdown and feedback. Only the body identifies a question.
339
+ const promptRegion = region.querySelector('#generic-question-wrapper-id');
340
+ const clone = (promptRegion || region).cloneNode(true);
341
+ clone.querySelectorAll('textarea,input,button,[role="button"],script,style,[role="timer"],[class*="SkipButtonContainer"],[class*="TimerText"],#next-button-container').forEach(node => node.remove());
342
+ const prompt = textOf(clone);
343
+ if (prompt.length >= 15 && prompt.length <= 7000)
344
+ return {
345
+ type: 'short-answer', text: prompt, options: [], input, submit, container: region, form: input.closest('form'),
346
+ };
347
+ // The answer/actions wrapper may be separate from the prompt.
348
+ // Keep walking until their common question container is found.
349
+ }
350
+ }
351
+ return null;
352
+ },
353
+ getSubmit(_doc, question) {
354
+ if (question.type === 'short-answer') {
355
+ const button = question.submit;
356
+ return button?.isConnected && !button.disabled && button.getAttribute('aria-disabled') !== 'true' ? button : null;
357
+ }
358
+ const scope = question.form || question.container?.closest('fieldset,[role="group"],article,section,main') || question.container?.parentElement;
359
+ const enabled = button => button.isConnected && button.getClientRects().length > 0
360
+ && !button.disabled && button.getAttribute('aria-disabled') !== 'true' && !button.closest('#assistant-root');
361
+ const explicitSubmits = [...(scope?.querySelectorAll('button[type="submit"],input[type="submit"],[role="button"][type="submit"]') || [])].filter(enabled);
362
+ if (explicitSubmits.length)
363
+ return explicitSubmits.length === 1 ? explicitSubmits[0] : null;
364
+ const namedSubmit = [...(_doc.querySelectorAll('button:not(:disabled),[role="button"]:not([aria-disabled="true"])'))]
365
+ .filter(enabled)
366
+ .filter((button) => /^(submit|check answer|submit answer|verify answer|save\s*&\s*next|save and next)$/i.test((button.innerText || button.textContent || button.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim()));
367
+ const local = namedSubmit.filter(button => scope?.contains(button));
368
+ return local.length === 1 ? local[0] : local.length ? null : namedSubmit.length === 1 ? namedSubmit[0] : null;
369
+ },
370
+ getSubmittedAdvance(doc) {
371
+ if (this.isSaving(doc))
372
+ return null;
373
+ // CCBP renders this actions container only with the submitted
374
+ // question's Next/Retry controls. An unrelated Next is insufficient.
375
+ const candidates = [...doc.querySelectorAll('#default-question-next-button,#next-button-container button,#next-button-container [role="button"],[class*="SubmittedQuestionButtonsContainer"] button,[class*="SubmittedQuestionButtonsContainer"] [role="button"]')]
376
+ .filter(button => button.getClientRects().length > 0 && !button.disabled
377
+ && button.getAttribute('aria-disabled') !== 'true' && !button.closest('#assistant-root')
378
+ && /^next$/i.test((button.innerText || button.textContent || button.getAttribute('aria-label') || '').trim()));
379
+ const unique = [...new Set(candidates)];
380
+ return unique.length === 1 ? unique[0] : null;
381
+ },
382
+ getBlockingReason(doc) {
383
+ const expired = [...doc.querySelectorAll('p,span,div,h1,h2,h3,h4')].some(node => node.getClientRects().length && !node.closest('#assistant-root') && !node.children.length
384
+ && /^time['’]s up!\s*you cannot attempt this assessment now\.?$/i.test((node.innerText || node.textContent || '').trim()));
385
+ return expired ? 'The portal says this assessment has expired. Start a new attempt in the portal before restarting automation.' : null;
386
+ },
387
+ isSaving(doc) {
388
+ return [...doc.querySelectorAll('span,small,p,div')].some(node => !node.children.length
389
+ && node.getClientRects().length && !node.closest('#assistant-root')
390
+ && /^saving(?:\s*(?:\.{3}|…))?$/i.test((node.innerText || node.textContent || '').trim()));
391
+ },
392
+ getFinalQuizSubmit(doc) {
393
+ if (this.isSaving(doc))
394
+ return null;
395
+ const body = doc.body.cloneNode(true);
396
+ body.querySelector('#assistant-root')?.remove();
397
+ const text = (body.textContent || '').replace(/\s+/g, ' ');
398
+ if (!/question palette/i.test(text))
399
+ return null;
400
+ const current = text.match(/(\d+)\s*(?:of|\/)\s*(\d+)\s*questions/i);
401
+ const answered = text.match(/(\d+)\s*Answered/i);
402
+ const pending = text.match(/(\d+)\s*Pending/i);
403
+ if (!current || !answered || !pending || Number(current[2]) <= 0
404
+ || Number(current[1]) !== Number(current[2]) || Number(answered[1]) !== Number(current[2]) || Number(pending[1]) !== 0)
405
+ return null;
406
+ const actions = [...doc.querySelectorAll('button,[role="button"]')].filter(node => node.getClientRects().length
407
+ && !node.closest('#assistant-root') && !node.disabled && node.getAttribute('aria-disabled') !== 'true');
408
+ const local = actions.filter(node => /^submit$/i.test((node.innerText || node.textContent || '').trim()));
409
+ const quiz = actions.filter(node => /^submit quiz$/i.test((node.innerText || node.textContent || '').trim()));
410
+ return local.length === 1 ? local[0] : local.length === 0 && quiz.length === 1 ? quiz[0] : null;
411
+ },
412
+ getQuizConfirmation(doc) {
413
+ if (this.isSaving(doc))
414
+ return null;
415
+ const dialogs = [...doc.querySelectorAll('[role="dialog"],[aria-modal="true"]')].filter(node => node.getClientRects().length);
416
+ for (const title of doc.querySelectorAll('h1,h2,h3,p,span,div')) {
417
+ if (title.children.length || !title.getClientRects().length || !/^submit (?:quiz|assessment)\?$/i.test((title.textContent || '').trim()))
418
+ continue;
419
+ for (let scope = title.parentElement; scope && scope !== doc.body; scope = scope.parentElement) {
420
+ const buttons = [...scope.querySelectorAll('button,[role="button"]')];
421
+ if (buttons.some(node => /^cancel$/i.test((node.textContent || '').trim()))
422
+ && buttons.some(node => /^confirm submit$/i.test((node.textContent || '').trim()))) {
423
+ dialogs.push(scope);
424
+ break;
425
+ }
426
+ }
427
+ }
428
+ const choices = dialogs.filter(node => /(?:submit (?:the |your |this )?(?:quiz|assessment)|(?:quiz|assessment) submission)/i.test(node.textContent || ''))
429
+ .flatMap(dialog => [...dialog.querySelectorAll('button,[role="button"]')].filter(node => node.getClientRects().length && !node.disabled
430
+ && /^(?:submit|submit quiz|confirm|confirm submit|yes,? submit)$/i.test((node.innerText || node.textContent || '').trim())));
431
+ const unique = [...new Set(choices)];
432
+ return unique.length === 1 ? unique[0] : null;
433
+ },
434
+ async applyOrder(doc, question, order, active, nativeDrag) {
435
+ if (order.length !== question.options.length || new Set(order).size !== order.length
436
+ || !order.every(index => Number.isInteger(index) && index >= 0 && index < question.options.length))
437
+ return false;
438
+ if (question.dragKind === 'native-ccbp') {
439
+ const desired = order.map(index => question.options[index].text);
440
+ const read = () => this.getQuestion(doc);
441
+ const delay = () => new Promise(resolve => setTimeout(resolve, 180));
442
+ for (let position = 0; position < desired.length; position++) {
443
+ if (!active())
444
+ return false;
445
+ const current = read();
446
+ if (current?.dragKind !== 'native-ccbp' || current.text !== question.text || current.options.length !== desired.length)
447
+ return false;
448
+ const from = current.options.findIndex(option => option.text === desired[position]);
449
+ if (from < 0)
450
+ return false;
451
+ if (from === position)
452
+ continue;
453
+ if (nativeDrag) {
454
+ const result = await nativeDrag(current.text, current.options.map(option => option.text), from, position);
455
+ if (!active() || !result?.ok)
456
+ return false;
457
+ const moved = read();
458
+ if (moved?.text !== question.text || moved.options[position]?.text !== desired[position])
459
+ return false;
460
+ continue;
461
+ }
462
+ const source = current.options[from].node.querySelector('.drag-icon-container[draggable="true"]');
463
+ let target = current.options[position].node.querySelector('.drag-icon-container[draggable="true"]');
464
+ const transfer = new DataTransfer();
465
+ transfer.effectAllowed = 'move';
466
+ const send = (node, type) => {
467
+ const rect = node.getBoundingClientRect();
468
+ node.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: transfer,
469
+ clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }));
470
+ };
471
+ send(source, 'dragstart');
472
+ try {
473
+ // React may commit the active dragged item on the next
474
+ // render. A synchronous drop can see the previous state.
475
+ await delay();
476
+ if (!active())
477
+ return false;
478
+ const started = read();
479
+ if (started?.text !== question.text || started.options.length !== desired.length)
480
+ return false;
481
+ target = started.options.find(option => option.text === current.options[position].text)?.node.querySelector('.drag-icon-container[draggable="true"]');
482
+ if (!target)
483
+ return false;
484
+ send(target, 'dragenter');
485
+ send(target, 'dragover');
486
+ await delay();
487
+ if (!active())
488
+ return false;
489
+ const hovered = read();
490
+ if (hovered?.text !== question.text)
491
+ return false;
492
+ // Some handlers reorder on hover, others on drop.
493
+ target = hovered.options.find(option => option.text === current.options[position].text)?.node.querySelector('.drag-icon-container[draggable="true"]');
494
+ if (!target)
495
+ return false;
496
+ send(target, 'drop');
497
+ }
498
+ finally {
499
+ send(source, 'dragend');
500
+ }
501
+ let moved = false;
502
+ for (let attempt = 0; attempt < 10 && active(); attempt++) {
503
+ await delay();
504
+ const updated = read();
505
+ if (updated?.text !== question.text)
506
+ return false;
507
+ if (updated.options[position]?.text === desired[position]) {
508
+ moved = true;
509
+ break;
510
+ }
511
+ }
512
+ if (!moved)
513
+ return false;
514
+ }
515
+ const final = read();
516
+ return active() && final?.text === question.text && final.options.length === desired.length
517
+ && final.options.every((option, index) => option.text === desired[index]);
518
+ }
519
+ const selector = '[data-rbd-draggable-id],[data-rfd-draggable-id]';
520
+ const ids = () => [...doc.querySelectorAll(selector)].filter(node => node.getClientRects().length).map(node => node.getAttribute('data-rbd-draggable-id') || node.getAttribute('data-rfd-draggable-id'));
521
+ const desired = order.map(index => question.options[index].id);
522
+ const delay = () => new Promise(resolve => setTimeout(resolve, 180));
523
+ const press = (node, key, code, keyCode) => node.dispatchEvent(new KeyboardEvent('keydown', { key, code, keyCode, which: keyCode, bubbles: true, cancelable: true }));
524
+ for (let position = 0; position < desired.length; position++) {
525
+ if (!active())
526
+ return false;
527
+ const current = ids();
528
+ const from = current.indexOf(desired[position]);
529
+ if (from < 0 || current.length !== desired.length)
530
+ return false;
531
+ if (from === position)
532
+ continue;
533
+ const row = [...doc.querySelectorAll(selector)].find(node => (node.getAttribute('data-rbd-draggable-id') || node.getAttribute('data-rfd-draggable-id')) === desired[position]);
534
+ const handle = row.matches('[data-rbd-drag-handle-draggable-id],[data-rfd-drag-handle-draggable-id]') ? row : row.querySelector('[data-rbd-drag-handle-draggable-id],[data-rfd-drag-handle-draggable-id]');
535
+ if (!handle)
536
+ return false;
537
+ handle.focus();
538
+ press(handle, ' ', 'Space', 32);
539
+ await delay();
540
+ for (let step = from; step > position; step--) {
541
+ if (!active()) {
542
+ press(handle, 'Escape', 'Escape', 27);
543
+ return false;
544
+ }
545
+ press(handle, 'ArrowUp', 'ArrowUp', 38);
546
+ await delay();
547
+ }
548
+ press(handle, ' ', 'Space', 32);
549
+ await delay();
550
+ if (ids()[position] !== desired[position])
551
+ return false;
552
+ }
553
+ return ids().join('\n') === desired.join('\n');
554
+ },
555
+ getAdvance(doc) {
556
+ const buttons = [...doc.querySelectorAll('button:not(:disabled),[role="button"]:not([aria-disabled="true"])')]
557
+ .filter((button) => button.getClientRects().length > 0);
558
+ const next = buttons.filter((button) => /^(next|continue)$/i.test((button.innerText || button.getAttribute('aria-label') || '').trim()));
559
+ return next.length === 1 ? next[0] : null;
560
+ },
561
+ isWaitingForParticipants(doc) {
562
+ const labels = [...doc.querySelectorAll('p,span,div,h1,h2,h3,h4')]
563
+ .filter(node => node.getClientRects().length && !node.closest('#assistant-root') && !node.children.length)
564
+ .map(node => (node.innerText || node.textContent || '').replace(/\s+/g, ' ').trim());
565
+ return labels.some(value => /^answer submitted[.!]?$/i.test(value))
566
+ && labels.some(value => /^waiting for all students to submit[.!]?$/i.test(value));
567
+ },
568
+ getNextPractice(doc) {
569
+ const actions = [...doc.querySelectorAll('a[href],button:not(:disabled),[role="button"]:not([aria-disabled="true"])')]
570
+ .filter((element) => element.getClientRects().length > 0);
571
+ const next = actions.filter((element) => /^(next practice|start next practice|continue to next practice)$/i.test((element.innerText || element.getAttribute('aria-label') || '').trim()));
572
+ return next.length === 1 ? next[0] : null;
573
+ },
574
+ getCompletionProceed(doc) {
575
+ const actions = [...doc.querySelectorAll('a[href],button:not(:disabled),[role="button"]:not([aria-disabled="true"])')]
576
+ .filter((element) => element.getClientRects().length > 0);
577
+ const proceed = actions.filter((element) => /^proceed$/i.test((element.innerText || element.getAttribute('aria-label') || '').trim()));
578
+ return proceed.length === 1 ? proceed[0] : null;
579
+ },
580
+ getCurrentPracticeTitle(doc) {
581
+ const match = (doc.body?.innerText || '').match(/MCQ\s+Practice\s*\|\s*[^\n]+/i);
582
+ return match ? match[0].replace(/\s+/g, ' ').trim() : '';
583
+ },
584
+ getNextCoursePractice(doc, currentTitle) {
585
+ const textOf = (element) => (element?.innerText || element?.textContent || '').replace(/\s+/g, ' ').trim();
586
+ const visible = (element) => element.getClientRects().length > 0;
587
+ const candidates = [...doc.querySelectorAll('body *')]
588
+ .filter((element) => visible(element) && /^MCQ\s+Practice\s*\|/i.test(textOf(element)))
589
+ .filter((element) => ![...element.children].some((child) => /^MCQ\s+Practice\s*\|/i.test(textOf(child))));
590
+ if (!candidates.length)
591
+ return null;
592
+ const normalizedCurrent = currentTitle.replace(/\s+/g, ' ').toLowerCase();
593
+ const currentIndex = candidates.findIndex((element) => textOf(element).toLowerCase() === normalizedCurrent);
594
+ return candidates[currentIndex >= 0 ? currentIndex + 1 : 0] || null;
595
+ },
596
+ getStartPractice(doc) {
597
+ const actions = [...doc.querySelectorAll('a[href],button:not(:disabled),[role="button"]:not([aria-disabled="true"])')]
598
+ .filter((element) => element.getClientRects().length > 0);
599
+ const start = actions.filter((element) => /^start practice$/i.test((element.innerText || element.getAttribute('aria-label') || '').trim()));
600
+ return start.length === 1 ? start[0] : null;
601
+ },
602
+ // The course sidebar and instructions contain words such as
603
+ // "completed", so whole-page text is not a reliable completion
604
+ // signal. The runner instead advances only through getCompletionProceed.
605
+ isComplete(doc) {
606
+ return [...doc.querySelectorAll('p,span,div,h1,h2,h3,h4')].some(node => node.getClientRects().length
607
+ && !node.closest('#assistant-root') && !node.children.length
608
+ && /^(?:quiz completed|quiz ended|the quiz has ended|quiz submitted(?: successfully)?|assessment submitted(?: successfully)?)[.!]?$/i.test((node.innerText || node.textContent || '').trim()));
609
+ },
610
+ });
611
+ window.BrowserAssistantAutomationPolicy = Object.freeze({
612
+ resolveTarget, determineMode, canExecute,
613
+ getAdapter(id) { return adapters.get(id) || null; },
614
+ registerAdapter,
615
+ get targets() { return targets.map((target) => ({ ...target })); },
616
+ });
617
+ })();