diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index abe10efb..158a5195 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -6,7 +6,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **OPS67** — o cliente `scripts/lib/forgejo-api.mjs` ganhou **retry com backoff** nos fetchs: um `fetch failed` transitório (rede/DNS/reset — o sintoma que matava o run do safety net `agent-pr-ready-automerge.yml` no meio da espera de checks) agora retenta em **qualquer método**, e a leitura de body interrompida no meio (mesma classe de falha de rede) retenta **em GET**; 5xx do Cloudflare (502/503/504) retenta **só em GET** (protege o poll de 45 min do `waitForChecks` sem arriscar duplicar efeitos colaterais de escrita — body do 5xx cancelado antes do retry para liberar o socket); 4xx e body-drop em escrita continuam falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, `console.warn` pt-BR em cada retry (`tentativa N/4`), validação `retries >= 0`/`backoffMs >= 0`. Opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 12 specs novas em `forgejoApi.unit.spec.ts` (rede GET/POST → sucesso, body-drop GET retenta + POST fail-closed, exaustão de rede/5xx, `retries: 0`, backoff exponencial [100, 200, 400], jitter ±20% com `Math.random` pinado, 4xx sem retry, 5xx-GET retry/exaustão, 5xx-POST sem retry) + pin no spec 403 existente (1 fetch por invocação). Verificação: unit 2403 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: `docs/plans/ops67-forgejo-api-retry-backoff-impl.md` (aprovado no gate humano; refinamentos do /simplify absorvidos na execução). -**OPS67** — o cliente `scripts/lib/forgejo-api.mjs` ganhou **retry com backoff** nos fetchs: um `fetch failed` transitório (rede/DNS/reset — o sintoma que matava o run do safety net `agent-pr-ready-automerge.yml` no meio da espera de checks) agora retenta em **qualquer método**; 5xx do Cloudflare (502/503/504) retenta **só em GET** (protege o poll de 45 min do `waitForChecks` sem arriscar duplicar efeitos colaterais de escrita); 4xx continua falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, `console.warn` pt-BR em cada retry (`tentativa N/4`). Opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 6 specs novas em `forgejoApi.unit.spec.ts` (rede transitória→sucesso, rede persistente→throw após N, 4xx sem retry, 5xx-GET retry, 5xx-POST sem retry, backoff exponencial [100, 200, 400] + pin do 403 existente com calls=1). Verificação: unit 2397 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: `docs/plans/ops67-forgejo-api-retry-backoff-impl.md` (aprovado no gate humano). +**S4** — botão discreto de compartilhar no WhatsApp por card da seção "Acompanhe de perto" da home (jorgesolla1313.com.br): cada card (Artigo/YouTube/Instagram, o board S1+S2+S3) ganha um botão redondo no canto superior direito (visual 28px sobre área de toque 44px, `aria-label` "Compartilhar", sem competir com o CTA do card) que abre um popover de 2 ações: **"Compartilhar no WhatsApp"** — `wa.me` sem destinatário (WhatsApp do próprio remetente; o `buildWhatsAppTextShareUrl` do S6 foi movido para `src/lib/phone.ts` — dono do wa.me, re-exportado no `homeSearchShare.ts` — e o `contentShare.ts` reusa, sem twin) com mensagem pré-configurada **por fonte** (decisão B do gate: "Olha isso do Solla: {título} — {link}" / "Olha esse vídeo…" / "Olha esse post…"; link = página canônica do artigo resolvida contra `window.location.origin` no cliente, ou URL da plataforma para YT/IG; nova aba `noopener noreferrer") — e **"Copiar link"** (clipboard com "Link copiado" + `aria-live`para leitores de tela, fallback honesto em falha). Estrutura: o card inteiro é um anchor — o botão vive como **irmão** absoluto num wrapper`relative`(botão dentro do anchor seria HTML inválido); o conteúdo do popover usa`ui/Popover`(Radix, portal — escapa o`overflow-x-auto`do carrossel mobile que cliparia um menu absoluto), sem roles`menu`/`menuitem`(a semântica honesta de popover com 2 ações — Radix já emite`aria-expanded`). Zero migration/schema/Consent/escrita no servidor — montagem de URL 100% cliente, nada coletado (anti-goal da intenção). Lib pura `src/lib/contentShare.ts`(prefixos por fonte, resolução de link fail-soft contra permalink malformado, URL wa.me) + 11 units; e2e novo no describe serial da seção (cadeia 19/19 verde local): botão por card do bento, mensagem por fonte com URL absoluta,`target/rel`, copiar→clipboard real (`expect.poll`), Escape/clique-fora, mobile ≥44px + 28px visual + portal dentro do viewport, card ativo pinado por identidade. Verificação: unit 2402 + int 791 verdes, tsc/lint/format/cycles/build verdes; knip = limitação P3 pré-existente (não carrega `payload.config.ts`); CI valida. /simplify: 3 revisores paralelos absorvidos (twin do wa.me resolvido pelo owner move, máquina de feedback de copiar simplificada para effect único, foco/roles honestos, min-h-11 nos itens, `aria-live`, origin lazy sem guard morto, malformed-href fail-soft, e2e endurecido: pin de identidade do card ativo + unmount do popover antes do próximo trigger + helper de decode `+`); 1 débito deferido (dedupe do blob de 15 campos do `updateSocialFeedSettings` nos 5 testes do describe — quando os testes S2/S3 forem tocados de novo). **S6-FOLLOWUP** — CLI Playwright 1.58.2 e filtros e2e locais: `pnpm test:e2e:affected` rodava a suíte completa silenciosamente em runs selecionados. Causa raiz (medida com o binário do repo, config real): o commander parseia posicionais e `-g`/`--list` funcionam — quem arrastava a suíte era o closure de dependências dos projects no dev mode (`setup ← campaign ← frontend ← admin` em `playwright.config.ts`): `collectProjectsAndTestFiles` filtra os arquivos por project, mas `buildProjectsClosure` repõe todos os arquivos de cada project-dependência — filtrar um spec do `frontend` puxava `campaign` (160) + `setup` (2), 177 no total com o próprio arquivo (15); `admin` puxava a suíte inteira. Em prod mode (`CI=1`) os projects não têm deps e o filtro sempre funcionou (por isso o CI selecionado estava correto). Fix: `run-e2e-affected.mjs` agora gera `test:e2e --no-deps -- ` via helpers puros novos em `scripts/lib/playwright-e2e-args.mjs` (unit-testados, `tests/unit/playwrightE2eArgs.unit.spec.ts`) — `--no-deps` desliga o closure em runs filtrados (suíte completa mantém a cadeia OPS34 de prewarm); de quebra, o passthrough do script foi corrigido (`pnpm test:e2e:affected -- tests/x.spec.ts` descartava o path — pnpm consome o `--` e o argv nunca o continha; agora `parsePassthroughArgs` só remove um `--` líder e repassa o resto verbatim, preservando `-g grade`). Hardening: `scripts/run-e2e-affected.mjs` entrou em `HIGH_RISK_EXACT` (era o espelho local do job e2e sem gate — um PR só de runner não rodaria e2e). Docs: receitas de filtro no `playwright.config.ts` (posicional exige `--no-deps`; `--project= --no-deps`; `-g`/`--list` funcionam), header do script e nota no AGENTS.md. Sem mudança de specs, CI (`--shard`) ou `gate:ci`. Verificação: unit 2343+ verdes; contagem por `--list` do binário com a forma final (18); CI do PR roda o e2e completo (o diff toca `playwright.config.ts` e `run-e2e-affected.mjs`, high-risk). Débito absorvido: hang do `pnpm test:e2e` via pnpm neste worktree (pré-existente, ambiente — binário direto funciona; não escopo). Impl plan: `docs/plans/s6-followup-playwright-filtros-cli-ignorados-impl.md`. diff --git a/docs/changelog/2026-08-19-s4.md b/docs/changelog/2026-08-19-s4.md new file mode 100644 index 00000000..8191c7b5 --- /dev/null +++ b/docs/changelog/2026-08-19-s4.md @@ -0,0 +1 @@ +**S4** — botão discreto de compartilhar no WhatsApp por card da seção "Acompanhe de perto" da home (jorgesolla1313.com.br): cada card (Artigo/YouTube/Instagram, o board S1+S2+S3) ganha um botão redondo no canto superior direito (visual 28px sobre área de toque 44px, `aria-label` "Compartilhar", sem competir com o CTA do card) que abre um popover de 2 ações: **"Compartilhar no WhatsApp"** — `wa.me` sem destinatário (WhatsApp do próprio remetente; o `buildWhatsAppTextShareUrl` do S6 foi movido para `src/lib/phone.ts` — dono do wa.me, re-exportado no `homeSearchShare.ts` — e o `contentShare.ts` reusa, sem twin) com mensagem pré-configurada **por fonte** (decisão B do gate: "Olha isso do Solla: {título} — {link}" / "Olha esse vídeo…" / "Olha esse post…"; link = página canônica do artigo resolvida contra `window.location.origin` no cliente, ou URL da plataforma para YT/IG; nova aba `noopener noreferrer") — e **"Copiar link"** (clipboard com "Link copiado" + `aria-live`para leitores de tela, fallback honesto em falha). Estrutura: o card inteiro é um anchor — o botão vive como **irmão** absoluto num wrapper`relative`(botão dentro do anchor seria HTML inválido); o conteúdo do popover usa`ui/Popover`(Radix, portal — escapa o`overflow-x-auto`do carrossel mobile que cliparia um menu absoluto), sem roles`menu`/`menuitem`(a semântica honesta de popover com 2 ações — Radix já emite`aria-expanded`). Zero migration/schema/Consent/escrita no servidor — montagem de URL 100% cliente, nada coletado (anti-goal da intenção). Lib pura `src/lib/contentShare.ts`(prefixos por fonte, resolução de link fail-soft contra permalink malformado, URL wa.me) + 11 units; e2e novo no describe serial da seção (cadeia 19/19 verde local): botão por card do bento, mensagem por fonte com URL absoluta,`target/rel`, copiar→clipboard real (`expect.poll`), Escape/clique-fora, mobile ≥44px + 28px visual + portal dentro do viewport, card ativo pinado por identidade. Verificação: unit 2402 + int 791 verdes, tsc/lint/format/cycles/build verdes; knip = limitação P3 pré-existente (não carrega `payload.config.ts`); CI valida. /simplify: 3 revisores paralelos absorvidos (twin do wa.me resolvido pelo owner move, máquina de feedback de copiar simplificada para effect único, foco/roles honestos, min-h-11 nos itens, `aria-live`, origin lazy sem guard morto, malformed-href fail-soft, e2e endurecido: pin de identidade do card ativo + unmount do popover antes do próximo trigger + helper de decode `+`); 1 débito deferido (dedupe do blob de 15 campos do `updateSocialFeedSettings` nos 5 testes do describe — quando os testes S2/S3 forem tocados de novo). diff --git a/docs/plans/compartilhar-conteudos-home-whatsapp-impl.md b/docs/plans/compartilhar-conteudos-home-whatsapp-impl.md new file mode 100644 index 00000000..339a1f9d --- /dev/null +++ b/docs/plans/compartilhar-conteudos-home-whatsapp-impl.md @@ -0,0 +1,107 @@ +# Impl: Compartilhar conteúdos da home no WhatsApp (S4) + +Status: aprovado +Atualizado em: 2026-08-19 +Issue: #19 +Intenção: docs/plans/compartilhar-conteudos-home-whatsapp.md +Appetite restante: herdado (~0,5–1 dia eng) + +## Pós-entrega (2026-08-19) + +Entregue e mergeado em `main`. Desvios de execução em relação a este plano: + +- **`buildWhatsAppTextShareUrl` já existia** em `src/lib/homeSearchShare.ts` (S6) — em vez do builder próprio do rascunho, o `contentShare.ts` reusa o canônico, e o dono do builder foi movido para `src/lib/phone.ts` (o módulo wa.me) com re-export no `homeSearchShare.ts` (sem twin). +- **Roles `menu`/`menuitem` removidos do popover** (achado P1 do critique): Radix Popover não implementa o contrato de widget de menu (setas/roving focus); semântica honesta é popover de 2 ações com Tab — Radix emite `aria-expanded` no trigger. +- **Encoding do `text` é `+` para espaço** (URLSearchParams do builder canônico) — válido para o WhatsApp; os decodes de teste fazem `replace(/\+/g, ' ')`. +- **Débito deferido:** dedupe do blob de 15 campos do `updateSocialFeedSettings` (5 cópias no describe do e2e) — quando os testes S2/S3 forem tocados de novo. + +## Leitura da intenção + +- **Outcome:** todo card de conteúdo da home (Artigo/YouTube/Instagram — seção S1+S2+S3 entregue) ganha um botão discreto de compartilhar que abre `wa.me` com mensagem pronta por fonte + "Copiar link", sem competir com o CTA do card, sem coletar nada. +- **O que NÃO negociar:** mensagem por fonte (artigo/vídeo/post, decisão B travada); link = artigo → página canônica, YT/IG → URL da plataforma; nova aba com `noopener`; botão acessível (aria-label, alvo ≥44px no mobile) e sem mudar o layout do card; sem migration/schema/Consent — montagem de URL no cliente, sem escrita no servidor; mensagem honesta. +- **O que reavaliar:** a hipótese "reusar `buildWhatsAppUrl` (`src/utilities/phone.ts`)" está **errada** — (a) o módulo é `src/lib/phone.ts`, e (b) `buildWhatsAppUrl` **exige telefone brasileiro válido e lança erro sem ele**; S4 não tem destinatário — é o `wa.me/?text=` do próprio remetente (precedente exato: `PetitionSuccessDialog.tsx:39`). Também reavaliar: o card inteiro é um ``/`` — um ` + + + setOpen(false)} + className="flex min-h-11 items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-(--campaign-ink) hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none" + > + + Compartilhar no WhatsApp + + + + {COPY_LIVE_MESSAGES[copyFeedback]} + + + + ) +} diff --git a/src/lib/contentShare.ts b/src/lib/contentShare.ts new file mode 100644 index 00000000..0f58c1ee --- /dev/null +++ b/src/lib/contentShare.ts @@ -0,0 +1,48 @@ +import { buildWhatsAppTextShareUrl } from '@/lib/phone' + +/** Content source of a home content card — drives the share message template. */ +export type ContentShareKind = 'article' | 'video' | 'instagram' + +/** + * Message opener per source (S4 acceptance, decision B): article shares "isso", + * YouTube "esse vídeo", Instagram "esse post". The rest of the message is + * always "{title} — {link}". + */ +export const CONTENT_SHARE_PREFIXES: Record = { + article: 'Olha isso do Solla: ', + video: 'Olha esse vídeo do Solla: ', + instagram: 'Olha esse post do Solla: ', +} + +/** Pre-configured share message: opener + title + absolute link. */ +export const buildContentShareMessage = ( + kind: ContentShareKind, + title: string, + link: string, +): string => `${CONTENT_SHARE_PREFIXES[kind]}${title} — ${link}` + +/** + * Resolve the shared link to an absolute URL against the visitor's origin. + * Article paths are relative (`/noticia/saude/`); YouTube/Instagram + * hrefs are already absolute and pass through unchanged. Fail-soft on a + * malformed href (a bad feed permalink must degrade the share link, never + * crash the whole home section). + */ +export const buildContentShareLink = (href: string, origin: string): string => { + try { + return new URL(href, origin).toString() + } catch { + return href + } +} + +/** + * `wa.me` URL with the pre-filled message — the sender's own WhatsApp, no + * recipient phone (reuses the `phone.ts` text-share builder). Callers must + * open it in a new tab with `noopener noreferrer`. + */ +export const buildContentShareWhatsAppUrl = ( + kind: ContentShareKind, + title: string, + link: string, +): string => buildWhatsAppTextShareUrl(buildContentShareMessage(kind, title, link)) diff --git a/src/lib/homeSearchShare.ts b/src/lib/homeSearchShare.ts index 5c895717..4abeca14 100644 --- a/src/lib/homeSearchShare.ts +++ b/src/lib/homeSearchShare.ts @@ -1,13 +1,10 @@ +import { buildWhatsAppTextShareUrl } from '@/lib/phone' + /** Share payload for a home-search hit — title plus canonical detail URL. */ export const buildHomeSearchShareText = (title: string, absoluteUrl: string): string => `${title}\n${absoluteUrl}` -/** `wa.me` share link with pre-filled text (no recipient phone). */ -export const buildWhatsAppTextShareUrl = (message: string): string => { - const url = new URL('https://wa.me/') - url.searchParams.set('text', message) - return url.toString() -} +export { buildWhatsAppTextShareUrl } const canUseNativeWebShare = (): boolean => typeof window !== 'undefined' && diff --git a/src/lib/phone.ts b/src/lib/phone.ts index 68a129b5..6d3e3c53 100644 --- a/src/lib/phone.ts +++ b/src/lib/phone.ts @@ -59,6 +59,13 @@ export const buildWhatsAppUrl = (phone: string, message?: string): string => { return url.toString() } +/** `wa.me` share link with pre-filled text but no recipient — the sender's own WhatsApp picks the chat (PetitionSuccessDialog precedent). */ +export const buildWhatsAppTextShareUrl = (message: string): string => { + const url = new URL('https://wa.me/') + url.searchParams.set('text', message) + return url.toString() +} + /** `wa.me` href for a row's phone, or `null` when it doesn't normalize — the shared guard behind every per-row WhatsApp button in the campaign lists. */ export const whatsAppHrefForPhone = (phone: string | null): string | null => { if (!phone || !normalizeBrazilianPhone(phone)) return null diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 4ac2e3a4..342441ef 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -1,4 +1,4 @@ -import type { APIRequestContext, Page } from '@playwright/test' +import type { APIRequestContext, Locator, Page } from '@playwright/test' import { seedTestUser, testUser } from '../helpers/seedUser' import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub' @@ -1376,4 +1376,156 @@ test.describe('Campaign home content section', () => { await cleanupS2Fixtures(request, headers) } }) + + test('shares every content card on WhatsApp and copies the link (S4)', async ({ + page, + request, + context, + }) => { + await seedTestUser() + const headers = await adminHeaders(request) + await setYouTubeStubState(request, 'ok') + await setInstagramStubState(request, 'ok') + + const shareTagSlug = `e2e-share-${runSuffix}` + const shareTag = await createTag(request, headers, `E2e Share ${runSuffix}`, shareTagSlug) + const articleTitle = 'E2e Artigo do compartilhar' + await createPost(request, headers, { + title: articleTitle, + slug: `e2e-artigo-compartilhar-${runSuffix}`, + type: 'noticia', + category: shareTag.id, + publishedDate: new Date(Date.now() - 5 * 60_000).toISOString(), + }) + // Both feeds on, no exclusions: the board holds one card of each source + // (newest article featured + Instagram/YouTube from the stubs). + await updateSocialFeedSettings(request, headers, { + enabled: true, + youtubeEnabled: true, + youtubeApiKey: 'e2e-youtube-api-key', + youtubeChannelId: 'UCe2eTestChannel', + youtubeMaxItems: 3, + instagramEnabled: true, + instagramAccessToken: 'e2e-ig-token', + instagramUserId: '17841400000000000', + instagramMaxItems: 3, + excludedItems: [], + youtubeFeedSnapshot: null, + instagramFeedSnapshot: null, + }) + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + + try { + await waitForHomeHTML(request, [articleTitle, 'E2e Vídeo em destaque', 'E2e Post do muro']) + await page.setViewportSize({ width: 1280, height: 900 }) + await gotoHomeFresh(page) + const section = page.locator('[data-home-section="contents"]') + await expect(section).toBeVisible() + + // Every card of the 1+4 bento (CONTENT_SECTION_LIMIT = 5) carries + // exactly one discreet share button. + await expect(section.getByRole('button', { name: 'Compartilhar' })).toHaveCount(5) + const urlBeforeShare = page.url() + const waMessage = async (item: Locator) => { + const href = await item.getAttribute('href') + expect(href).toBeTruthy() + return decodeURIComponent(href!.slice('https://wa.me/?text='.length)).replace(/\+/g, ' ') + } + + // Article card (featured): the popover builds the per-source message with + // the canonical absolute article link, opened in a new tab with noopener. + const articleCard = section.getByRole('link', { name: /E2e Artigo do compartilhar/ }) + const articleShare = articleCard + .locator('xpath=..') + .getByRole('button', { name: 'Compartilhar' }) + await articleShare.click() + await expect(page).toHaveURL(urlBeforeShare) + const waItem = page.getByRole('link', { name: 'Compartilhar no WhatsApp' }) + await expect(waItem).toBeVisible() + await expect(waItem).toHaveAttribute('target', '_blank') + await expect(waItem).toHaveAttribute('rel', 'noopener noreferrer') + expect(await waMessage(waItem)).toBe( + `Olha isso do Solla: ${articleTitle} — ${baseURL}/noticia/${shareTagSlug}/e2e-artigo-compartilhar-${runSuffix}`, + ) + await page.keyboard.press('Escape') + await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) + + // Copy link puts the absolute URL in the clipboard and swaps the label. + await articleShare.click() + await page.getByRole('button', { name: /Copiar link/ }).click() + await expect(page.getByRole('button', { name: 'Link copiado' })).toBeVisible() + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(`${baseURL}/noticia/${shareTagSlug}/e2e-artigo-compartilhar-${runSuffix}`) + await page.keyboard.press('Escape') + await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) + + // Outside click also closes the popover. + await articleShare.click() + await section.getByRole('heading', { name: 'A caminhada, em tempo real' }).click() + await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) + + // Video card: the platform URL is shared with the video opener. + const videoCard = section.getByRole('link', { name: /E2e Vídeo em destaque/ }) + const videoShare = videoCard.locator('xpath=..').getByRole('button', { name: 'Compartilhar' }) + await videoShare.click() + expect(await waMessage(page.getByRole('link', { name: 'Compartilhar no WhatsApp' }))).toBe( + 'Olha esse vídeo do Solla: E2e Vídeo em destaque — https://www.youtube.com/watch?v=e2e-video-destaque-1', + ) + await page.keyboard.press('Escape') + await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) + + // Instagram card: the post opener. + const instagramCard = section.getByRole('link', { name: /E2e Post do muro/ }) + const instagramShare = instagramCard + .locator('xpath=..') + .getByRole('button', { name: 'Compartilhar' }) + await instagramShare.click() + expect(await waMessage(page.getByRole('link', { name: 'Compartilhar no WhatsApp' }))).toBe( + 'Olha esse post do Solla: E2e Post do muro — https://www.instagram.com/p/e2e-ig-muro-1/', + ) + await page.keyboard.press('Escape') + + // Mobile: the carousel exposes only the active card's share button, with + // a ≥44px touch target around the discreet 28px visual, and the portal + // menu stays fully inside the viewport (never clipped by the track). + await page.setViewportSize({ width: 390, height: 844 }) + await gotoHomeFresh(page) + const carousel = page.getByRole('region', { name: 'Conteúdos recentes' }) + await expect(carousel).toBeVisible() + // The active (featured) card is the newest item — the article seeded + // 5 min ago against the stubs' 20-min-oldest — which pins the identity + // of the single exposed share button below. + await expect(carousel.getByRole('heading', { name: articleTitle })).toBeVisible() + const mobileShare = carousel.getByRole('button', { name: 'Compartilhar' }) + await expect(mobileShare).toHaveCount(1) + await mobileShare.scrollIntoViewIfNeeded() + const buttonBox = await mobileShare.boundingBox() + expect(buttonBox).not.toBeNull() + expect(buttonBox!.width).toBeGreaterThanOrEqual(44) + expect(buttonBox!.height).toBeGreaterThanOrEqual(44) + const visualBox = await mobileShare.locator('span').first().boundingBox() + expect(visualBox).not.toBeNull() + expect(Math.abs(visualBox!.width - 28)).toBeLessThanOrEqual(1) + expect(Math.abs(visualBox!.height - 28)).toBeLessThanOrEqual(1) + + await mobileShare.click() + const mobileWaItem = page.getByRole('link', { name: 'Compartilhar no WhatsApp' }) + await expect(mobileWaItem).toBeVisible() + const mobileWaBox = await mobileWaItem.boundingBox() + expect(mobileWaBox).not.toBeNull() + expect(mobileWaBox!.x).toBeGreaterThanOrEqual(0) + expect(mobileWaBox!.x + mobileWaBox!.width).toBeLessThanOrEqual(390) + expect(await waMessage(mobileWaItem)).toBe( + `Olha isso do Solla: ${articleTitle} — ${baseURL}/noticia/${shareTagSlug}/e2e-artigo-compartilhar-${runSuffix}`, + ) + + await page.goto('about:blank') + await expect(page).toHaveURL(/about:blank/) + } finally { + await setYouTubeStubState(request, 'ok') + await setInstagramStubState(request, 'ok') + await cleanupS2Fixtures(request, headers) + } + }) }) diff --git a/tests/unit/contentShare.unit.spec.ts b/tests/unit/contentShare.unit.spec.ts new file mode 100644 index 00000000..3aa25513 --- /dev/null +++ b/tests/unit/contentShare.unit.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' + +import { + buildContentShareLink, + buildContentShareMessage, + buildContentShareWhatsAppUrl, + CONTENT_SHARE_PREFIXES, + type ContentShareKind, +} from '@/lib/contentShare' + +describe('buildContentShareMessage', () => { + it('uses the article opener for articles', () => { + expect(buildContentShareMessage('article', 'Título do artigo', 'https://site.com/artigo')).toBe( + 'Olha isso do Solla: Título do artigo — https://site.com/artigo', + ) + }) + + it('uses the video opener for YouTube cards', () => { + expect( + buildContentShareMessage('video', 'Título do vídeo', 'https://www.youtube.com/watch?v=abc'), + ).toBe('Olha esse vídeo do Solla: Título do vídeo — https://www.youtube.com/watch?v=abc') + }) + + it('uses the post opener for Instagram cards', () => { + expect( + buildContentShareMessage('instagram', 'Legenda do post', 'https://www.instagram.com/p/xyz/'), + ).toBe('Olha esse post do Solla: Legenda do post — https://www.instagram.com/p/xyz/') + }) + + it('keeps one opener per kind, matching the locked product decision', () => { + expect(CONTENT_SHARE_PREFIXES).toEqual({ + article: 'Olha isso do Solla: ', + video: 'Olha esse vídeo do Solla: ', + instagram: 'Olha esse post do Solla: ', + }) + }) +}) + +describe('buildContentShareLink', () => { + it('resolves a relative article path against the origin', () => { + expect( + buildContentShareLink('/noticia/saude/sus-pela-vida', 'https://jorgesolla1313.com.br'), + ).toBe('https://jorgesolla1313.com.br/noticia/saude/sus-pela-vida') + }) + + it('passes absolute platform URLs through unchanged', () => { + const videoUrl = 'https://www.youtube.com/watch?v=abc123' + expect(buildContentShareLink(videoUrl, 'https://jorgesolla1313.com.br')).toBe(videoUrl) + const postUrl = 'https://www.instagram.com/p/xyz/' + expect(buildContentShareLink(postUrl, 'https://jorgesolla1313.com.br')).toBe(postUrl) + }) + + it('resolves a trailing-slash origin without double slashes', () => { + expect(buildContentShareLink('/artigo', 'https://site.com/')).toBe('https://site.com/artigo') + }) + + it('fails soft on a malformed href instead of throwing', () => { + const broken = 'https://[malformed' + expect(buildContentShareLink(broken, 'https://site.com')).toBe(broken) + }) +}) + +/** Decode the `text` query param exactly as a URLSearchParams consumer would — `+` is space. */ +const decodeWaText = (url: string): string => + decodeURIComponent(url.slice('https://wa.me/?text='.length)).replace(/\+/g, ' ') + +describe('buildContentShareWhatsAppUrl', () => { + it('builds wa.me with the encoded pre-configured message', () => { + const url = buildContentShareWhatsAppUrl( + 'article', + 'Título', + 'https://site.com/noticia/saude/um', + ) + expect(url.startsWith('https://wa.me/?text=')).toBe(true) + expect(decodeWaText(url)).toBe('Olha isso do Solla: Título — https://site.com/noticia/saude/um') + }) + + it('encodes accents, spaces and the em dash', () => { + const url = buildContentShareWhatsAppUrl( + 'video', + 'Ação em Salvador', + 'https://site.com/video?x=1&y=2', + ) + expect(url).not.toMatch(/[áàãéêç—& ]/) + expect(decodeWaText(url)).toBe( + 'Olha esse vídeo do Solla: Ação em Salvador — https://site.com/video?x=1&y=2', + ) + }) + + it('keeps every kind producing the same wa.me shape', () => { + const kinds: ContentShareKind[] = ['article', 'video', 'instagram'] + for (const kind of kinds) { + expect(buildContentShareWhatsAppUrl(kind, 'T', 'https://site.com/x')).toMatch( + /^https:\/\/wa\.me\/\?text=/, + ) + } + }) +})