summaryrefslogtreecommitdiffstats
path: root/test
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 15:01:02 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 15:01:02 -0300
commit36486007ccada8c31d30e8f528d21233cabce32c (patch)
treed14a108845e4c984ec922ba98d153c8d68da6ebb /test
downloadautofill-ceafecfbd5171c08d15dc0ca123d6d9a2582000d.tar.gz
Initial importv0.1.0
Diffstat (limited to 'test')
-rw-r--r--test/browser.test.js336
-rw-r--r--test/fixture.html62
-rw-r--r--test/server.js171
3 files changed, 569 insertions, 0 deletions
diff --git a/test/browser.test.js b/test/browser.test.js
new file mode 100644
index 0000000..634df0d
--- /dev/null
+++ b/test/browser.test.js
@@ -0,0 +1,336 @@
+import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
+import { chromium } from "playwright-core";
+
+import { startApi, startPage } from "./server.js";
+
+function chromiumPath() {
+ if (process.env.CEPIS_CHROMIUM) {
+ return process.env.CEPIS_CHROMIUM;
+ }
+ const root = `${process.env.HOME}/.cache/ms-playwright`;
+ const found = [
+ ...new Bun.Glob("chromium-*/chrome-linux64/chrome").scanSync({ cwd: root, absolute: true }),
+ ].sort();
+ return found.at(-1);
+}
+
+let browser;
+let context;
+let page;
+let api;
+let pages;
+
+beforeAll(async () => {
+ api = startApi();
+ pages = await startPage();
+ browser = await chromium.launch({ headless: true, executablePath: chromiumPath() });
+ context = await browser.newContext();
+});
+
+afterAll(async () => {
+ await context?.close();
+ await browser?.close();
+ api?.stop();
+ pages?.stop();
+});
+
+afterEach(async () => {
+ await page?.close();
+ page = undefined;
+ api.reset();
+});
+
+async function open(options = {}) {
+ page = await context.newPage();
+ await page.goto(pages.url);
+ await page.evaluate((given) => window.__attach(given), { baseUrl: api.url, ...options });
+ return page;
+}
+
+function typeCep(text) {
+ return page.locator("#cep").pressSequentially(text, { delay: 15 });
+}
+
+async function clearCep() {
+ await page.locator("#cep").press("Control+a");
+ await page.locator("#cep").press("Backspace");
+}
+
+function value(selector) {
+ return page.locator(selector).inputValue();
+}
+
+function statusText() {
+ return page.locator(".cepis-autofill-status").textContent();
+}
+
+function events() {
+ return page.evaluate(() => window.__events);
+}
+
+async function waitForRequests(count, timeout = 4000) {
+ const deadline = Date.now() + timeout;
+ while (Date.now() < deadline) {
+ if (api.state.requests.length >= count) {
+ return;
+ }
+ await Bun.sleep(20);
+ }
+ throw new Error(`expected ${count} request(s), saw ${api.state.requests.length}`);
+}
+
+describe("filling", () => {
+ test("a typed CEP fills every mapped field", async () => {
+ await open();
+ await typeCep("01001000");
+
+ await page.waitForFunction(() => document.querySelector("#rua").value !== "");
+
+ expect(await value("#rua")).toBe("Praça da Sé");
+ expect(await value("#bairro")).toBe("Sé");
+ expect(await value("#cidade")).toBe("São Paulo");
+ expect(await value("#uf")).toBe("SP");
+ expect(await value("#numero")).toBe("");
+ expect(await events()).toContain("result:01001000");
+ });
+
+ test("the 01001-000 mask is accepted", async () => {
+ await open();
+ await typeCep("01001-000");
+
+ await page.waitForFunction(() => document.querySelector("#cidade").value !== "");
+
+ expect(await value("#cidade")).toBe("São Paulo");
+ expect(api.state.requests).toEqual(["01001000"]);
+ });
+
+ test("nothing is requested before the eighth digit", async () => {
+ await open();
+ await typeCep("0100100");
+ await Bun.sleep(300);
+
+ expect(api.state.requests).toEqual([]);
+ expect(await statusText()).toBe("");
+ });
+
+ test("focusNumero moves focus to the house number", async () => {
+ await open({ focusNumero: true });
+ await typeCep("01001000");
+
+ await page.waitForFunction(() => document.activeElement.id === "numero");
+ expect(await page.evaluate(() => document.activeElement.id)).toBe("numero");
+ });
+});
+
+describe("requests", () => {
+ test("debounce coalesces a burst of keystrokes into one request", async () => {
+ await open({ debounceMs: 200 });
+ await page.locator("#cep").pressSequentially("01001000", { delay: 5 });
+
+ await page.waitForFunction(() => document.querySelector("#rua").value !== "");
+ await Bun.sleep(300);
+
+ expect(api.state.requests).toEqual(["01001000"]);
+ }, 10000);
+
+ test("a stale response never overwrites a newer one", async () => {
+ api.state.delays["01001000"] = 600;
+ await open();
+
+ await typeCep("01001000");
+ await waitForRequests(1);
+ expect(api.state.requests).toEqual(["01001000"]);
+
+ await page.locator("#cep").press("Control+a");
+ await typeCep("20040020");
+
+ await page.waitForFunction(() => document.querySelector("#cidade").value === "Rio de Janeiro");
+ await Bun.sleep(800);
+
+ expect(await value("#rua")).toBe("Avenida Rio Branco");
+ expect(await value("#cidade")).toBe("Rio de Janeiro");
+ expect(await value("#uf")).toBe("RJ");
+ expect(await events()).toEqual(expect.arrayContaining(["result:20040020"]));
+ expect(await events()).not.toContain("result:01001000");
+ expect(api.state.aborted).toBeGreaterThan(0);
+ }, 15000);
+
+ test("a CEP replaced in one shot does not lose the race either", async () => {
+ api.state.delays["01001000"] = 600;
+ await open();
+
+ await typeCep("01001000");
+ await waitForRequests(1);
+
+ await page.locator("#cep").selectText();
+ await page.keyboard.insertText("20040020");
+
+ await page.waitForFunction(() => document.querySelector("#cidade").value === "Rio de Janeiro");
+ await Bun.sleep(800);
+
+ expect(await value("#rua")).toBe("Avenida Rio Branco");
+ expect(await value("#cidade")).toBe("Rio de Janeiro");
+ expect(await events()).not.toContain("result:01001000");
+ expect(await statusText()).toBe("Endereço encontrado.");
+ }, 15000);
+
+ test("the same CEP is only requested once", async () => {
+ await open();
+ await typeCep("01001000");
+ await page.waitForFunction(() => document.querySelector("#rua").value !== "");
+
+ await clearCep();
+ await typeCep("01001000");
+ await Bun.sleep(400);
+
+ expect(await value("#cidade")).toBe("São Paulo");
+ expect(api.state.requests).toEqual(["01001000"]);
+ }, 10000);
+
+ test("detach() stops every further request", async () => {
+ await open();
+ await page.evaluate(() => window.__handle.detach());
+
+ await typeCep("01001000");
+ await Bun.sleep(400);
+
+ expect(api.state.requests).toEqual([]);
+ expect(await value("#rua")).toBe("");
+ expect(await page.locator(".cepis-autofill-status").count()).toBe(0);
+ });
+});
+
+describe("errors", () => {
+ test("a CEP outside the database says so", async () => {
+ await open();
+ await typeCep("99999999");
+
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("não encontrado"),
+ );
+
+ expect(await statusText()).toContain("CEP não encontrado");
+ expect(await value("#rua")).toBe("");
+ expect(await events()).toContain("error:not_found");
+ });
+
+ test("an unreachable API says so", async () => {
+ const dead = startApi();
+ const url = dead.url;
+ dead.stop();
+
+ await open({ baseUrl: url });
+ await typeCep("01001000");
+
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("conexão"),
+ );
+
+ expect(await statusText()).toContain("Não foi possível consultar o CEP");
+ expect(await events()).toContain("error:network");
+ }, 10000);
+
+ test("a 500 and a 429 get their own message", async () => {
+ await open();
+ await typeCep("88888888");
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("indisponível"),
+ );
+ expect(await statusText()).toContain("O serviço de CEP está indisponível");
+
+ await clearCep();
+ await typeCep("77777777");
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("Muitas consultas"),
+ );
+ expect(await events()).toContain("error:rate_limited");
+ }, 10000);
+
+ test("more than eight digits is rejected without a request", async () => {
+ await open();
+ await typeCep("010010009");
+ await Bun.sleep(300);
+
+ expect(api.state.requests).toEqual([]);
+ expect(await statusText()).toContain("CEP inválido");
+ });
+});
+
+describe("what the shopper typed", () => {
+ test("overwrite: false leaves a filled field alone", async () => {
+ await open({ overwrite: false });
+ await page.locator("#rua").pressSequentially("Rua que eu mesmo digitei", { delay: 5 });
+ await typeCep("01001000");
+
+ await page.waitForFunction(() => document.querySelector("#bairro").value !== "");
+
+ expect(await value("#rua")).toBe("Rua que eu mesmo digitei");
+ expect(await value("#bairro")).toBe("Sé");
+ }, 10000);
+
+ test("by default a manual correction survives the next CEP", async () => {
+ await open();
+ await typeCep("01001000");
+ await page.waitForFunction(() => document.querySelector("#rua").value !== "");
+
+ await page.locator("#rua").press("Control+a");
+ await page.locator("#rua").pressSequentially("Praça da Sé, fundos", { delay: 5 });
+
+ await page.locator("#cep").press("Control+a");
+ await typeCep("20040020");
+ await page.waitForFunction(() => document.querySelector("#bairro").value === "Centro");
+
+ expect(await value("#rua")).toBe("Praça da Sé, fundos");
+ expect(await value("#cidade")).toBe("Rio de Janeiro");
+ }, 15000);
+
+ test("overwrite: true replaces it", async () => {
+ await open({ overwrite: true });
+ await page.locator("#rua").pressSequentially("Rua qualquer", { delay: 5 });
+ await typeCep("01001000");
+
+ await page.waitForFunction(() => document.querySelector("#rua").value === "Praça da Sé");
+ expect(await value("#rua")).toBe("Praça da Sé");
+ }, 10000);
+});
+
+describe("accessibility", () => {
+ test("the status region announces each state", async () => {
+ api.state.delays["01001000"] = 400;
+ await open();
+
+ const region = page.locator(".cepis-autofill-status");
+ expect(await region.getAttribute("role")).toBe("status");
+ expect(await region.getAttribute("aria-live")).toBe("polite");
+ expect(await region.getAttribute("aria-atomic")).toBe("true");
+
+ await typeCep("01001000");
+
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("Buscando"),
+ );
+ expect(await statusText()).toBe("Buscando endereço…");
+ expect(await page.locator("#cep").getAttribute("aria-busy")).toBe("true");
+
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("encontrado"),
+ );
+ expect(await statusText()).toBe("Endereço encontrado.");
+ expect(await page.locator("#cep").getAttribute("aria-busy")).toBeNull();
+
+ const id = await region.getAttribute("id");
+ expect(await page.locator("#cep").getAttribute("aria-describedby")).toContain(id);
+ expect(await events()).toEqual(expect.arrayContaining(["status:loading", "status:found"]));
+ }, 10000);
+
+ test("the region reserves its space before anything is announced", async () => {
+ await open();
+ const before = await page.locator("#checkout").boundingBox();
+
+ await typeCep("01001000");
+ await page.waitForFunction(() => document.querySelector("#rua").value !== "");
+
+ const after = await page.locator("#checkout").boundingBox();
+ expect(after.height).toBe(before.height);
+ });
+});
diff --git a/test/fixture.html b/test/fixture.html
new file mode 100644
index 0000000..6d6535d
--- /dev/null
+++ b/test/fixture.html
@@ -0,0 +1,62 @@
+<!doctype html>
+<html lang="pt-BR">
+ <head>
+ <meta charset="utf-8" />
+ <title>checkout fixture</title>
+ </head>
+ <body>
+ <form id="checkout">
+ <label for="cep">CEP</label>
+ <input id="cep" name="cep" autocomplete="postal-code" />
+
+ <label for="rua">Endereço</label>
+ <input id="rua" name="rua" />
+
+ <label for="numero">Número</label>
+ <input id="numero" name="numero" />
+
+ <label for="bairro">Bairro</label>
+ <input id="bairro" name="bairro" />
+
+ <label for="cidade">Cidade</label>
+ <input id="cidade" name="cidade" />
+
+ <label for="uf">Estado</label>
+ <select id="uf" name="uf">
+ <option value="">Selecione</option>
+ <option value="RJ">Rio de Janeiro</option>
+ <option value="SP">São Paulo</option>
+ </select>
+ </form>
+
+ <script src="/cepis-autofill.js"></script>
+ <script>
+ window.__events = [];
+ window.__attach = function (options) {
+ window.__handle = window.CepisAutofill.attach(
+ Object.assign(
+ {
+ cep: "#cep",
+ logradouro: "#rua",
+ bairro: "#bairro",
+ localidade: "#cidade",
+ uf: "#uf",
+ numero: "#numero",
+ debounceMs: 50,
+ onResult: function (address) {
+ window.__events.push("result:" + address.cep);
+ },
+ onError: function (error) {
+ window.__events.push("error:" + error.code);
+ },
+ onStatus: function (state) {
+ window.__events.push("status:" + state);
+ },
+ },
+ options,
+ ),
+ );
+ };
+ </script>
+ </body>
+</html>
diff --git a/test/server.js b/test/server.js
new file mode 100644
index 0000000..720f4a0
--- /dev/null
+++ b/test/server.js
@@ -0,0 +1,171 @@
+/**
+ * A fake cep.is, stood up with `Bun.serve` so the browser tests never touch the
+ * network. Same shapes and status codes as the real API, plus the counters the
+ * tests assert on.
+ *
+ * @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 CORS = {
+ "Access-Control-Allow-Origin": "*",
+ "Content-Type": "application/json; charset=utf-8",
+};
+
+/**
+ * Start the fake API on an ephemeral port.
+ *
+ * The returned `state` is live: set `state.delays[cep]` to hold a response
+ * back, or `state.force` to `{status}` to make every route fail. `requests`
+ * lists every CEP asked for, and `aborted` counts the requests the browser
+ * dropped mid-flight.
+ *
+ * @returns {{url: string, state: {requests: string[], aborted: number, delays: Record<string, number>, force: {status: number}|null}, reset: () => void, stop: () => void}}
+ */
+export function startApi() {
+ const state = { requests: [], aborted: 0, delays: {}, force: null };
+
+ const server = Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ async fetch(request) {
+ const path = new URL(request.url).pathname.slice(1);
+ state.requests.push(path);
+ request.signal.addEventListener("abort", () => {
+ state.aborted += 1;
+ });
+
+ const delay = state.delays[path] ?? 0;
+ if (delay > 0) {
+ await Bun.sleep(delay);
+ }
+ if (request.signal.aborted) {
+ return new Response(null, { status: 499 });
+ }
+
+ if (state.force) {
+ return new Response(JSON.stringify({ erro: "interno", mensagem: "falha interna" }), {
+ status: state.force.status,
+ headers: {
+ ...CORS,
+ ...(state.force.status === 429 ? { "Retry-After": "3" } : {}),
+ },
+ });
+ }
+ if (!/^\d{8}$/.test(path)) {
+ return new Response(
+ JSON.stringify({ erro: "cep_invalido", mensagem: "CEP deve ter 8 dígitos" }),
+ { status: 400, headers: CORS },
+ );
+ }
+ if (path === "88888888") {
+ return new Response(JSON.stringify({ erro: "interno", mensagem: "falha interna" }), {
+ status: 500,
+ headers: CORS,
+ });
+ }
+ if (path === "77777777") {
+ return new Response(JSON.stringify({ erro: "limite", mensagem: "muitas consultas" }), {
+ status: 429,
+ headers: { ...CORS, "Retry-After": "3" },
+ });
+ }
+ const address = ADDRESSES[path];
+ if (!address) {
+ return new Response(
+ JSON.stringify({ erro: "cep_nao_encontrado", mensagem: "CEP não existe na base do DNE" }),
+ { status: 404, headers: CORS },
+ );
+ }
+ return new Response(JSON.stringify(address), { status: 200, headers: CORS });
+ },
+ });
+
+ return {
+ url: `http://127.0.0.1:${server.port}`,
+ state,
+ reset() {
+ state.requests.length = 0;
+ state.aborted = 0;
+ state.delays = {};
+ state.force = null;
+ },
+ stop() {
+ server.stop(true);
+ },
+ };
+}
+
+/**
+ * Serve the checkout fixture page together with a freshly built `<script>`
+ * bundle of the widget, so the tests exercise the file consumers actually
+ * drop into a page.
+ *
+ * @returns {Promise<{url: string, stop: () => void}>}
+ */
+export async function startPage() {
+ const build = await Bun.build({
+ entrypoints: [new URL("../src/global.js", import.meta.url).pathname],
+ target: "browser",
+ format: "iife",
+ });
+ if (!build.success) {
+ throw new AggregateError(build.logs, "could not build the widget");
+ }
+ const bundle = await build.outputs[0].text();
+ const fixture = await Bun.file(new URL("./fixture.html", import.meta.url).pathname).text();
+
+ const server = Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ fetch(request) {
+ const path = new URL(request.url).pathname;
+ if (path === "/cepis-autofill.js") {
+ return new Response(bundle, {
+ headers: { "Content-Type": "text/javascript; charset=utf-8" },
+ });
+ }
+ return new Response(fixture, { headers: { "Content-Type": "text/html; charset=utf-8" } });
+ },
+ });
+
+ return {
+ url: `http://127.0.0.1:${server.port}/`,
+ stop() {
+ server.stop(true);
+ },
+ };
+}