auditSeoParity function

Future<SeoAuditReport> auditSeoParity({
  1. required List<SeoRoute> routes,
  2. required String siteBase,
  3. required List<String> paths,
  4. required Future<void> pump(
    1. String path
    ),
  5. SeoParityPolicy policy = const SeoParityPolicy(),
  6. List<String> additionalPaths = const [],
})

Checks that the app renders what the route table promises.

pump is called once per path and must mount the app at that route — how is up to the caller's router:

testWidgets('bots and users see the same pages', (tester) async {
  final report = await auditSeoParity(
    routes: seoRoutes,
    siteBase: siteBase,
    paths: const ['/', '/docs'],
    pump: (path) async {
      await tester.pumpWidget(MyApp(initialRoute: path));
      await tester.pumpAndSettle();
    },
  );
  expect(report.passes(), isTrue, reason: '\n${report.describe()}');
});

Paths not covered by paths are reported once as SeoCheck.parityNotCovered — a shrinking sample should not quietly become no sample at all. DOM-first routes are excluded: their route body is the presentation rather than a second Flutter tree, so pumping Flutter would test the wrong owner.

Implementation

Future<SeoAuditReport> auditSeoParity({
  required List<SeoRoute> routes,
  required String siteBase,
  required List<String> paths,
  required Future<void> Function(String path) pump,
  SeoParityPolicy policy = const SeoParityPolicy(),
  List<String> additionalPaths = const [],
}) async {
  enableSeoForParity();

  final resolved = await resolveSeoPages(
    routes: routes,
    canonicalBase: siteBase,
    additionalPaths: additionalPaths,
    detail: SeoDetail.full,
  );
  final byPath = {for (final page in resolved) page.path: page};

  final findings = <SeoFinding>[];
  var covered = 0;

  final seen = <String>{};
  for (final rawPath in paths) {
    final path = normalizeSeoPath(rawPath);
    // The same path listed twice would pump twice and report every
    // finding twice.
    if (!seen.add(path)) continue;

    var page = byPath[path];
    if (page == null) {
      // Not in the enumeration is not the same as not served. A
      // `/products/:slug` route without `enumeratePaths` serves this URL
      // perfectly well, so resolve it on the spot instead of claiming
      // the table does not know it — which was simply untrue.
      final match = matchSeoRoute(routes, path);
      if (match != null) {
        page = SeoResolvedPage(
          path: path,
          route: match.route,
          params: match.params,
          resolution: await match.resolve(
            detail: SeoDetail.full,
            canonicalBase: siteBase,
          ),
        );
      }
    }
    if (page == null) {
      findings.add(SeoFinding(
        check: SeoCheck.parityNotCovered,
        severity: SeoSeverity.warning,
        path: path,
        message: 'asked to check parity for a path the route table does '
            'not serve',
      ));
      continue;
    }
    if (page.route?.isDomFirst ?? false) continue;
    final document = page.document;
    if (document == null) continue; // a redirect has no body to compare

    await pump(path);
    covered++;

    findings.addAll(compareSeoTrees(
      path: path,
      ssr: document.body,
      app: captureSeoNodes(),
      policy: policy,
    ));
  }

  // Say plainly which indexable pages nobody looked at, so a sample
  // that quietly shrank to one route is visible in the report.
  //
  // Severity depends on whether anything was checked at all, because
  // "info" alone did not hold up the claim this file makes: with
  // `passes()` failing only on errors, `paths: []` was a green run that
  // proved nothing. Checking no page is a failure; checking some is a
  // judgement call the caller may have made deliberately.
  final unchecked = [
    for (final page in resolved)
      if (page.isIndexable &&
          !(page.route?.isDomFirst ?? false) &&
          !paths.map(normalizeSeoPath).contains(page.path))
        page.path,
  ];
  final hasParityCandidate = resolved.any(
    (page) => page.isIndexable && !(page.route?.isDomFirst ?? false),
  );
  if (covered == 0 && hasParityCandidate) {
    findings.add(SeoFinding(
      check: SeoCheck.parityNotCovered,
      severity: SeoSeverity.error,
      message: 'no page was checked for parity, so this run proves nothing '
          '— pass the paths you want compared',
      detail: unchecked.isEmpty ? null : unchecked.take(5).join(', '),
    ));
  } else if (unchecked.isNotEmpty) {
    findings.add(SeoFinding(
      check: SeoCheck.parityNotCovered,
      severity: SeoSeverity.info,
      message: '${unchecked.length} indexable page(s) were not checked for '
          'parity',
      detail: unchecked.take(5).join(', '),
    ));
  }

  return SeoAuditReport(findings: findings, pagesAudited: covered);
}