@speedkit/cli 4.26.0 → 4.27.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.
Files changed (147) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +186 -15
  3. package/dist/commands/auto-prewarm.d.ts +8 -1
  4. package/dist/commands/auto-prewarm.js +18 -7
  5. package/dist/commands/generate-pop-config.d.ts +1 -0
  6. package/dist/commands/generate-pop-config.js +9 -3
  7. package/dist/commands/prewarm.js +3 -1
  8. package/dist/commands/query/parameter.d.ts +26 -0
  9. package/dist/commands/query/parameter.js +27 -0
  10. package/dist/commands/query/prewarm.d.ts +31 -0
  11. package/dist/commands/query/prewarm.js +31 -0
  12. package/dist/helpers/clipboard.d.ts +19 -0
  13. package/dist/helpers/clipboard.js +70 -0
  14. package/dist/helpers/evaluate-speed-kit-config.d.ts +8 -18
  15. package/dist/helpers/evaluate-speed-kit-config.js +8 -6
  16. package/dist/helpers/evaluate-speed-kit-config.spec.d.ts +1 -0
  17. package/dist/helpers/evaluate-speed-kit-config.spec.js +78 -0
  18. package/dist/models/cli-parameters.d.ts +73 -0
  19. package/dist/models/cli-parameters.js +151 -0
  20. package/dist/models/cli-parameters.spec.d.ts +1 -0
  21. package/dist/models/cli-parameters.spec.js +47 -0
  22. package/dist/services/athena/athena-service.d.ts +2 -0
  23. package/dist/services/athena/athena-service.js +15 -4
  24. package/dist/services/athena/athena-service.spec.d.ts +1 -0
  25. package/dist/services/athena/athena-service.spec.js +74 -0
  26. package/dist/services/deploy/handler/install-resource-handler.js +3 -4
  27. package/dist/services/onboarding/onboarding-service-factory.js +2 -2
  28. package/dist/services/pop-config/pop-config-service.d.ts +8 -1
  29. package/dist/services/pop-config/pop-config-service.js +16 -28
  30. package/dist/services/prewarm/assets/asset-api-client.d.ts +1 -1
  31. package/dist/services/prewarm/assets/asset-api-client.js +5 -4
  32. package/dist/services/prewarm/auto-pre-warm-factory.d.ts +12 -2
  33. package/dist/services/prewarm/auto-pre-warm-factory.js +20 -6
  34. package/dist/services/prewarm/csv-reader.d.ts +21 -2
  35. package/dist/services/prewarm/csv-reader.js +71 -9
  36. package/dist/services/prewarm/csv-reader.spec.d.ts +1 -0
  37. package/dist/services/prewarm/csv-reader.spec.js +75 -0
  38. package/dist/services/prewarm/index.d.ts +1 -0
  39. package/dist/services/prewarm/index.js +1 -0
  40. package/dist/services/prewarm/pre-warm-factory.d.ts +0 -2
  41. package/dist/services/prewarm/pre-warm-factory.js +8 -11
  42. package/dist/services/prewarm/pre-warm-model.d.ts +14 -1
  43. package/dist/services/prewarm/pre-warm-model.js +0 -1
  44. package/dist/services/prewarm/pre-warm-service.d.ts +9 -4
  45. package/dist/services/prewarm/pre-warm-service.js +18 -14
  46. package/dist/services/prewarm/prewarm-targets.d.ts +12 -0
  47. package/dist/services/prewarm/prewarm-targets.js +18 -0
  48. package/dist/services/prewarm/prewarm-targets.spec.d.ts +1 -0
  49. package/dist/services/prewarm/prewarm-targets.spec.js +29 -0
  50. package/dist/services/query-builder/error/match-matches-nothing-error.d.ts +11 -0
  51. package/dist/services/query-builder/error/match-matches-nothing-error.js +17 -0
  52. package/dist/services/query-builder/queries/device.d.ts +21 -0
  53. package/dist/services/query-builder/queries/device.js +26 -0
  54. package/dist/services/query-builder/queries/index.d.ts +6 -0
  55. package/dist/services/query-builder/queries/index.js +6 -0
  56. package/dist/services/query-builder/queries/page-filter.d.ts +39 -0
  57. package/dist/services/query-builder/queries/page-filter.js +135 -0
  58. package/dist/services/query-builder/queries/page-source.d.ts +30 -0
  59. package/dist/services/query-builder/queries/page-source.js +79 -0
  60. package/dist/services/query-builder/queries/parameter-query.d.ts +14 -0
  61. package/dist/services/query-builder/queries/parameter-query.js +142 -0
  62. package/dist/services/query-builder/queries/pops-query.d.ts +27 -0
  63. package/dist/services/query-builder/queries/pops-query.js +65 -0
  64. package/dist/services/query-builder/queries/prewarm-query.d.ts +18 -0
  65. package/dist/services/query-builder/queries/prewarm-query.js +227 -0
  66. package/dist/services/query-builder/queries/query-model.d.ts +97 -0
  67. package/dist/services/query-builder/queries/query-model.js +19 -0
  68. package/dist/services/query-builder/queries/spec/golden-configs.d.ts +18 -0
  69. package/dist/services/query-builder/queries/spec/golden-configs.js +116 -0
  70. package/dist/services/query-builder/queries/spec/golden.spec.d.ts +1 -0
  71. package/dist/services/query-builder/queries/spec/golden.spec.js +161 -0
  72. package/dist/services/query-builder/queries/spec/pops-query.spec.d.ts +1 -0
  73. package/dist/services/query-builder/queries/spec/pops-query.spec.js +58 -0
  74. package/dist/services/query-builder/queries/spec/queries.spec.d.ts +1 -0
  75. package/dist/services/query-builder/queries/spec/queries.spec.js +312 -0
  76. package/dist/services/query-builder/queries/spec/reported-bugs.spec.d.ts +1 -0
  77. package/dist/services/query-builder/queries/spec/reported-bugs.spec.js +86 -0
  78. package/dist/services/query-builder/query-builder-factory.d.ts +2 -2
  79. package/dist/services/query-builder/query-builder-factory.js +35 -7
  80. package/dist/services/query-builder/query-builder-model.d.ts +31 -19
  81. package/dist/services/query-builder/query-builder-model.js +5 -5
  82. package/dist/services/query-builder/query-builder-service.d.ts +26 -5
  83. package/dist/services/query-builder/query-builder-service.js +157 -44
  84. package/dist/services/query-builder/query-command.d.ts +26 -0
  85. package/dist/services/query-builder/query-command.js +35 -0
  86. package/dist/services/query-builder/rules/index.d.ts +8 -0
  87. package/dist/services/query-builder/rules/index.js +8 -0
  88. package/dist/services/query-builder/rules/match-pattern.d.ts +51 -0
  89. package/dist/services/query-builder/rules/match-pattern.js +141 -0
  90. package/dist/services/query-builder/rules/pattern-language.d.ts +59 -0
  91. package/dist/services/query-builder/rules/pattern-language.js +159 -0
  92. package/dist/services/query-builder/rules/rule-model.d.ts +107 -0
  93. package/dist/services/query-builder/rules/rule-model.js +54 -0
  94. package/dist/services/query-builder/rules/rule-parser.d.ts +33 -0
  95. package/dist/services/query-builder/rules/rule-parser.js +189 -0
  96. package/dist/services/query-builder/rules/rule-reducer.d.ts +71 -0
  97. package/dist/services/query-builder/rules/rule-reducer.js +383 -0
  98. package/dist/services/query-builder/rules/rule-relevance.d.ts +43 -0
  99. package/dist/services/query-builder/rules/rule-relevance.js +69 -0
  100. package/dist/services/query-builder/rules/rule-to-sql.d.ts +51 -0
  101. package/dist/services/query-builder/rules/rule-to-sql.js +75 -0
  102. package/dist/services/query-builder/rules/spec/pattern-language.spec.d.ts +1 -0
  103. package/dist/services/query-builder/rules/spec/pattern-language.spec.js +90 -0
  104. package/dist/services/query-builder/rules/spec/rule-parser.spec.d.ts +1 -0
  105. package/dist/services/query-builder/rules/spec/rule-parser.spec.js +150 -0
  106. package/dist/services/query-builder/rules/spec/rule-reducer.spec.d.ts +1 -0
  107. package/dist/services/query-builder/rules/spec/rule-reducer.spec.js +169 -0
  108. package/dist/services/query-builder/rules/spec/rule-relevance.spec.d.ts +1 -0
  109. package/dist/services/query-builder/rules/spec/rule-relevance.spec.js +88 -0
  110. package/dist/services/query-builder/rules/spec/rule-to-sql.spec.d.ts +1 -0
  111. package/dist/services/query-builder/rules/spec/rule-to-sql.spec.js +110 -0
  112. package/dist/services/query-builder/rules/spec/runtime-semantics.spec.d.ts +1 -0
  113. package/dist/services/query-builder/rules/spec/runtime-semantics.spec.js +55 -0
  114. package/dist/services/query-builder/rules/spec/strip-parameters.spec.d.ts +1 -0
  115. package/dist/services/query-builder/rules/spec/strip-parameters.spec.js +85 -0
  116. package/dist/services/query-builder/rules/strip-parameters.d.ts +39 -0
  117. package/dist/services/query-builder/rules/strip-parameters.js +197 -0
  118. package/dist/services/query-builder/sql/expression.d.ts +73 -0
  119. package/dist/services/query-builder/sql/expression.js +133 -0
  120. package/dist/services/query-builder/sql/format.d.ts +14 -0
  121. package/dist/services/query-builder/sql/format.js +48 -0
  122. package/dist/services/query-builder/sql/index.d.ts +4 -0
  123. package/dist/services/query-builder/sql/index.js +4 -0
  124. package/dist/services/query-builder/sql/predicate.d.ts +66 -0
  125. package/dist/services/query-builder/sql/predicate.js +124 -0
  126. package/dist/services/query-builder/sql/regex.d.ts +28 -0
  127. package/dist/services/query-builder/sql/regex.js +81 -0
  128. package/dist/services/query-builder/sql/spec/expression.spec.d.ts +1 -0
  129. package/dist/services/query-builder/sql/spec/expression.spec.js +75 -0
  130. package/dist/services/query-builder/sql/spec/format.spec.d.ts +1 -0
  131. package/dist/services/query-builder/sql/spec/format.spec.js +37 -0
  132. package/dist/services/query-builder/sql/spec/predicate.spec.d.ts +1 -0
  133. package/dist/services/query-builder/sql/spec/predicate.spec.js +78 -0
  134. package/dist/services/query-builder/sql/spec/regex.spec.d.ts +1 -0
  135. package/dist/services/query-builder/sql/spec/regex.spec.js +62 -0
  136. package/oclif.manifest.json +350 -100
  137. package/package.json +11 -2
  138. package/dist/commands/build-parameter-query.d.ts +0 -14
  139. package/dist/commands/build-parameter-query.js +0 -25
  140. package/dist/commands/build-prewarm-query.d.ts +0 -16
  141. package/dist/commands/build-prewarm-query.js +0 -37
  142. package/dist/helpers/build-query-helper.d.ts +0 -32
  143. package/dist/helpers/build-query-helper.js +0 -223
  144. package/dist/helpers/get-parsed-config.d.ts +0 -15
  145. package/dist/helpers/get-parsed-config.js +0 -62
  146. package/dist/services/onboarding/dashboard/parameter-query-builder.d.ts +0 -8
  147. package/dist/services/onboarding/dashboard/parameter-query-builder.js +0 -63
@@ -0,0 +1,69 @@
1
+ import { testCondition } from "@speedkit/rules";
2
+ import { DOCUMENT_CONTENT_TYPES } from "./rule-model.js";
3
+ /**
4
+ * Rules the query cannot possibly be affected by, removed.
5
+ *
6
+ * This is the first half of ruling the rule sets against each other: not rule
7
+ * against rule yet, but every rule against the frame the query already sets —
8
+ * documents, on this app's origins. A `contentType: ['image']` blacklist entry
9
+ * or a rule for a third-party host says nothing about a page impression, and
10
+ * keeping it only narrows the result for no reason.
11
+ *
12
+ * Nothing is dropped silently: every decision comes back in `dropped` or
13
+ * `approximated` so it can be reported.
14
+ */
15
+ export function keepRelevantRules(rules, context = {}) {
16
+ const contentTypes = context.contentTypes ?? DOCUMENT_CONTENT_TYPES;
17
+ const hosts = context.hosts ?? [];
18
+ const approximated = [];
19
+ const dropped = [];
20
+ const kept = [];
21
+ for (const rule of rules) {
22
+ const appliesTo = rule.contentTypes;
23
+ if (appliesTo && !appliesTo.some((type) => contentTypes.includes(type))) {
24
+ dropped.push({
25
+ reason: `applies to ${appliesTo.join(", ")} only, not to documents`,
26
+ source: rule.source,
27
+ });
28
+ continue;
29
+ }
30
+ if (rule.conditions.length === 0 && rule.criteriaOutsideTheUrl.length > 0) {
31
+ dropped.push({
32
+ reason: `matches on ${rule.criteriaOutsideTheUrl.join(", ")} only, which a page impression does not carry`,
33
+ source: rule.source,
34
+ });
35
+ continue;
36
+ }
37
+ if (hosts.length > 0 && !matchesAnyHost(rule, hosts)) {
38
+ dropped.push({
39
+ reason: `its host condition matches none of the queried origins (${hosts.join(", ")})`,
40
+ source: rule.source,
41
+ });
42
+ continue;
43
+ }
44
+ if (rule.criteriaOutsideTheUrl.length > 0) {
45
+ approximated.push({
46
+ reason: `also requires ${rule.criteriaOutsideTheUrl.join(", ")}, which a page impression does not carry — the filter is wider than the rule`,
47
+ source: rule.source,
48
+ });
49
+ }
50
+ kept.push(rule);
51
+ }
52
+ return { approximated, dropped, rules: kept };
53
+ }
54
+ /**
55
+ * True unless the rule has a host condition that provably matches none of the
56
+ * given hosts. A rule without a host condition always passes.
57
+ *
58
+ * The condition is evaluated with `testCondition` from `@speedkit/rules` —
59
+ * the very function the runtime uses — so this decision cannot drift from the
60
+ * browser's.
61
+ */
62
+ function matchesAnyHost(rule, hosts) {
63
+ const condition = rule.conditions.find(({ subject }) => subject === "host");
64
+ if (!condition) {
65
+ return true;
66
+ }
67
+ const patterns = condition.patterns.map((pattern) => pattern.value);
68
+ return hosts.some((host) => testCondition(host, patterns, rule.ruleSetVersion));
69
+ }
@@ -0,0 +1,51 @@
1
+ import { Predicate, SqlExpression } from "../sql/index.js";
2
+ import { NormalizedRule } from "./rule-model.js";
3
+ /**
4
+ * The expressions a rule condition is matched against. The query decides how
5
+ * it provides them — inline over the `url` column, or as columns of a CTE that
6
+ * computes them once.
7
+ */
8
+ export interface RuleSubjectExpressions {
9
+ readonly host: SqlExpression;
10
+ /** The path without the query string — what version 2 matches. */
11
+ readonly path: SqlExpression;
12
+ /** The path with the query string appended — what version 1 matches. */
13
+ readonly pathWithSearch: SqlExpression;
14
+ readonly url: SqlExpression;
15
+ }
16
+ export interface RuleTranslation {
17
+ readonly predicate: Predicate;
18
+ /** Constructs whose meaning may differ in Athena. See `toAthenaRegex`. */
19
+ readonly warnings: string[];
20
+ }
21
+ export interface TranslateRulesOptions {
22
+ /**
23
+ * What an empty rule list means. `enabledSites: []` accelerates every page,
24
+ * `blacklist: []` excludes nothing — both are `TRUE` for the set's own
25
+ * question, but the caller decides how the result is combined.
26
+ */
27
+ readonly whenEmpty?: Predicate;
28
+ }
29
+ /**
30
+ * Derives the subject expressions from a URL expression, the way
31
+ * `@speedkit/rules` derives them from a request in the browser.
32
+ *
33
+ * Three of those derivations are easy to get wrong:
34
+ *
35
+ * - A `url` condition is matched against the URL *without its scheme and
36
+ * fragment* (`stripForRules`), so `www.shop.de/p/1`, not
37
+ * `https://www.shop.de/p/1#reviews`.
38
+ * - A version 1 `pathname` condition is matched against `pathname + search`,
39
+ * so a pattern anchored with `$` sees the query string too.
40
+ * - `new URL('https://shop.de').pathname` is `/`, while `url_extract_path`
41
+ * returns nothing for the same URL.
42
+ */
43
+ export declare function urlSubjects(url: SqlExpression): RuleSubjectExpressions;
44
+ /**
45
+ * Translates a list of rules into the predicate that holds when any of them
46
+ * matches — rules are OR'd, the conditions of a rule are AND'd, and the
47
+ * entries of a condition are OR'd again.
48
+ */
49
+ export declare function translateRules(rules: readonly NormalizedRule[], subjects: RuleSubjectExpressions, options?: TranslateRulesOptions): RuleTranslation;
50
+ /** Translates one rule: every one of its conditions has to match. */
51
+ export declare function translateRule(rule: NormalizedRule, subjects: RuleSubjectExpressions): RuleTranslation;
@@ -0,0 +1,75 @@
1
+ import { and, call, concat, equalsIgnoreCase, FALSE, literal, or, regexpLike, startsWith, toAthenaRegex, TRUE, } from "../sql/index.js";
2
+ import { matchesExactly, resolvedSubject, } from "./rule-model.js";
3
+ /**
4
+ * Derives the subject expressions from a URL expression, the way
5
+ * `@speedkit/rules` derives them from a request in the browser.
6
+ *
7
+ * Three of those derivations are easy to get wrong:
8
+ *
9
+ * - A `url` condition is matched against the URL *without its scheme and
10
+ * fragment* (`stripForRules`), so `www.shop.de/p/1`, not
11
+ * `https://www.shop.de/p/1#reviews`.
12
+ * - A version 1 `pathname` condition is matched against `pathname + search`,
13
+ * so a pattern anchored with `$` sees the query string too.
14
+ * - `new URL('https://shop.de').pathname` is `/`, while `url_extract_path`
15
+ * returns nothing for the same URL.
16
+ */
17
+ export function urlSubjects(url) {
18
+ const query = call("url_extract_query", url);
19
+ const path = call("coalesce", call("nullif", call("url_extract_path", url), literal("")), literal("/"));
20
+ return {
21
+ // `url_extract_host` drops a port the runtime would keep, which only
22
+ // matters for a host condition naming one.
23
+ host: call("url_extract_host", url),
24
+ path,
25
+ pathWithSearch: concat(path, call("coalesce", call("nullif", concat(literal("?"), query), literal("?")), literal(""))),
26
+ url: call("regexp_replace", call("regexp_replace", url, literal("#.*"), literal("")), literal(String.raw `^(\w+:)?//`), literal("")),
27
+ };
28
+ }
29
+ /**
30
+ * Translates a list of rules into the predicate that holds when any of them
31
+ * matches — rules are OR'd, the conditions of a rule are AND'd, and the
32
+ * entries of a condition are OR'd again.
33
+ */
34
+ export function translateRules(rules, subjects, options = {}) {
35
+ if (rules.length === 0) {
36
+ return { predicate: options.whenEmpty ?? TRUE, warnings: [] };
37
+ }
38
+ const translations = rules.map((rule) => translateRule(rule, subjects));
39
+ return {
40
+ predicate: or(...translations.map(({ predicate }) => predicate)),
41
+ warnings: translations.flatMap(({ warnings }) => warnings),
42
+ };
43
+ }
44
+ /** Translates one rule: every one of its conditions has to match. */
45
+ export function translateRule(rule, subjects) {
46
+ // A rule with no condition, or with a condition nothing satisfies.
47
+ if (rule.matchesNothing) {
48
+ return { predicate: FALSE, warnings: [] };
49
+ }
50
+ const warnings = [];
51
+ const predicates = rule.conditions.map((condition) => translateCondition(condition, rule, subjects, warnings));
52
+ // Conditions the query cannot express were taken out by the relevance pass,
53
+ // which is why a rule can arrive here with none left: it then matches every
54
+ // row of a query that is already restricted to documents.
55
+ return { predicate: and(...predicates), warnings };
56
+ }
57
+ function translateCondition(condition, rule, subjects, warnings) {
58
+ const subject = subjectExpression(condition.subject, rule, subjects);
59
+ return or(...condition.patterns.map((pattern) => translatePattern(pattern, subject, rule, warnings)));
60
+ }
61
+ function translatePattern(pattern, subject, rule, warnings) {
62
+ if (pattern.kind === "regexp") {
63
+ const { pattern: athenaPattern, warnings: regexWarnings } = toAthenaRegex(pattern.value);
64
+ warnings.push(...regexWarnings.map((warning) => `${rule.source}: ${warning}`));
65
+ return regexpLike(subject, athenaPattern);
66
+ }
67
+ // A string condition is case-insensitive on both sides, and it is a prefix
68
+ // under version 1 while version 2 compares it exactly.
69
+ return matchesExactly(rule.ruleSetVersion)
70
+ ? equalsIgnoreCase(subject, pattern.value)
71
+ : startsWith(subject, pattern.value);
72
+ }
73
+ function subjectExpression(subject, rule, subjects) {
74
+ return subjects[resolvedSubject(subject, rule.ruleSetVersion)];
75
+ }
@@ -0,0 +1,90 @@
1
+ import { expect } from "chai";
2
+ import { describe, it } from "mocha";
3
+ import { conditionLanguage, isInside, languagesDisjoint, outsideOf, } from "../pattern-language.js";
4
+ function condition(...patterns) {
5
+ return {
6
+ patterns: patterns.map((value) => typeof value === "string"
7
+ ? { kind: "string", value }
8
+ : { kind: "regexp", value }),
9
+ subject: "pathname",
10
+ };
11
+ }
12
+ function languageOf(pattern, bound, version = 2) {
13
+ return conditionLanguage(condition(pattern), version, bound);
14
+ }
15
+ /** Whether the first pattern's matches all fall inside the second's. */
16
+ function inside(narrower, wider, version = 2) {
17
+ const atLeast = languageOf(narrower, "atLeast", version);
18
+ const atMost = languageOf(wider, "atMost", version);
19
+ if (!atLeast || !atMost) {
20
+ return undefined;
21
+ }
22
+ const outside = outsideOf(atMost);
23
+ return outside && isInside(atLeast, outside);
24
+ }
25
+ function disjoint(left, right, version = 2) {
26
+ const first = languageOf(left, "atLeast", version);
27
+ const second = languageOf(right, "atLeast", version);
28
+ return first && second ? languagesDisjoint(first, second) : undefined;
29
+ }
30
+ describe("pattern languages", () => {
31
+ it("should see an anchored pattern inside a shorter one", () => {
32
+ expect(inside(/^\/p\/shoes\//i, /^\/p\//i)).to.equal(true);
33
+ expect(inside(/^\/p\//i, /^\/p\/shoes\//i)).to.equal(false);
34
+ });
35
+ it("should see disjoint prefixes as disjoint", () => {
36
+ expect(disjoint(/^\/p\//i, /^\/checkout/i)).to.equal(true);
37
+ });
38
+ it("should not call overlapping patterns disjoint", () => {
39
+ expect(disjoint(/^\/p\//i, /shoes/i)).to.equal(false);
40
+ });
41
+ it("should read an unanchored pattern as matching anywhere", () => {
42
+ expect(inside(/^\/a\/b/i, /b/i)).to.equal(true);
43
+ expect(disjoint(/^\/a\/b/i, /b/i)).to.equal(false);
44
+ });
45
+ it("should respect the case of a pattern without the i flag", () => {
46
+ expect(disjoint(/^\/Shop/, /^\/shop/)).to.equal(true);
47
+ expect(disjoint(/^\/Shop/i, /^\/shop/)).to.equal(false);
48
+ });
49
+ it("should read a string as a prefix under version 1 and exactly under 2", () => {
50
+ expect(inside("/shop/nike", "/shop", 1)).to.equal(true);
51
+ expect(inside("/shop/nike", "/shop", 2)).to.equal(false);
52
+ });
53
+ it("should ignore the case of a string condition", () => {
54
+ expect(disjoint("/SHOP", "/shop", 2)).to.equal(false);
55
+ });
56
+ it("should union the entries of a condition", () => {
57
+ const language = conditionLanguage(condition(/^\/a/, /^\/b/), 2, "atLeast");
58
+ const outside = language && outsideOf(language);
59
+ const a = languageOf(/^\/a\/1/, "atLeast");
60
+ expect(outside && a && isInside(a, outside)).to.equal(true);
61
+ });
62
+ });
63
+ describe("pattern languages with an assertion", () => {
64
+ // `(\/|$)` and `\b` have no automaton, so each bound approximates — and the
65
+ // two bounds have to fall on opposite sides of the truth.
66
+ it("should read an assertion as widely as possible for atLeast", () => {
67
+ // /^\/shop(\/|$)/ widened means "starts with /shop", which covers /shopx.
68
+ expect(disjoint(/^\/shop(\/|$)/, /^\/shopx/)).to.equal(false);
69
+ });
70
+ it("should read an assertion as narrowly as possible for atMost", () => {
71
+ // Narrowed, the same pattern covers only /shop/… — not /shop itself.
72
+ expect(inside(/^\/shop$/, /^\/shop(\/|$)/)).to.equal(false);
73
+ });
74
+ it("should still place a widened pattern inside a wider one", () => {
75
+ expect(inside(/\bshop\b/i, /shop/i)).to.equal(true);
76
+ });
77
+ });
78
+ describe("patterns without any automaton", () => {
79
+ // A back reference to a single character is expressible as a union and
80
+ // `refa` resolves it; one to a group of any length is not.
81
+ it("should resolve a back reference to a single character", () => {
82
+ expect(languageOf(/(\w)\1/, "atLeast")).to.not.equal(undefined);
83
+ });
84
+ it("should give up on a back reference it cannot resolve", () => {
85
+ expect(languageOf(/(\w+)\1/, "atLeast")).to.equal(undefined);
86
+ });
87
+ it("should give up on the whole condition when one entry has none", () => {
88
+ expect(conditionLanguage(condition(/^\/a/, /(\w+)\1/), 2, "atLeast")).to.equal(undefined);
89
+ });
90
+ });
@@ -0,0 +1,150 @@
1
+ import * as vm from "node:vm";
2
+ import { expect } from "chai";
3
+ import { describe, it } from "mocha";
4
+ import { parseCondition, parseRuleSets, parseRuleSetVersion, parseStripQueryParams, } from "../rule-parser.js";
5
+ describe("parseRuleSetVersion", () => {
6
+ it("should default to version 1", () => {
7
+ expect(parseRuleSetVersion({})).to.equal(1);
8
+ });
9
+ it("should read the declared version", () => {
10
+ expect(parseRuleSetVersion({ ruleSetConfig: { version: 2 } })).to.equal(2);
11
+ });
12
+ it("should read a version given as a string", () => {
13
+ expect(parseRuleSetVersion({ ruleSetConfig: { version: "2" } })).to.equal(2);
14
+ });
15
+ it("should fall back for an unusable version", () => {
16
+ expect(parseRuleSetVersion({ ruleSetConfig: { version: "x" } })).to.equal(1);
17
+ });
18
+ });
19
+ describe("parseCondition", () => {
20
+ it("should wrap a single string", () => {
21
+ expect(parseCondition("/shop")).to.deep.equal([
22
+ { kind: "string", value: "/shop" },
23
+ ]);
24
+ });
25
+ it("should wrap a single regular expression", () => {
26
+ const pattern = /^\/p\//i;
27
+ expect(parseCondition(pattern)).to.deep.equal([
28
+ { kind: "regexp", value: pattern },
29
+ ]);
30
+ });
31
+ it("should keep the order of a mixed array", () => {
32
+ expect(parseCondition(["/a", /b/]).map(({ kind }) => kind)).to.deep.equal([
33
+ "string",
34
+ "regexp",
35
+ ]);
36
+ });
37
+ it("should return nothing for a missing condition", () => {
38
+ expect(parseCondition(undefined)).to.deep.equal([]);
39
+ });
40
+ it("should recognise a regular expression from another realm", () => {
41
+ // The config is evaluated with `vm.runInNewContext`, so its regular
42
+ // expressions are not `instanceof RegExp` here. Reading them as strings
43
+ // would drop every pattern of every rule.
44
+ const foreign = vm.runInNewContext("/^\\/p\\//i");
45
+ expect(foreign instanceof RegExp).to.equal(false);
46
+ const [pattern] = parseCondition(foreign);
47
+ expect(pattern.kind).to.equal("regexp");
48
+ expect(pattern.value).to.be.instanceOf(RegExp);
49
+ expect(String(pattern.value)).to.equal("/^\\/p\\//i");
50
+ });
51
+ });
52
+ describe("parseRuleSets", () => {
53
+ it("should read every whitelist rule, not just the first", () => {
54
+ const { whitelist } = parseRuleSets({
55
+ whitelist: [{ host: ["www.shop.de"] }, { host: ["m.shop.de"] }],
56
+ });
57
+ expect(whitelist).to.have.lengthOf(2);
58
+ expect(whitelist[1].source).to.equal("whitelist[1]");
59
+ });
60
+ it("should keep the conditions of a rule together", () => {
61
+ const { enabledSites } = parseRuleSets({
62
+ enabledSites: [{ host: ["www.shop.de"], pathname: [/^\/p\//i] }],
63
+ });
64
+ expect(enabledSites[0].conditions.map(({ subject }) => subject)).to.deep.eq(["host", "pathname"]);
65
+ });
66
+ it("should normalize the content types", () => {
67
+ const { blacklist } = parseRuleSets({
68
+ blacklist: [{ contentType: "Image" }],
69
+ });
70
+ expect(blacklist[0].contentTypes).to.deep.equal(["image"]);
71
+ });
72
+ it("should leave the content types undefined when there are none", () => {
73
+ const { blacklist } = parseRuleSets({ blacklist: [{ pathname: "/a" }] });
74
+ expect(blacklist[0].contentTypes).to.equal(undefined);
75
+ });
76
+ it("should record criteria that are not part of the url", () => {
77
+ const { disabledSites } = parseRuleSets({
78
+ disabledSites: [{ cookie: /loggedIn/, mobile: true, pathname: "/a" }],
79
+ });
80
+ expect(disabledSites[0].criteriaOutsideTheUrl).to.deep.equal([
81
+ "cookie",
82
+ "mobile",
83
+ ]);
84
+ });
85
+ it("should apply the config version to every rule", () => {
86
+ const { enabledSites } = parseRuleSets({
87
+ enabledSites: [{ pathname: "/a" }],
88
+ ruleSetConfig: { version: 2 },
89
+ });
90
+ expect(enabledSites[0].ruleSetVersion).to.equal(2);
91
+ });
92
+ it("should let a rule override the version", () => {
93
+ const { enabledSites } = parseRuleSets({
94
+ enabledSites: [{ pathname: "/a", ruleSetVersion: 1 }],
95
+ ruleSetConfig: { version: 2 },
96
+ });
97
+ expect(enabledSites[0].ruleSetVersion).to.equal(1);
98
+ });
99
+ it("should survive a config without any rule set", () => {
100
+ const parsed = parseRuleSets({});
101
+ expect(parsed.whitelist).to.deep.equal([]);
102
+ expect(parsed.enabledSites).to.deep.equal([]);
103
+ expect(parsed.stripQueryParams).to.deep.equal([]);
104
+ });
105
+ it("should ignore a rule set that is not an array", () => {
106
+ expect(parseRuleSets({ blacklist: "everything" }).blacklist).to.deep.equal([]);
107
+ });
108
+ it("should keep a rule without conditions", () => {
109
+ const { whitelist } = parseRuleSets({ whitelist: [{}] });
110
+ expect(whitelist).to.have.lengthOf(1);
111
+ expect(whitelist[0].conditions).to.deep.equal([]);
112
+ expect(whitelist[0].matchesNothing).to.equal(true);
113
+ });
114
+ it("should mark a rule with an empty condition as matching nothing", () => {
115
+ const { blacklist } = parseRuleSets({ blacklist: [{ host: [] }] });
116
+ expect(blacklist[0].matchesNothing).to.equal(true);
117
+ });
118
+ it("should mark an empty content type as matching nothing", () => {
119
+ const { blacklist } = parseRuleSets({ blacklist: [{ contentType: [] }] });
120
+ expect(blacklist[0].matchesNothing).to.equal(true);
121
+ });
122
+ it("should not mark a rule with a usable condition", () => {
123
+ const { blacklist } = parseRuleSets({ blacklist: [{ host: ["a.de"] }] });
124
+ expect(blacklist[0].matchesNothing).to.equal(false);
125
+ });
126
+ });
127
+ describe("parseStripQueryParams", () => {
128
+ it("should read a bare list of parameters", () => {
129
+ const rules = parseStripQueryParams(["gclid", /^utm[_-]/], 2);
130
+ expect(rules).to.have.lengthOf(1);
131
+ expect(rules[0].params).to.have.lengthOf(2);
132
+ expect(rules[0].enabledSites).to.equal(undefined);
133
+ });
134
+ it("should read a single parameter", () => {
135
+ expect(parseStripQueryParams("gclid", 2)[0].params).to.deep.equal([
136
+ { kind: "string", value: "gclid" },
137
+ ]);
138
+ });
139
+ it("should keep the pages a rule is scoped to", () => {
140
+ const rules = parseStripQueryParams([{ enabledSites: [{ pathname: [/^\/p\//i] }], params: ["size"] }], 2);
141
+ expect(rules[0].enabledSites).to.have.lengthOf(1);
142
+ expect(rules[0].enabledSites[0].source).to.equal("stripQueryParams[0].enabledSites[0]");
143
+ });
144
+ it("should skip a rule without parameters", () => {
145
+ expect(parseStripQueryParams([{ enabledSites: [] }], 2)).to.deep.equal([]);
146
+ });
147
+ it("should return nothing when the property is missing", () => {
148
+ expect(parseStripQueryParams(undefined, 2)).to.deep.equal([]);
149
+ });
150
+ });
@@ -0,0 +1,169 @@
1
+ import { expect } from "chai";
2
+ import { describe, it } from "mocha";
3
+ import { parseRuleSets } from "../rule-parser.js";
4
+ import { absorbRules, dropUnreachableRules, findShadowedRules, reductionBudget, } from "../rule-reducer.js";
5
+ function rulesOf(config, version = 2) {
6
+ return parseRuleSets({ ruleSetConfig: { version }, ...config });
7
+ }
8
+ function blacklist(rules, version = 2) {
9
+ return rulesOf({ blacklist: rules }, version).blacklist;
10
+ }
11
+ function enabled(rules, version = 2) {
12
+ return rulesOf({ enabledSites: rules }, version).enabledSites;
13
+ }
14
+ /** The entries left on the first rule's first condition. */
15
+ function patternsOf(rules) {
16
+ return (rules[0]?.conditions[0]?.patterns ?? []).map(({ value }) => String(value));
17
+ }
18
+ describe("absorbRules", () => {
19
+ it("should drop a rule a sibling covers", () => {
20
+ const { notes, rules } = absorbRules(blacklist([{ pathname: [/^\/p\/shoes\//i] }, { pathname: [/^\/p\//i] }]));
21
+ expect(rules).to.have.lengthOf(1);
22
+ expect(rules[0].source).to.equal("blacklist[1]");
23
+ expect(notes[0].reason).to.contain("already covered by blacklist[1]");
24
+ });
25
+ it("should keep the first of two equivalent rules", () => {
26
+ const { rules } = absorbRules(blacklist([{ pathname: [/^\/p\//i] }, { pathname: [/^\/p\//i] }]));
27
+ expect(rules).to.have.lengthOf(1);
28
+ expect(rules[0].source).to.equal("blacklist[0]");
29
+ });
30
+ it("should not compare rules about different parts of the url", () => {
31
+ const { rules } = absorbRules(blacklist([{ host: ["www.shop.de"] }, { pathname: [/^\/p\//i] }]));
32
+ expect(rules).to.have.lengthOf(2);
33
+ });
34
+ it("should keep a rule that is wider on a subject the other constrains", () => {
35
+ const { rules } = absorbRules(blacklist([
36
+ { host: ["www.shop.de"], pathname: [/^\/p\//i] },
37
+ { pathname: [/^\/p\//i] },
38
+ ]));
39
+ expect(rules).to.have.lengthOf(1);
40
+ expect(rules[0].source).to.equal("blacklist[1]");
41
+ });
42
+ it("should drop a condition entry a sibling entry covers", () => {
43
+ const { rules } = absorbRules(blacklist([{ pathname: [/^\/p\/shoes\//i, /^\/p\//i] }]));
44
+ expect(patternsOf(rules)).to.deep.equal(["/^\\/p\\//i"]);
45
+ });
46
+ it("should keep entries that only overlap", () => {
47
+ const { rules } = absorbRules(blacklist([{ pathname: [/^\/p\//i, /shoes/i] }]));
48
+ expect(patternsOf(rules)).to.have.lengthOf(2);
49
+ });
50
+ it("should absorb an entry with an assertion into a wider one", () => {
51
+ // `\b` has no automaton, but read as widely as possible the entry means
52
+ // "contains p" — which /p/i does cover, so the absorption holds.
53
+ const { rules } = absorbRules(blacklist([{ pathname: [/\bp\b/i, /p/i] }]));
54
+ expect(patternsOf(rules)).to.deep.equal(["/p/i"]);
55
+ });
56
+ it("should not let an entry with an assertion absorb a wider one", () => {
57
+ // Read as narrowly as possible the entry means nothing at all, so it
58
+ // cannot stand in for /p/i.
59
+ const { rules } = absorbRules(blacklist([{ pathname: [/p/i, /\bp\b/i] }]));
60
+ expect(patternsOf(rules)).to.deep.equal(["/p/i"]);
61
+ });
62
+ });
63
+ describe("dropUnreachableRules", () => {
64
+ const reachable = [enabled([{ pathname: [/^\/$/, /^\/p\//i] }])];
65
+ it("should drop an entry no enabled page can match", () => {
66
+ const { notes, rules } = dropUnreachableRules(blacklist([{ pathname: [/^\/checkout/i, /^\/p\//i] }]), reachable);
67
+ expect(patternsOf(rules)).to.deep.equal(["/^\\/p\\//i"]);
68
+ expect(notes[0].reason).to.contain("matches no page enabledSites");
69
+ });
70
+ it("should drop the rule once no entry of a condition is left", () => {
71
+ const { notes, rules } = dropUnreachableRules(blacklist([{ pathname: [/^\/checkout/i, /^\/account/i] }]), reachable);
72
+ expect(rules).to.deep.equal([]);
73
+ expect(notes[0].source).to.equal("blacklist[0]");
74
+ });
75
+ it("should keep an unanchored entry that can hit an enabled page", () => {
76
+ const { rules } = dropUnreachableRules(blacklist([{ pathname: [/\.pdf/i] }]), reachable);
77
+ expect(patternsOf(rules)).to.have.lengthOf(1);
78
+ });
79
+ it("should keep an entry when the enabled rules leave the subject free", () => {
80
+ const { rules } = dropUnreachableRules(blacklist([{ host: ["cdn.shop.de"] }]), reachable);
81
+ expect(rules).to.have.lengthOf(1);
82
+ });
83
+ it("should reduce nothing without an including set", () => {
84
+ const { rules } = dropUnreachableRules(blacklist([{ pathname: [/^\/checkout/i] }]), []);
85
+ expect(rules).to.have.lengthOf(1);
86
+ });
87
+ it("should not read an assertion as an exclusion", () => {
88
+ // `(\/|$)` has no automaton. Reading it as "never matches" would make
89
+ // /shop/x look unreachable, and the guard would vanish from the query.
90
+ const { rules } = dropUnreachableRules(blacklist([{ pathname: [/^\/shop\/x/i] }]), [enabled([{ pathname: [/^\/shop(\/|$)/i] }])]);
91
+ expect(rules).to.have.lengthOf(1);
92
+ });
93
+ });
94
+ describe("the reduction budget", () => {
95
+ it("should spend the same comparisons on the same input, twice", () => {
96
+ // The budget counts work, not milliseconds: a busier machine must not
97
+ // reduce a config differently than a quiet one.
98
+ const rules = () => blacklist([{ pathname: [/^\/p\/shoes\//i] }, { pathname: [/^\/p\//i] }]);
99
+ const budget = reductionBudget(3);
100
+ const first = absorbRules(rules(), budget);
101
+ const second = absorbRules(rules(), reductionBudget(3));
102
+ expect(first.rules.map(({ source }) => source)).to.deep.equal(second.rules.map(({ source }) => source));
103
+ expect(first.notes).to.deep.equal(second.notes);
104
+ });
105
+ const spent = () => reductionBudget(0);
106
+ it("should keep every rule once the budget is gone", () => {
107
+ const { notes, rules } = absorbRules(blacklist([{ pathname: [/^\/p\/shoes\//i] }, { pathname: [/^\/p\//i] }]), spent());
108
+ expect(rules).to.have.lengthOf(2);
109
+ expect(notes[0].reason).to.contain("stopped after");
110
+ expect(notes[0].reason).to.contain("comparisons");
111
+ });
112
+ it("should keep every condition entry once the budget is gone", () => {
113
+ const { rules } = dropUnreachableRules(blacklist([{ pathname: [/^\/checkout/i] }]), [enabled([{ pathname: [/^\/p\//i] }])], spent());
114
+ expect(rules).to.have.lengthOf(1);
115
+ expect(patternsOf(rules)).to.have.lengthOf(1);
116
+ });
117
+ it("should report no finding once the budget is gone", () => {
118
+ expect(findShadowedRules(enabled([{ pathname: [/^\/p\/shoes\//i] }]), blacklist([{ pathname: [/^\/p\//i] }]), spent())).to.deep.equal([]);
119
+ });
120
+ it("should reduce normally with comparisons left", () => {
121
+ const { rules } = absorbRules(blacklist([{ pathname: [/^\/p\/shoes\//i] }, { pathname: [/^\/p\//i] }]), reductionBudget(100));
122
+ expect(rules).to.have.lengthOf(1);
123
+ });
124
+ });
125
+ describe("findShadowedRules", () => {
126
+ it("should report an enabled rule an excluding rule covers", () => {
127
+ const notes = findShadowedRules(enabled([{ pathname: [/^\/p\/shoes\//i] }]), blacklist([{ pathname: [/^\/p\//i] }]));
128
+ expect(notes).to.have.lengthOf(1);
129
+ expect(notes[0].source).to.equal("enabledSites[0]");
130
+ expect(notes[0].reason).to.contain("blacklist[0]");
131
+ });
132
+ it("should say nothing about a rule that is only narrowed", () => {
133
+ const notes = findShadowedRules(enabled([{ pathname: [/^\/p\//i] }]), blacklist([{ pathname: [/^\/p\/shoes\//i] }]));
134
+ expect(notes).to.deep.equal([]);
135
+ });
136
+ });
137
+ describe("disabledScopes as rules", () => {
138
+ it("should read a scope as a case-sensitive path prefix", () => {
139
+ const { disabledScopes } = rulesOf({ disabledScopes: ["/checkout"] });
140
+ expect(disabledScopes).to.have.lengthOf(1);
141
+ expect(patternsOf(disabledScopes)).to.deep.equal(["/^\\/checkout/"]);
142
+ expect(disabledScopes[0].source).to.equal("disabledScopes[0]");
143
+ });
144
+ it("should escape a scope that reads as a pattern", () => {
145
+ const { disabledScopes } = rulesOf({ disabledScopes: ["/a.b"] });
146
+ expect(patternsOf(disabledScopes)).to.deep.equal([String.raw `/^\/a\.b/`]);
147
+ });
148
+ it("should ignore entries that are not a prefix", () => {
149
+ const { disabledScopes } = rulesOf({
150
+ disabledScopes: ["/cart", "", null, 7],
151
+ });
152
+ expect(disabledScopes).to.have.lengthOf(1);
153
+ });
154
+ it("should return nothing when the property is missing", () => {
155
+ expect(rulesOf({}).disabledScopes).to.deep.equal([]);
156
+ });
157
+ it("should cover the sibling paths a service worker scope covers", () => {
158
+ // A scope is a plain prefix, so `/checkout` claims `/checkoutTunnel` too
159
+ // and a page type living there can never be accelerated.
160
+ const notes = findShadowedRules(enabled([{ pathname: [/^\/checkoutTunnel/] }]), rulesOf({ disabledScopes: ["/checkout"] }).disabledScopes);
161
+ expect(notes).to.have.lengthOf(1);
162
+ expect(notes[0].reason).to.contain("disabledScopes[0]");
163
+ });
164
+ it("should not claim a path that only differs in case", () => {
165
+ // Scope matching is case-sensitive, unlike a string condition.
166
+ const notes = findShadowedRules(enabled([{ pathname: [/^\/Checkout/] }]), rulesOf({ disabledScopes: ["/checkout"] }).disabledScopes);
167
+ expect(notes).to.deep.equal([]);
168
+ });
169
+ });