Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,4 @@ Mα»—i finding khi review (subagent fresh-context, bot, hoαΊ·c người) phαΊ£i
- **Vòng 1:** Sửa cÑc finding hợp lý ở mọi mức.
- **Tα»« vΓ²ng 2 trở Δ‘i:** Trong PR hiện tαΊ‘i chỉ sα»­a P0/P1, mọi finding về bαΊ£o mαΊ­t hoαΊ·c dα»― liệu, lα»—i trong hΓ nh vi mΓ  chΓ­nh PR nΓ y thΓͺm vΓ o, vΓ  required check Δ‘ang đỏ. CΓ‘c P2/P3 cΓ²n lαΊ‘i gom vΓ o **mα»™t** issue follow-up duy nhαΊ₯t; trαΊ£ lời tα»«ng thread kΓ¨m link issue Δ‘Γ³ rα»“i resolve thread.
- Mα»™t lượt review cα»§a bot bα»‹ rate-limit, bα»‹ bỏ qua (skipped) hoαΊ·c khΓ΄ng cΓ³ nα»™i dung **khΓ΄ng phαΊ£i lΓ  review**. KhΓ΄ng chờ vΓ  khΓ΄ng tΓ­nh lΓ  Δ‘Γ£ review.
- Cα»•ng review thαΊ­t sα»± lΓ  **review bαΊ±ng subagent fresh-context** (Mα»₯c 0.2). Required check `Typecheck & Build Monorepo` phαΊ£i xanh trΖ°α»›c khi merge (ruleset `dev-main-light` trΓͺn `dev` vΓ  `main`: chαΊ·n xΓ³a nhΓ‘nh vΓ  force-push, khΓ΄ng yΓͺu cαΊ§u approval). RiΓͺng `main` cΓ³ thΓͺm ruleset `main-release`: bαΊ―t buα»™c qua PR, chỉ cho merge commit, vΓ  phαΊ£i xanh thΓͺm `PR into main comes from dev` vΓ  `main must not lead dev` (Mα»₯c 0.6).
- Cα»•ng review thαΊ­t sα»± lΓ  **review bαΊ±ng subagent fresh-context** (Mα»₯c 0.2). CΓ‘c required check `Typecheck & Build Monorepo`, `E2E (Playwright)` vΓ  `Secret scan (gitleaks)` phαΊ£i xanh trΖ°α»›c khi merge (ruleset `dev-main-light` trΓͺn `dev` vΓ  `main`: chαΊ·n xΓ³a nhΓ‘nh vΓ  force-push, khΓ΄ng yΓͺu cαΊ§u approval). RiΓͺng `main` cΓ³ thΓͺm ruleset `main-release`: bαΊ―t buα»™c qua PR, chỉ cho merge commit, vΓ  phαΊ£i xanh thΓͺm `PR into main comes from dev` vΓ  `main must not lead dev` (Mα»₯c 0.6).
6 changes: 3 additions & 3 deletions Docs/Resources/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ This is the release workflow for `rate.vn`. It follows the same model as DOS/DOS

## Normal release

1. Feature PRs go into `dev` as usual: review, green `Typecheck & Build Monorepo`, squash merge.
1. Feature PRs go into `dev` as usual: review, green required checks (`Typecheck & Build Monorepo`, `E2E (Playwright)`, `Secret scan (gitleaks)`, `task-claim`), squash merge.
2. Check `dev` on its Vercel Preview deployment.
3. Open the promotion PR from `dev` itself, not from a copy branch:
`gh pr create --repo DOS/Rate --base main --head dev --title "chore(release): promote dev to main"`
List the PRs being released in the body.
4. Wait for the required checks on the promotion PR: `Typecheck & Build Monorepo`, `PR into main comes from dev`, `main must not lead dev`.
4. Wait for the required checks on the promotion PR: `Typecheck & Build Monorepo`, `E2E (Playwright)`, `Secret scan (gitleaks)`, `PR into main comes from dev`, `main must not lead dev`.
`node scripts/ops/promote.mjs` does steps 3 to 5 in one command: it opens the promotion PR from `dev` (or reuses the open one) with the released PRs listed, then merges it with a merge commit once its checks pass. `--no-merge` leaves it open.
5. Promotions are batched and need no per-release question: the agent promotes at the first checkpoint reached: the backlog runs out, 10 or more PRs are waiting on `dev`, the oldest unpromoted change is more than 24 hours old, or the agent is ending a long working session. Promote only when `dev` head CI is green and no PR is mid-way through fixing a production bug. The agent merges it with a merge commit: `gh pr merge <number> --repo DOS/Rate --merge`. JOY does not merge PRs; never ask JOY to click Merge. **Never squash or rebase a promotion.** Squashing creates a commit on `main` that can never exist on `dev`, and the next promotion then diffs from a stale merge base.
6. Vercel builds `main` and assigns `rate.vn`. Verify the live revision (the deployment's Git SHA equals `main`), `/api/health`, the main routes and a browser check before calling the release done. `node scripts/ops/verify-live.mjs --wait 900` checks the first three: `/api/health` reports the deployment's `commit`, which must equal `main`, and `checks.database` must be `ok`. It re-reads `main` on every poll. Only when the live commit is behind `main` does it print `vercel promote <main's production deployment> --scope metados`; when live is ahead of or diverged from `main` it says to do nothing. Never redeploy or promote a deployment built from another commit: that rolls the release back. `promote` does not rebuild, and it turns production domain auto-assignment back on after a `vercel rollback`. To check a build before it takes traffic, create it from Git with `autoAssignCustomDomains: false` (Vercel API `POST /v13/deployments` with `gitSource` ref `main` and `target: "production"`), verify it with `vercel curl --deployment <id>`, then `vercel promote <id>`. Promoting an existing production deployment does not rebuild.
Expand Down Expand Up @@ -44,7 +44,7 @@ This is the release workflow for `rate.vn`. It follows the same model as DOS/DOS
- **Build guard:** `vercel.json` runs `scripts/vercel-production-guard.mjs --vercel` before every Vercel build. A production build fails unless `VERCEL_GIT_PROVIDER=github`, the repository is `DOS/Rate` and `VERCEL_GIT_COMMIT_REF=main`. A CLI production upload fails at build time from any branch, `main` included, because Vercel reports no Git source for CLI builds (observed on a remote CLI build: `git=:/@`). So does any other build where Vercel does not report a GitHub source. Preview builds are not affected. Tests: `node --test scripts/vercel-production-guard.test.mjs` (runs in CI).
- **Validate PR Base** (`.github/workflows/validate-pr-base.yml`): a PR into `main` must have head `dev` from this repository, or carry `emergency-hotfix`.
- **Branch Divergence** (`.github/workflows/branch-divergence.yml`): fails when `main` has a non-merge commit that `dev` lacks. `.github/branch-divergence-baseline.txt` should stay empty.
- **GitHub rulesets:** `dev-main-light` blocks deletion and force push on `dev` and `main` and requires `Typecheck & Build Monorepo`. `main-release` on `main` requires a PR, allows only merge commits, and requires the two checks above.
- **GitHub rulesets:** `dev-main-light` blocks deletion and force push on `dev` and `main` and requires `Typecheck & Build Monorepo`, `E2E (Playwright)` and `Secret scan (gitleaks)`. `main-release` on `main` requires a PR, allows only merge commits, and requires the two checks above.

The build guard is a guardrail against mistakes, not a security boundary. Anyone with production deploy rights on the Vercel project can get past it, for example with `--build-env` values that imitate a Git build or by uploading an edited `vercel.json`. Doing that breaks the rule above. The only hard limit is Vercel RBAC: a Developer role without the "Full Production Deployment" permission can reach production only by merging to `main`. Whether the `metados` team's plan offers that control has not been verified yet.

Expand Down
2 changes: 1 addition & 1 deletion Docs/Resources/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Hệ thα»‘ng **Rate Platform (`rate.vn`)** vαΊ­n hΓ nh trΓͺn kiαΊΏn trΓΊc 4 tαΊ§
- [x] Dependabot security updates vΓ  CodeQL default setup Δ‘Γ£ bαΊ­t.
- [x] QuΓ©t secret bΓΉ trα»«: gitleaks quΓ©t commit cα»§a mα»—i PR vΓ o `dev`/`main` (`DOS/Rate#305`).
- [ ] Secret Scanning + Push Protection: repo public nΓͺn miα»…n phΓ­, nhΖ°ng API trαΊ£ `422` "blocked by an enterprise policy"; cαΊ§n chα»§ enterprise mở policy hoαΊ·c gαΊ―n cαΊ₯u hΓ¬nh "Secret Protection enabled" cα»§a org cho repo.
- [ ] Branch Rulesets: `dev-main-light` vΓ  `main-release` Δ‘Γ£ bαΊ­t lαΊ‘i sau khi lΓ m sαΊ‘ch lα»‹ch sα»­ (`#153`); cΓ²n thiαΊΏu `Secret scan (gitleaks)` trong danh sΓ‘ch check bαΊ―t buα»™c (cαΊ§n chα»§ repo cho phΓ©p).
- [x] Branch Rulesets: `dev-main-light` vΓ  `main-release` Δ‘Γ£ bαΊ­t lαΊ‘i sau khi lΓ m sαΊ‘ch lα»‹ch sα»­ (`#153`); `dev-main-light` bαΊ―t buα»™c `Typecheck & Build Monorepo`, `E2E (Playwright)` vΓ  `Secret scan (gitleaks)` trΓͺn `dev` vΓ  `main` (JOY cho phΓ©p ngΓ y 2026-10-25, `#1073`).
- [x] **6.6. KhΓ³a Đường Ghi CΓ΄ng Khai Theo Người DΓΉng & PhiΓͺn Cookie (`DOS/Rate#155`, `#194` - Audit K1, K2, C4, T13):**
- `POST /api/v1/rate/reviews` vΓ  `/watchlist` bαΊ―t buα»™c Δ‘Δƒng nhαΊ­p, gọi RPC dΖ°α»›i quyền người dΓΉng (khΓ΄ng cΓ²n service role), khΓ΄ng bΓ‘o thΓ nh cΓ΄ng giαΊ£, mΓ£ lα»—i RPC β†’ 403/409/422/429. Client service role tΓ‘ch sang `@repo/shared-data/server` (ESLint cαΊ₯m trong `apps/web`).
- PhiΓͺn Δ‘Δƒng nhαΊ­p lΖ°u trong cookie qua `@supabase/ssr`; `middleware.ts` β†’ `proxy.ts` (Next.js 16) refresh phiΓͺn; request dΓΉng cookie phαΊ£i cΓΉng origin.
Expand Down
41 changes: 41 additions & 0 deletions apps/web/e2e/input-labels.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test';

// A placeholder is not a label: it disappears as soon as the user types.
// Every visible field must be named by a <label>, aria-label or
// aria-labelledby (WCAG 1.3.1, 3.3.2; DOS/Rate#1077).
const PAGES = ['/smartphones', '/deals', '/merchants', '/leaderboard'];

for (const path of PAGES) {
test(`visible fields have a label on ${path}`, async ({ page }) => {
await page.setViewportSize({ width: 1366, height: 900 });
const res = await page.goto(path);
expect(res?.status()).toBe(200);
await expect(
page.locator('input:not([type=hidden]):visible').first()
).toBeVisible();
const unlabelled = await page.evaluate(() =>
[
...document.querySelectorAll<HTMLInputElement>(
'input:not([type=hidden]), select, textarea'
),
]
.filter((el) => el.getBoundingClientRect().width > 0)
.filter(
(el) =>
!el.labels?.length &&
!el.getAttribute('aria-label')?.trim() &&
!el.getAttribute('aria-labelledby')
)
.map((el) => `${el.type} name=${el.name} placeholder=${el.placeholder}`)
);
expect(unlabelled).toEqual([]);
});
}

test('catalog search fields are named by their visible captions', async ({
page,
}) => {
await page.goto('/smartphones');
await expect(page.getByRole('textbox', { name: 'Tìm mÑy' })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'HΓ£ng' })).toBeVisible();
});
15 changes: 9 additions & 6 deletions apps/web/e2e/leaderboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ test.use({
},
});

// The category switcher is a group of single-select buttons (DOS/Rate#1067).
const categories = (page: Page) =>
page.getByRole('group', { name: 'Chọn ngành hàng' });
const selectedTab = (page: Page) =>
page.getByRole('tablist').getByRole('tab', { selected: true });
categories(page).getByRole('button', { pressed: true });
// The active criterion is the dark pill.
const activeCriterion = (page: Page) => page.locator('button.btn-dark');

Expand All @@ -30,8 +33,9 @@ test('/leaderboard is served from the page cache', async ({ request }) => {
const res = await request.get('/leaderboard?cat=earbuds');
expect(res.status()).toBe(200);
expect(res.headers()['x-nextjs-cache']).toMatch(/^(HIT|STALE)$/);
// Only the URL reader is client-rendered: the tabs are in the server HTML.
expect(await res.text()).toContain('role="tablist"');
// Only the URL reader is client-rendered: the category switcher is in the
// server HTML.
expect(await res.text()).toContain('aria-label="Chọn ngành hàng"');
});

test('a leaderboard deep link opens its tab, and a click updates the URL', async ({
Expand All @@ -41,9 +45,8 @@ test('a leaderboard deep link opens its tab, and a click updates the URL', async
await expect(selectedTab(page)).toContainText('Tai nghe ANC');
await expect(activeCriterion(page)).toContainText('Điểm người mua thα»±c chαΊ₯t');

await page
.getByRole('tablist')
.getByRole('tab', { name: /Laptop & MacBook/ })
await categories(page)
.getByRole('button', { name: /Laptop & MacBook/ })
.click();
await expect(selectedTab(page)).toContainText('Laptop & MacBook');
await expect(page).toHaveURL(/\/leaderboard\?cat=laptops&criterion=user$/);
Expand Down
83 changes: 83 additions & 0 deletions apps/web/e2e/pill-group-state.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { expect, test, type Page } from '@playwright/test';

// Single-select pill groups across the site are named groups that expose the
// pressed choice, not only its colour (DOS/Rate#1069).
const PAGES: { path: string; groups: (string | RegExp)[] }[] = [
{
path: '/smartphones/iphone-16-pro-max-256gb',
groups: ['Gian hΓ ng:', 'KhoαΊ£ng thời gian', 'Chu kα»³ sα»­ dα»₯ng'],
},
{
path: '/compare/iphone-16-pro-max-256gb-vs-samsung-galaxy-s25-ultra-256gb',
groups: [/ChαΊ₯m theo nhu cαΊ§u/],
},
{ path: '/deals', groups: [/NgΓ nh hΓ ng/, /Ζ―u tiΓͺn chαΊ₯m Δ‘iểm/] },
{
path: '/merchants',
groups: ['Lọc theo sΓ n', 'Lọc theo rα»§i ro Δ‘Γ‘nh giΓ‘'],
},
{ path: '/developers', groups: [/Chọn nhanh mα»™t truy vαΊ₯n mαΊ«u/, /MΓ£/] },
];

async function pressedIn(page: Page, name: string | RegExp) {
const group = page.getByRole('group', { name }).first();
await expect(group).toBeVisible();
return group.getByRole('button', { pressed: true });
}

for (const { path, groups } of PAGES) {
test(`${path} pill groups expose the pressed choice`, async ({ page }) => {
expect((await page.goto(path))?.status()).toBe(200);
for (const name of groups) {
await expect(await pressedIn(page, name)).toHaveCount(1);
}
});
}

test('choosing another pill moves the pressed state', async ({ page }) => {
expect((await page.goto('/developers'))?.status()).toBe(200);
const group = page.getByRole('group', { name: /MΓ£/ }).first();
const python = group.getByRole('button', { name: 'Python' });
await expect(python).toHaveAttribute('aria-pressed', 'false');
// A click before hydration does nothing; retry until React handles it.
await expect(async () => {
if ((await python.getAttribute('aria-pressed')) !== 'true') {
await python.click();
}
await expect(python).toHaveAttribute('aria-pressed', 'true', {
timeout: 500,
});
}).toPass();
await expect(group.getByRole('button', { pressed: true })).toHaveCount(1);
});

test('compare custom weights are named on the sliders toggle', async ({
page,
}) => {
expect(
(
await page.goto(
'/compare/iphone-16-pro-max-256gb-vs-samsung-galaxy-s25-ultra-256gb'
)
)?.status()
).toBe(200);
const toggle = page.getByRole('button', { name: /TΓΉy chỉnh trọng sα»‘/ });
await expect(async () => {
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click();
}
await expect(toggle).toHaveAttribute('aria-expanded', 'true', {
timeout: 500,
});
}).toPass();
// useId ids hold characters that need escaping in a #id selector.
const panelId = (await toggle.getAttribute('aria-controls')) ?? '';
const panel = page.locator(`[id="${panelId}"]`);
await expect(panel).toBeVisible();
await expect(toggle).not.toHaveAccessibleName(/Trọng sα»‘ tΓΉy chỉnh/);
await panel.locator('input[type="range"]').first().focus();
await page.keyboard.press('ArrowRight');
await expect(toggle).toHaveAccessibleName(/Trọng sα»‘ tΓΉy chỉnh/);
const presets = page.getByRole('group', { name: /ChαΊ₯m theo nhu cαΊ§u/ });
await expect(presets.getByRole('button', { pressed: true })).toHaveCount(0);
});
24 changes: 24 additions & 0 deletions apps/web/e2e/review-bars.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';

// The review authenticity bar draws the percentages shown in its badges, so it
// is hidden from assistive tech instead of carrying a label no role supports
// (DOS/Rate#1071).
test('the review authenticity bar is decorative', async ({ page }) => {
expect(
(await page.goto('/smartphones/iphone-16-pro-max-256gb'))?.status()
).toBe(200);
const bars = page.locator('.progress:has(.progress-bar.bg-success)');
await expect(bars.first()).toBeAttached();
for (const bar of await bars.all()) {
if (await bar.getAttribute('aria-label')) {
expect(await bar.getAttribute('role')).not.toBeNull();
}
}

await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page })
.withRules(['aria-prohibited-attr'])
.analyze();
expect([...results.violations, ...results.incomplete]).toEqual([]);
});
59 changes: 59 additions & 0 deletions apps/web/e2e/selector-state.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';

// Leaderboard and compare selectors are named button groups that expose the
// pressed choice, not colour alone, and no tab roles without tab panels
// (DOS/Rate#1067).
test('leaderboard selectors expose their state', async ({ page }) => {
expect((await page.goto('/leaderboard?criterion=user'))?.status()).toBe(200);
await expect(page.getByRole('tab')).toHaveCount(0);

const groups = [
page.getByRole('group', { name: 'Chọn ngành hàng' }),
page.getByRole('group', { name: /XαΊΏp hαΊ‘ng theo tiΓͺu chΓ­/ }),
page.getByRole('group', { name: /Gợi ý theo nhu cầu/ }),
page.getByRole('group', { name: 'Lọc theo tầm giÑ' }),
];
for (const group of groups) {
await expect(group).toBeVisible();
await expect(group.getByRole('button', { pressed: true })).toHaveCount(1);
}
await expect(groups[1]!.getByRole('button', { pressed: true })).toContainText(
'Điểm người mua thα»±c chαΊ₯t'
);

// Choosing a price segment moves the pressed state.
const price = groups[3]!;
const unpressed = price.getByRole('button', { pressed: false }).first();
const label = (await unpressed.innerText()).trim();
await unpressed.click();
await expect(price.getByRole('button', { pressed: true })).toHaveText(label);

// The customize button reports the weight panel it opens.
const customize = page.locator(
'button[aria-controls="leaderboard-weight-sliders"]'
);
await expect(customize).toHaveAttribute('aria-expanded', 'false');
await customize.click();
await expect(customize).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('#leaderboard-weight-sliders')).toBeVisible();

// Moving a slider makes the weights custom: no preset is pressed, and the
// customize button, coloured for it, says so in its name.
await expect(customize).not.toHaveAccessibleName(/Trọng sα»‘ cΓ‘ nhΓ’n hΓ³a/);
const slider = page
.locator('#leaderboard-weight-sliders input[type="range"]')
.first();
await slider.focus();
await page.keyboard.press('ArrowRight');
await expect(customize).toHaveAccessibleName(/Trọng sα»‘ cΓ‘ nhΓ’n hΓ³a/);
await expect(groups[2]!.getByRole('button', { pressed: true })).toHaveCount(
0
);
});

test('the compare category switcher exposes its state', async ({ page }) => {
expect((await page.goto('/compare'))?.status()).toBe(200);
await expect(page.getByRole('tab')).toHaveCount(0);
const group = page.getByRole('group', { name: 'Chọn danh mα»₯c Δ‘α»‘i chiαΊΏu' });
await expect(group.getByRole('button', { pressed: true })).toHaveCount(1);
});
12 changes: 12 additions & 0 deletions apps/web/e2e/touch-targets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,15 @@ test('footer links are at least 44px tall on a phone', async ({ page }) => {
expect(box).toBeGreaterThanOrEqual(44);
}
});

// WCAG 2.2 target size (24px minimum): the catalog deal-radar link was 21px
// tall next to other targets (DOS/Rate#1075).
test('the catalog deal-radar link is at least 24px tall', async ({ page }) => {
await page.goto('/smartphones');
const link = page.locator(
'[data-testid="category-deal-radar"] .rate-radar-spread-head > a'
);
await expect(link).toBeVisible();
const box = await link.boundingBox();
expect(box!.height).toBeGreaterThanOrEqual(24);
});
20 changes: 20 additions & 0 deletions apps/web/e2e/unique-ids.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect, test } from '@playwright/test';

// The navbar renders the locale switcher twice, and the English flag used
// fixed clipPath ids, so every page had duplicate ids (DOS/Rate#1079).
// The English flag is drawn as the current locale's flag on /en pages.
for (const path of ['/en', '/en/smartphones']) {
test(`ids are unique on ${path}`, async ({ page }) => {
const res = await page.goto(path);
expect(res?.status()).toBe(200);
await expect(page.locator('nav').first()).toBeVisible();
const duplicates = await page.evaluate(() => {
const counts = new Map<string, number>();
for (const el of document.querySelectorAll('[id]')) {
counts.set(el.id, (counts.get(el.id) ?? 0) + 1);
}
return [...counts].filter(([, n]) => n > 1).map(([id]) => id);
});
expect(duplicates).toEqual([]);
});
}
Loading
Loading