/** * The plugin driven through a real browser against a real WooCommerce. * * Nothing here is stubbed except the API itself: WordPress, WooCommerce, the * theme, the block checkout and its data store all run for real in the * containers `compose.yml` describes, and Chromium drives them. The one fake is * `fake-api.js` standing in for cep.is, so a test never depends on the network. * * Chromium is told to resolve `fakecep` to the loopback address; the WordPress * container resolves the same name to the host gateway. Both sides therefore * spell the API's address the same way, which is what lets one `base_url` * setting serve the direct and the proxy mode. * * @module */ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { readdirSync } from "node:fs"; import { chromium } from "playwright-core"; import { startFakeApi } from "./fake-api.js"; import { FAKE_PORT, FAKE_URL, SITE, drainDebugLog, setOption, wp, wpEval } from "./wp.js"; const PRODUCT = wp(["option", "get", "cepis_test_product"]); const BLOCKS_PAGE = wp(["option", "get", "cepis_test_blocks_page"]); const CLASSIC_PAGE = wp(["option", "get", "cepis_test_classic_page"]); let browser; let api; /** * Locate the Chromium that Playwright installed. * * @returns {string} Path to the executable. */ function chromiumPath() { if (process.env.CEPIS_CHROMIUM) { return process.env.CEPIS_CHROMIUM; } const root = `${process.env.HOME}/.cache/ms-playwright`; const build = readdirSync(root) .filter((name) => name.startsWith("chromium-")) .sort() .pop(); return `${root}/${build}/chrome-linux64/chrome`; } beforeAll(async () => { api = startFakeApi(FAKE_PORT); browser = await chromium.launch({ executablePath: chromiumPath(), args: ["--no-sandbox", "--host-resolver-rules=MAP fakecep 127.0.0.1"], }); setOption("cepis_woo_enabled", "yes"); setOption("cepis_woo_base_url", FAKE_URL); setOption("cepis_woo_mode", "direct"); setOption("cepis_woo_overwrite", "auto"); setOption("cepis_woo_focus_number", "yes"); setOption("cepis_test_br_fields", "no"); drainDebugLog(); }); afterAll(async () => { setOption("woocommerce_checkout_page_id", BLOCKS_PAGE); await browser?.close(); api?.stop(); }); beforeEach(() => { api.reset(); }); /** * Open a checkout with one product in the cart, collecting what the page asks * the network for. * * @param {string} path Path of the checkout page. * @returns {Promise<{page: import("playwright-core").Page, requests: Array<{url: string, body: string}>, close: () => Promise}>} */ async function checkout(path) { const context = await browser.newContext(); const page = await context.newPage(); const requests = []; const errors = []; page.on("request", (request) => { requests.push({ url: request.url(), body: request.postData() || "" }); }); page.on("pageerror", (error) => errors.push(String(error))); await page.goto(`${SITE}/?add-to-cart=${PRODUCT}`, { waitUntil: "domcontentloaded" }); await page.goto(`${SITE}${path}`, { waitUntil: "domcontentloaded" }); return { page, requests, errors, async close() { await context.close(); }, }; } /** * Wait until the Checkout block has hydrated and stopped talking to the Store * API. * * The block seeds its address fields from the cart the server returns, and * typing while that is in flight loses characters — the response resets the * controlled input. That is WooCommerce's own behaviour on a freshly opened * checkout, not something this plugin causes, so the test waits it out. * * @param {import("playwright-core").Page} page Page showing the block checkout. */ async function settled(page) { await page.waitForSelector("#shipping-postcode"); await page.waitForSelector(".wc-block-checkout:not(.is-loading)", { timeout: 30000 }); await page.waitForFunction( () => { const store = window.wp && window.wp.data && window.wp.data.select("wc/store/cart"); return !!(store && store.getCustomerData && store.getCustomerData().shippingAddress); }, undefined, { timeout: 30000 }, ); await page.waitForLoadState("networkidle"); await page.waitForTimeout(500); } /** * Wait until the classic checkout has stopped moving. * * WooCommerce announces "added to your cart" in a banner that takes focus for * screen-reader users a moment after the page loads. Typing before that has * happened means the banner steals focus mid-CEP, which a real shopper never * experiences — they are already looking at the form by then. * * @param {import("playwright-core").Page} page Page showing the classic checkout. */ async function ready(page) { await page.waitForSelector("#billing_postcode"); await page.waitForLoadState("networkidle"); await page.waitForTimeout(500); } /** * Type a CEP the way a shopper does. * * The block checkout's inputs are React-controlled and setting `value` in one * go is not reliably seen; real key events are, and they exercise the widget's * debounce as well. * * @param {import("playwright-core").Page} page Page under test. * @param {string} selector CEP field. * @param {string} cep Eight digits. */ async function typeCep(page, selector, cep) { await page.click(selector); await page.keyboard.press("ControlOrMeta+A"); await page.locator(selector).pressSequentially(cep, { delay: 40 }); } describe("plugin", () => { test("is active", () => { expect(wp(["plugin", "get", "cepis-woocommerce", "--field=status"])).toBe("active"); }); test("declares the version WooCommerce reports", () => { expect(wp(["plugin", "get", "cepis-woocommerce", "--field=version"])).toBe("0.2.0"); }); }); describe("block checkout", () => { beforeAll(() => { setOption("woocommerce_checkout_page_id", BLOCKS_PAGE); setOption("cepis_woo_mode", "direct"); }); test("fills the address and WooCommerce sees the state change", async () => { const { page, requests, errors, close } = await checkout("/checkout/"); await settled(page); await typeCep(page, "#shipping-postcode", "01001000"); await page.waitForFunction( () => document.querySelector("#shipping-address_1").value !== "", undefined, { timeout: 15000 }, ); const filled = await page.evaluate(() => ({ street: document.querySelector("#shipping-address_1").value, city: document.querySelector("#shipping-city").value, state: document.querySelector("#shipping-state").value, stateTag: document.querySelector("#shipping-state").tagName, store: window.wp.data.select("wc/store/cart").getCustomerData().shippingAddress, })); expect(filled.street).toBe("Praça da Sé"); expect(filled.city).toBe("São Paulo"); expect(filled.stateTag).toBe("SELECT"); expect(filled.state).toBe("SP"); expect(filled.store.state).toBe("SP"); expect(filled.store.city).toBe("São Paulo"); await page.waitForFunction( () => performance .getEntriesByType("resource") .some((entry) => entry.name.includes("/wc/store/v1/")), undefined, { timeout: 15000 }, ); const pushed = requests.filter( (request) => request.url.includes("/wc/store/v1/") && request.body.includes('"state":"SP"'), ); expect(pushed.length).toBeGreaterThan(0); expect(pushed.some((request) => request.body.includes("São Paulo"))).toBe(true); expect(api.state.requests.filter((entry) => entry.cep === "01001000")).toHaveLength(1); expect(errors).toEqual([]); await close(); }, 90000); test("replaces the state WooCommerce pre-selected", async () => { const { page, close } = await checkout("/checkout/"); await settled(page); expect(await page.inputValue("#shipping-state")).toBe("SP"); await typeCep(page, "#shipping-postcode", "20040020"); await page.waitForFunction( () => document.querySelector("#shipping-state").value === "RJ", undefined, { timeout: 15000 }, ); const filled = await page.evaluate(() => ({ street: document.querySelector("#shipping-address_1").value, city: document.querySelector("#shipping-city").value, store: window.wp.data.select("wc/store/cart").getCustomerData().shippingAddress, })); expect(filled.street).toBe("Avenida Rio Branco"); expect(filled.city).toBe("Rio de Janeiro"); expect(filled.store.state).toBe("RJ"); await close(); }, 90000); test("a CEP that does not exist shows a message and clears nothing", async () => { const { page, close } = await checkout("/checkout/"); await settled(page); await typeCep(page, "#shipping-postcode", "01001000"); await page.waitForFunction( () => document.querySelector("#shipping-address_1").value !== "", undefined, { timeout: 15000 }, ); await typeCep(page, "#shipping-postcode", "99999999"); await page.waitForFunction(() => document.body.innerText.includes("CEP not found"), undefined, { timeout: 15000, }); const after = await page.evaluate(() => ({ street: document.querySelector("#shipping-address_1").value, city: document.querySelector("#shipping-city").value, state: document.querySelector("#shipping-state").value, })); expect(after.street).toBe("Praça da Sé"); expect(after.city).toBe("São Paulo"); expect(after.state).toBe("SP"); await close(); }, 90000); test("a CEP that does not exist names its municipality and still fills nothing", async () => { const { page, close } = await checkout("/checkout/"); await settled(page); await typeCep(page, "#shipping-postcode", "01001000"); await page.waitForFunction( () => document.querySelector("#shipping-address_1").value !== "", undefined, { timeout: 15000 }, ); await typeCep(page, "#shipping-postcode", "25845000"); await page.waitForFunction( () => document.body.innerText.includes( "would be in Areal/RJ, which has CEPs from 25845-001 to 25849-899", ), undefined, { timeout: 15000 }, ); const after = await page.evaluate(() => ({ street: document.querySelector("#shipping-address_1").value, city: document.querySelector("#shipping-city").value, state: document.querySelector("#shipping-state").value, store: JSON.stringify(window.wp.data.select("wc/store/cart").getCustomerData()), })); // The address the previous, real CEP produced is still there, untouched. expect(after.street).toBe("Praça da Sé"); expect(after.city).toBe("São Paulo"); expect(after.state).toBe("SP"); // And nothing from the 404 reached the data store the order is built from. // A municipality named in an error is a guess about a typo, not an address. expect(after.store).not.toContain("Areal"); expect(after.store).not.toContain('"state":"RJ"'); await close(); }, 90000); }); describe("classic checkout", () => { beforeAll(() => { setOption("woocommerce_checkout_page_id", CLASSIC_PAGE); setOption("cepis_woo_mode", "direct"); }); test("fills the address and WooCommerce recalculates with the new state", async () => { setOption("cepis_test_br_fields", "no"); const { page, requests, errors, close } = await checkout("/checkout-classico/"); await ready(page); await typeCep(page, "#billing_postcode", "01001000"); await page.waitForFunction( () => document.querySelector("#billing_address_1").value !== "", undefined, { timeout: 15000 }, ); const filled = await page.evaluate(() => ({ street: document.querySelector("#billing_address_1").value, city: document.querySelector("#billing_city").value, state: document.querySelector("#billing_state").value, stateTag: document.querySelector("#billing_state").tagName, shown: (document.querySelector("#select2-billing_state-container") || {}).textContent || "", status: (document.querySelector(".cepis-autofill-status") || {}).textContent || "", })); expect(filled.street).toBe("Praça da Sé"); expect(filled.city).toBe("São Paulo"); expect(filled.stateTag).toBe("SELECT"); expect(filled.state).toBe("SP"); expect(filled.shown).toContain("São Paulo"); expect(filled.status).toBe("Address found."); await page.waitForFunction( () => window.jQuery && window.jQuery("form.checkout").length > 0, undefined, { timeout: 10000 }, ); await page.waitForTimeout(3000); const reviews = requests.filter( (request) => request.url.includes("wc-ajax=update_order_review") && decodeURIComponent(request.body).includes("billing_state=SP"), ); expect(reviews.length).toBeGreaterThan(0); expect(errors).toEqual([]); await close(); }, 90000); test("fills neighbourhood and moves to the number when the store has those fields", async () => { setOption("cepis_test_br_fields", "yes"); const { page, close } = await checkout("/checkout-classico/"); await ready(page); await page.waitForSelector("#billing_neighborhood"); await typeCep(page, "#billing_postcode", "01001000"); await page.waitForFunction( () => document.querySelector("#billing_neighborhood").value !== "", undefined, { timeout: 15000 }, ); const filled = await page.evaluate(() => ({ neighborhood: document.querySelector("#billing_neighborhood").value, number: document.querySelector("#billing_number").value, focused: document.activeElement.id, })); expect(filled.neighborhood).toBe("Sé"); expect(filled.number).toBe(""); expect(filled.focused).toBe("billing_number"); setOption("cepis_test_br_fields", "no"); await close(); }, 90000); test("replaces the state WooCommerce pre-selected, and works without the Brazilian fields", async () => { setOption("cepis_test_br_fields", "no"); const { page, requests, errors, close } = await checkout("/checkout-classico/"); await ready(page); expect(await page.inputValue("#billing_state")).toBe("SP"); await typeCep(page, "#billing_postcode", "20040020"); await page.waitForFunction( () => document.querySelector("#billing_state").value === "RJ", undefined, { timeout: 15000 }, ); const filled = await page.evaluate(() => ({ street: document.querySelector("#billing_address_1").value, city: document.querySelector("#billing_city").value, state: document.querySelector("#billing_state").value, shown: (document.querySelector("#select2-billing_state-container") || {}).textContent || "", address2: document.querySelector("#billing_address_2").value, hasNeighborhood: !!document.querySelector("#billing_neighborhood"), hasNumber: !!document.querySelector("#billing_number"), })); expect(filled.hasNeighborhood).toBe(false); expect(filled.hasNumber).toBe(false); expect(filled.street).toBe("Avenida Rio Branco"); expect(filled.city).toBe("Rio de Janeiro"); expect(filled.state).toBe("RJ"); expect(filled.shown).toContain("Rio de Janeiro"); expect(filled.address2).toBe(""); await page.waitForTimeout(3000); const reviews = requests.filter( (request) => request.url.includes("wc-ajax=update_order_review") && decodeURIComponent(request.body).includes("billing_state=RJ"), ); expect(reviews.length).toBeGreaterThan(0); expect(errors).toEqual([]); await close(); }, 90000); test("a CEP that does not exist shows a message and clears nothing", async () => { const { page, close } = await checkout("/checkout-classico/"); await ready(page); await typeCep(page, "#billing_postcode", "01001000"); await page.waitForFunction( () => document.querySelector("#billing_address_1").value !== "", undefined, { timeout: 15000 }, ); await typeCep(page, "#billing_postcode", "99999999"); await page.waitForFunction( () => (document.querySelector(".cepis-autofill-status") || {}).textContent === "CEP not found. Check the number or fill the address in by hand.", undefined, { timeout: 15000 }, ); const after = await page.evaluate(() => ({ street: document.querySelector("#billing_address_1").value, city: document.querySelector("#billing_city").value, state: document.querySelector("#billing_state").value, invalid: document.querySelector("#billing_postcode").getAttribute("aria-invalid"), })); expect(after.street).toBe("Praça da Sé"); expect(after.city).toBe("São Paulo"); expect(after.state).toBe("SP"); expect(after.invalid).toBe("true"); await close(); }, 90000); test("a CEP that does not exist names its municipality and still fills nothing", async () => { const { page, errors, close } = await checkout("/checkout-classico/"); await ready(page); await typeCep(page, "#billing_postcode", "01001000"); await page.waitForFunction( () => document.querySelector("#billing_address_1").value !== "", undefined, { timeout: 15000 }, ); await typeCep(page, "#billing_postcode", "25845000"); await page.waitForFunction( () => (document.querySelector(".cepis-autofill-status") || {}).textContent === "CEP not found. This number would be in Areal/RJ, which has CEPs from 25845-001 to 25849-899. Check the number.", undefined, { timeout: 15000 }, ); const after = await page.evaluate(() => ({ street: document.querySelector("#billing_address_1").value, city: document.querySelector("#billing_city").value, state: document.querySelector("#billing_state").value, invalid: document.querySelector("#billing_postcode").getAttribute("aria-invalid"), leaked: Array.from(document.querySelectorAll("input, select, textarea")) .map((field) => String(field.value == null ? "" : field.value)) .filter((value) => /areal|rio de janeiro|^RJ$/i.test(value)), })); // What the previous, real CEP filled in is still there. expect(after.street).toBe("Praça da Sé"); expect(after.city).toBe("São Paulo"); expect(after.state).toBe("SP"); expect(after.invalid).toBe("true"); // The whole point: naming the municipality must not put it in a field. // Shipping to a town the CEP does not belong to is a parcel gone astray. expect(after.leaked).toEqual([]); expect(errors).toEqual([]); await close(); }, 90000); test("a faixa it cannot use falls back to the plain message", async () => { const { page, errors, close } = await checkout("/checkout-classico/"); const plain = "CEP not found. Check the number or fill the address in by hand."; await ready(page); await typeCep(page, "#billing_postcode", "25849000"); await page.waitForFunction( (expected) => (document.querySelector(".cepis-autofill-status") || {}).textContent === expected, plain, { timeout: 15000 }, ); // Long enough for the follow-up request to have come back and, if it were // going to, made a mess of the line. await page.waitForTimeout(3000); const after = await page.evaluate(() => ({ status: (document.querySelector(".cepis-autofill-status") || {}).textContent, street: document.querySelector("#billing_address_1").value, city: document.querySelector("#billing_city").value, })); expect(after.status).toBe(plain); expect(after.street).toBe(""); expect(after.city).toBe(""); expect(errors).toEqual([]); await close(); }, 90000); }); describe("proxy mode", () => { beforeAll(() => { setOption("woocommerce_checkout_page_id", CLASSIC_PAGE); setOption("cepis_woo_mode", "proxy"); wpEval( "global $wpdb; $wpdb->query( \"DELETE FROM {$wpdb->options} WHERE option_name LIKE '%_transient_%cepis_woo_%'\" );", ); }); afterAll(() => { setOption("cepis_woo_mode", "direct"); }); test("the browser talks to this site and PHP talks to the API", async () => { const { page, requests, errors, close } = await checkout("/checkout-classico/"); await ready(page); await typeCep(page, "#billing_postcode", "01001000"); await page.waitForFunction( () => document.querySelector("#billing_address_1").value !== "", undefined, { timeout: 20000 }, ); expect(await page.inputValue("#billing_address_1")).toBe("Praça da Sé"); expect(await page.inputValue("#billing_city")).toBe("São Paulo"); expect(await page.inputValue("#billing_state")).toBe("SP"); const proxied = requests.filter((request) => /\/cepis\/v1\/lookup\/[A-Za-z0-9]+\/01001000/.test(request.url), ); expect(proxied.length).toBeGreaterThan(0); expect(requests.some((request) => request.url.includes("fakecep"))).toBe(false); const served = api.state.requests.filter((entry) => entry.cep === "01001000"); expect(served.length).toBe(1); expect(served[0].userAgent).toContain("cepis-woocommerce/0.2.0"); expect(errors).toEqual([]); await close(); }, 90000); test("the answer is cached, so a second shopper costs no request", async () => { api.reset(); const first = await checkout("/checkout-classico/"); await ready(first.page); await typeCep(first.page, "#billing_postcode", "20040020"); await first.page.waitForFunction( () => document.querySelector("#billing_address_1").value !== "", undefined, { timeout: 20000 }, ); await first.close(); expect(api.state.requests.filter((entry) => entry.cep === "20040020").length).toBe(1); const second = await checkout("/checkout-classico/"); await ready(second.page); await typeCep(second.page, "#billing_postcode", "20040020"); await second.page.waitForFunction( () => document.querySelector("#billing_address_1").value !== "", undefined, { timeout: 20000 }, ); expect(await second.page.inputValue("#billing_city")).toBe("Rio de Janeiro"); await second.close(); expect(api.state.requests.filter((entry) => entry.cep === "20040020").length).toBe(1); }, 120000); test("the municipality survives the proxy and its cache, and fills nothing", async () => { api.reset(); const { page, close } = await checkout("/checkout-classico/"); await ready(page); await typeCep(page, "#billing_postcode", "25845000"); await page.waitForFunction( () => (document.querySelector(".cepis-autofill-status") || {}).textContent === "CEP not found. This number would be in Areal/RJ, which has CEPs from 25845-001 to 25849-899. Check the number.", undefined, { timeout: 20000 }, ); const after = await page.evaluate(() => ({ street: document.querySelector("#billing_address_1").value, leaked: Array.from(document.querySelectorAll("input, select, textarea")) .map((field) => String(field.value == null ? "" : field.value)) .filter((value) => /areal|rio de janeiro|^RJ$/i.test(value)), })); expect(after.street).toBe(""); expect(after.leaked).toEqual([]); // One request reached the API. The lookup itself made it; the follow-up // that reads the municipality out of the 404 body was served from the // transient, which is only possible if the miss was cached with it. expect(api.state.requests.filter((entry) => entry.cep === "25845000")).toHaveLength(1); await close(); }, 90000); test("the REST route refuses a request without a valid token", async () => { const context = await browser.newContext(); const page = await context.newPage(); const withoutToken = await page.evaluate(async (site) => { const response = await fetch(`${site}/wp-json/cepis/v1/lookup/01001000`); return response.status; }, SITE); const wrongToken = await page.evaluate(async (site) => { const response = await fetch(`${site}/wp-json/cepis/v1/lookup/deadbeef00/01001000`); return { status: response.status, body: await response.text() }; }, SITE); expect(withoutToken).toBe(404); expect(wrongToken.status).toBe(403); expect(wrongToken.body).toContain("cepis_invalid_token"); await context.close(); }, 60000); }); describe("translations", () => { beforeAll(() => { setOption("woocommerce_checkout_page_id", CLASSIC_PAGE); setOption("cepis_test_locale", "pt_BR"); }); afterAll(() => { setOption("cepis_test_locale", ""); }); test("a store in Portuguese gets the shipped Portuguese messages", async () => { const { page, close } = await checkout("/checkout-classico/"); await ready(page); await typeCep(page, "#billing_postcode", "99999999"); await page.waitForFunction( () => (document.querySelector(".cepis-autofill-status") || {}).textContent === "CEP não encontrado. Confira o número ou preencha o endereço manualmente.", undefined, { timeout: 15000 }, ); await close(); }, 90000); test("so is the message that names the municipality", async () => { const { page, close } = await checkout("/checkout-classico/"); await ready(page); await typeCep(page, "#billing_postcode", "25845000"); await page.waitForFunction( () => (document.querySelector(".cepis-autofill-status") || {}).textContent === "CEP não encontrado. Esse número seria de Areal/RJ, que tem CEPs de 25845-001 a 25849-899. Confira o número.", undefined, { timeout: 15000 }, ); await close(); }, 90000); }); describe("housekeeping", () => { test("uninstalling removes every option and cached answer", () => { wpEval( 'define( "WP_UNINSTALL_PLUGIN", "cepis-woocommerce/cepis-woocommerce.php" );' + 'include WP_PLUGIN_DIR . "/cepis-woocommerce/uninstall.php";', ); const left = wpEval( "global $wpdb;" + "echo (int) $wpdb->get_var( \"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE 'cepis_woo_%' OR option_name LIKE '%transient%cepis_woo_%'\" );", ); expect(left).toBe("0"); setOption("cepis_woo_enabled", "yes"); setOption("cepis_woo_base_url", FAKE_URL); setOption("cepis_woo_mode", "direct"); }); test("WP_DEBUG logged nothing while all of that happened", () => { const log = drainDebugLog(); const ours = log .split("\n") .filter((line) => line.trim() !== "") .filter((line) => !line.includes("/wp-content/plugins/woocommerce/")) .filter((line) => !line.includes("/wp-includes/")); expect(ours).toEqual([]); }); });