browser-assistant 2.8.4 → 2.8.5

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.
@@ -379,6 +379,25 @@
379
379
  const unique = [...new Set(candidates)];
380
380
  return unique.length === 1 ? unique[0] : null;
381
381
  },
382
+ getPracticeSkip(doc, question) {
383
+ const anchor = question.form || question.container;
384
+ if (!anchor)
385
+ return null;
386
+ const skips = [...doc.querySelectorAll('button,[role="button"]')].filter(button => button.isConnected && button.getClientRects().length && !button.disabled
387
+ && button.getAttribute('aria-disabled') !== 'true' && !button.closest('#assistant-root')
388
+ && /^skip(?: question)?$/i.test((button.innerText || button.textContent || button.getAttribute('aria-label') || '').trim()));
389
+ if (skips.length !== 1)
390
+ return null;
391
+ for (let scope = anchor; scope && scope !== doc.body; scope = scope.parentElement) {
392
+ if (!scope.contains(skips[0]))
393
+ continue;
394
+ const submits = [...scope.querySelectorAll('button,[role="button"],input[type="submit"]')].filter(button => button.getClientRects().length && !button.closest('#assistant-root')
395
+ && /^submit(?: answer)?$/i.test((button.innerText || button.textContent || button.value || button.getAttribute('aria-label') || '').trim()));
396
+ if (submits.length === 1)
397
+ return skips[0];
398
+ }
399
+ return null;
400
+ },
382
401
  getBlockingReason(doc) {
383
402
  const expired = [...doc.querySelectorAll('p,span,div,h1,h2,h3,h4')].some(node => node.getClientRects().length && !node.closest('#assistant-root') && !node.children.length
384
403
  && /^time['’]s up!\s*you cannot attempt this assessment now\.?$/i.test((node.innerText || node.textContent || '').trim()));
@@ -415,6 +415,7 @@
415
415
  const currentQuestion = automation.batch?.title || (automation.running && automation.codingQuestionText)
416
416
  || (authorized ? getAuthorizedQuestion(context)?.text : '');
417
417
  const pageName = authorized ? context.target.id : window.location.hostname;
418
+ const deferred = automation.deferredQuestions?.size ?? 0;
418
419
  if (status) {
419
420
  status.textContent = authorized
420
421
  ? `Build ${extensionVersion} · ${context.target.id}: ${automation.state} · Completed: ${automation.completed}${automation.error ? ` · ${automation.error}` : ''}`
@@ -431,14 +432,15 @@
431
432
  if (question)
432
433
  question.textContent = currentQuestion || '—';
433
434
  if (progress)
434
- progress.textContent = `${automation.completed} / —`;
435
+ progress.textContent = deferred ? `${automation.completed} completed · ${deferred} deferred` : `${automation.completed} / —`;
435
436
  if (latest) {
437
+ const reviewing = ['WAITING_FOR_REVIEW', 'DEFERRING_QUESTION', 'REVIEW_NEEDED'].includes(automation.state);
436
438
  latest.textContent = automation.error
437
- ? `⚠ ${automation.error}`
439
+ ? reviewing ? automation.error : `⚠ ${automation.error}`
438
440
  : authorized
439
441
  ? `Current status: ${automation.state.replace(/_/g, ' ').toLowerCase()}.`
440
442
  : 'Automation is available only on an authorized QA page.';
441
- latest.classList.toggle('has-error', Boolean(automation.error));
443
+ latest.classList.toggle('has-error', Boolean(automation.error) && !reviewing);
442
444
  }
443
445
  if (start)
444
446
  start.disabled = !authorized || automation.running;
@@ -477,6 +479,8 @@
477
479
  automation.paused = false;
478
480
  automation.pendingNavigation = false;
479
481
  automation.state = reason;
482
+ if (reason === 'REVIEW_NEEDED')
483
+ automation.error = 'Review deferred questions on the portal.';
480
484
  updateAutomationUI();
481
485
  }
482
486
  async function waitWhilePaused(runId) {
@@ -1178,6 +1182,73 @@
1178
1182
  stopAutomation('STOPPED');
1179
1183
  }
1180
1184
  }
1185
+ async function continueAfterUnverifiedQuestion(question, runId, reason, allowSkip = true) {
1186
+ if (!(await waitWhilePaused(runId)))
1187
+ return false;
1188
+ const initial = getAutomationContext();
1189
+ const adapter = getAutomationAdapter(initial);
1190
+ if (!adapter || initial.target.id !== automation.targetId) {
1191
+ stopAutomation('UNAUTHORIZED');
1192
+ return false;
1193
+ }
1194
+ const fresh = getAuthorizedQuestion(initial);
1195
+ const sameQuestion = fresh?.signature === question.signature && hasSameQuestionOptions(question, fresh);
1196
+ const practice = initial.target.id === 'ccbp-learning-practice'
1197
+ && /^\/(?:default-question|primitive-coding(?:-review)?)\/practice\//.test(location.pathname);
1198
+ const hasDraft = fresh?.input?.value?.trim() || fresh?.options.some(option => option.isSelected?.() || option.input?.checked);
1199
+ if (allowSkip && practice && sameQuestion && !hasDraft && question.type !== 'ordering'
1200
+ && !adapter.isSaving?.(document) && !adapter.getSubmittedAdvance?.(document)
1201
+ && automationPolicy.canExecute('click', initial) && automationPolicy.canExecute('validate', initial)) {
1202
+ const skip = adapter.getPracticeSkip?.(document, fresh);
1203
+ if (skip) {
1204
+ automation.state = 'DEFERRING_QUESTION';
1205
+ automation.error = reason;
1206
+ automation.pendingNavigation = true;
1207
+ updateAutomationUI();
1208
+ skip.click();
1209
+ const transition = await waitForPracticeTransition(question.signature, runId);
1210
+ if (!automation.running || automation.runId !== runId)
1211
+ return false;
1212
+ automation.pendingNavigation = false;
1213
+ if (transition === 'next' || transition === 'complete') {
1214
+ automation.deferredQuestions.add(question.signature);
1215
+ automation.error = '';
1216
+ return true;
1217
+ }
1218
+ }
1219
+ }
1220
+ automation.state = 'WAITING_FOR_REVIEW';
1221
+ automation.error = `${reason} Use the page controls to review and move to the next question. Automation will continue here.`;
1222
+ updateAutomationUI();
1223
+ while (await waitWhilePaused(runId)) {
1224
+ if (automation.state !== 'WAITING_FOR_REVIEW') {
1225
+ automation.state = 'WAITING_FOR_REVIEW';
1226
+ updateAutomationUI();
1227
+ }
1228
+ const context = getAutomationContext();
1229
+ const currentAdapter = getAutomationAdapter(context);
1230
+ if (context.mode !== 'FULL' || context.target.id !== initial.target.id || currentAdapter?.id !== adapter.id) {
1231
+ stopAutomation('UNAUTHORIZED');
1232
+ return false;
1233
+ }
1234
+ const blocked = currentAdapter.getBlockingReason?.(document);
1235
+ if (blocked) {
1236
+ automation.error = blocked;
1237
+ stopAutomation('PAUSED');
1238
+ return false;
1239
+ }
1240
+ if (!currentAdapter.isSaving?.(document)) {
1241
+ const current = getAuthorizedQuestion(context);
1242
+ if (currentAdapter.getSubmittedAdvance?.(document) || currentAdapter.isComplete(document)
1243
+ || (current && current.signature !== question.signature && !hasSameQuestionOptions(question, current))) {
1244
+ automation.error = '';
1245
+ return true;
1246
+ }
1247
+ }
1248
+ await new Promise(resolve => setTimeout(resolve, 250));
1249
+ }
1250
+ return false;
1251
+ }
1181
1252
  async function startPracticeAutomation() {
1182
1253
  if (window.BrowserAssistantJournal?.matches()) {
1183
1254
  await window.BrowserAssistantJournal.start();
@@ -1230,6 +1301,7 @@
1230
1301
  automation.paused = false;
1231
1302
  automation.error = '';
1232
1303
  automation.completed = 0;
1304
+ automation.deferredQuestions = new Set();
1233
1305
  automation.targetId = initialContext.target.id;
1234
1306
  automation.pendingNavigation = false;
1235
1307
  automation.currentSignature = null;
@@ -1286,10 +1358,12 @@
1286
1358
  const transition = await waitForPracticeTransition(previousSignature, runId);
1287
1359
  if (!automation.running || automation.runId !== runId)
1288
1360
  break;
1289
- if (transition === 'next' || transition === 'complete')
1361
+ if (transition === 'next' || transition === 'complete') {
1290
1362
  automation.completed += 1;
1363
+ automation.deferredQuestions.delete(previousSignature);
1364
+ }
1291
1365
  if (transition === 'complete') {
1292
- stopAutomation('COMPLETED');
1366
+ stopAutomation(automation.deferredQuestions.size ? 'REVIEW_NEEDED' : 'COMPLETED');
1293
1367
  break;
1294
1368
  }
1295
1369
  if (transition !== 'next') {
@@ -1300,6 +1374,10 @@
1300
1374
  continue;
1301
1375
  }
1302
1376
  if (currentAdapter.isComplete(document)) {
1377
+ if (automation.deferredQuestions.size) {
1378
+ stopAutomation('REVIEW_NEEDED');
1379
+ break;
1380
+ }
1303
1381
  const nextPractice = currentAdapter.getNextPractice?.(document);
1304
1382
  if (!nextPractice) {
1305
1383
  stopAutomation('COMPLETED');
@@ -1341,6 +1419,12 @@
1341
1419
  await runCodingAutomation(question, runId);
1342
1420
  break;
1343
1421
  }
1422
+ if (automation.deferredQuestions.has(question.signature)) {
1423
+ automation.currentSignature = question.signature;
1424
+ if (await continueAfterUnverifiedQuestion(question, runId, 'This question was deferred earlier.', false))
1425
+ continue;
1426
+ break;
1427
+ }
1344
1428
  if (automation.processed.has(question.signature)) {
1345
1429
  automation.error = 'Repeated question detected.';
1346
1430
  stopAutomation('PAUSED');
@@ -1403,7 +1487,12 @@
1403
1487
  if (!automation.running || automation.runId !== runId)
1404
1488
  break;
1405
1489
  automation.error = error.message || 'Local AI server unavailable.';
1406
- stopAutomation(['answer_unconfirmed', 'vision_unconfirmed'].includes(error.code) ? 'PAUSED' : 'ERROR');
1490
+ if (['answer_unconfirmed', 'vision_unconfirmed'].includes(error.code)) {
1491
+ if (await continueAfterUnverifiedQuestion(question, runId, automation.error))
1492
+ continue;
1493
+ }
1494
+ else
1495
+ stopAutomation('ERROR');
1407
1496
  break;
1408
1497
  }
1409
1498
  if (!automation.running || automation.runId !== runId || !response)
@@ -1474,6 +1563,11 @@
1474
1563
  // transient code-block markup.
1475
1564
  if (!answerIndexes.length || !current || !hasSameQuestionOptions(question, current)) {
1476
1565
  automation.error = !answerIndexes.length ? 'The verified answer did not match the displayed options exactly.' : !current ? 'Question controls disappeared while solving.' : 'Question text or options changed while solving. Please retry.';
1566
+ if (!answerIndexes.length && current?.signature === question.signature && hasSameQuestionOptions(question, current)) {
1567
+ if (await continueAfterUnverifiedQuestion(question, runId, automation.error))
1568
+ continue;
1569
+ break;
1570
+ }
1477
1571
  stopAutomation('PAUSED');
1478
1572
  break;
1479
1573
  }
@@ -1590,7 +1684,7 @@
1590
1684
  }
1591
1685
  if (transition === 'complete') {
1592
1686
  automation.completed += 1;
1593
- stopAutomation('COMPLETED');
1687
+ stopAutomation(automation.deferredQuestions.size ? 'REVIEW_NEEDED' : 'COMPLETED');
1594
1688
  break;
1595
1689
  }
1596
1690
  if (transition !== 'next') {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Browser Assistant",
4
- "version": "2.8.4",
4
+ "version": "2.8.5",
5
5
  "description": "Scans webpages for MCQs and coding problems. Powered by Groq (cloud) and Ollama (local fallback).",
6
6
  "permissions": [
7
7
  "activeTab",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-assistant",
3
- "version": "2.8.4",
3
+ "version": "2.8.5",
4
4
  "description": "Chrome extension and local Python backend for exploring webpage questions and coding exercises",
5
5
  "license": "MIT",
6
6
  "repository": {
package/server/main.py CHANGED
@@ -190,7 +190,7 @@ async def lifespan(_app: FastAPI):
190
190
  client.close()
191
191
 
192
192
 
193
- app = FastAPI(title="Browser Assistant API", version="2.8.4", lifespan=lifespan)
193
+ app = FastAPI(title="Browser Assistant API", version="2.8.5", lifespan=lifespan)
194
194
  install_local_guards(app)
195
195
 
196
196
  @app.exception_handler(HTTPException)