diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 15:01:02 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 15:01:02 -0300 |
| commit | 36486007ccada8c31d30e8f528d21233cabce32c (patch) | |
| tree | d14a108845e4c984ec922ba98d153c8d68da6ebb /test | |
| download | autofill-ceafecfbd5171c08d15dc0ca123d6d9a2582000d.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'test')
| -rw-r--r-- | test/browser.test.js | 336 | ||||
| -rw-r--r-- | test/fixture.html | 62 | ||||
| -rw-r--r-- | test/server.js | 171 |
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); + }, + }; +} |