summaryrefslogtreecommitdiffstats
path: root/test
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 16:41:48 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 16:47:14 -0300
commit535dbda6b48171f8115b455e54790de94d97d42a (patch)
tree5c0410011e61a883bdf063ee612875a637fb1d22 /test
downloadwoocommerce-535dbda6b48171f8115b455e54790de94d97d42a.tar.gz
Initial importv0.1.0
Diffstat (limited to 'test')
-rw-r--r--test/checkout.test.js598
-rw-r--r--test/compose.yml61
-rw-r--r--test/fake-api.js104
-rwxr-xr-xtest/setup.sh158
-rw-r--r--test/wp.js88
5 files changed, 1009 insertions, 0 deletions
diff --git a/test/checkout.test.js b/test/checkout.test.js
new file mode 100644
index 0000000..074b13a
--- /dev/null
+++ b/test/checkout.test.js
@@ -0,0 +1,598 @@
+/**
+ * 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<void>}>}
+ */
+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.1.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);
+});
+
+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);
+});
+
+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.1.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 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);
+});
+
+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([]);
+ });
+});
diff --git a/test/compose.yml b/test/compose.yml
new file mode 100644
index 0000000..ff0cbdd
--- /dev/null
+++ b/test/compose.yml
@@ -0,0 +1,61 @@
+name: cepis-woocommerce-test
+
+services:
+ db:
+ image: mysql:8
+ environment:
+ MYSQL_ROOT_PASSWORD: wordpress
+ MYSQL_DATABASE: wordpress
+ MYSQL_USER: wordpress
+ MYSQL_PASSWORD: wordpress
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uwordpress", "-pwordpress"]
+ interval: 3s
+ timeout: 5s
+ retries: 30
+
+ wordpress:
+ image: wordpress:latest
+ depends_on:
+ db:
+ condition: service_healthy
+ ports:
+ - "8090:80"
+ extra_hosts:
+ - "fakecep:host-gateway"
+ environment:
+ WORDPRESS_DB_HOST: db:3306
+ WORDPRESS_DB_USER: wordpress
+ WORDPRESS_DB_PASSWORD: wordpress
+ WORDPRESS_DB_NAME: wordpress
+ WORDPRESS_DEBUG: "1"
+ WORDPRESS_CONFIG_EXTRA: |
+ define( 'WP_DEBUG', true );
+ define( 'WP_DEBUG_LOG', true );
+ define( 'WP_DEBUG_DISPLAY', false );
+ define( 'SCRIPT_DEBUG', true );
+ define( 'DISABLE_WP_CRON', true );
+ volumes:
+ - wp:/var/www/html
+ - ..:/var/www/html/wp-content/plugins/cepis-woocommerce
+
+ cli:
+ image: wordpress:cli
+ depends_on:
+ - wordpress
+ user: "33:33"
+ extra_hosts:
+ - "fakecep:host-gateway"
+ environment:
+ WORDPRESS_DB_HOST: db:3306
+ WORDPRESS_DB_USER: wordpress
+ WORDPRESS_DB_PASSWORD: wordpress
+ WORDPRESS_DB_NAME: wordpress
+ WP_CLI_CACHE_DIR: /tmp/wp-cli-cache
+ volumes:
+ - wp:/var/www/html
+ - ..:/var/www/html/wp-content/plugins/cepis-woocommerce
+ entrypoint: ["tail", "-f", "/dev/null"]
+
+volumes:
+ wp:
diff --git a/test/fake-api.js b/test/fake-api.js
new file mode 100644
index 0000000..4b6ac39
--- /dev/null
+++ b/test/fake-api.js
@@ -0,0 +1,104 @@
+/**
+ * A fake cep.is, so the test suite never touches the network.
+ *
+ * Same routes, bodies and status codes as the real API, plus a request log the
+ * tests assert on. It listens on every interface because two different clients
+ * have to reach it: the browser on the host, and PHP inside the WordPress
+ * container through the `fakecep` host alias.
+ *
+ * @module
+ */
+
+const ADDRESSES = {
+ "01001000": {
+ cep: "01001000",
+ logradouro: "Praça da Sé",
+ complemento: "lado ímpar",
+ unidade: "",
+ bairro: "Sé",
+ localidade: "São Paulo",
+ uf: "SP",
+ estado: "São Paulo",
+ regiao: "Sudeste",
+ ibge: "3550308",
+ gia: "1004",
+ ddd: "11",
+ siafi: "7107",
+ geo: { lat: -23.567202, lon: -46.649659, raio_m: 1084, precisao: "logradouro" },
+ },
+ 20040020: {
+ cep: "20040020",
+ logradouro: "Avenida Rio Branco",
+ complemento: "até 0999 - lado ímpar",
+ unidade: "",
+ bairro: "Centro",
+ localidade: "Rio de Janeiro",
+ uf: "RJ",
+ estado: "Rio de Janeiro",
+ regiao: "Sudeste",
+ ibge: "3304557",
+ gia: "",
+ ddd: "21",
+ siafi: "6001",
+ },
+};
+
+const HEADERS = {
+ "Access-Control-Allow-Origin": "*",
+ "Content-Type": "application/json; charset=utf-8",
+};
+
+/**
+ * Start the fake API.
+ *
+ * `state.requests` collects `{cep, userAgent}` for every lookup, which is how
+ * the proxy tests prove the request came from PHP rather than the browser.
+ *
+ * @param {number} port Port to listen on.
+ * @returns {{port: number, state: {requests: Array<{cep: string, userAgent: string}>}, reset: () => void, stop: () => void}}
+ */
+export function startFakeApi(port) {
+ const state = { requests: [] };
+
+ const server = Bun.serve({
+ port,
+ hostname: "0.0.0.0",
+ fetch(request) {
+ const cep = new URL(request.url).pathname.slice(1);
+ state.requests.push({ cep, userAgent: request.headers.get("user-agent") || "" });
+
+ if (!/^\d{8}$/.test(cep)) {
+ return new Response(
+ JSON.stringify({ erro: "cep_invalido", mensagem: "CEP deve ter 8 dígitos" }),
+ { status: 400, headers: HEADERS },
+ );
+ }
+
+ const address = ADDRESSES[cep];
+ if (!address) {
+ return new Response(
+ JSON.stringify({ erro: "cep_nao_encontrado", mensagem: "CEP não existe na base do DNE" }),
+ { status: 404, headers: HEADERS },
+ );
+ }
+
+ return new Response(JSON.stringify(address), { status: 200, headers: HEADERS });
+ },
+ });
+
+ return {
+ port: server.port,
+ state,
+ reset() {
+ state.requests.length = 0;
+ },
+ stop() {
+ server.stop(true);
+ },
+ };
+}
+
+if (import.meta.main) {
+ const api = startFakeApi(Number(process.argv[2] || 8899));
+ console.log(`fake cep.is on :${api.port}`);
+}
diff --git a/test/setup.sh b/test/setup.sh
new file mode 100755
index 0000000..6925386
--- /dev/null
+++ b/test/setup.sh
@@ -0,0 +1,158 @@
+#!/bin/sh
+#
+# Stand up the WordPress + WooCommerce store the browser tests drive.
+#
+# Idempotent: running it again on a live stack reconfigures rather than
+# rebuilds. `make clean` is what throws the volumes away.
+
+set -eu
+
+cd "$(dirname "$0")"
+
+SITE="http://127.0.0.1:8090"
+
+wp() {
+ docker compose exec -T cli wp "$@"
+}
+
+printf 'bringing the stack up\n'
+docker compose up -d >/dev/null
+
+printf 'waiting for the web server\n'
+i=0
+while [ "$i" -lt 90 ]; do
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 "$SITE/" 2>/dev/null || echo 000)
+ [ "$code" != "000" ] && break
+ i=$((i + 1))
+ sleep 2
+done
+[ "$code" = "000" ] && { printf 'web server never answered\n' >&2; exit 1; }
+
+if ! wp core is-installed >/dev/null 2>&1; then
+ printf 'installing WordPress\n'
+ wp core install \
+ --url="$SITE" \
+ --title="CEP Test Store" \
+ --admin_user=admin \
+ --admin_password=admin \
+ --skip-email >/dev/null
+fi
+
+wp rewrite structure '/%postname%/' >/dev/null 2>&1
+wp rewrite flush >/dev/null 2>&1
+
+if ! wp plugin is-installed woocommerce >/dev/null 2>&1; then
+ printf 'installing WooCommerce\n'
+ wp plugin install woocommerce >/dev/null
+fi
+wp plugin activate woocommerce >/dev/null
+
+printf 'configuring the store\n'
+wp option update woocommerce_store_address "Praça da Sé" >/dev/null
+wp option update woocommerce_store_city "São Paulo" >/dev/null
+wp option update woocommerce_default_country "BR:SP" >/dev/null
+wp option update woocommerce_store_postcode "01001000" >/dev/null
+wp option update woocommerce_currency BRL >/dev/null
+wp option update woocommerce_allowed_countries all >/dev/null
+wp option update woocommerce_enable_guest_checkout yes >/dev/null
+wp option update woocommerce_enable_checkout_login_reminder no >/dev/null
+wp option update woocommerce_calc_taxes no >/dev/null
+wp option update woocommerce_cod_settings \
+ '{"enabled":"yes","title":"Pagamento na entrega","description":"","instructions":""}' \
+ --format=json >/dev/null
+wp eval 'if ( ! WC_Shipping_Zones::get_zone( 0 )->get_shipping_methods() ) { WC_Shipping_Zones::get_zone( 0 )->add_shipping_method( "flat_rate" ); }' >/dev/null
+
+wp eval '
+$id = (int) get_option( "cepis_test_product", 0 );
+if ( ! $id || ! wc_get_product( $id ) ) {
+ $product = new WC_Product_Simple();
+ $product->set_name( "Caneca" );
+ $product->set_regular_price( "25" );
+ $product->set_virtual( false );
+ $id = $product->save();
+ update_option( "cepis_test_product", $id );
+}
+' >/dev/null
+
+wp eval '
+$blocks = 0;
+foreach ( get_pages( array( "post_status" => "publish" ) ) as $page ) {
+ if ( has_block( "woocommerce/checkout", $page ) ) {
+ $blocks = (int) $page->ID;
+ break;
+ }
+}
+if ( ! $blocks ) {
+ WP_CLI::error( "no page holds the woocommerce/checkout block" );
+}
+update_option( "cepis_test_blocks_page", $blocks );
+update_option( "woocommerce_checkout_page_id", $blocks );
+
+$classic = get_page_by_path( "checkout-classico" );
+if ( ! $classic ) {
+ $id = wp_insert_post( array(
+ "post_title" => "Checkout classico",
+ "post_name" => "checkout-classico",
+ "post_content" => "[woocommerce_checkout]",
+ "post_status" => "publish",
+ "post_type" => "page",
+ ) );
+} else {
+ $id = (int) $classic->ID;
+}
+update_option( "cepis_test_classic_page", $id );
+' >/dev/null
+
+printf 'installing the fixture that imitates Brazilian Market on WooCommerce\n'
+docker compose exec -T cli sh -c 'mkdir -p /var/www/html/wp-content/mu-plugins && cat > /var/www/html/wp-content/mu-plugins/cepis-test-br-fields.php' <<'PHP'
+<?php
+/**
+ * Adds the house-number and neighbourhood fields that "Brazilian Market on
+ * WooCommerce" adds, so the plugin can be tested with and without them.
+ * Switched by the `cepis_test_br_fields` option.
+ */
+
+function cepis_test_br_fields( $fields, $prefix ) {
+ if ( 'yes' !== get_option( 'cepis_test_br_fields' ) ) {
+ return $fields;
+ }
+
+ $fields[ $prefix . '_number' ] = array(
+ 'label' => 'Número',
+ 'required' => false,
+ 'class' => array( 'form-row-first', 'address-field' ),
+ 'priority' => 51,
+ );
+ $fields[ $prefix . '_neighborhood' ] = array(
+ 'label' => 'Bairro',
+ 'required' => false,
+ 'class' => array( 'form-row-last', 'address-field' ),
+ 'priority' => 52,
+ );
+
+ return $fields;
+}
+
+add_filter( 'woocommerce_billing_fields', function ( $fields ) {
+ return cepis_test_br_fields( $fields, 'billing' );
+} );
+
+add_filter( 'woocommerce_shipping_fields', function ( $fields ) {
+ return cepis_test_br_fields( $fields, 'shipping' );
+} );
+
+/**
+ * Force the site locale from an option, so the shipped translation can be
+ * exercised without downloading WordPress's own language pack.
+ */
+add_filter( 'locale', function ( $locale ) {
+ $forced = get_option( 'cepis_test_locale' );
+
+ return $forced ? $forced : $locale;
+} );
+PHP
+
+wp plugin activate cepis-woocommerce >/dev/null
+
+printf 'ready: %s\n' "$SITE"
diff --git a/test/wp.js b/test/wp.js
new file mode 100644
index 0000000..ab2d2a7
--- /dev/null
+++ b/test/wp.js
@@ -0,0 +1,88 @@
+/**
+ * Talking to the WordPress under test.
+ *
+ * Everything goes through WP-CLI in the `cli` container of `compose.yml`, so
+ * the tests configure the store exactly the way a store owner would.
+ *
+ * @module
+ */
+
+import { spawnSync } from "node:child_process";
+import { dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+/** Where the site answers, from the host. */
+export const SITE = "http://127.0.0.1:8090";
+
+/** Hostname the fake API is reached by, from both the browser and PHP. */
+export const FAKE_HOST = "fakecep";
+
+/** Port the fake API listens on. */
+export const FAKE_PORT = 8899;
+
+/** Base URL of the fake API, as both sides must spell it. */
+export const FAKE_URL = `http://${FAKE_HOST}:${FAKE_PORT}`;
+
+/**
+ * Run a WP-CLI command in the container.
+ *
+ * @param {string[]} args Arguments after `wp`.
+ * @returns {string} Trimmed stdout.
+ */
+export function wp(args) {
+ const result = spawnSync("docker", ["compose", "exec", "-T", "cli", "wp", ...args], {
+ cwd: HERE,
+ encoding: "utf8",
+ });
+
+ if (result.status !== 0) {
+ throw new Error(`wp ${args.join(" ")} failed:\n${result.stdout}\n${result.stderr}`);
+ }
+
+ return String(result.stdout).trim();
+}
+
+/**
+ * Run PHP inside WordPress.
+ *
+ * @param {string} code PHP to evaluate.
+ * @returns {string} Trimmed stdout.
+ */
+export function wpEval(code) {
+ return wp(["eval", code]);
+}
+
+/**
+ * Set one option.
+ *
+ * @param {string} name Option name.
+ * @param {string} value Option value.
+ */
+export function setOption(name, value) {
+ wp(["option", "update", name, value]);
+}
+
+/**
+ * Read the WordPress debug log and clear it.
+ *
+ * @returns {string} Whatever had been logged.
+ */
+export function drainDebugLog() {
+ const result = spawnSync(
+ "docker",
+ [
+ "compose",
+ "exec",
+ "-T",
+ "cli",
+ "sh",
+ "-c",
+ "cat /var/www/html/wp-content/debug.log 2>/dev/null; : > /var/www/html/wp-content/debug.log 2>/dev/null || true",
+ ],
+ { cwd: HERE, encoding: "utf8" },
+ );
+
+ return String(result.stdout || "").trim();
+}