@nuxtseo/protocol 0.5.8 → 0.5.10

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.
@@ -355,26 +355,76 @@ export const fieldVitalFindingsQuerySchema = schema.strictObject({
355
355
  limit: schema.coerce.number().int().min(1).max(50).default(20),
356
356
  offset: schema.coerce.number().int().min(0).default(0),
357
357
  });
358
- export const fieldVitalFindingSchema = defineResponseObject({
359
- metric: schema.enum(['lcp', 'inp', 'cls']),
358
+ const fieldVitalFindingBaseShape = {
360
359
  severity: fieldVitalRatingSchema,
361
360
  p75: schema.number().nullable(),
362
361
  route: schema.string(),
363
362
  selector: schema.string(),
364
363
  estimatedViews: schema.number(),
364
+ /** Share of sampled views rated poor, 0 to 1. Null when the provider sent no distribution. */
365
+ poorFraction: schema.number().min(0).max(1).nullable(),
366
+ /** The blocking script a lab Scan of the same route found. Null when no Scan matches. */
367
+ labHint: schema.string().nullable(),
365
368
  fixPrompt: schema.string().min(1),
366
- }, {
367
- metric: schema.enum(['lcp', 'inp', 'cls']),
368
- severity: fieldVitalRatingSchema,
369
- p75: schema.number().nullable(),
370
- route: schema.string(),
371
- selector: schema.string(),
372
- estimatedViews: schema.number(),
373
- fixPrompt: schema.string().min(1),
374
- });
369
+ };
370
+ // A newer client reading an older server sees no structured fields, so the
371
+ // client marks each one optional.
372
+ const fieldVitalFindingBaseClientShape = {
373
+ ...fieldVitalFindingBaseShape,
374
+ poorFraction: fieldVitalFindingBaseShape.poorFraction.optional(),
375
+ labHint: fieldVitalFindingBaseShape.labHint.optional(),
376
+ };
377
+ /** Mean LCP sub-phase durations in milliseconds across the sampled views. */
378
+ const fieldVitalLcpPhasesShape = {
379
+ timeToFirstByte: schema.number().nullable(),
380
+ resourceLoadDelay: schema.number().nullable(),
381
+ resourceLoadTime: schema.number().nullable(),
382
+ elementRenderDelay: schema.number().nullable(),
383
+ };
384
+ const fieldVitalLcpShape = {
385
+ /** Host and path of the LCP resource. Null for a text LCP element. */
386
+ resource: schema.string().nullable(),
387
+ /** The `fetchpriority` the browser saw on the LCP resource. */
388
+ fetchPriority: schema.string().nullable(),
389
+ /** How the resource was requested, for example `img` or `css`. */
390
+ initiatorType: schema.string().nullable(),
391
+ phases: schema.strictObject(fieldVitalLcpPhasesShape),
392
+ };
393
+ /**
394
+ * One failing real-user vital attributed to a DOM element. The fields after
395
+ * `selector` depend on `metric`: an LCP finding names its resource and phases,
396
+ * an INP finding names the interaction. `fixPrompt` restates the same facts as
397
+ * prose for an agent to apply.
398
+ */
399
+ export const fieldVitalFindingSchema = {
400
+ producer: schema.discriminatedUnion('metric', [
401
+ schema.strictObject({ metric: schema.literal('lcp'), ...fieldVitalFindingBaseShape, ...fieldVitalLcpShape }),
402
+ schema.strictObject({ metric: schema.literal('inp'), ...fieldVitalFindingBaseShape, interaction: schema.string().nullable() }),
403
+ schema.strictObject({ metric: schema.literal('cls'), ...fieldVitalFindingBaseShape }),
404
+ ]),
405
+ client: schema.discriminatedUnion('metric', [
406
+ schema.looseObject({
407
+ metric: schema.literal('lcp'),
408
+ ...fieldVitalFindingBaseClientShape,
409
+ resource: fieldVitalLcpShape.resource.optional(),
410
+ fetchPriority: fieldVitalLcpShape.fetchPriority.optional(),
411
+ initiatorType: fieldVitalLcpShape.initiatorType.optional(),
412
+ phases: schema.looseObject(fieldVitalLcpPhasesShape).optional(),
413
+ }),
414
+ schema.looseObject({ metric: schema.literal('inp'), ...fieldVitalFindingBaseClientShape, interaction: schema.string().nullable().optional() }),
415
+ schema.looseObject({ metric: schema.literal('cls'), ...fieldVitalFindingBaseClientShape }),
416
+ ]),
417
+ };
418
+ /** The dates the Web Analytics provider aggregated, inclusive, as `YYYY-MM-DD`. */
419
+ const fieldVitalWindowShape = {
420
+ from: schema.iso.date(),
421
+ to: schema.iso.date(),
422
+ };
375
423
  export const fieldVitalFindingsDataSchema = defineResponseObject({
376
424
  source: schema.string().nullable(),
377
425
  message: schema.string().nullable(),
426
+ /** Null when no Web Analytics provider answered. */
427
+ window: schema.strictObject(fieldVitalWindowShape).nullable(),
378
428
  findings: schema.array(fieldVitalFindingSchema.producer),
379
429
  total: schema.number().int().nonnegative(),
380
430
  limit: schema.number().int().positive(),
@@ -382,6 +432,7 @@ export const fieldVitalFindingsDataSchema = defineResponseObject({
382
432
  }, {
383
433
  source: schema.string().nullable(),
384
434
  message: schema.string().nullable(),
435
+ window: schema.looseObject(fieldVitalWindowShape).nullable().optional(),
385
436
  findings: schema.array(fieldVitalFindingSchema.client),
386
437
  total: schema.number().int().nonnegative(),
387
438
  limit: schema.number().int().positive(),
@@ -427,7 +478,7 @@ export const fieldVitalFindingsOperation = defineOperation({
427
478
  lifecycle: { introduced: '1.2.0' },
428
479
  docs: {
429
480
  summary: 'List field Core Web Vitals findings',
430
- description: 'Returns each failing real-user vital attributed to the DOM element behind it, with the p75, the CSS selector, and a ready-to-apply fix prompt. It requires a connected Web Analytics provider. If no provider is connected, findings is empty and message says so.',
481
+ description: 'Returns each failing real-user vital attributed to the DOM element behind it, with the p75, the CSS selector, and a ready-to-apply fix prompt. An LCP finding also names its resource, fetchPriority and phases. An INP finding names the interaction. window gives the dates the provider aggregated. It requires a connected Web Analytics provider. If no provider is connected, findings is empty and message says so.',
431
482
  tags: ['Pages'],
432
483
  examples: [{
433
484
  request: { params: { siteId: 'site_01JXYZ' }, query: { onlyFailing: '1', limit: 20, offset: 0 } },
@@ -437,6 +488,7 @@ export const fieldVitalFindingsOperation = defineOperation({
437
488
  data: {
438
489
  source: 'cloudflare',
439
490
  message: null,
491
+ window: { from: '2026-07-13', to: '2026-08-09' },
440
492
  findings: [{
441
493
  metric: 'lcp',
442
494
  severity: 'poor',
@@ -444,6 +496,12 @@ export const fieldVitalFindingsOperation = defineOperation({
444
496
  route: '/blog',
445
497
  selector: 'main > img.hero',
446
498
  estimatedViews: 1840,
499
+ poorFraction: 0.31,
500
+ labHint: null,
501
+ resource: 'example.com/images/hero.jpg',
502
+ fetchPriority: 'auto',
503
+ initiatorType: 'img',
504
+ phases: { timeToFirstByte: 600, resourceLoadDelay: 1900, resourceLoadTime: 1200, elementRenderDelay: 600 },
447
505
  fixPrompt: 'The LCP element on /blog is main > img.hero at 4300ms.',
448
506
  }],
449
507
  total: 1,
@@ -475,31 +533,130 @@ const scanScoresShape = {
475
533
  bestPractices: nullableMetricSchema,
476
534
  seo: nullableMetricSchema,
477
535
  };
478
- export const performanceScanSummarySchema = defineResponseObject({
536
+ /**
537
+ * Why a Scan started. `claim-recheck` follows a claimed Action. `deploy`
538
+ * follows a new Nuxt build. `confirmation` repeats a Scan that looked like a
539
+ * regression.
540
+ */
541
+ export const PERFORMANCE_SCAN_TRIGGERS = ['scheduled', 'manual', 'claim-recheck', 'deploy', 'confirmation'];
542
+ /**
543
+ * Why a Scan failed. `timeout`, `unavailable`, `quota`, `rejected`, `network`,
544
+ * `empty`, `malformed` and `oversize` describe the PageSpeed Insights request.
545
+ * The rest describe the pipeline around it. `rejected` means PSI refused the
546
+ * URL, so a retry of the same URL fails the same way.
547
+ */
548
+ export const PERFORMANCE_SCAN_FAILURE_KINDS = [
549
+ 'timeout',
550
+ 'unavailable',
551
+ 'quota',
552
+ 'rejected',
553
+ 'network',
554
+ 'empty',
555
+ 'malformed',
556
+ 'oversize',
557
+ 'artifact-missing',
558
+ 'processing',
559
+ 'abandoned',
560
+ 'unqueued',
561
+ 'monitoring-changed',
562
+ 'internal',
563
+ ];
564
+ /**
565
+ * Fields every Scan carries, in the Scan list and the Scan read alike.
566
+ * `failureKind` and `error` are null unless `status` is `failed`. A Scan that
567
+ * failed before failure kinds were recorded has a null `failureKind`.
568
+ */
569
+ const scanBaseShape = {
479
570
  id: schema.string().min(1),
480
571
  url: schema.string().min(1),
481
- strategy: schema.string().min(1),
482
- status: schema.string().min(1),
483
- trigger: schema.string().nullable(),
572
+ strategy: schema.enum(['mobile', 'desktop']),
573
+ status: schema.enum(['running', 'complete', 'failed']),
574
+ trigger: schema.enum(PERFORMANCE_SCAN_TRIGGERS),
575
+ failureKind: schema.enum(PERFORMANCE_SCAN_FAILURE_KINDS).nullable(),
576
+ error: schema.string().nullable(),
577
+ lighthouseVersion: schema.string().nullable(),
484
578
  scores: schema.strictObject(scanScoresShape),
485
579
  lcp: nullableMetricSchema,
486
580
  tbt: nullableMetricSchema,
487
581
  cls: nullableMetricSchema,
488
582
  completedAt: schema.string().datetime().nullable(),
489
- error: schema.string().nullable(),
490
- }, {
583
+ };
584
+ // Strings, not enums, so an older client accepts a status, trigger or failure
585
+ // kind that a newer server adds. Fields added after 1.2.0 are optional.
586
+ const scanBaseClientShape = {
491
587
  id: schema.string().min(1),
492
588
  url: schema.string().min(1),
493
589
  strategy: schema.string().min(1),
494
590
  status: schema.string().min(1),
495
- trigger: schema.string().nullable(),
591
+ trigger: schema.string().nullable().optional(),
592
+ failureKind: schema.string().nullable().optional(),
593
+ error: schema.string().nullable().optional(),
594
+ lighthouseVersion: schema.string().nullable().optional(),
496
595
  scores: schema.looseObject(scanScoresShape),
497
596
  lcp: nullableMetricSchema,
498
597
  tbt: nullableMetricSchema,
499
598
  cls: nullableMetricSchema,
500
599
  completedAt: schema.string().datetime().nullable(),
501
- error: schema.string().nullable(),
502
- });
600
+ };
601
+ export const performanceScanSummarySchema = defineResponseObject(scanBaseShape, scanBaseClientShape);
602
+ /** Metrics a Scan comparison can name. Same names and units as the Scan fields. */
603
+ export const PERFORMANCE_SCAN_COMPARISON_METRICS = ['perf', 'a11y', 'bestPractices', 'seo', 'lcp', 'tbt', 'cls', 'inp', 'fcp', 'speedIndex', 'ttfb'];
604
+ /** Why two Scans of one Page cannot be compared. */
605
+ export const PERFORMANCE_SCAN_INCOMPATIBILITY_REASONS = [
606
+ 'source',
607
+ 'target-url',
608
+ 'strategy',
609
+ 'lighthouse-version',
610
+ 'form-factor',
611
+ 'cpu-throttling',
612
+ 'network-throttling',
613
+ 'host-browser',
614
+ 'runtime-environment',
615
+ ];
616
+ const scanChangeSeveritySchema = schema.enum(['improvement', 'regression', 'neutral']);
617
+ const scanMetricChangeShape = {
618
+ metric: schema.enum(PERFORMANCE_SCAN_COMPARISON_METRICS),
619
+ previous: schema.number(),
620
+ current: schema.number(),
621
+ delta: schema.number(),
622
+ severity: scanChangeSeveritySchema,
623
+ };
624
+ /**
625
+ * This Scan against the previous comparable Scan of the same Page and strategy.
626
+ * `compared` lists each metric both Scans measured. `incompatible` names why
627
+ * the two Scans differ, for example a Lighthouse version change. `none` means
628
+ * no previous Scan was compared, which is always true for the homepage slot.
629
+ */
630
+ export const performanceScanComparisonSchema = {
631
+ producer: schema.discriminatedUnion('_tag', [
632
+ schema.strictObject({
633
+ _tag: schema.literal('compared'),
634
+ previousScanId: schema.string().min(1).nullable(),
635
+ severity: scanChangeSeveritySchema,
636
+ changes: schema.array(schema.strictObject(scanMetricChangeShape)),
637
+ }),
638
+ schema.strictObject({
639
+ _tag: schema.literal('incompatible'),
640
+ previousScanId: schema.string().min(1).nullable(),
641
+ reasons: schema.array(schema.enum(PERFORMANCE_SCAN_INCOMPATIBILITY_REASONS)),
642
+ }),
643
+ schema.strictObject({ _tag: schema.literal('none') }),
644
+ ]),
645
+ client: schema.discriminatedUnion('_tag', [
646
+ schema.looseObject({
647
+ _tag: schema.literal('compared'),
648
+ previousScanId: schema.string().min(1).nullable(),
649
+ severity: schema.string().min(1),
650
+ changes: schema.array(schema.looseObject({ ...scanMetricChangeShape, metric: schema.string().min(1), severity: schema.string().min(1) })),
651
+ }),
652
+ schema.looseObject({
653
+ _tag: schema.literal('incompatible'),
654
+ previousScanId: schema.string().min(1).nullable(),
655
+ reasons: schema.array(schema.string().min(1)),
656
+ }),
657
+ schema.looseObject({ _tag: schema.literal('none') }),
658
+ ]),
659
+ };
503
660
  export const performanceScansDataSchema = defineResponseObject({
504
661
  scans: schema.array(performanceScanSummarySchema.producer),
505
662
  total: schema.number().int().nonnegative(),
@@ -546,7 +703,7 @@ export const performanceScansOperation = defineOperation({
546
703
  lifecycle: { introduced: '1.2.0' },
547
704
  docs: {
548
705
  summary: 'List Lighthouse Scans',
549
- description: 'Returns retained Lighthouse Scans for the Site, newest first, with the scores and lab vitals each one recorded. A failed Scan carries its error. Use one Scan id with the Scan read for the failing checks behind a score.',
706
+ description: 'Returns retained Lighthouse Scans for the Site, newest first, with the scores and lab vitals each one recorded. A failed Scan carries its failureKind and error, and its scores are null. Use one Scan id with the Scan read for the failing checks behind a score.',
550
707
  tags: ['Pages'],
551
708
  examples: [{
552
709
  request: { params: { siteId: 'site_01JXYZ' }, query: { limit: 25 } },
@@ -560,14 +717,30 @@ export const performanceScansOperation = defineOperation({
560
717
  strategy: 'mobile',
561
718
  status: 'complete',
562
719
  trigger: 'scheduled',
720
+ failureKind: null,
721
+ error: null,
722
+ lighthouseVersion: '13.5.0',
563
723
  scores: { perf: 82, a11y: 96, bestPractices: 92, seo: 98 },
564
724
  lcp: 2200,
565
725
  tbt: 180,
566
726
  cls: 0.08,
567
727
  completedAt: '2026-08-10T01:00:00.000Z',
568
- error: null,
728
+ }, {
729
+ id: 'scan_01JXZA',
730
+ url: 'https://example.com/blog',
731
+ strategy: 'mobile',
732
+ status: 'failed',
733
+ trigger: 'scheduled',
734
+ failureKind: 'timeout',
735
+ error: 'PSI timed out loading the page',
736
+ lighthouseVersion: null,
737
+ scores: { perf: null, a11y: null, bestPractices: null, seo: null },
738
+ lcp: null,
739
+ tbt: null,
740
+ cls: null,
741
+ completedAt: '2026-08-10T01:05:00.000Z',
569
742
  }],
570
- total: 1,
743
+ total: 2,
571
744
  limit: 25,
572
745
  },
573
746
  meta: { requestId: 'req_01JXYZ', version: '1.0' },
@@ -576,17 +749,29 @@ export const performanceScansOperation = defineOperation({
576
749
  }],
577
750
  },
578
751
  });
752
+ /**
753
+ * Why the scheduler stopped scanning a Page. `gone`: the URL answers 404 or
754
+ * 410. `redirected`: it redirects to another page. `unreachable`: Scans keep
755
+ * timing out or failing. `rejected`: PageSpeed Insights keeps refusing it.
756
+ * Null while the Page is scanned on schedule.
757
+ */
758
+ const MONITORED_PAGE_PAUSE_REASONS = ['gone', 'redirected', 'unreachable', 'rejected'];
579
759
  export const monitoredPageSchema = defineResponseObject({
580
760
  id: schema.string().min(1),
581
761
  path: schema.string(),
582
762
  source: schema.string().nullable(),
583
763
  enabled: schema.boolean(),
764
+ pauseReason: schema.enum(MONITORED_PAGE_PAUSE_REASONS).nullable(),
765
+ pausedAt: schema.string().datetime().nullable(),
584
766
  lastScanAt: schema.string().datetime().nullable(),
585
767
  }, {
586
768
  id: schema.string().min(1),
587
769
  path: schema.string(),
588
770
  source: schema.string().nullable(),
589
771
  enabled: schema.boolean(),
772
+ // A client accepts reasons a newer server adds.
773
+ pauseReason: schema.string().min(1).nullable().optional(),
774
+ pausedAt: schema.string().datetime().nullable().optional(),
590
775
  lastScanAt: schema.string().datetime().nullable(),
591
776
  });
592
777
  export const monitoredPagesDataSchema = defineResponseObject({
@@ -633,7 +818,7 @@ export const monitoredPagesOperation = defineOperation({
633
818
  lifecycle: { introduced: '1.2.0' },
634
819
  docs: {
635
820
  summary: 'List monitored Pages',
636
- description: 'Returns the Pages this Site scans on a schedule, in display order, with the last time each one was scanned. Start a Scan for a new Page with the Page scan operation.',
821
+ description: 'Returns the Pages this Site monitors, in display order, with the last time each one was scanned. A Page with a pauseReason is out of the schedule until the owner adds it again. Start a Scan for a new Page with the Page scan operation.',
637
822
  tags: ['Pages'],
638
823
  examples: [{
639
824
  request: { params: { siteId: 'site_01JXYZ' } },
@@ -641,8 +826,11 @@ export const monitoredPagesOperation = defineOperation({
641
826
  status: 200,
642
827
  body: {
643
828
  data: {
644
- pages: [{ id: 'lhp_01JXYZ', path: '/', source: 'homepage', enabled: true, lastScanAt: '2026-08-10T01:00:00.000Z' }],
645
- total: 1,
829
+ pages: [
830
+ { id: 'lhp_01JXYZ', path: '/pricing', source: 'auto-gsc', enabled: true, pauseReason: null, pausedAt: null, lastScanAt: '2026-08-10T01:00:00.000Z' },
831
+ { id: 'lhp_01JXZA', path: '/blog/old-post', source: 'auto-gsc', enabled: true, pauseReason: 'redirected', pausedAt: '2026-08-09T01:00:00.000Z', lastScanAt: '2026-08-09T01:00:00.000Z' },
832
+ ],
833
+ total: 2,
646
834
  },
647
835
  meta: { requestId: 'req_01JXYZ', version: '1.0' },
648
836
  },
@@ -764,21 +952,65 @@ export const lcpObservedTraceSchema = schema.discriminatedUnion('_tag', [
764
952
  context.addIssue({ code: 'custom', message: 'Trace phases must describe the observed LCP' });
765
953
  }
766
954
  });
955
+ const scanCruxVitalShape = {
956
+ p75: schema.number().nonnegative(),
957
+ rating: fieldVitalRatingSchema,
958
+ };
959
+ const scanCruxVitalClientShape = {
960
+ p75: schema.number(),
961
+ rating: schema.string().min(1),
962
+ };
963
+ /** CrUX for one scope as PSI returned it with a Scan. `no_data` means CrUX holds no record for that scope. */
964
+ const scanCruxReadingSchema = {
965
+ producer: schema.discriminatedUnion('_tag', [
966
+ schema.strictObject({
967
+ _tag: schema.literal('measured'),
968
+ target: schema.string(),
969
+ lcp: schema.strictObject(scanCruxVitalShape).nullable(),
970
+ inp: schema.strictObject(scanCruxVitalShape).nullable(),
971
+ cls: schema.strictObject(scanCruxVitalShape).nullable(),
972
+ }),
973
+ schema.strictObject({ _tag: schema.literal('no_data'), target: schema.string() }),
974
+ ]),
975
+ client: schema.discriminatedUnion('_tag', [
976
+ schema.looseObject({
977
+ _tag: schema.literal('measured'),
978
+ target: schema.string(),
979
+ lcp: schema.looseObject(scanCruxVitalClientShape).nullable(),
980
+ inp: schema.looseObject(scanCruxVitalClientShape).nullable(),
981
+ cls: schema.looseObject(scanCruxVitalClientShape).nullable(),
982
+ }),
983
+ schema.looseObject({ _tag: schema.literal('no_data'), target: schema.string() }),
984
+ ]),
985
+ };
986
+ /**
987
+ * The CrUX field reading PSI returned with the Scan: real Chrome users' p75
988
+ * over the 28 days before it, for the Scan URL and its origin. Null on a Scan
989
+ * processed before these readings were stored.
990
+ */
991
+ const scanCruxSchema = {
992
+ producer: schema.strictObject({
993
+ formFactor: schema.enum(['PHONE', 'DESKTOP']),
994
+ capturedAt: schema.string().datetime(),
995
+ url: scanCruxReadingSchema.producer.nullable(),
996
+ origin: scanCruxReadingSchema.producer.nullable(),
997
+ }).nullable(),
998
+ client: schema.looseObject({
999
+ formFactor: schema.string().min(1),
1000
+ capturedAt: schema.string().datetime(),
1001
+ url: scanCruxReadingSchema.client.nullable(),
1002
+ origin: scanCruxReadingSchema.client.nullable(),
1003
+ }).nullable(),
1004
+ };
767
1005
  export const performanceScanDetailDataSchema = defineResponseObject({
768
- id: schema.string().min(1),
769
- url: schema.string().min(1),
770
- strategy: schema.string().min(1),
771
- status: schema.string().min(1),
772
- scores: schema.strictObject(scanScoresShape),
773
- lcp: nullableMetricSchema,
774
- tbt: nullableMetricSchema,
775
- cls: nullableMetricSchema,
1006
+ ...scanBaseShape,
776
1007
  fcp: nullableMetricSchema,
777
1008
  speedIndex: nullableMetricSchema,
778
1009
  ttfb: nullableMetricSchema,
779
- completedAt: schema.string().datetime().nullable(),
1010
+ comparison: performanceScanComparisonSchema.producer,
780
1011
  javascript: schema.strictObject(scanJavascriptShape).nullable(),
781
1012
  observedTrace: lcpObservedTraceSchema.optional(),
1013
+ crux: scanCruxSchema.producer,
782
1014
  performanceIssues: schema.array(scanPerformanceIssueSchema.producer),
783
1015
  cachePolicy: schema.array(scanCachePolicySchema.producer),
784
1016
  thirdParties: schema.array(scanThirdPartySchema.producer),
@@ -791,20 +1023,14 @@ export const performanceScanDetailDataSchema = defineResponseObject({
791
1023
  bestPractices: schema.array(scanFailingCheckSchema.producer),
792
1024
  coverage: schema.strictObject(scanCoverageShape),
793
1025
  }, {
794
- id: schema.string().min(1),
795
- url: schema.string().min(1),
796
- strategy: schema.string().min(1),
797
- status: schema.string().min(1),
798
- scores: schema.looseObject(scanScoresShape),
799
- lcp: nullableMetricSchema,
800
- tbt: nullableMetricSchema,
801
- cls: nullableMetricSchema,
1026
+ ...scanBaseClientShape,
802
1027
  fcp: nullableMetricSchema,
803
1028
  speedIndex: nullableMetricSchema,
804
1029
  ttfb: nullableMetricSchema,
805
- completedAt: schema.string().datetime().nullable(),
1030
+ comparison: performanceScanComparisonSchema.client.optional(),
806
1031
  javascript: schema.looseObject(scanJavascriptShape).nullable().optional(),
807
1032
  observedTrace: lcpObservedTraceSchema.optional(),
1033
+ crux: scanCruxSchema.client.optional(),
808
1034
  performanceIssues: schema.array(scanPerformanceIssueSchema.client),
809
1035
  cachePolicy: schema.array(scanCachePolicySchema.client),
810
1036
  thirdParties: schema.array(scanThirdPartySchema.client),
@@ -854,7 +1080,7 @@ export const performanceScanDetailOperation = defineOperation({
854
1080
  lifecycle: { introduced: '1.2.0' },
855
1081
  docs: {
856
1082
  summary: 'Get one Lighthouse Scan',
857
- description: 'Returns one Scan with the evidence behind its scores: the wasteful assets, the repeat-visit cache policy, the third-party scripts, the LCP element, and every failing accessibility and best-practices check with the CSS selectors and DOM snippets that failed. Read coverage before quoting a total: elements and checks are both capped per Scan.',
1083
+ description: 'Returns one Scan with the evidence behind its scores: the wasteful assets, the repeat-visit cache policy, the third-party scripts, the LCP element, and every failing accessibility and best-practices check with the CSS selectors and DOM snippets that failed. It also returns why the Scan started, the Lighthouse version, and the comparison with the previous Scan of the same Page. crux is the CrUX p75 for the Scan URL and its origin that PSI returned with the Scan, null on a Scan stored before these readings. A failed Scan carries its failureKind and error. Read coverage before quoting a total: elements and checks are both capped per Scan. A null blockingTime or wastedMs is unmeasured, never zero.',
858
1084
  tags: ['Pages'],
859
1085
  examples: [{
860
1086
  request: { params: { siteId: 'site_01JXYZ', scanId: 'scan_01JXYZ' } },
@@ -863,9 +1089,13 @@ export const performanceScanDetailOperation = defineOperation({
863
1089
  body: {
864
1090
  data: {
865
1091
  id: 'scan_01JXYZ',
866
- url: 'https://example.com/',
1092
+ url: 'https://example.com/pricing',
867
1093
  strategy: 'mobile',
868
1094
  status: 'complete',
1095
+ trigger: 'scheduled',
1096
+ failureKind: null,
1097
+ error: null,
1098
+ lighthouseVersion: '13.5.0',
869
1099
  scores: { perf: 82, a11y: 96, bestPractices: 92, seo: 98 },
870
1100
  lcp: 2200,
871
1101
  tbt: 180,
@@ -874,7 +1104,28 @@ export const performanceScanDetailOperation = defineOperation({
874
1104
  speedIndex: 2400,
875
1105
  ttfb: 210,
876
1106
  completedAt: '2026-08-10T01:00:00.000Z',
1107
+ comparison: {
1108
+ _tag: 'compared',
1109
+ previousScanId: 'scan_01JXXX',
1110
+ severity: 'regression',
1111
+ changes: [
1112
+ { metric: 'lcp', previous: 1500, current: 2200, delta: 700, severity: 'regression' },
1113
+ { metric: 'perf', previous: 85, current: 82, delta: -3, severity: 'neutral' },
1114
+ ],
1115
+ },
877
1116
  javascript: null,
1117
+ crux: {
1118
+ formFactor: 'PHONE',
1119
+ capturedAt: '2026-08-10T00:58:00.000Z',
1120
+ url: { _tag: 'no_data', target: 'https://example.com/pricing' },
1121
+ origin: {
1122
+ _tag: 'measured',
1123
+ target: 'https://example.com',
1124
+ lcp: { p75: 2100, rating: 'good' },
1125
+ inp: { p75: 180, rating: 'good' },
1126
+ cls: { p75: 0.05, rating: 'good' },
1127
+ },
1128
+ },
878
1129
  performanceIssues: [],
879
1130
  cachePolicy: [],
880
1131
  thirdParties: [],