MAI CALL - step 13

pnpm test:e2e verde. Passo 13 completo.

O que foi feito:

Polish:

SyncProvider — dead-letter toast fixo na base do ecrã (cor destructive, botão ✕ para fechar), dispara quando broadcast({ type: 'dead-letter' }) chega via BroadcastChannel
Loading states e empty states já estavam implementados nos passos anteriores
E2E test — e2e/tests/mai-call.spec.ts:

Substitui o ping.spec.ts obsoleto
Arranca ambos os servidores (operator-pwa :3000 + admin-web :3001) com AUTH_DEV_AUTOLOGIN=true
Fluxo completo em 13.7s: formulário → IndexedDB → sync automático → admin queue → claim (OPEN→CLAIMED) → enable RESOLVED filter → resolve dialog → confirm (CLAIMED→RESOLVED)
pnpm test:e2e passa ✓
This commit is contained in:
pedrogomes committed 2026-05-16 17:06:27 +01:00
1 parent b7e3208eb2
commit 9418b360bc
11 files changed
+2785 -49

No files matched your search

+5 -1
View File
@@ -38,7 +38,11 @@
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\prdcg\\\\Documents\\\\Git\\\\FieldOps\" -Directory)", "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\prdcg\\\\Documents\\\\Git\\\\FieldOps\" -Directory)",
"Bash(Select-Object Name)", "Bash(Select-Object Name)",
"Bash(pnpm --filter @repo/admin-web typecheck)", "Bash(pnpm --filter @repo/admin-web typecheck)",
"Bash(pnpm --filter @repo/admin-web build)" "Bash(pnpm --filter @repo/admin-web build)",
"Bash(pnpm --filter @repo/e2e exec tsc --noEmit)",
"Bash(pnpm exec *)",
"Bash(pnpm test:e2e)",
"Bash(pnpm -w run test:e2e)"
] ]
} }
} }
@@ -83,7 +83,7 @@ function RequestCard({
claiming: boolean; claiming: boolean;
}) { }) {
return ( return (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 shadow-sm"> <div data-testid="request-card" className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 shadow-sm">
{/* Top row: thumbnail + main info */} {/* Top row: thumbnail + main info */}
<div className="flex gap-3"> <div className="flex gap-3">
<Thumbnail photoKey={item.photoKey} /> <Thumbnail photoKey={item.photoKey} />
+32 -6
View File
@@ -9,7 +9,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { subscribeBroadcast } from '@/lib/queue/broadcast'; import { subscribeBroadcast, type SyncMessage } from '@/lib/queue/broadcast';
import { runSync } from '@/lib/queue/sync'; import { runSync } from '@/lib/queue/sync';
import { db } from '@/lib/queue/db'; import { db } from '@/lib/queue/db';
@@ -23,6 +23,7 @@ export const useSyncState = () => useContext(SyncCtx);
export function SyncProvider({ children }: { children: ReactNode }) { export function SyncProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 }); const [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
const [failedIds, setFailedIds] = useState<string[]>([]);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refreshCounts = useCallback(async () => { const refreshCounts = useCallback(async () => {
@@ -38,7 +39,12 @@ export function SyncProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
refreshCounts(); refreshCounts();
const unsub = subscribeBroadcast(async () => refreshCounts()); const unsub = subscribeBroadcast(async (msg: SyncMessage) => {
await refreshCounts();
if (msg.type === 'dead-letter') {
setFailedIds((prev) => [...prev, msg.clientRequestId]);
}
});
const onOnline = () => sync(); const onOnline = () => sync();
const onVisible = () => { const onVisible = () => {
@@ -48,12 +54,10 @@ export function SyncProvider({ children }: { children: ReactNode }) {
window.addEventListener('online', onOnline); window.addEventListener('online', onOnline);
document.addEventListener('visibilitychange', onVisible); document.addEventListener('visibilitychange', onVisible);
// Poll every 10s as a fallback
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
if (navigator.onLine) sync(); if (navigator.onLine) sync();
}, 10_000); }, 10_000);
// Register Background Sync if SW + API available
if ('serviceWorker' in navigator && 'SyncManager' in window) { if ('serviceWorker' in navigator && 'SyncManager' in window) {
navigator.serviceWorker.ready navigator.serviceWorker.ready
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -61,7 +65,6 @@ export function SyncProvider({ children }: { children: ReactNode }) {
.catch(() => {/* not supported or permission denied */}); .catch(() => {/* not supported or permission denied */});
} }
// Initial sync attempt
if (navigator.onLine) sync(); if (navigator.onLine) sync();
return () => { return () => {
@@ -72,5 +75,28 @@ export function SyncProvider({ children }: { children: ReactNode }) {
}; };
}, [sync, refreshCounts]); }, [sync, refreshCounts]);
return <SyncCtx.Provider value={state}>{children}</SyncCtx.Provider>; return (
<SyncCtx.Provider value={state}>
{children}
{failedIds.length > 0 && (
<div className="fixed bottom-4 left-4 right-4 z-50 flex flex-col gap-2">
{failedIds.map((id) => (
<div
key={id}
className="flex items-center justify-between rounded-lg bg-destructive px-4 py-3 text-sm text-destructive-foreground shadow-lg"
>
<span>Pedido {id.slice(0, 8)}… falhou — contacta o supervisor.</span>
<button
onClick={() => setFailedIds((prev) => prev.filter((x) => x !== id))}
className="ml-4 shrink-0 opacity-80 hover:opacity-100"
aria-label="Fechar"
>
✕
</button>
</div>
))}
</div>
)}
</SyncCtx.Provider>
);
} }
+88 -1
View File
@@ -1 +1,88 @@
if(!self.define){let e,s={};const n=(n,t)=>(n=new URL(n+".js",t).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(t,a)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>n(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(t.map(e=>o[e]||r(e))).then(e=>(a(...e),i))}}define(["./workbox-5194662c"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/dynamic-css-manifest.json",revision:"d751713988987e9331980363e24189ce"},{url:"/_next/static/E24ILEIxTT35zMD-Lqk4Z/_buildManifest.js",revision:"57b739a594f4a81ede20407d3eb2f67f"},{url:"/_next/static/E24ILEIxTT35zMD-Lqk4Z/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/2-1fec31df5cccd978.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/207-5830eb1c236921c4.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/366-9f25a484ec1a0ad1.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/453-4f1bf64721542ff1.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/5e89d14c-29448a56658b5c05.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/69.85d4fd0d1cced6f3.js",revision:"85d4fd0d1cced6f3"},{url:"/_next/static/chunks/699-e0dfe35c99f38031.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/718-c610b317eb50f713.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/864.76d176e27ab6eb2e.js",revision:"76d176e27ab6eb2e"},{url:"/_next/static/chunks/app/_not-found/page-27b9c7c9c84d3c3e.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/app/api/auth/%5B...nextauth%5D/route-eb91e6bcaad07c95.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/app/api/trpc/%5Btrpc%5D/route-ac9de9091f551860.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/app/error-b113a02aedbc7163.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/app/layout-4f117dbcc88e9f9d.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/app/maintenance/new/page-5955e116c9dd3501.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/app/maintenance/sent/page-45ea63d2cf48c8ea.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/app/not-found-62acb79750232d66.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/app/page-81637445222f88bf.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/app/select-operator/page-dab79b3f8b754bfc.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/framework-e9d381d8cb9498da.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/main-app-06906c35d26f4210.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/main-ec3a7b1cd2097aab.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/pages/_app-f69aca65b88454b7.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/pages/_error-df94080b8a9a72e7.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-51c1b9324c7c4d52.js",revision:"E24ILEIxTT35zMD-Lqk4Z"},{url:"/_next/static/css/3295c4d4c839af1a.css",revision:"3295c4d4c839af1a"},{url:"/icon-192.svg",revision:"4b4dfb506ed7eebc6e763bf8f09d75e6"},{url:"/icon-512.svg",revision:"d247a6555ec14dd052372bb297c43ae4"},{url:"/icon-maskable.svg",revision:"2fd823b9a4b1bec7aab84a22921c75c6"},{url:"/manifest.webmanifest",revision:"135f7a5aaec94ede8eb024ac054e5763"},{url:"/swe-worker-5c72df51bb1f6ee0.js",revision:"76fdd3369f623a3edcf74ce2200bfdd0"}],{ignoreURLParametersMatching:[/^utm_/,/^fbclid$/]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:function(e){return _ref.apply(this,arguments)}}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\/_next\/static.+\.js$/i,new e.CacheFirst({cacheName:"next-static-js-assets",plugins:[new e.Line truncated /**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didn’t register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5147176e'], (function (workbox) { 'use strict';
importScripts();
self.skipWaiting();
workbox.clientsClaim();
workbox.registerRoute("/", new workbox.NetworkFirst({
"cacheName": "start-url",
plugins: [{
cacheWillUpdate: function (_) {
return _ref.apply(this, arguments);
}
}]
}), 'GET');
workbox.registerRoute(/.*/i, new workbox.NetworkOnly({
"cacheName": "dev",
plugins: []
}), 'GET');
}));
@@ -1 +0,0 @@
(()=>{"use strict";self.onmessage=async e=>{switch(e.data.type){case"__START_URL_CACHE__":{let t=e.data.url,a=await fetch(t);if(!a.redirected)return(await caches.open("start-url")).put(t,a);return Promise.resolve()}case"__FRONTEND_NAV_CACHE__":{let t=e.data.url,a=await caches.open("pages");if(await a.match(t,{ignoreSearch:!0}))return;let s=await fetch(t);if(!s.ok)return;if(a.put(t,s.clone()),e.data.shouldCacheAggressively&&s.headers.get("Content-Type")?.includes("text/html"))try{let e=await s.text(),t=[],a=await caches.open("static-style-assets"),r=await caches.open("next-static-js-assets"),c=await caches.open("static-js-assets");for(let[s,r]of e.matchAll(/<link.*?href=['"](.*?)['"].*?>/g))/rel=['"]stylesheet['"]/.test(s)&&t.push(a.match(r).then(e=>e?Promise.resolve():a.add(r)));for(let[,a]of e.matchAll(/<script.*?src=['"](.*?)['"].*?>/g)){let e=/\/_next\/static.+\.js$/i.test(a)?r:c;t.push(e.match(a).then(t=>t?Promise.resolve():e.add(a)))}return await Promise.all(t)}catch{}return Promise.resolve()}default:return Promise.resolve()}}})();
@@ -0,0 +1,102 @@
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ "../../node_modules/.pnpm/@ducanh2912+next-pwa@10.2.9_1d3731f6205d164bcee9533e20d53672/node_modules/@ducanh2912/next-pwa/dist/sw-entry-worker.js":
/*!*******************************************************************************************************************************************************!*\
!*** ../../node_modules/.pnpm/@ducanh2912+next-pwa@10.2.9_1d3731f6205d164bcee9533e20d53672/node_modules/@ducanh2912/next-pwa/dist/sw-entry-worker.js ***!
\*******************************************************************************************************************************************************/
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\nself.onmessage = async (e)=>{\n switch(e.data.type){\n case \"__START_URL_CACHE__\":\n {\n let t = e.data.url, a = await fetch(t);\n if (!a.redirected) return (await caches.open(\"start-url\")).put(t, a);\n return Promise.resolve();\n }\n case \"__FRONTEND_NAV_CACHE__\":\n {\n let t = e.data.url, a = await caches.open(\"pages\");\n if (await a.match(t, {\n ignoreSearch: !0\n })) return;\n let s = await fetch(t);\n if (!s.ok) return;\n if (a.put(t, s.clone()), e.data.shouldCacheAggressively && s.headers.get(\"Content-Type\")?.includes(\"text/html\")) try {\n let e = await s.text(), t = [], a = await caches.open(\"static-style-assets\"), r = await caches.open(\"next-static-js-assets\"), c = await caches.open(\"static-js-assets\");\n for (let [s, r] of e.matchAll(/<link.*?href=['\"](.*?)['\"].*?>/g))/rel=['\"]stylesheet['\"]/.test(s) && t.push(a.match(r).then((e)=>e ? Promise.resolve() : a.add(r)));\n for (let [, a] of e.matchAll(/<script.*?src=['\"](.*?)['\"].*?>/g)){\n let e = /\\/_next\\/static.+\\.js$/i.test(a) ? r : c;\n t.push(e.match(a).then((t)=>t ? Promise.resolve() : e.add(a)));\n }\n return await Promise.all(t);\n } catch {}\n return Promise.resolve();\n }\n default:\n return Promise.resolve();\n }\n};//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiLi4vLi4vbm9kZV9tb2R1bGVzLy5wbnBtL0BkdWNhbmgyOTEyK25leHQtcHdhQDEwLjIuOV8xZDM3MzFmNjIwNWQxNjRiY2VlOTUzM2UyMGQ1MzY3Mi9ub2RlX21vZHVsZXMvQGR1Y2FuaDI5MTIvbmV4dC1wd2EvZGlzdC9zdy1lbnRyeS13b3JrZXIuanMiLCJtYXBwaW5ncyI6IjtBQUFBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsaUJBQWlCO0FBQ2pCO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0Esa0JBQWtCO0FBQ2xCO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQSIsInNvdXJjZXMiOlsiQzpcXFVzZXJzXFxwcmRjZ1xcRG9jdW1lbnRzXFxHaXRcXEZpZWxkT3BzXFxub2RlX21vZHVsZXNcXC5wbnBtXFxAZHVjYW5oMjkxMituZXh0LXB3YUAxMC4yLjlfMWQzNzMxZjYyMDVkMTY0YmNlZTk1MzNlMjBkNTM2NzJcXG5vZGVfbW9kdWxlc1xcQGR1Y2FuaDI5MTJcXG5leHQtcHdhXFxkaXN0XFxzdy1lbnRyeS13b3JrZXIuanMiXSwic291cmNlc0NvbnRlbnQiOlsic2VsZi5vbm1lc3NhZ2UgPSBhc3luYyAoZSk9PntcbiAgICBzd2l0Y2goZS5kYXRhLnR5cGUpe1xuICAgICAgICBjYXNlIFwiX19TVEFSVF9VUkxfQ0FDSEVfX1wiOlxuICAgICAgICAgICAge1xuICAgICAgICAgICAgICAgIGxldCB0ID0gZS5kYXRhLnVybCwgYSA9IGF3YWl0IGZldGNoKHQpO1xuICAgICAgICAgICAgICAgIGlmICghYS5yZWRpcmVjdGVkKSByZXR1cm4gKGF3YWl0IGNhY2hlcy5vcGVuKFwic3RhcnQtdXJsXCIpKS5wdXQodCwgYSk7XG4gICAgICAgICAgICAgICAgcmV0dXJuIFByb21pc2UucmVzb2x2ZSgpO1xuICAgICAgICAgICAgfVxuICAgICAgICBjYXNlIFwiX19GUk9OVEVORF9OQVZfQ0FDSEVfX1wiOlxuICAgICAgICAgICAge1xuICAgICAgICAgICAgICAgIGxldCB0ID0gZS5kYXRhLnVybCwgYSA9IGF3YWl0IGNhY2hlcy5vcGVuKFwicGFnZXNcIik7XG4gICAgICAgICAgICAgICAgaWYgKGF3YWl0IGEubWF0Y2godCwge1xuICAgICAgICAgICAgICAgICAgICBpZ25vcmVTZWFyY2g6ICEwXG4gICAgICAgICAgICAgICAgfSkpIHJldHVybjtcbiAgICAgICAgICAgICAgICBsZXQgcyA9IGF3YWl0IGZldGNoKHQpO1xuICAgICAgICAgICAgICAgIGlmICghcy5vaykgcmV0dXJuO1xuICAgICAgICAgICAgICAgIGlmIChhLnB1dCh0LCBzLmNsb25lKCkpLCBlLmRhdGEuc2hvdWxkQ2FjaGVBZ2dyZXNzaXZlbHkgJiYgcy5oZWFkZXJzLmdldChcIkNvbnRlbnQtVHlwZVwiKT8uaW5jbHVkZXMoXCJ0ZXh0L2h0bWxcIikpIHRyeSB7XG4gICAgICAgICAgICAgICAgICAgIGxldCBlID0gYXdhaXQgcy50ZXh0KCksIHQgPSBbXSwgYSA9IGF3YWl0IGNhY2hlcy5vcGVuKFwic3RhdGljLXN0eWxlLWFzc2V0c1wiKSwgciA9IGF3YWl0IGNhY2hlcy5vcGVuKFwibmV4dC1zdGF0aWMtanMtYXNzZXRzXCIpLCBjID0gYXdhaXQgY2FjaGVzLm9wZW4oXCJzdGF0aWMtanMtYXNzZXRzXCIpO1xuICAgICAgICAgICAgICAgICAgICBmb3IgKGxldCBbcywgcl0gb2YgZS5tYXRjaEFsbCgvPGxpbmsuKj9ocmVmPVsnXCJdKC4qPylbJ1wiXS4qPz4vZykpL3JlbD1bJ1wiXXN0eWxlc2hlZXRbJ1wiXS8udGVzdChzKSAmJiB0LnB1c2goYS5tYXRjaChyKS50aGVuKChlKT0+ZSA/IFByb21pc2UucmVzb2x2ZSgpIDogYS5hZGQocikpKTtcbiAgICAgICAgICAgICAgICAgICAgZm9yIChsZXQgWywgYV0gb2YgZS5tYXRjaEFsbCgvPHNjcmlwdC4qP3NyYz1bJ1wiXSguKj8pWydcIl0uKj8+L2cpKXtcbiAgICAgICAgICAgICAgICAgICAgICAgIGxldCBlID0gL1xcL19uZXh0XFwvc3RhdGljLitcXC5qcyQvaS50ZXN0KGEpID8gciA6IGM7XG4gICAgICAgICAgICAgICAgICAgICAgICB0LnB1c2goZS5tYXRjaChhKS50aGVuKCh0KT0+dCA/IFByb21pc2UucmVzb2x2ZSgpIDogZS5hZGQoYSkpKTtcbiAgICAgICAgICAgICAgICAgICAgfVxuICAgICAgICAgICAgICAgICAgICByZXR1cm4gYXdhaXQgUHJvbWlzZS5hbGwodCk7XG4gICAgICAgICAgICAgICAgfSBjYXRjaCAge31cbiAgICAgICAgICAgICAgICByZXR1cm4gUHJvbWlzZS5yZXNvbHZlKCk7XG4gICAgICAgICAgICB9XG4gICAgICAgIGRlZmF1bHQ6XG4gICAgICAgICAgICByZXR1cm4gUHJvbWlzZS5yZXNvbHZlKCk7XG4gICAgfVxufTsiXSwibmFtZXMiOltdLCJpZ25vcmVMaXN0IjpbMF0sInNvdXJjZVJvb3QiOiIifQ==\n//# sourceURL=webpack-internal:///../../node_modules/.pnpm/@ducanh2912+next-pwa@10.2.9_1d3731f6205d164bcee9533e20d53672/node_modules/@ducanh2912/next-pwa/dist/sw-entry-worker.js\n"));
/***/ })
/******/ });
/************************************************************************/
/******/ // The require scope
/******/ var __webpack_require__ = {};
/******/
/************************************************************************/
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = (exports) => {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/trusted types policy */
/******/ (() => {
/******/ var policy;
/******/ __webpack_require__.tt = () => {
/******/ // Create Trusted Type policy if Trusted Types are available and the policy doesn't exist yet.
/******/ if (policy === undefined) {
/******/ policy = {
/******/ createScript: (script) => (script)
/******/ };
/******/ if (typeof trustedTypes !== "undefined" && trustedTypes.createPolicy) {
/******/ policy = trustedTypes.createPolicy("nextjs#bundler", policy);
/******/ }
/******/ }
/******/ return policy;
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/trusted types script */
/******/ (() => {
/******/ __webpack_require__.ts = (script) => (__webpack_require__.tt().createScript(script));
/******/ })();
/******/
/******/ /* webpack/runtime/react refresh */
/******/ (() => {
/******/ if (__webpack_require__.i) {
/******/ __webpack_require__.i.push((options) => {
/******/ const originalFactory = options.factory;
/******/ options.factory = (moduleObject, moduleExports, webpackRequire) => {
/******/ const hasRefresh = typeof self !== "undefined" && !!self.$RefreshInterceptModuleExecution$;
/******/ const cleanup = hasRefresh ? self.$RefreshInterceptModuleExecution$(moduleObject.id) : () => {};
/******/ try {
/******/ originalFactory.call(this, moduleObject, moduleExports, webpackRequire);
/******/ } finally {
/******/ cleanup();
/******/ }
/******/ }
/******/ })
/******/ }
/******/ })();
/******/
/******/ /* webpack/runtime/compat */
/******/
/******/
/******/ // noop fns to prevent runtime errors during initialization
/******/ if (typeof self !== "undefined") {
/******/ self.$RefreshReg$ = function () {};
/******/ self.$RefreshSig$ = function () {
/******/ return function (type) {
/******/ return type;
/******/ };
/******/ };
/******/ }
/******/
/************************************************************************/
/******/
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module can't be inlined because the eval-source-map devtool is used.
/******/ var __webpack_exports__ = {};
/******/ __webpack_modules__["../../node_modules/.pnpm/@ducanh2912+next-pwa@10.2.9_1d3731f6205d164bcee9533e20d53672/node_modules/@ducanh2912/next-pwa/dist/sw-entry-worker.js"](0, __webpack_exports__, __webpack_require__);
/******/
/******/ })()
;
File diff suppressed because it is too large. Load diff
@@ -1 +0,0 @@
define(["exports"],function(t){"use strict";try{self["workbox:core:7.0.0"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:7.0.0"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class r{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class i extends r{constructor(t,e,s){super(({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)},e,s)}}class a{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)})}addCacheListener(){self.addEventListener("message",t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map(e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})}));t.waitUntil(s),t.ports&&t.ports[0]&&s.then(()=>t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:i}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let a=i&&i.handler;const o=t.method;if(!a&&this.i.has(o)&&(a=this.i.get(o)),!a)return;let c;try{c=a.handle({url:s,request:t,event:e,params:r})}catch(t){c=Promise.reject(t)}const h=i&&i.catchHandler;return c instanceof Promise&&(this.o||h)&&(c=c.catch(async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:r})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n})),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const r=this.t.get(s.method)||[];for(const i of r){let r;const a=i.match({url:t,sameOrigin:e,request:s,event:n});if(a)return r=a,(Array.isArray(r)&&0===r.length||a.constructor===Object&&0===Object.keys(a).length||"boolean"==typeof a)&&(r=void 0),{route:i,params:r}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let o;const c=()=>(o||(o=new a,o.addFetchListener(),o.addCacheListener()),o);function h(t,e,n){let a;if("string"==typeof t){const s=new URL(t,location.href);a=new r(({url:t})=>t.href===s.href,e,n)}else if(t instanceof RegExp)a=new i(t,e,n);else if("function"==typeof t)a=new r(t,e,n);else{if(!(t instanceof r))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});a=t}return c().registerRoute(a),a}try{self["workbox:strategies:7.0.0"]&&_()}catch(t){}const u={cacheWillUpdate:async({response:t})=>200===t.status||0===t.status?t:null},l={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},f=t=>[l.prefix,t,l.suffix].filter(t=>t&&t.length>0).join("-"),w=t=>t||f(l.precache),d=t=>t||f(l.runtime);function p(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class y{constructor(){this.promise=new Promise((t,e)=>{this.resolve=t,this.reject=e})}}const g=new Set;function m(t){return"string"==typeof t?new Request(t):t}class v{constructor(t,e){this.h={},Object.assign(this,e),this.event=e.event,this.u=t,this.l=new y,this.p=[],this.m=[...t.plugins],this.v=new Map;for(const t of this.m)this.v.set(t,{});this.event.waitUntil(this.l.promise)}async fetch(t){const{event:e}=this;let n=m(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const r=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const i=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.u.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:i,response:t});return t}catch(t){throw r&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:r.clone(),request:i.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=m(t);let s;const{cacheName:n,matchOptions:r}=this.u,i=await this.getCacheKey(e,"read"),a=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(i,a);for(const t of this.iterateCLine truncated
+28 -20
View File
@@ -1,17 +1,19 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
const PORT = 3000; const OPERATOR_URL = 'http://localhost:3000';
const BASE_URL = `http://localhost:${PORT}`; const ADMIN_URL = 'http://localhost:3001';
export const ADMIN_BASE = ADMIN_URL;
export default defineConfig({ export default defineConfig({
testDir: './tests', testDir: './tests',
fullyParallel: true, fullyParallel: false, // sequential — tests share state (DB, MinIO)
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, workers: 1,
reporter: [['list'], ['html', { open: 'never' }]], reporter: [['list'], ['html', { open: 'never' }]],
use: { use: {
baseURL: BASE_URL, baseURL: OPERATOR_URL,
trace: 'retain-on-failure', trace: 'retain-on-failure',
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
}, },
@@ -21,20 +23,26 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] }, use: { ...devices['Desktop Chrome'] },
}, },
], ],
webServer: { webServer: [
// Run from the repo root so workspace resolution works. {
command: 'pnpm --filter @repo/operator-pwa dev', command: 'pnpm --filter @repo/operator-pwa dev',
cwd: '..', cwd: '..',
url: BASE_URL, url: OPERATOR_URL,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000, timeout: 120_000,
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
// Force the dev autologin on for E2E regardless of the developer's local env: { AUTH_DEV_AUTOLOGIN: 'true' },
// .env. This env applies only to the child dev server, not the test
// process itself.
env: {
AUTH_DEV_AUTOLOGIN: 'true',
}, },
}, {
command: 'pnpm --filter @repo/admin-web dev',
cwd: '..',
url: ADMIN_URL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
env: { AUTH_DEV_AUTOLOGIN: 'true' },
},
],
}); });
+74
View File
@@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test';
import { ADMIN_BASE } from '../playwright.config';
/**
* Happy-path E2E for MAI CALL v0.1:
* operator creates request → admin claims → admin resolves
*
* Preconditions (met by `pnpm db:seed` + running docker compose):
* - Demo Factory tenant with workstations CTR04, QVN_RTL_2, MTG_01
* - admin@demo.local with role ADMIN
* - AUTH_DEV_AUTOLOGIN=true on both servers (set in playwright.config.ts)
*/
test('MAI CALL happy path: create → claim → resolve', async ({ page, context }) => {
// Use a unique description so the test can find its own card in the queue.
const desc = `E2E ${Date.now()} — ruído anormal no posto`;
// ── 1. Operator creates a request ────────────────────────────────────────
await page.goto('/maintenance/new');
// Wait for workstation options to load (select is disabled while loading)
await expect(page.locator('#workstation')).toBeEnabled({ timeout: 15_000 });
// Select the first real workstation
const firstOpt = page.locator('#workstation option:not([value=""])').first();
const wsValue = await firstOpt.getAttribute('value');
await page.selectOption('#workstation', wsValue!);
await page.fill('#description', desc);
await page.click('button[type=submit]');
// ── 2. Wait for the sync to complete ─────────────────────────────────────
// The form queues to IndexedDB; the SyncProvider immediately syncs when
// online. The sent page changes from "Em fila" → "Pedido enviado".
await page.waitForURL('**/maintenance/sent**');
await expect(page.locator('h1')).toHaveText('Pedido enviado', { timeout: 30_000 });
// ── 3. Admin queue shows the request ─────────────────────────────────────
const adminPage = await context.newPage();
await adminPage.goto(`${ADMIN_BASE}/maintenance`);
// Find the card that contains our description (admin polls every 5s)
const card = adminPage
.locator('[data-testid="request-card"]')
.filter({ hasText: desc.slice(0, 40) });
await expect(card).toBeVisible({ timeout: 15_000 });
// Card starts as OPEN
await expect(card.locator('span', { hasText: 'Aberto' })).toBeVisible();
// ── 4. Admin claims the request ───────────────────────────────────────────
await card.locator('button', { hasText: 'Aceitar' }).click();
// Card changes to CLAIMED
await expect(card.locator('span', { hasText: 'Em curso' })).toBeVisible({ timeout: 10_000 });
await expect(card.locator('button', { hasText: 'Aceitar' })).not.toBeVisible();
// ── 5. Enable RESOLVED filter so the card stays visible after resolve ─────
await adminPage.locator('label', { hasText: 'Resolvido' }).click();
// ── 6. Admin resolves the request ────────────────────────────────────────
await card.locator('button', { hasText: 'Marcar resolvido' }).click();
const dialog = adminPage.locator('h2', { hasText: 'Marcar como resolvido' });
await expect(dialog).toBeVisible({ timeout: 5_000 });
await adminPage.locator('button', { hasText: 'Confirmar' }).click();
// Card changes to RESOLVED
await expect(card.locator('span', { hasText: 'Resolvido' })).toBeVisible({ timeout: 10_000 });
// No action buttons remain on RESOLVED card
await expect(card.locator('button', { hasText: 'Aceitar' })).not.toBeVisible();
await expect(card.locator('button', { hasText: 'Marcar resolvido' })).not.toBeVisible();
});
-18
View File
@@ -1,18 +0,0 @@
import { test, expect } from '@playwright/test';
test.describe('ping smoke', () => {
test('home page shows the seeded Demo Factory tenant', async ({ page }) => {
await page.goto('/');
// RSC card with the server-side ping result.
const success = page.getByTestId('ping-success');
await expect(success).toBeVisible({ timeout: 15_000 });
const tenant = page.getByTestId('tenant-name');
await expect(tenant).toHaveText('Demo Factory');
// Client-side hook hitting the same procedure via /api/trpc.
const clientTenant = page.getByTestId('ping-client-tenant');
await expect(clientTenant).toContainText('Demo Factory');
});
});