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
234 changes: 234 additions & 0 deletions web/tests/e2e/specs/flows/admin-groups-select-all-flags.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* Flow spec — upstream issue sbpp/sourcebans-pp#1436: the web admin
* groups permission flag grid needs a bulk toggle so operators don't
* have to tick every permission checkbox individually.
*
* What this locks in
* ------------------
* `?p=admin&c=groups&section=list` renders the master-detail flag grid
* (one checkbox per web-permission flag). Two buttons next to the
* "Permission flags" label:
* - `[data-testid="flag-select-all"]` — checks every enabled flag
* - `[data-testid="flag-select-none"]` — unchecks every flag
* Both refresh the live `[data-testid="flag-bitmask"]` preview, and the
* folded OR-sum round-trips through Save exactly like a manual toggle.
*
* The expected bitmask is recomputed in-spec from each checkbox's
* `data-flag-value` with plain (non-bitwise) arithmetic — deliberately
* NOT reusing the page's `SbppFoldFlags`, so the assertion stays an
* independent oracle rather than a tautology. `$all_flags` strips the
* `ALL_WEB` / `ADMIN_OWNER` meta entries (see `web/pages/admin.groups.php`),
* but the remainder still includes `ADMIN_UNBAN_GROUP_BANS` (bit 31), so
* "select all" is exactly the case where a bare `|=` OR-fold would go
* negative — the #1272 regression this spec re-guards on the bulk path.
*
* A bulk toggle also has to arm the master-detail dirty tracker: setting
* `.checked` from script fires no `change`, so without an explicit
* dispatch the left-rail selection handler would consider the pane
* pristine and drop the operator's bulk change with no prompt. The
* second test locks that in via the shared confirm `<dialog>`
* (`[data-testid="sbpp-confirm-dialog"]`).
*
* Project gating mirrors `admin-groups-bitmask.spec.ts` (desktop
* chromium only; the spec mutates the shared e2e DB).
*/

import type { Page } from '@playwright/test';

import { expect, test } from '../../fixtures/auth.ts';
import { truncateE2eDb } from '../../fixtures/db.ts';

const GROUPS_LIST_ROUTE = '/index.php?p=admin&c=groups&section=list';

const FIXTURE = {
groupA: 'e2e-select-all-flags-group-a',
groupB: 'e2e-select-all-flags-group-b',
};

async function seedWebGroup(page: Page, name: string): Promise<void> {
const envelope = await page.evaluate(async (groupName) => {
const w = window as unknown as {
sb: {
api: {
call: (
action: string,
params: Record<string, unknown>,
) => Promise<{ ok: boolean; error?: { code: string; message: string } }>;
};
};
Actions: Record<string, string>;
};
return await w.sb.api.call(w.Actions.GroupsAdd, {
name: groupName,
type: '1',
bitmask: 0,
srvflags: '',
});
}, name);

expect(envelope.ok, `groups.add must succeed: ${JSON.stringify(envelope)}`).toBe(true);
}

/**
* Independent OR-fold of every rendered flag value, computed with
* addition over a de-duplicated bit set instead of `|=` so the oracle
* cannot reproduce the signed-Int32 bug it is meant to catch.
*/
async function expectedSelectAllBitmask(page: Page): Promise<number> {
return await page.evaluate(() => {
const inputs = Array.from(
document.querySelectorAll<HTMLInputElement>(
'[data-testid="flag-grid"] input[name="flags[]"]',
),
);
const bits = new Set<number>();
for (const input of inputs) {
if (input.disabled) continue;
const value = Number(input.dataset.flagValue || input.value);
for (let bit = 0; bit < 32; bit++) {
// `Math.floor(value / 2 ** bit) % 2` — no bitwise ops.
if (Math.floor(value / 2 ** bit) % 2 === 1) bits.add(bit);
}
}
let total = 0;
bits.forEach((bit) => {
total += 2 ** bit;
});
return total;
});
}

test.describe('flow: admin groups select-all permission flags (upstream #1436)', () => {
test.skip(({ isMobile }) => isMobile, 'flow spec runs only on desktop chromium');

test.beforeEach(async () => {
await truncateE2eDb();
});

test('Select all / Select none toggle the whole flag grid and round-trip', async ({ page }) => {
await page.goto('/');
await seedWebGroup(page, FIXTURE.groupA);

await page.goto(GROUPS_LIST_ROUTE);

const detail = page.locator('[data-testid="group-detail"]');
await expect(detail).toBeVisible();

const flagGrid = detail.locator('[data-testid="flag-grid"]');
const bitmaskBadge = detail.locator('[data-testid="flag-bitmask"]');
const selectAll = detail.locator('[data-testid="flag-select-all"]');
const selectNone = detail.locator('[data-testid="flag-select-none"]');
const checkboxes = flagGrid.locator('input[name="flags[]"]');

await expect(flagGrid).toBeVisible();
await expect(selectAll).toBeVisible();
await expect(selectNone).toBeVisible();
// Real buttons, not links styled as buttons — a submitting
// control here would POST the JS-less form and lose the change.
await expect(selectAll).toHaveAttribute('type', 'button');
await expect(selectNone).toHaveAttribute('type', 'button');
await expect(bitmaskBadge).toHaveText(/^0 bitmask$/);

const total = await checkboxes.count();
expect(total).toBeGreaterThan(0);

const expected = await expectedSelectAllBitmask(page);
// Sanity: the grid must still carry a bit-31 flag, otherwise this
// spec silently stops guarding the unsigned-fold path.
expect(expected, 'select-all must exercise bit 31 (see #1272)').toBeGreaterThanOrEqual(
2 ** 31,
);

// ---- Select all → every checkbox checked, badge = unsigned OR ----
await selectAll.click();
for (let i = 0; i < total; i++) {
await expect(checkboxes.nth(i)).toBeChecked();
}
await expect(bitmaskBadge).toHaveText(`${expected} bitmask`);
await expect(bitmaskBadge).not.toContainText('-');

// Idempotent: a second press changes nothing and must not corrupt
// the preview.
await selectAll.click();
await expect(bitmaskBadge).toHaveText(`${expected} bitmask`);

// ---- Save round-trips the folded OR-sum --------------------------
const saveButton = detail.locator('[data-testid="group-save"]');
const editResponsePromise = page.waitForResponse(
(response) =>
response.url().includes('api.php') &&
response.request().method() === 'POST' &&
response.status() === 200,
);
await saveButton.click();
const editEnvelope = await (await editResponsePromise).json();
expect(editEnvelope.ok, `groups.edit must succeed: ${JSON.stringify(editEnvelope)}`).toBe(
true,
);

// SSR re-render must show the same unsigned value and every flag
// re-checked — not just the first one.
await page.goto(GROUPS_LIST_ROUTE);
const reloadedDetail = page.locator('[data-testid="group-detail"]');
const reloadedGrid = reloadedDetail.locator('[data-testid="flag-grid"]');
const reloadedBadge = reloadedDetail.locator('[data-testid="flag-bitmask"]');
await expect(reloadedBadge).toHaveText(`${expected} bitmask`);
await expect(reloadedBadge).not.toContainText('-');

const reloadedChecks = reloadedGrid.locator('input[name="flags[]"]');
const reloadedTotal = await reloadedChecks.count();
expect(reloadedTotal).toBe(total);
for (let i = 0; i < reloadedTotal; i++) {
await expect(reloadedChecks.nth(i)).toBeChecked();
}

// ---- Select none → every checkbox cleared, badge back to 0 -------
await reloadedDetail.locator('[data-testid="flag-select-none"]').click();
for (let i = 0; i < reloadedTotal; i++) {
await expect(reloadedChecks.nth(i)).not.toBeChecked();
}
await expect(reloadedBadge).toHaveText(/^0 bitmask$/);
});

test('a bulk toggle arms the unsaved-changes guard', async ({ page }) => {
await page.goto('/');
await seedWebGroup(page, FIXTURE.groupA);
await seedWebGroup(page, FIXTURE.groupB);

await page.goto(GROUPS_LIST_ROUTE);

const detail = page.locator('[data-testid="group-detail"]');
await expect(detail).toBeVisible();

const rows = page.locator('[data-testid="group-row"]');
const otherRow = rows.filter({ hasText: FIXTURE.groupB });
const currentName = await detail
.locator('[data-testid="group-detail-name"]')
.textContent();
// The master-detail pane auto-selects the first row; pick whichever
// of the two seeded groups is NOT currently painted.
const target =
currentName?.trim() === FIXTURE.groupB ? rows.filter({ hasText: FIXTURE.groupA }) : otherRow;
await expect(target).toHaveCount(1);

await detail.locator('[data-testid="flag-select-all"]').click();

// Programmatic `.checked =` fires no `change`; without an explicit
// dispatch the selection handler would repaint straight over the
// bulk change with no prompt.
await target.click();
const confirmDialog = page.locator('[data-testid="sbpp-confirm-dialog"]');
await expect(confirmDialog).toBeVisible();
await expect(page.locator('[data-testid="sbpp-confirm-title"]')).toHaveText(
'Unsaved changes',
);

// Cancelling keeps the operator on the edited group with the bulk
// selection intact.
await page.locator('[data-testid="sbpp-confirm-cancel"]').click();
await expect(confirmDialog).toBeHidden();
await expect(
detail.locator('[data-testid="flag-grid"] input[name="flags[]"]').first(),
).toBeChecked();
});
});
76 changes: 71 additions & 5 deletions web/themes/default/page_admin_groups_list.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,28 @@
<div>
<div class="flex items-center justify-between gap-2 mb-2">
<label class="label m-0">Permission flags</label>
{* #1258: `data-testid="flag-bitmask"` lets the page-tail JS
below (and future E2E specs) anchor on the contract instead
of visible copy. SSR is the source of truth for the initial
paint; the listener re-folds the OR-sum on each `change`. *}
<span class="text-xs text-muted" data-testid="flag-bitmask">{$selected_group.flags} bitmask</span>
<div class="flex items-center gap-2">
{* #1436: bulk toggle for the flag grid so operators don't
have to tick every permission individually. Gated on
`permission_editgroup` like the checkboxes themselves. *}
{if $permission_editgroup}
<button type="button"
class="btn btn--ghost btn--sm"
data-testid="flag-select-all"
aria-label="Select all permission flags"
onclick="SbppGroupsToggleAllFlags(true);">Select all</button>
<button type="button"
class="btn btn--ghost btn--sm"
data-testid="flag-select-none"
aria-label="Select no permission flags"
onclick="SbppGroupsToggleAllFlags(false);">Select none</button>
{/if}
{* #1258: `data-testid="flag-bitmask"` lets the page-tail JS
below (and future E2E specs) anchor on the contract instead
of visible copy. SSR is the source of truth for the initial
paint; the listener re-folds the OR-sum on each `change`. *}
<span class="text-xs text-muted" data-testid="flag-bitmask">{$selected_group.flags} bitmask</span>
</div>
</div>
{* #1258: per-flag rows are bare `<label class="flex items-center
gap-2">` — no inline border / background / radius — so the grid
Expand Down Expand Up @@ -549,6 +566,55 @@ function SbppFoldFlags(root) {
return bitmask >>> 0;
}

/**
* #1436 — bulk toggle for the web-group permission flag grid.
* Sets every enabled `input[name="flags[]"]` checkbox to `checked`,
* then refreshes the live bitmask preview so the operator sees the
* new value before saving. Disabled checkboxes (no `permission_editgroup`)
* are left untouched, though the buttons are not rendered in that case.
*
* A programmatic `input.checked = …` fires NO `change` event, so the two
* listeners further down that a manual click would have driven — the
* grid's live-preview fold and, more importantly, the master-detail
* `markDirty` tracker bound on the form — would both miss a bulk toggle.
* Missing `markDirty` is the dangerous half: the left-rail selection
* handler would treat the pane as pristine and silently discard the
* bulk change when the operator clicks another group, instead of raising
* the "Unsaved changes" confirm. So dispatch one bubbling `change` from
* the grid (the tracker only cares that something under the form
* changed) and repaint the preview here — the grid's own listener
* ignores non-checkbox targets by design.
*
* The repaint goes through `SbppFoldFlags`, the same helper
* `SbppGroupsSave` and the `change` listener use, so the `>>> 0`
* unsigned-32-bit projection from #1272 can't drift on this path:
* OR-folding every flag sets bit 31 (`ADMIN_UNBAN_GROUP_BANS`), which
* a bare `|=` would render negative.
*
* @param {boolean} checked
*/
function SbppGroupsToggleAllFlags(checked) {
var grid = document.querySelector('[data-testid="flag-grid"]');
if (!grid) return;
var next = !!checked;
var checks = grid.querySelectorAll('input[name="flags[]"]');
var changed = false;
for (var i = 0; i < checks.length; i++) {
var input = /** @type {HTMLInputElement} */ (checks[i]);
if (input.disabled || input.checked === next) continue;
input.checked = next;
changed = true;
}
// No-op click (already all-on / all-off): leave the form pristine
// so a stray press doesn't arm the unsaved-changes prompt.
if (!changed) return;

grid.dispatchEvent(new Event('change', { bubbles: true }));

var preview = document.querySelector('[data-testid="flag-bitmask"]');
if (preview) preview.textContent = SbppFoldFlags(grid) + ' bitmask';
}

/**
* Inline replacement for the legacy `applyApiResponse` global from
* `web/scripts/sourcebans.js` (deleted at #1123 D1, see AGENTS.md
Expand Down
Loading