@avi2dg/checks 0.34.0 → 0.35.1
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/CHANGELOG.md +21 -0
- package/README.md +9 -0
- package/dependency-cruiser.config.js +2 -1
- package/docs/configs/dependency-rules.md +1 -1
- package/docs/design.md +23 -1
- package/docs/gates/checks-browser.md +201 -0
- package/docs/gates/checks-ci-wiring.md +10 -2
- package/docs/gates/checks-frontend-syntax.md +97 -0
- package/docs/gates/checks-lint.md +3 -1
- package/package.json +37 -4
- package/src/core/gates.ts +3 -0
- package/src/delivery/ci-wiring.ts +34 -38
- package/src/delivery/mutation-job.ts +131 -0
- package/src/delivery/shell-command.ts +69 -5
- package/src/docs/prose-matchers.ts +4 -4
- package/src/docs/sentence-length.ts +2 -2
- package/src/quality/browser/assets.ts +23 -0
- package/src/quality/browser/axe.ts +33 -0
- package/src/quality/browser/browser.ts +134 -0
- package/src/quality/browser/declaration.ts +107 -0
- package/src/quality/browser/dom.ts +121 -0
- package/src/quality/browser/hooks.ts +57 -0
- package/src/quality/browser/keyboard.ts +72 -0
- package/src/quality/browser/layout.ts +208 -0
- package/src/quality/browser/motion.ts +85 -0
- package/src/quality/browser/nesting.ts +23 -0
- package/src/quality/browser/page.ts +66 -0
- package/src/quality/browser/report.ts +48 -0
- package/src/quality/browser/serve.ts +48 -0
- package/src/quality/browser/targets.ts +23 -0
- package/src/quality/frontend-syntax.ts +138 -0
- package/stryker.preset.js +4 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Config, Effect, Schema } from "effect";
|
|
2
|
+
import type { Browser, BrowserContext, CDPSession, Page } from "playwright-core";
|
|
3
|
+
import type { Visit } from "./declaration.ts";
|
|
4
|
+
|
|
5
|
+
export class BrowserFailure extends Schema.TaggedError<BrowserFailure>()("BrowserFailure", {
|
|
6
|
+
message: Schema.String,
|
|
7
|
+
}) {}
|
|
8
|
+
|
|
9
|
+
export const attempt = <A>(doing: string, run: () => Promise<A>): Effect.Effect<A, BrowserFailure> =>
|
|
10
|
+
Effect.tryPromise({
|
|
11
|
+
try: run,
|
|
12
|
+
catch: (cause) => new BrowserFailure({ message: `${doing}: ${cause instanceof Error ? cause.message : String(cause)}` }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Playwright's own Chromium build is a separate download, so the runner drives the Chrome the machine already has.
|
|
16
|
+
export const launchChrome = Effect.gen(function* () {
|
|
17
|
+
const { chromium } = yield* attempt("cannot load playwright-core, which a product that runs the browser checks installs beside the kit", () =>
|
|
18
|
+
import("playwright-core"),
|
|
19
|
+
);
|
|
20
|
+
const executablePath = yield* Config.String("CHROME_PATH").pipe(Config.withDefault(""));
|
|
21
|
+
return yield* Effect.acquireRelease(
|
|
22
|
+
attempt("cannot launch Chrome, so set CHROME_PATH to its executable", () =>
|
|
23
|
+
chromium.launch(executablePath === "" ? { channel: "chrome" } : { executablePath }),
|
|
24
|
+
),
|
|
25
|
+
(browser) => attempt("closing Chrome", () => browser.close()).pipe(Effect.catchTag("BrowserFailure", () => Effect.void)),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type Opened = {
|
|
30
|
+
readonly context: BrowserContext;
|
|
31
|
+
readonly page: Page;
|
|
32
|
+
readonly cdp: CDPSession;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type Preparation = (context: BrowserContext) => Promise<unknown>;
|
|
36
|
+
|
|
37
|
+
const NO_PREPARATION: Preparation = () => Promise.resolve();
|
|
38
|
+
|
|
39
|
+
export const openVisit = Effect.fn("openVisit")(function* (browser: Browser, { viewport, state }: Visit, prepare: Preparation = NO_PREPARATION) {
|
|
40
|
+
const touch = viewport.touch === true;
|
|
41
|
+
const context = yield* Effect.acquireRelease(
|
|
42
|
+
attempt("cannot open a browser context", () =>
|
|
43
|
+
browser.newContext({
|
|
44
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
45
|
+
hasTouch: touch,
|
|
46
|
+
isMobile: touch,
|
|
47
|
+
reducedMotion: state.reducedMotion ?? "no-preference",
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
(opened) => attempt("closing a browser context", () => opened.close()).pipe(Effect.catchTag("BrowserFailure", () => Effect.void)),
|
|
51
|
+
);
|
|
52
|
+
yield* attempt("cannot prepare the browser context", () => prepare(context));
|
|
53
|
+
const page = yield* attempt("cannot open a page", () => context.newPage());
|
|
54
|
+
const cdp = yield* attempt("cannot open a DevTools session", () => context.newCDPSession(page));
|
|
55
|
+
yield* attempt("cannot enable the DevTools page domain", () => cdp.send("Page.enable"));
|
|
56
|
+
if (state.textPx !== undefined) {
|
|
57
|
+
const standard = state.textPx;
|
|
58
|
+
yield* attempt("cannot enlarge the text", () => cdp.send("Page.setFontSizes", { fontSizes: { standard } }));
|
|
59
|
+
}
|
|
60
|
+
return { context, page, cdp } satisfies Opened;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const load = Effect.fn("load")(function* (page: Page, url: string) {
|
|
64
|
+
yield* attempt(`cannot load ${url}`, () => page.goto(url, { waitUntil: "load" }));
|
|
65
|
+
yield* attempt(`fonts never settle on ${url}`, () => page.evaluate("document.fonts.ready.then(() => undefined)"));
|
|
66
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describeVisit, type Declaration, type Visit } from "./declaration.ts";
|
|
2
|
+
|
|
3
|
+
export const NAME = "browser";
|
|
4
|
+
|
|
5
|
+
export type Run = {
|
|
6
|
+
readonly check: string;
|
|
7
|
+
readonly visit: Visit;
|
|
8
|
+
readonly inventory: readonly string[];
|
|
9
|
+
readonly found: readonly string[];
|
|
10
|
+
readonly unverified: readonly string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type Outcome = {
|
|
14
|
+
readonly runs: readonly Run[];
|
|
15
|
+
readonly missingRoutes: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function counted(names: readonly string[]): readonly string[] {
|
|
19
|
+
const counts = new Map<string, number>();
|
|
20
|
+
for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
21
|
+
return [...counts].map(([name, count]) => `${name} ${count}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function failuresOf({ runs, missingRoutes }: Outcome): readonly string[] {
|
|
25
|
+
return [
|
|
26
|
+
...missingRoutes.map((path) => `route ${path} serves no page from the declared site`),
|
|
27
|
+
...(runs.length === 0 ? ["the declaration leaves no check to run"] : []),
|
|
28
|
+
...runs.flatMap(({ check, visit, found }) => found.map((one) => `${check} at ${describeVisit(visit)}: ${one}`)),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function passes(outcome: Outcome): boolean {
|
|
33
|
+
return failuresOf(outcome).length === 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function report(declaration: Declaration, outcome: Outcome): string {
|
|
37
|
+
const failures = failuresOf(outcome);
|
|
38
|
+
const unverified = outcome.runs.flatMap(({ check, visit, unverified: some }) => some.map((one) => `${check} at ${describeVisit(visit)}: ${one}`));
|
|
39
|
+
const scope = [
|
|
40
|
+
`${outcome.runs.length} check run(s) over ${declaration.routes.length} route(s), ${declaration.viewports.length} viewport(s),`,
|
|
41
|
+
`${declaration.states.length} state(s) and ${declaration.targets.length} target(s)`,
|
|
42
|
+
].join(" ");
|
|
43
|
+
return [
|
|
44
|
+
...outcome.runs.map(({ check, visit, inventory }) => `${NAME}: ${check} at ${describeVisit(visit)} scanned ${inventory.length === 0 ? "nothing" : inventory.join(", ")}`),
|
|
45
|
+
...(unverified.length === 0 ? [] : [`${NAME}: ${unverified.length} contrast check(s) axe cannot measure, reported as unverified:`, ...unverified.map((one) => ` ${one}`)]),
|
|
46
|
+
...(failures.length === 0 ? [`${NAME}: ${scope} pass`] : [`${NAME}: ${failures.length} failure(s) in ${scope}:`, ...failures.map((one) => ` ${one}`)]),
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path } from "effect";
|
|
2
|
+
import { Usage } from "../../core/main.ts";
|
|
3
|
+
|
|
4
|
+
const INDEX = "index.html";
|
|
5
|
+
|
|
6
|
+
export type Site = {
|
|
7
|
+
readonly files: ReadonlyMap<string, string>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const readSite = Effect.fn("readSite")(function* (root: string, site: string) {
|
|
11
|
+
const fs = yield* FileSystem.FileSystem;
|
|
12
|
+
const path = yield* Path.Path;
|
|
13
|
+
const dir = path.resolve(root, site);
|
|
14
|
+
const isDirectory = yield* fs.stat(dir).pipe(
|
|
15
|
+
Effect.map(({ type }) => type === "Directory"),
|
|
16
|
+
Effect.orElseSucceed(() => false),
|
|
17
|
+
);
|
|
18
|
+
if (!isDirectory) return yield* new Usage({ message: `the declared site ${site} is not a directory, so build the site before the browser checks run` });
|
|
19
|
+
const files = new Map<string, string>();
|
|
20
|
+
for (const entry of yield* fs.readDirectory(dir, { recursive: true })) {
|
|
21
|
+
const file = path.join(dir, entry);
|
|
22
|
+
if ((yield* fs.stat(file)).type !== "File") continue;
|
|
23
|
+
const url = `/${entry.split(path.sep).join("/")}`;
|
|
24
|
+
files.set(url, file);
|
|
25
|
+
if (url.endsWith(`/${INDEX}`)) files.set(url.slice(0, -INDEX.length), file);
|
|
26
|
+
}
|
|
27
|
+
return { files } satisfies Site;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function answer(site: Site, request: Request): Response {
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
33
|
+
const file = site.files.get(pathname);
|
|
34
|
+
if (file !== undefined) return new Response(Bun.file(file));
|
|
35
|
+
if (site.files.has(`${pathname}/`)) {
|
|
36
|
+
url.pathname = `${url.pathname}/`;
|
|
37
|
+
return Response.redirect(url.href, 301);
|
|
38
|
+
}
|
|
39
|
+
return new Response("not found", { status: 404 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const serveSite = Effect.fn("serveSite")(function* (site: Site) {
|
|
43
|
+
const server = yield* Effect.acquireRelease(
|
|
44
|
+
Effect.sync(() => Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => answer(site, request) })),
|
|
45
|
+
(served) => Effect.promise(() => served.stop()),
|
|
46
|
+
);
|
|
47
|
+
return `http://127.0.0.1:${server.port}/`;
|
|
48
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { Page } from "playwright-core";
|
|
3
|
+
import type { Target } from "./declaration.ts";
|
|
4
|
+
import { attempt, type BrowserFailure } from "./page.ts";
|
|
5
|
+
|
|
6
|
+
type TargetsReport = {
|
|
7
|
+
readonly found: readonly string[];
|
|
8
|
+
readonly visible: ReadonlyMap<string, number>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const countVisible = (page: Page, selector: string): Effect.Effect<number, BrowserFailure> =>
|
|
12
|
+
attempt(`cannot query ${selector}`, () => page.locator(selector).filter({ visible: true }).count());
|
|
13
|
+
|
|
14
|
+
export const judgeTargets = Effect.fn("judgeTargets")(function* (page: Page, targets: readonly Target[]) {
|
|
15
|
+
const found: string[] = [];
|
|
16
|
+
const visible = new Map<string, number>();
|
|
17
|
+
for (const target of targets) {
|
|
18
|
+
const shown = yield* countVisible(page, target.selector);
|
|
19
|
+
visible.set(target.name, shown);
|
|
20
|
+
if (shown === 0) found.push(`${target.name} (${target.selector}) matches no visible element`);
|
|
21
|
+
}
|
|
22
|
+
return { found, visible } satisfies TargetsReport;
|
|
23
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import type { LintResult } from "stylelint";
|
|
4
|
+
import { runMain, Usage } from "../core/main.ts";
|
|
5
|
+
|
|
6
|
+
const NAME = "frontend-syntax";
|
|
7
|
+
|
|
8
|
+
const DECLARATION = "frontend-syntax.json";
|
|
9
|
+
|
|
10
|
+
const TRANSITION_ALL = "declaration-property-value-disallowed-list";
|
|
11
|
+
const BODY_WIDE_USER_SELECT = "rule-selector-property-disallowed-list";
|
|
12
|
+
const UNPARSED = "CssSyntaxError";
|
|
13
|
+
|
|
14
|
+
const RULES = {
|
|
15
|
+
[TRANSITION_ALL]: [{ "/^(?:-[a-z]+-)?transition(?:-property)?$/": ["/(?:^|[\\s,])all(?:[\\s,]|$)/i"] }],
|
|
16
|
+
[BODY_WIDE_USER_SELECT]: [{ "/(?:^|,)\\s*(?:html|body|:root|\\*|:global\\(\\s*(?:html|body|:root|\\*)\\s*\\))\\s*(?:,|$)/": ["/^(?:-[a-z]+-)?user-select$/"] }],
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
const ADVICE: Readonly<Record<string, string>> = {
|
|
20
|
+
[TRANSITION_ALL]: "Name the properties the transition animates.",
|
|
21
|
+
[BODY_WIDE_USER_SELECT]: "Leave text selectable across the page, and turn selection off only on the control that needs it.",
|
|
22
|
+
[UNPARSED]: "The gate judges only what parses.",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MARKUP = ["**/*.html", "**/*.astro", "**/*.vue", "**/*.svelte"];
|
|
26
|
+
|
|
27
|
+
const Declaration = Schema.Struct({ inputs: Schema.NonEmptyArray(Schema.NonEmptyString) });
|
|
28
|
+
|
|
29
|
+
const decodeDeclaration = Schema.decodeUnknownEffect(Schema.fromJsonString(Declaration));
|
|
30
|
+
|
|
31
|
+
export type Violation = {
|
|
32
|
+
readonly file: string;
|
|
33
|
+
readonly line: number;
|
|
34
|
+
readonly column: number;
|
|
35
|
+
readonly text: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type Input = {
|
|
39
|
+
readonly glob: string;
|
|
40
|
+
readonly files: readonly string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type Scan = {
|
|
44
|
+
readonly inputs: readonly Input[];
|
|
45
|
+
readonly violations: readonly Violation[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
class FrontendSyntaxError extends Schema.TaggedError<FrontendSyntaxError>()("FrontendSyntaxError", {
|
|
49
|
+
message: Schema.String,
|
|
50
|
+
}) {}
|
|
51
|
+
|
|
52
|
+
class NoFilesFound extends Schema.TaggedError<NoFilesFound>()("NoFilesFound", {}) {}
|
|
53
|
+
|
|
54
|
+
function isNoFilesFound(cause: unknown): boolean {
|
|
55
|
+
return cause instanceof Error && cause.name === "NoFilesFoundError";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const loadPeer = <A>(name: string, load: () => Promise<A>) =>
|
|
59
|
+
Effect.tryPromise({
|
|
60
|
+
try: load,
|
|
61
|
+
catch: () =>
|
|
62
|
+
new Usage({ message: `cannot load ${name}, which a repository that declares ${DECLARATION} installs beside the kit as an optional peer` }),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
|
|
66
|
+
const fs = yield* FileSystem.FileSystem;
|
|
67
|
+
const file = (yield* Path.Path).join(root, DECLARATION);
|
|
68
|
+
if (!(yield* fs.exists(file))) {
|
|
69
|
+
return yield* new Usage({ message: `${DECLARATION} is missing, and a repository opts in to this gate by declaring its inputs there` });
|
|
70
|
+
}
|
|
71
|
+
return yield* decodeDeclaration(yield* fs.readFileString(file)).pipe(
|
|
72
|
+
Effect.mapError(({ message }) => new Usage({ message: `${DECLARATION} does not decode: ${message}` })),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function violationsOf(result: LintResult, relative: (file: string) => string): readonly Violation[] {
|
|
77
|
+
const file = relative(result.source ?? "");
|
|
78
|
+
return result.warnings.map(({ line, column, rule, text }) => ({
|
|
79
|
+
file,
|
|
80
|
+
line,
|
|
81
|
+
column,
|
|
82
|
+
text: `${text.replace(` (${rule})`, "")}. ${ADVICE[rule] ?? ""}`.trim(),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const scanInputs = Effect.fn("scanInputs")(function* (root: string, globs: readonly string[]) {
|
|
87
|
+
const path = yield* Path.Path;
|
|
88
|
+
const stylelint = (yield* loadPeer("stylelint", () => import("stylelint"))).default;
|
|
89
|
+
const html = (yield* loadPeer("postcss-html", () => import("postcss-html"))).default;
|
|
90
|
+
const config = { rules: RULES, overrides: [{ files: MARKUP, customSyntax: html }] };
|
|
91
|
+
const relative = (file: string): string => path.relative(root, file);
|
|
92
|
+
const inputs: Input[] = [];
|
|
93
|
+
const violations = new Map<string, Violation>();
|
|
94
|
+
for (const glob of globs) {
|
|
95
|
+
const linted: readonly LintResult[] = yield* Effect.tryPromise({
|
|
96
|
+
try: () => stylelint.lint({ files: glob, cwd: root, config }),
|
|
97
|
+
catch: (cause) => (isNoFilesFound(cause) ? new NoFilesFound() : new FrontendSyntaxError({ message: `stylelint fails on ${glob}: ${String(cause)}` })),
|
|
98
|
+
}).pipe(
|
|
99
|
+
Effect.map(({ results }) => results),
|
|
100
|
+
Effect.catchTag("NoFilesFound", () => Effect.succeed([])),
|
|
101
|
+
);
|
|
102
|
+
const results = linted.filter(({ ignored }) => ignored !== true);
|
|
103
|
+
const invalid = results.flatMap(({ invalidOptionWarnings }) => invalidOptionWarnings.map(({ text }) => text));
|
|
104
|
+
if (invalid.length > 0) return yield* new FrontendSyntaxError({ message: `stylelint refuses the kit's rule options: ${invalid.join("; ")}` });
|
|
105
|
+
inputs.push({ glob, files: results.map((result) => relative(result.source ?? "")).toSorted() });
|
|
106
|
+
for (const violation of results.flatMap((result) => violationsOf(result, relative))) {
|
|
107
|
+
violations.set(`${violation.file}:${violation.line}:${violation.column}:${violation.text}`, violation);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const byPlace = (a: Violation, b: Violation): number => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column;
|
|
111
|
+
return { inputs, violations: [...violations.values()].toSorted(byPlace) } satisfies Scan;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export function passes({ inputs, violations }: Scan): boolean {
|
|
115
|
+
return violations.length === 0 && inputs.every(({ files }) => files.length > 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function report({ inputs, violations }: Scan): string {
|
|
119
|
+
const files = new Set(inputs.flatMap((input) => input.files)).size;
|
|
120
|
+
const scanned = `${files} file(s) from ${inputs.length} declared input(s)`;
|
|
121
|
+
const empty = inputs.filter((input) => input.files.length === 0);
|
|
122
|
+
if (violations.length === 0 && empty.length === 0) return `${NAME}: no violation in ${scanned}`;
|
|
123
|
+
return [
|
|
124
|
+
`${NAME}: ${violations.length + empty.length} problem(s) in ${scanned}:`,
|
|
125
|
+
...empty.map(({ glob }) => ` ${glob}: the declared input matches no file`),
|
|
126
|
+
...violations.map(({ file, line, column, text }) => ` ${file}:${line}:${column} ${text}`),
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const gate = Effect.gen(function* () {
|
|
131
|
+
const root = (yield* Path.Path).resolve(process.argv[2] ?? ".");
|
|
132
|
+
const { inputs } = yield* readDeclaration(root);
|
|
133
|
+
const scan = yield* scanInputs(root, inputs);
|
|
134
|
+
yield* Console.log(report(scan));
|
|
135
|
+
return passes(scan);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (import.meta.main) runMain(NAME, gate);
|
package/stryker.preset.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { availableParallelism } from "node:os";
|
|
1
2
|
import { fileURLToPath } from "node:url";
|
|
2
3
|
import { GUARD_IGNORER } from "./src/testing/mutation-guard-plugin.js";
|
|
3
4
|
import { fullRunRefusal } from "./src/testing/mutation-scope.js";
|
|
@@ -6,6 +7,8 @@ const [, , command, ...args] = process.argv;
|
|
|
6
7
|
const refusal = command === "run" ? fullRunRefusal(args, process.env.CI ?? "") : undefined;
|
|
7
8
|
if (refusal !== undefined) throw new Error(refusal);
|
|
8
9
|
|
|
10
|
+
export const halfAvailableCores = (cores = availableParallelism()) => Math.max(1, Math.floor(cores / 2));
|
|
11
|
+
|
|
9
12
|
export default {
|
|
10
13
|
packageManager: "npm",
|
|
11
14
|
plugins: [
|
|
@@ -21,6 +24,6 @@ export default {
|
|
|
21
24
|
coverageAnalysis: "perTest",
|
|
22
25
|
reporters: ["html", "json", "clear-text", "progress"],
|
|
23
26
|
timeoutMS: 60000,
|
|
24
|
-
concurrency:
|
|
27
|
+
concurrency: halfAvailableCores(),
|
|
25
28
|
thresholds: { high: 85, low: 70, break: null },
|
|
26
29
|
};
|