All files / qunitx-cli/lib/selection filter.ts

97.84% Statements 91/93
90.9% Branches 10/11
100% Functions 3/3
97.84% Lines 91/93

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 501x 501x 501x 501x 501x 501x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 290x 275x 275x 15x 15x 15x 15x 15x 15x 290x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 406x 2x 2x 2x 2x 2x     2x 2x 2x  
import path from 'node:path';
import type { Config } from '../types.ts';
 
/**
 * The slice of {@link Config} the filter helpers read — every function below takes this
 * rather than a full Config, so callers with only the three fields qualify.
 *
 * ```ts
 * import type { Config } from '../types.ts';
 *
 * type FilterConfig = Pick<Config, 'filter' | 'lineTargets' | 'state'>;
 * const expression: FilterConfig['filter'] = '/^Cart(:| >)/'; // the exact-module recipe
 * ```
 */
type FilterConfig = Pick<Config, 'filter' | 'lineTargets' | 'state'>;
 
/**
 * True when this run selects a subset of the tests *inside* the files it loads.
 *
 * File-level narrowing (`--only-failed`, `--changed`) does not count: those run whole files, so
 * their timings stay representative and their failure set is complete for the files they ran.
 * A test-level filter breaks both, which is why the timing and failure caches skip filtered runs.
 *
 * `lineTargets` counts even before it resolves to selectors — it is read on the parent config,
 * where per-group selectors are not visible.
 *
 * ```ts
 * import * as RunState from '../setup/run-state.ts';
 *
 * const state = RunState.create();
 * isFilteredRun({ state }); // false — whole files, caches stay valid
 * isFilteredRun({ filter: 'cart', state }); // true — a test-level subset
 * ```
 */
export function isFilteredRun(config: FilterConfig): boolean {
  return Boolean(
    config.filter ||
    config.state.group.selectors?.length ||
    (config.lineTargets && Object.keys(config.lineTargets).length),
  );
}
 
/**
 * Builds the `?filter=…` query that carries the test filter (`-t`/`--filter`/`-m`/`--module`)
 * into the page.
 *
 * This is the only channel that works: QUnit evaluates `config.filter` at test *declaration*
 * time, and its html-reporter block unconditionally overwrites `config.filter` from
 * `location.search` at bundle-eval time — so a preconfig global would be clobbered.
 * Returns '' when no filter is set, leaving URLs byte-identical to before.
 *
 * ```ts
 * import * as RunState from '../setup/run-state.ts';
 *
 * const state = RunState.create();
 * buildQUnitFilterQuery({ filter: 'cart checkout', state }); // '?filter=cart+checkout'
 * buildQUnitFilterQuery({ state }); // '' — URL byte-identical to an unfiltered run
 * ```
 */
export function buildQUnitFilterQuery(config: FilterConfig): string {
  if (!config.filter) {
    return '';
  }
  // URLSearchParams encodes spaces as '+', which is exactly what QUnit's decodeQueryParam
  // turns back into a space before decodeURIComponent.
  const params = new URLSearchParams();
  params.set('filter', config.filter);
 
  return `?${params.toString()}`;
}
 
/**
 * Human-readable description of the active filters, for the "nothing matched" message.
 *
 * ```ts
 * import * as RunState from '../setup/run-state.ts';
 *
 * const state = RunState.create();
 * describeActiveFilters({ filter: 'cart', lineTargets: { '/proj/cart-test.ts': [34, 60] }, state });
 * // '--filter=cart cart-test.ts#34 cart-test.ts#60'
 * ```
 */
export function describeActiveFilters(config: FilterConfig): string {
  const parts: string[] = [];
  if (config.filter) {
    parts.push(`--filter=${config.filter}`);
  }
  for (const [file, lines] of Object.entries(config.lineTargets ?? {})) {
    parts.push(lines.map((line) => `${path.basename(file)}#${line}`).join(' '));
  }
 
  return parts.join(' ');
}