chainlesschain 0.47.9 → 0.49.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/bin/chainlesschain.js +0 -0
- package/package.json +1 -1
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/{AppLayout-6SPt_8Y_.js → AppLayout-Rvi759IS.js} +1 -1
- package/src/assets/web-panel/assets/Dashboard-BS-tzGNj.css +1 -0
- package/src/assets/web-panel/assets/{Dashboard-Br7kCwKJ.js → Dashboard-DBhFxXYQ.js} +2 -2
- package/src/assets/web-panel/assets/{index-tN-8TosE.js → index-uL0cZ8N_.js} +2 -2
- package/src/assets/web-panel/index.html +2 -2
- package/src/commands/codegen.js +303 -0
- package/src/commands/collab.js +482 -0
- package/src/commands/crosschain.js +382 -0
- package/src/commands/dbevo.js +388 -0
- package/src/commands/dev.js +411 -0
- package/src/commands/federation.js +427 -0
- package/src/commands/fusion.js +332 -0
- package/src/commands/governance.js +505 -0
- package/src/commands/hardening.js +110 -0
- package/src/commands/incentive.js +373 -0
- package/src/commands/inference.js +304 -0
- package/src/commands/infra.js +361 -0
- package/src/commands/kg.js +371 -0
- package/src/commands/marketplace.js +326 -0
- package/src/commands/mcp.js +97 -18
- package/src/commands/nlprog.js +329 -0
- package/src/commands/ops.js +408 -0
- package/src/commands/perception.js +385 -0
- package/src/commands/pqc.js +34 -0
- package/src/commands/privacy.js +345 -0
- package/src/commands/quantization.js +280 -0
- package/src/commands/recommend.js +336 -0
- package/src/commands/reputation.js +349 -0
- package/src/commands/runtime.js +500 -0
- package/src/commands/sla.js +352 -0
- package/src/commands/stress.js +252 -0
- package/src/commands/tech.js +268 -0
- package/src/commands/tenant.js +576 -0
- package/src/commands/trust.js +366 -0
- package/src/harness/mcp-client.js +330 -54
- package/src/index.js +112 -0
- package/src/lib/aiops.js +523 -0
- package/src/lib/autonomous-developer.js +524 -0
- package/src/lib/code-agent.js +442 -0
- package/src/lib/collaboration-governance.js +556 -0
- package/src/lib/community-governance.js +649 -0
- package/src/lib/content-recommendation.js +600 -0
- package/src/lib/cross-chain.js +669 -0
- package/src/lib/dbevo.js +669 -0
- package/src/lib/decentral-infra.js +445 -0
- package/src/lib/federation-hardening.js +587 -0
- package/src/lib/hardening-manager.js +409 -0
- package/src/lib/inference-network.js +407 -0
- package/src/lib/knowledge-graph.js +530 -0
- package/src/lib/mcp-client.js +3 -0
- package/src/lib/multimodal.js +698 -0
- package/src/lib/nl-programming.js +595 -0
- package/src/lib/perception.js +500 -0
- package/src/lib/pqc-manager.js +141 -9
- package/src/lib/privacy-computing.js +575 -0
- package/src/lib/protocol-fusion.js +535 -0
- package/src/lib/quantization.js +362 -0
- package/src/lib/reputation-optimizer.js +509 -0
- package/src/lib/skill-marketplace.js +397 -0
- package/src/lib/sla-manager.js +484 -0
- package/src/lib/stress-tester.js +383 -0
- package/src/lib/tech-learning-engine.js +651 -0
- package/src/lib/tenant-saas.js +831 -0
- package/src/lib/token-incentive.js +513 -0
- package/src/lib/trust-security.js +473 -0
- package/src/lib/universal-runtime.js +771 -0
- package/src/assets/web-panel/assets/Dashboard-CKeMmCoT.css +0 -1
|
@@ -4,11 +4,33 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import crypto from "crypto";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
|
|
10
|
+
/* ── Dependency injection (for tests) ─────────────────────── */
|
|
11
|
+
const _deps = { fs, path };
|
|
12
|
+
export { _deps };
|
|
7
13
|
|
|
8
14
|
/* ── In-memory stores ──────────────────────────────────────── */
|
|
9
15
|
const _baselines = new Map();
|
|
10
16
|
const _audits = new Map();
|
|
11
17
|
|
|
18
|
+
/* ── Config audit constants ────────────────────────────────── */
|
|
19
|
+
|
|
20
|
+
const DEFAULT_FORBIDDEN_PLACEHOLDERS = [
|
|
21
|
+
"changeme",
|
|
22
|
+
"change-me",
|
|
23
|
+
"your-api-key",
|
|
24
|
+
"your-secret",
|
|
25
|
+
"xxx",
|
|
26
|
+
"todo",
|
|
27
|
+
"replace-me",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const DEFAULT_REQUIRED_KEYS = []; // caller-supplied; empty default keeps helper generic
|
|
31
|
+
|
|
32
|
+
const CONFIG_CHECK_PREFIX = "config:";
|
|
33
|
+
|
|
12
34
|
const BASELINE_STATUS = {
|
|
13
35
|
COLLECTING: "collecting",
|
|
14
36
|
COMPLETE: "complete",
|
|
@@ -267,6 +289,393 @@ export function getAuditReport(auditId) {
|
|
|
267
289
|
return audit;
|
|
268
290
|
}
|
|
269
291
|
|
|
292
|
+
/* ── Config Audit (real, deterministic) ───────────────────── */
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Load a JSON config file from disk. Returns `null` if missing or unreadable.
|
|
296
|
+
* Throws `SyntaxError` on malformed JSON so callers can distinguish
|
|
297
|
+
* "file not present" (checklist: absent) from "file invalid" (checklist: fail).
|
|
298
|
+
*/
|
|
299
|
+
export function loadConfig(configPath) {
|
|
300
|
+
if (!configPath) return null;
|
|
301
|
+
if (!_deps.fs.existsSync(configPath)) return null;
|
|
302
|
+
const raw = _deps.fs.readFileSync(configPath, "utf-8");
|
|
303
|
+
return JSON.parse(raw);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function _getByPath(obj, keyPath) {
|
|
307
|
+
if (obj == null) return undefined;
|
|
308
|
+
const parts = keyPath.split(".");
|
|
309
|
+
let cur = obj;
|
|
310
|
+
for (const p of parts) {
|
|
311
|
+
if (cur == null || typeof cur !== "object") return undefined;
|
|
312
|
+
cur = cur[p];
|
|
313
|
+
}
|
|
314
|
+
return cur;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function _containsPlaceholder(value, placeholders) {
|
|
318
|
+
if (typeof value !== "string") return null;
|
|
319
|
+
const lower = value.toLowerCase();
|
|
320
|
+
for (const ph of placeholders) {
|
|
321
|
+
if (lower.includes(ph.toLowerCase())) return ph;
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Pure config inspection. Accepts a parsed config object (or null when the
|
|
328
|
+
* file is absent) and returns a check list.
|
|
329
|
+
*
|
|
330
|
+
* opts.configPath — reporting only (used in detail messages)
|
|
331
|
+
* opts.requiredKeys — dot-paths that MUST be present and non-empty
|
|
332
|
+
* opts.forbiddenPlaceholders — substrings that disqualify a string value
|
|
333
|
+
* (defaults to common "changeme" style)
|
|
334
|
+
* opts.dangerousDefaults — [{path, badValue}] — path MUST NOT equal badValue
|
|
335
|
+
*/
|
|
336
|
+
export function checkConfig(config, opts = {}) {
|
|
337
|
+
const {
|
|
338
|
+
configPath = "(inline)",
|
|
339
|
+
requiredKeys = DEFAULT_REQUIRED_KEYS,
|
|
340
|
+
forbiddenPlaceholders = DEFAULT_FORBIDDEN_PLACEHOLDERS,
|
|
341
|
+
dangerousDefaults = [],
|
|
342
|
+
} = opts;
|
|
343
|
+
|
|
344
|
+
const checks = [];
|
|
345
|
+
|
|
346
|
+
// File presence
|
|
347
|
+
checks.push(
|
|
348
|
+
config == null
|
|
349
|
+
? {
|
|
350
|
+
name: "config.file_present",
|
|
351
|
+
status: "fail",
|
|
352
|
+
severity: "high",
|
|
353
|
+
detail: `Config file not found: ${configPath}`,
|
|
354
|
+
}
|
|
355
|
+
: {
|
|
356
|
+
name: "config.file_present",
|
|
357
|
+
status: "pass",
|
|
358
|
+
severity: "info",
|
|
359
|
+
detail: `Loaded ${configPath}`,
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
if (config != null) {
|
|
364
|
+
// Required keys
|
|
365
|
+
for (const key of requiredKeys) {
|
|
366
|
+
const val = _getByPath(config, key);
|
|
367
|
+
const missing = val === undefined || val === null || val === "";
|
|
368
|
+
checks.push({
|
|
369
|
+
name: `config.required.${key}`,
|
|
370
|
+
status: missing ? "fail" : "pass",
|
|
371
|
+
severity: missing ? "high" : "info",
|
|
372
|
+
detail: missing
|
|
373
|
+
? `Required key missing or empty: ${key}`
|
|
374
|
+
: `Present: ${key}`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Placeholder scan — recurse through string values
|
|
379
|
+
const offenders = [];
|
|
380
|
+
const scan = (val, trail) => {
|
|
381
|
+
if (val == null) return;
|
|
382
|
+
if (typeof val === "string") {
|
|
383
|
+
const hit = _containsPlaceholder(val, forbiddenPlaceholders);
|
|
384
|
+
if (hit) offenders.push({ path: trail, placeholder: hit });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (typeof val !== "object") return;
|
|
388
|
+
for (const [k, v] of Object.entries(val)) {
|
|
389
|
+
scan(v, trail ? `${trail}.${k}` : k);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
scan(config, "");
|
|
393
|
+
if (offenders.length === 0) {
|
|
394
|
+
checks.push({
|
|
395
|
+
name: "config.no_placeholders",
|
|
396
|
+
status: "pass",
|
|
397
|
+
severity: "info",
|
|
398
|
+
detail: "No placeholder values detected",
|
|
399
|
+
});
|
|
400
|
+
} else {
|
|
401
|
+
for (const o of offenders) {
|
|
402
|
+
checks.push({
|
|
403
|
+
name: `config.placeholder.${o.path}`,
|
|
404
|
+
status: "fail",
|
|
405
|
+
severity: "critical",
|
|
406
|
+
detail: `Placeholder "${o.placeholder}" at ${o.path}`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Dangerous default detection
|
|
412
|
+
for (const { path: p, badValue } of dangerousDefaults) {
|
|
413
|
+
const val = _getByPath(config, p);
|
|
414
|
+
const dangerous = val === badValue;
|
|
415
|
+
checks.push({
|
|
416
|
+
name: `config.dangerous_default.${p}`,
|
|
417
|
+
status: dangerous ? "fail" : "pass",
|
|
418
|
+
severity: dangerous ? "high" : "info",
|
|
419
|
+
detail: dangerous
|
|
420
|
+
? `${p} still at dangerous default (${JSON.stringify(badValue)})`
|
|
421
|
+
: `${p} overridden`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const passed = checks.filter((c) => c.status === "pass").length;
|
|
427
|
+
const failed = checks.length - passed;
|
|
428
|
+
const score = checks.length ? Math.round((passed / checks.length) * 100) : 0;
|
|
429
|
+
|
|
430
|
+
return { checks, passed, failed, score, configPath };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Run a config audit and persist it to `hardening_audits`. Audit name is
|
|
435
|
+
* prefixed with `CONFIG_CHECK_PREFIX` so `deployCheck` can locate it.
|
|
436
|
+
*/
|
|
437
|
+
export function runConfigAudit(db, opts = {}) {
|
|
438
|
+
const {
|
|
439
|
+
name = "default",
|
|
440
|
+
configPath,
|
|
441
|
+
requiredKeys,
|
|
442
|
+
forbiddenPlaceholders,
|
|
443
|
+
dangerousDefaults,
|
|
444
|
+
} = opts;
|
|
445
|
+
if (!configPath) throw new Error("configPath is required");
|
|
446
|
+
|
|
447
|
+
let config = null;
|
|
448
|
+
let loadError = null;
|
|
449
|
+
try {
|
|
450
|
+
config = loadConfig(configPath);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
loadError = err;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const result =
|
|
456
|
+
loadError != null
|
|
457
|
+
? {
|
|
458
|
+
checks: [
|
|
459
|
+
{
|
|
460
|
+
name: "config.file_parseable",
|
|
461
|
+
status: "fail",
|
|
462
|
+
severity: "critical",
|
|
463
|
+
detail: `Failed to parse ${configPath}: ${loadError.message}`,
|
|
464
|
+
},
|
|
465
|
+
],
|
|
466
|
+
passed: 0,
|
|
467
|
+
failed: 1,
|
|
468
|
+
score: 0,
|
|
469
|
+
configPath,
|
|
470
|
+
}
|
|
471
|
+
: checkConfig(config, {
|
|
472
|
+
configPath,
|
|
473
|
+
requiredKeys,
|
|
474
|
+
forbiddenPlaceholders,
|
|
475
|
+
dangerousDefaults,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const id = crypto.randomUUID();
|
|
479
|
+
const now = new Date().toISOString();
|
|
480
|
+
const recommendations = result.checks
|
|
481
|
+
.filter((c) => c.status === "fail")
|
|
482
|
+
.map((c) => `Fix [${c.severity || "medium"}]: ${c.name} — ${c.detail}`);
|
|
483
|
+
|
|
484
|
+
const audit = {
|
|
485
|
+
id,
|
|
486
|
+
name: `${CONFIG_CHECK_PREFIX}${name}`,
|
|
487
|
+
checks: result.checks,
|
|
488
|
+
passed: result.passed,
|
|
489
|
+
failed: result.failed,
|
|
490
|
+
score: result.score,
|
|
491
|
+
recommendations,
|
|
492
|
+
configPath,
|
|
493
|
+
createdAt: now,
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
_audits.set(id, audit);
|
|
497
|
+
|
|
498
|
+
db.prepare(
|
|
499
|
+
`INSERT INTO hardening_audits (id, name, checks, passed, failed, score, recommendations, created_at)
|
|
500
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
501
|
+
).run(
|
|
502
|
+
id,
|
|
503
|
+
audit.name,
|
|
504
|
+
JSON.stringify(result.checks),
|
|
505
|
+
result.passed,
|
|
506
|
+
result.failed,
|
|
507
|
+
result.score,
|
|
508
|
+
JSON.stringify(recommendations),
|
|
509
|
+
now,
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
return audit;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/* ── Deployment Readiness Check ───────────────────────────── */
|
|
516
|
+
|
|
517
|
+
function _severityCounts(checks) {
|
|
518
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
519
|
+
for (const c of checks || []) {
|
|
520
|
+
if (c.status !== "fail") continue;
|
|
521
|
+
const sev = c.severity || "medium";
|
|
522
|
+
if (counts[sev] != null) counts[sev]++;
|
|
523
|
+
}
|
|
524
|
+
return counts;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Evaluate the six "§八 部署检查清单" items against the current
|
|
529
|
+
* `_baselines` / `_audits` stores.
|
|
530
|
+
*
|
|
531
|
+
* Returns `{ ready, items[], summary }` where each item is
|
|
532
|
+
* `{ id, label, status: "pass"|"fail"|"skipped", detail }`.
|
|
533
|
+
*
|
|
534
|
+
* CLI-evaluable items:
|
|
535
|
+
* - baseline_established — any baseline in store
|
|
536
|
+
* - security_audit_score_80 — latest non-config audit score >= 80
|
|
537
|
+
* - no_critical_high_vulns — latest non-config audit: 0 critical + 0 high fails
|
|
538
|
+
* - config_items_checked — latest config:* audit has score >= 80
|
|
539
|
+
*
|
|
540
|
+
* Desktop-only items (reported as skipped):
|
|
541
|
+
* - alerting_tested, monitoring_dashboard
|
|
542
|
+
*/
|
|
543
|
+
export function deployCheck() {
|
|
544
|
+
const baselines = [..._baselines.values()];
|
|
545
|
+
const audits = [..._audits.values()];
|
|
546
|
+
const configAudits = audits.filter((a) =>
|
|
547
|
+
(a.name || "").startsWith(CONFIG_CHECK_PREFIX),
|
|
548
|
+
);
|
|
549
|
+
const nonConfigAudits = audits.filter(
|
|
550
|
+
(a) => !(a.name || "").startsWith(CONFIG_CHECK_PREFIX),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
const latestBy = (arr) =>
|
|
554
|
+
arr.length === 0
|
|
555
|
+
? null
|
|
556
|
+
: arr.reduce((a, b) => (a.createdAt > b.createdAt ? a : b));
|
|
557
|
+
|
|
558
|
+
const latestAudit = latestBy(nonConfigAudits);
|
|
559
|
+
const latestConfigAudit = latestBy(configAudits);
|
|
560
|
+
|
|
561
|
+
const items = [];
|
|
562
|
+
|
|
563
|
+
items.push(
|
|
564
|
+
baselines.length > 0
|
|
565
|
+
? {
|
|
566
|
+
id: "baseline_established",
|
|
567
|
+
label: "性能基线已建立",
|
|
568
|
+
status: "pass",
|
|
569
|
+
detail: `${baselines.length} baseline(s) recorded`,
|
|
570
|
+
}
|
|
571
|
+
: {
|
|
572
|
+
id: "baseline_established",
|
|
573
|
+
label: "性能基线已建立",
|
|
574
|
+
status: "fail",
|
|
575
|
+
detail:
|
|
576
|
+
"No baseline collected — run `cc hardening baseline collect <name>`",
|
|
577
|
+
},
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
items.push(
|
|
581
|
+
latestAudit == null
|
|
582
|
+
? {
|
|
583
|
+
id: "security_audit_score_80",
|
|
584
|
+
label: "安全审计已完成且评分 >= 80",
|
|
585
|
+
status: "fail",
|
|
586
|
+
detail: "No security audit has been run",
|
|
587
|
+
}
|
|
588
|
+
: latestAudit.score >= 80
|
|
589
|
+
? {
|
|
590
|
+
id: "security_audit_score_80",
|
|
591
|
+
label: "安全审计已完成且评分 >= 80",
|
|
592
|
+
status: "pass",
|
|
593
|
+
detail: `Latest audit "${latestAudit.name}" score=${latestAudit.score}%`,
|
|
594
|
+
}
|
|
595
|
+
: {
|
|
596
|
+
id: "security_audit_score_80",
|
|
597
|
+
label: "安全审计已完成且评分 >= 80",
|
|
598
|
+
status: "fail",
|
|
599
|
+
detail: `Latest audit score ${latestAudit.score}% < 80`,
|
|
600
|
+
},
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
if (latestAudit == null) {
|
|
604
|
+
items.push({
|
|
605
|
+
id: "no_critical_high_vulns",
|
|
606
|
+
label: "无 CRITICAL 和 HIGH 级别漏洞",
|
|
607
|
+
status: "fail",
|
|
608
|
+
detail: "No audit to evaluate severities",
|
|
609
|
+
});
|
|
610
|
+
} else {
|
|
611
|
+
const sev = _severityCounts(latestAudit.checks);
|
|
612
|
+
const bad = sev.critical + sev.high;
|
|
613
|
+
items.push(
|
|
614
|
+
bad === 0
|
|
615
|
+
? {
|
|
616
|
+
id: "no_critical_high_vulns",
|
|
617
|
+
label: "无 CRITICAL 和 HIGH 级别漏洞",
|
|
618
|
+
status: "pass",
|
|
619
|
+
detail: `critical=0 high=0 (medium=${sev.medium} low=${sev.low})`,
|
|
620
|
+
}
|
|
621
|
+
: {
|
|
622
|
+
id: "no_critical_high_vulns",
|
|
623
|
+
label: "无 CRITICAL 和 HIGH 级别漏洞",
|
|
624
|
+
status: "fail",
|
|
625
|
+
detail: `critical=${sev.critical} high=${sev.high}`,
|
|
626
|
+
},
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
items.push(
|
|
631
|
+
latestConfigAudit == null
|
|
632
|
+
? {
|
|
633
|
+
id: "config_items_checked",
|
|
634
|
+
label: "所有配置项已检查",
|
|
635
|
+
status: "fail",
|
|
636
|
+
detail:
|
|
637
|
+
"No config audit has been run — use `cc hardening config-check`",
|
|
638
|
+
}
|
|
639
|
+
: latestConfigAudit.score >= 80
|
|
640
|
+
? {
|
|
641
|
+
id: "config_items_checked",
|
|
642
|
+
label: "所有配置项已检查",
|
|
643
|
+
status: "pass",
|
|
644
|
+
detail: `Config audit "${latestConfigAudit.name}" score=${latestConfigAudit.score}%`,
|
|
645
|
+
}
|
|
646
|
+
: {
|
|
647
|
+
id: "config_items_checked",
|
|
648
|
+
label: "所有配置项已检查",
|
|
649
|
+
status: "fail",
|
|
650
|
+
detail: `Config audit score ${latestConfigAudit.score}% < 80`,
|
|
651
|
+
},
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
items.push({
|
|
655
|
+
id: "alerting_tested",
|
|
656
|
+
label: "告警机制已测试",
|
|
657
|
+
status: "skipped",
|
|
658
|
+
detail: "Desktop-only (alert UI + notification subsystem)",
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
items.push({
|
|
662
|
+
id: "monitoring_dashboard",
|
|
663
|
+
label: "监控仪表板已配置",
|
|
664
|
+
status: "skipped",
|
|
665
|
+
detail: "Desktop-only (Ant Design chart dashboard)",
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
const blocking = items.filter((i) => i.status === "fail");
|
|
669
|
+
const skipped = items.filter((i) => i.status === "skipped");
|
|
670
|
+
const passed = items.filter((i) => i.status === "pass");
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
ready: blocking.length === 0,
|
|
674
|
+
items,
|
|
675
|
+
summary: `${passed.length} pass, ${blocking.length} fail, ${skipped.length} skipped (desktop-only)`,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
270
679
|
/* ── Reset (for testing) ───────────────────────────────────── */
|
|
271
680
|
|
|
272
681
|
export function _resetState() {
|