Skip to content

Commit 2ea3046

Browse files
authored
member flow hardening (#60)
* Harden member flows * Clarify local e2e env setup * Clarify Playwright env docs * Address review feedback * Harden inline mutation guard * slim chat payloads and type yak theme bridge * fix review follow-ups and chat view migration * fix latest copilot follow-ups
1 parent 48255ec commit 2ea3046

36 files changed

Lines changed: 1999 additions & 849 deletions

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,39 @@ Peels keeps testing intentionally small:
262262

263263
The smoke suite covers seeded sign-in, public listing, profile, and chat flows against local Supabase data. It is designed to stay small and high-signal rather than grow into a broad frontend testing matrix.
264264

265+
Before `npm run test:e2e` or `npm run test:e2e:prod`, make sure you are using the local seeded app stack, not hosted Supabase:
266+
267+
- Run `npm run supabase:start`
268+
- Run `npm run supabase:reset`
269+
- Run `npm run seed:local-media`
270+
- Run `npm run supabase:env`
271+
- Make sure `.env.local` uses `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331`
272+
- Copy the local `ANON_KEY` into `NEXT_PUBLIC_SUPABASE_ANON_KEY`
273+
274+
If your `.env.local` was previously set up for the hosted Peels project, update it before running Playwright. A working local test setup looks like:
275+
276+
```bash
277+
NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3000
278+
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331
279+
NEXT_PUBLIC_SUPABASE_ANON_KEY=<paste the ANON_KEY from npm run supabase:env>
280+
```
281+
282+
Then run either:
283+
284+
```bash
285+
npm run test:e2e
286+
```
287+
288+
or:
289+
290+
```bash
291+
npm run test:e2e:prod
292+
```
293+
294+
Use the local anon key that matches the running local Supabase stack. Do not mix the hosted project URL with the local anon key, or the local URL with a hosted anon key.
295+
296+
The production-like Playwright config starts its own `next start` server so it does not accidentally reuse a stray `next dev` process on port `3000`.
297+
265298
In CI, pull requests run `npm run check` and `npm run build`. Pushes to `main` also run the production-like Playwright smoke suite.
266299

267300
For the first local Playwright run, install the browser once with:

docs/supabase-local-first.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,29 @@ Finally:
9898
npm run dev
9999
```
100100

101+
### Local `.env.local` for Playwright
102+
103+
Both Playwright suites expect the local seeded Supabase stack, not the hosted Peels project.
104+
105+
Use this shape in `.env.local` before `npm run test:e2e:prod`:
106+
107+
```bash
108+
NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3000
109+
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331
110+
NEXT_PUBLIC_SUPABASE_ANON_KEY=<paste the ANON_KEY value from npm run supabase:env>
111+
```
112+
113+
Recommended sequence:
114+
115+
1. Run `npm run supabase:start`
116+
2. Run `npm run supabase:reset`
117+
3. Run `npm run seed:local-media`
118+
4. Run `npm run supabase:env`
119+
5. Paste the printed `ANON_KEY` into `.env.local`
120+
6. Run `npm run test:e2e` or `npm run test:e2e:prod`
121+
122+
If `.env.local` still points at `https://mfnaqdyunuafbwukbbyr.supabase.co`, the smoke suite will not see the seeded local listings, demo accounts, or demo chat thread.
123+
101124
### Local URLs
102125

103126
- App: `http://127.0.0.1:3000`

e2e/chat.spec.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { expect, test } from "@playwright/test";
2-
import { DONOR_EMAIL, SEEDED_THREAD_ID, signIn } from "./helpers";
2+
import {
3+
DONOR_EMAIL,
4+
SEEDED_THREAD_ID,
5+
delayChatSendRequests,
6+
failChatSendRequests,
7+
signIn,
8+
} from "./helpers";
39

410
test("chat loads the seeded thread and composer for a signed-in donor", async ({
511
page,
@@ -21,6 +27,51 @@ test("chat loads the seeded thread and composer for a signed-in donor", async ({
2127
await expect(page.getByTestId("chat-composer-input")).toBeVisible();
2228
});
2329

30+
test("chat send disables the composer while pending and appends the new message", async ({
31+
page,
32+
}) => {
33+
await signIn(page, {
34+
email: DONOR_EMAIL,
35+
redirectTo: `/chats/${SEEDED_THREAD_ID}`,
36+
});
37+
await delayChatSendRequests(page);
38+
39+
const composerInput = page.getByTestId("chat-composer-input");
40+
const sendButton = page.getByTestId("chat-composer-send");
41+
const message = `Playwright chat message ${Date.now()}`;
42+
43+
await composerInput.fill(message);
44+
45+
const messageVisible = page
46+
.getByTestId("chat-message-list")
47+
.getByText(message);
48+
await sendButton.click();
49+
await expect(sendButton).toBeDisabled();
50+
await expect(sendButton).toHaveAttribute("aria-busy", "true");
51+
await expect(composerInput).toBeDisabled();
52+
await expect(messageVisible).toBeVisible();
53+
await expect(composerInput).toHaveValue("");
54+
});
55+
56+
test("chat send failures preserve the draft and show inline feedback", async ({
57+
page,
58+
}) => {
59+
await signIn(page, {
60+
email: DONOR_EMAIL,
61+
redirectTo: `/chats/${SEEDED_THREAD_ID}`,
62+
});
63+
await failChatSendRequests(page);
64+
65+
const composerInput = page.getByTestId("chat-composer-input");
66+
const failedMessage = `Chat failure draft ${Date.now()}`;
67+
68+
await composerInput.fill(failedMessage);
69+
await page.getByTestId("chat-composer-send").click();
70+
71+
await expect(composerInput).toHaveValue(failedMessage);
72+
await expect(page.getByText("Synthetic chat failure")).toBeVisible();
73+
});
74+
2475
test("invalid chat thread ids redirect back to the chat index", async ({
2576
page,
2677
}) => {

e2e/helpers.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,51 @@ export async function signIn(
3737
]);
3838
}
3939

40+
export async function delayServerActionRequests(page: Page, delayMs = 500) {
41+
await page.route("**/*", async (route) => {
42+
const request = route.request();
43+
const isServerActionRequest =
44+
request.method() === "POST" &&
45+
request.headers()["next-action"] !== undefined;
46+
47+
if (!isServerActionRequest) {
48+
await route.fallback();
49+
return;
50+
}
51+
52+
await page.waitForTimeout(delayMs);
53+
await route.continue();
54+
});
55+
}
56+
4057
export async function delayProfileActionRequests(page: Page, delayMs = 500) {
41-
await page.route(/\/profile(?:\/|\?|$)/, async (route) => {
58+
await delayServerActionRequests(page, delayMs);
59+
}
60+
61+
export async function delayChatSendRequests(page: Page, delayMs = 500) {
62+
await page.route(/\/rest\/v1\/chat_messages(?:\?|$)/, async (route) => {
4263
if (route.request().method() === "POST") {
4364
await page.waitForTimeout(delayMs);
4465
}
4566

4667
await route.continue();
4768
});
4869
}
70+
71+
export async function failChatSendRequests(
72+
page: Page,
73+
message = "Synthetic chat failure"
74+
) {
75+
await page.route(/\/rest\/v1\/chat_messages(?:\?|$)/, async (route) => {
76+
if (route.request().method() === "POST") {
77+
await route.fulfill({
78+
status: 500,
79+
contentType: "application/json",
80+
body: JSON.stringify({ message }),
81+
});
82+
return;
83+
}
84+
85+
await route.continue();
86+
});
87+
}

e2e/listings.spec.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { expect, test } from "@playwright/test";
2-
import { HOST_EMAIL, PROFILE_RENDER_TIMEOUT_MS, signIn } from "./helpers";
2+
import {
3+
HOST_EMAIL,
4+
PROFILE_RENDER_TIMEOUT_MS,
5+
delayServerActionRequests,
6+
signIn,
7+
} from "./helpers";
38

49
const BUSINESS_LISTING_EDIT_PATH = "/profile/listings/demo-inner-west-cafe";
510
const ALTERNATE_BUSINESS_DESCRIPTION =
@@ -83,11 +88,16 @@ test("listing edit saves and restores seeded business fields", async ({
8388

8489
await descriptionInput.fill(updatedDescription);
8590
await visibilityInput.selectOption(updatedVisibility);
91+
await delayServerActionRequests(page);
8692

87-
await Promise.all([
88-
page.waitForURL(/\/listings\/demo-inner-west-cafe\?status=updated$/),
89-
page.getByTestId("listing-write-submit").click(),
90-
]);
93+
const submitButton = page.getByTestId("listing-write-submit");
94+
const updateNavigation = page.waitForURL(
95+
/\/listings\/demo-inner-west-cafe\?status=updated$/
96+
);
97+
await submitButton.click();
98+
await expect(submitButton).toBeDisabled();
99+
await expect(submitButton).toHaveAttribute("aria-busy", "true");
100+
await updateNavigation;
91101

92102
await page.goto(BUSINESS_LISTING_EDIT_PATH);
93103
await expect(page.locator("#description")).toHaveValue(updatedDescription);

messages/de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"signInToContact": "Zum Kontaktieren anmelden",
3535
"signOut": "Abmelden",
3636
"signUp": "Registrieren",
37+
"tryAgain": "Erneut versuchen",
3738
"update": "Aktualisieren",
3839
"viewFullListing": "Vollständigen Eintrag ansehen",
3940
"viewListing": "Eintrag ansehen"

messages/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"signInToContact": "Sign in to contact",
3535
"signOut": "Sign out",
3636
"signUp": "Sign up",
37+
"tryAgain": "Try again",
3738
"update": "Update",
3839
"viewFullListing": "View full listing",
3940
"viewListing": "View listing"

messages/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"signInToContact": "Inicia sesión para contactar",
3535
"signOut": "Cerrar sesión",
3636
"signUp": "Registrarse",
37+
"tryAgain": "Intentarlo de nuevo",
3738
"update": "Actualizar",
3839
"viewFullListing": "Ver anuncio completo",
3940
"viewListing": "Ver anuncio"

messages/fr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"signInToContact": "Se connecter pour contacter",
3535
"signOut": "Se déconnecter",
3636
"signUp": "S’inscrire",
37+
"tryAgain": "Réessayer",
3738
"update": "Mettre à jour",
3839
"viewFullListing": "Voir l’annonce complète",
3940
"viewListing": "Voir l’annonce"

messages/pt-BR.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"signInToContact": "Entrar para falar",
3535
"signOut": "Sair",
3636
"signUp": "Criar conta",
37+
"tryAgain": "Tentar novamente",
3738
"update": "Atualizar",
3839
"viewFullListing": "Ver anúncio completo",
3940
"viewListing": "Ver anúncio"

0 commit comments

Comments
 (0)