Improvements: Prefill Routing Forms and connect prefilling with Booking Form (#8780)

* Support prefilling routing form and prefilling Booking form through routing form

* Use Option Value as is instead of lowercasing

* Fix prefill validation issue

* Add prefill tests

* Fix Routing Form tests

* Small fix
This commit is contained in:
Hariom Balhara 2023-05-17 14:17:48 +05:30 committed by GitHub
parent 81655f9988
commit b8b6c48d7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 258 additions and 72 deletions

View File

@ -1815,6 +1815,7 @@
"open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.",
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
"book_my_cal": "Book my Cal",
"form_updated_successfully":"Form updated successfully.",
"email_not_cal_member_cta": "Join your team",
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",
"disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.",

View File

@ -276,6 +276,9 @@ export function FormActionsProvider({ appUrl, children }: { appUrl: string; chil
}
return { previousValue };
},
onSuccess: () => {
showToast(t("form_updated_successfully"), "success");
},
onSettled: (routingForm) => {
utils.viewer.appRoutingForms.forms.invalidate();
if (routingForm) {
@ -463,7 +466,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
const Component = as || Button;
if (!dropdown) {
return (
<Component ref={forwardedRef} {...actionProps}>
<Component data-testid={`form-action-${actionName}`} ref={forwardedRef} {...actionProps}>
{children}
</Component>
);

View File

@ -1,6 +1,7 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import type { Dispatch, SetStateAction } from "react";
import getFieldIdentifier from "../lib/getFieldIdentifier";
import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import type { SerializableForm, Response } from "../types/types";
@ -52,7 +53,7 @@ export default function FormInputFields(props: Props) {
/* @ts-ignore */
required={!!field.required}
listValues={options}
data-testid="form-field"
data-testid={`form-field-${getFieldIdentifier(field)}`}
setValue={(value) => {
setResponse((response) => {
response = response || {};

View File

@ -251,7 +251,7 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) {
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onSuccess() {
showToast("Form updated successfully.", "success");
showToast(t("form_updated_successfully"), "success");
},
onError(e) {
if (e.message) {

View File

@ -157,7 +157,7 @@ const MultiSelectWidget = ({
};
});
const defaultValue = selectItems.filter((item) => value?.includes(item.value));
const optionsFromList = selectItems.filter((item) => value?.includes(item.value));
return (
<Select
@ -165,7 +165,7 @@ const MultiSelectWidget = ({
onChange={(items) => {
setValue(items?.map((item) => item.value));
}}
defaultValue={defaultValue}
value={optionsFromList}
isMulti={true}
isDisabled={remainingProps.readOnly}
options={selectItems}
@ -184,7 +184,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
value: item.value,
};
});
const defaultValue = selectItems.find((item) => item.value === value);
const optionFromList = selectItems.find((item) => item.value === value);
return (
<Select
@ -196,7 +196,7 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
setValue(item.value);
}}
isDisabled={remainingProps.readOnly}
defaultValue={defaultValue}
value={optionFromList}
options={selectItems}
{...remainingProps}
/>

View File

@ -134,7 +134,7 @@ function Field({
<TextField
disabled={!!router}
label="Identifier"
name="identifier"
name={`${hookFieldNamespace}.identifier`}
required
placeholder={t("identifies_name_field")}
value={identifier}

View File

@ -175,7 +175,7 @@ const Route = ({
</div>
<Select
isDisabled={disabled}
className="block w-full flex-grow px-2"
className="data-testid-select-routing-action block w-full flex-grow px-2"
required
value={RoutingPages.find((page) => page.value === route.action?.type)}
onChange={(item) => {

View File

@ -16,10 +16,12 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button, showToast, useCalcomTheme } from "@calcom/ui";
import FormInputFields from "../../components/FormInputFields";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import { getSerializableForm } from "../../lib/getSerializableForm";
import { processRoute } from "../../lib/processRoute";
import type { Response, Route } from "../../types/types";
type Props = inferSSRProps<typeof getServerSideProps>;
const useBrandColors = ({
brandColor,
darkBrandColor,
@ -34,7 +36,7 @@ const useBrandColors = ({
useCalcomTheme(brandTheme);
};
function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getServerSideProps>) {
function RoutingForm({ form, profile, ...restProps }: Props) {
const [customPageMessage, setCustomPageMessage] = useState<Route["action"]["value"]>("");
const formFillerIdRef = useRef(uuidv4());
const isEmbed = useIsEmbed(restProps.isEmbed);
@ -43,12 +45,15 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
brandColor: profile.brandColor,
darkBrandColor: profile.darkBrandColor,
});
const [response, setResponse] = usePrefilledResponse(form);
// TODO: We might want to prevent spam from a single user by having same formFillerId across pageviews
// But technically, a user can fill form multiple times due to any number of reasons and we currently can't differentiate b/w that.
// - like a network error
// - or he abandoned booking flow in between
const formFillerId = formFillerIdRef.current;
const decidedActionRef = useRef<Route["action"]>();
const decidedActionWithFormResponseRef = useRef<{ action: Route["action"]; response: Response }>();
const router = useRouter();
const onSubmit = (response: Response) => {
@ -65,7 +70,10 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
formFillerId,
response: response,
});
decidedActionRef.current = decidedAction;
decidedActionWithFormResponseRef.current = {
action: decidedAction,
response,
};
};
useEffect(() => {
@ -75,19 +83,26 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
const responseMutation = trpc.viewer.appRoutingForms.public.response.useMutation({
onSuccess: () => {
const decidedAction = decidedActionRef.current;
if (!decidedAction) {
const decidedActionWithFormResponse = decidedActionWithFormResponseRef.current;
if (!decidedActionWithFormResponse) {
return;
}
const fields = form.fields;
if (!fields) {
throw new Error("Routing Form fields must exist here");
}
const allURLSearchParams = getUrlSearchParamsToForward(decidedActionWithFormResponse.response, fields);
const decidedAction = decidedActionWithFormResponse.action;
//TODO: Maybe take action after successful mutation
if (decidedAction.type === "customPageMessage") {
setCustomPageMessage(decidedAction.value);
} else if (decidedAction.type === "eventTypeRedirectUrl") {
router.push(`/${decidedAction.value}`);
router.push(`/${decidedAction.value}?${allURLSearchParams}`);
} else if (decidedAction.type === "externalRedirectUrl") {
window.parent.location.href = decidedAction.value;
window.parent.location.href = `${decidedAction.value}?${allURLSearchParams}`;
}
// We don't want to show this message as it doesn't look good in Embed.
// showToast("Form submitted successfully! Redirecting now ...", "success");
},
onError: (e) => {
@ -97,12 +112,11 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
if (e?.data?.code === "CONFLICT") {
return void showToast("Form already submitted", "error");
}
// We don't want to show this error as it doesn't look good in Embed.
// showToast("Something went wrong", "error");
},
});
const [response, setResponse] = useState<Response>({});
const handleOnSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(response);
@ -161,6 +175,53 @@ function RoutingForm({ form, profile, ...restProps }: inferSSRProps<typeof getSe
);
}
function getUrlSearchParamsToForward(response: Response, fields: NonNullable<Props["form"]["fields"]>) {
type Params = Record<string, string | string[]>;
const paramsFromResponse: Params = {};
const paramsFromCurrentUrl: Params = {};
// Build query params from response
Object.entries(response).forEach(([key, fieldResponse]) => {
const foundField = fields.find((f) => f.id === key);
if (!foundField) {
// If for some reason, the field isn't there, let's just
return;
}
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
fieldResponse.value;
});
// Build query params from current URL. It excludes route params
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
for (const [name, value] of new URLSearchParams(window.location.search).entries()) {
const target = paramsFromCurrentUrl[name];
if (target instanceof Array) {
target.push(value);
} else {
paramsFromCurrentUrl[name] = [value];
}
}
const allQueryParams: Params = {
...paramsFromCurrentUrl,
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
...paramsFromResponse,
};
const allQueryURLSearchParams = new URLSearchParams();
// Make serializable URLSearchParams instance
Object.entries(allQueryParams).forEach(([param, value]) => {
const valueArray = value instanceof Array ? value : [value];
valueArray.forEach((v) => {
allQueryURLSearchParams.append(param, v);
});
});
return allQueryURLSearchParams;
}
export default function RoutingLink(props: inferSSRProps<typeof getServerSideProps>) {
return <RoutingForm {...props} />;
}
@ -220,3 +281,19 @@ export const getServerSideProps = async function getServerSideProps(
},
};
};
const usePrefilledResponse = (form: Props["form"]) => {
const router = useRouter();
const prefillResponse: Response = {};
// Prefill the form from query params
form.fields?.forEach((field) => {
prefillResponse[field.id] = {
value: router.query[getFieldIdentifier(field)] || "",
label: field.label,
};
});
const [response, setResponse] = useState<Response>(prefillResponse);
return [response, setResponse] as const;
};

View File

@ -16,21 +16,19 @@ test.describe("Routing Forms", () => {
const formId = await addForm(page);
await page.click('[href="/apps/routing-forms/forms"]');
// TODO: Workaround for bug in https://github.com/calcom/cal.com/issues/3410
await page.click('[href="/apps/routing-forms/forms"]');
await page.waitForSelector('[data-testid="routing-forms-list"]');
// Ensure that it's visible in forms list
expect(await page.locator('[data-testid="routing-forms-list"] > li').count()).toBe(1);
await gotoRoutingLink(page, formId);
await page.isVisible("text=Test Form Name");
await gotoRoutingLink({ page, formId });
await expect(page.locator("text=Test Form Name")).toBeVisible();
await page.goto(`apps/routing-forms/route-builder/${formId}`);
await page.click('[data-testid="toggle-form"] [value="on"]');
await gotoRoutingLink(page, formId);
await page.isVisible("text=ERROR 404");
await disableForm(page);
await gotoRoutingLink({ page, formId });
await expect(page.locator("text=ERROR 404")).toBeVisible();
});
test("should be able to edit the form", async ({ page }) => {
@ -41,16 +39,18 @@ test.describe("Routing Forms", () => {
const createdFields: Record<number, { label: string; typeIndex: number }> = {};
const { types } = await addMultipleFieldsAndSaveForm(formId, page, { description, label });
await page.reload();
const { fieldTypesList: types, fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
description,
label,
});
expect(await page.inputValue(`[data-testid="description"]`)).toBe(description);
expect(await page.locator('[data-testid="field"]').count()).toBe(types.length);
types.forEach((item, index) => {
createdFields[index] = { label: `Test Label ${index + 1}`, typeIndex: index };
fields.forEach((item, index) => {
createdFields[index] = { label: item.label, typeIndex: index };
});
await expectCurrentFormToHaveFields(page, createdFields, types);
await page.click('[href*="/apps/routing-forms/route-builder/"]');
@ -63,9 +63,7 @@ test.describe("Routing Forms", () => {
});
test.describe("F1<-F2 Relationship", () => {
// TODO: Fix this test, it is very flaky
// prettier-ignore
test.fixme("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
test("Create relationship by adding F1 as route.Editing F1 should update F2", async ({ page }) => {
const form1Id = await addForm(page, { name: "F1" });
const form2Id = await addForm(page, { name: "F2" });
@ -118,6 +116,63 @@ test.describe("Routing Forms", () => {
todo("Create relationship by using duplicate with live connect");
});
test("should be able to submit a prefilled form with all types of fields", async ({ page }) => {
const formId = await addForm(page);
await page.click('[href*="/apps/routing-forms/route-builder/"]');
await selectNewRoute(page);
await selectOption({
selector: {
selector: ".data-testid-select-routing-action",
nth: 0,
},
option: 2,
page,
});
await page.fill("[name=externalRedirectUrl]", "https://www.google.com");
await saveCurrentForm(page);
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
description: "Description",
label: "Test Field",
});
const queryString =
"firstField=456&Test Field Number=456&Test Field Select=456&Test Field MultiSelect=456&Test Field MultiSelect=789&Test Field Phone=456&Test Field Email=456@example.com";
await gotoRoutingLink({ page, queryString });
await page.fill('[data-testid="form-field-Test Field Long Text"]', "manual-fill");
expect(await page.locator(`[data-testid="form-field-firstField"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Number"]`).inputValue()).toBe("456");
// TODO: Verify select and multiselect has prefilled values.
// expect(await page.locator(`[data-testid="form-field-Test Field Select"]`).inputValue()).toBe("456");
// expect(await page.locator(`[data-testid="form-field-Test Field MultiSelect"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Phone"]`).inputValue()).toBe("456");
expect(await page.locator(`[data-testid="form-field-Test Field Email"]`).inputValue()).toBe(
"456@example.com"
);
await page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.hostname.includes("google.com");
});
const url = new URL(page.url());
// Coming from the response filled by booker
expect(url.searchParams.get("firstField")).toBe("456");
// All other params come from prefill URL
expect(url.searchParams.get("Test Field Number")).toBe("456");
expect(url.searchParams.get("Test Field Long Text")).toBe("manual-fill");
expect(url.searchParams.get("Test Field Select")).toBe("456");
expect(url.searchParams.getAll("Test Field MultiSelect")).toMatchObject(["456", "789"]);
expect(url.searchParams.get("Test Field Phone")).toBe("456");
expect(url.searchParams.get("Test Field Email")).toBe("456@example.com");
});
// TODO: How to install the app just once?
test.beforeEach(async ({ page, users }) => {
const user = await users.create(
@ -266,16 +321,16 @@ test.describe("Routing Forms", () => {
});
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
await page.isVisible("text=Custom Page Result");
await expect(page.locator("text=Custom Page Result")).toBeVisible();
await page.goto(`/router?form=${routingForm.id}&Test field=doesntmatter&multi=Option-2`);
await page.isVisible("text=Multiselect chosen");
await expect(page.locator("text=Multiselect chosen")).toBeVisible();
});
test("Routing Link should validate fields", async ({ page, users }) => {
const user = await createUserAndLoginAndInstallApp({ users, page });
const routingForm = user.routingForms[0];
await gotoRoutingLink(page, routingForm.id);
await gotoRoutingLink({ page, formId: routingForm.id });
page.click('button[type="submit"]');
const firstInputMissingValue = await page.evaluate(() => {
return document.querySelectorAll("input")[0].validity.valueMissing;
@ -291,40 +346,45 @@ test.describe("Routing Forms", () => {
await page.click('[data-testid="test-preview"]');
// //event redirect
await page.fill('[data-testid="form-field"]', "event-routing");
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
await page.click('[data-testid="test-routing"]');
let routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
let route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("Event Redirect");
await expect(route).toBe("pro/30min");
expect(routingType).toBe("Event Redirect");
expect(route).toBe("pro/30min");
//custom page
await page.fill('[data-testid="form-field"]', "custom-page");
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("Custom Page");
await expect(route).toBe("Custom Page Result");
expect(routingType).toBe("Custom Page");
expect(route).toBe("Custom Page Result");
//external redirect
await page.fill('[data-testid="form-field"]', "external-redirect");
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("External Redirect");
await expect(route).toBe("https://google.com");
expect(routingType).toBe("External Redirect");
expect(route).toBe("https://google.com");
//fallback route
await page.fill('[data-testid="form-field"]', "fallback");
await page.fill('[data-testid="form-field-Test field"]', "fallback");
await page.click('[data-testid="test-routing"]');
routingType = await page.locator('[data-testid="test-routing-result-type"]').innerText();
route = await page.locator('[data-testid="test-routing-result"]').innerText();
await expect(routingType).toBe("Custom Page");
await expect(route).toBe("Fallback Message");
expect(routingType).toBe("Custom Page");
expect(route).toBe("Fallback Message");
});
});
});
async function disableForm(page: Page) {
await page.click('[data-testid="toggle-form"] [value="on"]');
await page.waitForSelector(".data-testid-toast-success");
}
async function expectCurrentFormToHaveFields(
page: Page,
fields: {
@ -341,24 +401,24 @@ async function expectCurrentFormToHaveFields(
}
async function fillSeededForm(page: Page, routingFormId: string) {
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="form-field"]', "event-routing");
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "event-routing");
page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.pathname.endsWith("/pro/30min");
});
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="form-field"]', "external-redirect");
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "external-redirect");
page.click('button[type="submit"]');
await page.waitForURL((url) => {
return url.hostname.includes("google.com");
});
await gotoRoutingLink(page, routingFormId);
await page.fill('[data-testid="form-field"]', "custom-page");
await gotoRoutingLink({ page, formId: routingFormId });
await page.fill('[data-testid="form-field-Test field"]', "custom-page");
await page.click('button[type="submit"]');
await page.isVisible("text=Custom Page Result");
await expect(page.locator("text=Custom Page Result")).toBeVisible();
}
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
@ -375,7 +435,7 @@ export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
return formId;
}
async function addMultipleFieldsAndSaveForm(
async function addAllTypesOfFieldsAndSaveForm(
formId: string,
page: Page,
form: { description: string; label: string }
@ -384,33 +444,51 @@ async function addMultipleFieldsAndSaveForm(
await page.click('[data-testid="add-field"]');
await page.fill('[data-testid="description"]', form.description);
const { optionsInUi: types } = await verifySelectOptions(
const { optionsInUi: fieldTypesList } = await verifySelectOptions(
{ selector: ".data-testid-field-type", nth: 0 },
["Email", "Long Text", "MultiSelect", "Number", "Phone", "Select", "Short Text"],
page
);
await page.fill(`[name="fields.0.label"]`, `${form.label} 1`);
await page.click('[data-testid="add-field"]');
const fields = [];
for (let index = 0; index < fieldTypesList.length; index++) {
const fieldTypeLabel = fieldTypesList[index];
const nth = index;
const label = `${form.label} ${fieldTypeLabel}`;
let identifier = "";
const withoutFirstValue = [...types].filter((val) => val !== "Short Text");
if (index !== 0) {
identifier = label;
// Click on the field type dropdown.
await page.locator(".data-testid-field-type").nth(nth).click();
// Click on the dropdown option.
await page.locator(`[data-testid="select-option-${fieldTypeLabel}"]`).click();
} else {
// Set the identifier manually for the first field to test out a case when identifier isn't computed from label automatically
// First field type is by default selected. So, no need to choose from dropdown
identifier = "firstField";
}
for (let index = 0; index < withoutFirstValue.length; index++) {
const fieldName = withoutFirstValue[index];
const nth = index + 1;
const label = `${form.label} ${index + 2}`;
if (fieldTypeLabel === "MultiSelect" || fieldTypeLabel === "Select") {
await page.fill(`[name="fields.${nth}.selectText"]`, "123\n456\n789");
}
await page.locator(".data-testid-field-type").nth(nth).click();
await page.locator(`[data-testid="select-option-${fieldName}"]`).click();
await page.fill(`[name="fields.${nth}.label"]`, label);
if (index !== withoutFirstValue.length - 1) {
if (identifier !== label) {
await page.fill(`[name="fields.${nth}.identifier"]`, identifier);
}
if (index !== fieldTypesList.length - 1) {
await page.click('[data-testid="add-field"]');
}
fields.push({ identifier: identifier, label, type: fieldTypeLabel });
}
await saveCurrentForm(page);
return {
types,
fieldTypesList,
fields,
};
}
@ -461,6 +539,9 @@ async function selectOption({
}: {
page: Page;
selector: { selector: string; nth: number };
/**
* Index of option to select. Starts from 1
*/
option: number;
}) {
const locatorForSelect = page.locator(selector.selector).nth(selector.nth);
@ -513,8 +594,29 @@ async function selectNewRoute(page: Page, { routeSelectNumber = 1 } = {}) {
});
}
async function gotoRoutingLink(page: Page, formId: string) {
await page.goto(`/forms/${formId}`);
async function gotoRoutingLink({
page,
formId,
queryString = "",
}: {
page: Page;
formId?: string;
queryString?: string;
}) {
let previewLink = null;
if (!formId) {
// Instead of clicking on the preview link, we are going to the preview link directly because the earlier opens a new tab which is a bit difficult to manage with Playwright
const href = await page.locator('[data-testid="form-action-preview"]').getAttribute("href");
if (!href) {
throw new Error("Preview link not found");
}
previewLink = href;
} else {
previewLink = `/forms/${formId}`;
}
await page.goto(`${previewLink}${queryString ? `?${queryString}` : ""}`);
// HACK: There seems to be some issue with the inputs to the form getting reset if we don't wait.
await new Promise((resolve) => setTimeout(resolve, 500));
}

View File

@ -201,7 +201,7 @@ export const FormBuilder = function FormBuilder({
// It has the same drawback that if the label is changed, the value of the option will change. It is not a big deal for now.
value.splice(index, 1, {
label: e.target.value,
value: e.target.value.toLowerCase().trim(),
value: e.target.value.trim(),
});
onChange(value);
}}

View File

@ -53,7 +53,9 @@ if (IS_EMBED_REACT_TEST) {
const config: PlaywrightTestConfig = {
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: os.cpus().length,
// While debugging it should be focussed mode
// eslint-disable-next-line turbo/no-undeclared-env-vars
workers: process.env.PWDEBUG ? 1 : os.cpus().length,
timeout: DEFAULT_TEST_TIMEOUT,
maxFailures: headless ? 10 : undefined,
fullyParallel: true,