Add frontend autoformatting and set CI to require formatted code for all languages (#6052)

# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
@@ -1,23 +1,23 @@
import { test, expect, type Page } from '@playwright/test';
import path from 'path';
import { test, expect, type Page } from "@playwright/test";
import path from "path";
// ---------------------------------------------------------------------------
// Test fixtures — pre-generated keystores in test-fixtures/certs/
// ---------------------------------------------------------------------------
const CERTS_DIR = path.join(__dirname, '../test-fixtures/certs');
const VALID_P12 = path.join(CERTS_DIR, 'valid-test.p12');
const EXPIRED_P12 = path.join(CERTS_DIR, 'expired-test.p12');
const NOT_YET_VALID_P12 = path.join(CERTS_DIR, 'not-yet-valid-test.p12');
const VALID_JKS = path.join(CERTS_DIR, 'valid-test.jks');
const CERTS_DIR = path.join(__dirname, "../test-fixtures/certs");
const VALID_P12 = path.join(CERTS_DIR, "valid-test.p12");
const EXPIRED_P12 = path.join(CERTS_DIR, "expired-test.p12");
const NOT_YET_VALID_P12 = path.join(CERTS_DIR, "not-yet-valid-test.p12");
const VALID_JKS = path.join(CERTS_DIR, "valid-test.jks");
// ---------------------------------------------------------------------------
// Stable mock data returned by the mocked backend
// ---------------------------------------------------------------------------
const MOCK_SESSION = {
sessionId: 'test-session-id',
documentName: 'test-document.pdf',
status: 'IN_PROGRESS',
ownerUsername: 'test-owner',
sessionId: "test-session-id",
documentName: "test-document.pdf",
status: "IN_PROGRESS",
ownerUsername: "test-owner",
message: null,
dueDate: null,
participants: [],
@@ -25,10 +25,10 @@ const MOCK_SESSION = {
const MOCK_PARTICIPANT = {
id: 1,
email: '[email protected]',
name: 'Test User',
status: 'PENDING',
shareToken: 'test-token',
email: "[email protected]",
name: "Test User",
status: "PENDING",
shareToken: "test-token",
expiresAt: null,
hasCompleted: false,
isExpired: false,
@@ -40,30 +40,26 @@ const MOCK_PARTICIPANT = {
// ---------------------------------------------------------------------------
async function mockParticipantApis(page: Page) {
// Mock auth so AppProviders/Landing don't redirect to /login
await page.route('**/api/v1/auth/me', (route) =>
await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({
json: {
id: 1,
username: 'testuser',
email: '[email protected]',
roles: ['ROLE_USER'],
username: "testuser",
email: "[email protected]",
roles: ["ROLE_USER"],
},
})
}),
);
await page.route('**/api/v1/workflow/participant/session**', (route) =>
route.fulfill({ json: MOCK_SESSION })
);
await page.route('**/api/v1/workflow/participant/details**', (route) =>
route.fulfill({ json: MOCK_PARTICIPANT })
);
await page.route("**/api/v1/workflow/participant/session**", (route) => route.fulfill({ json: MOCK_SESSION }));
await page.route("**/api/v1/workflow/participant/details**", (route) => route.fulfill({ json: MOCK_PARTICIPANT }));
// Minimal stub so the download-document call doesn't throw
await page.route('**/api/v1/workflow/participant/document**', (route) =>
await page.route("**/api/v1/workflow/participant/document**", (route) =>
route.fulfill({
status: 200,
contentType: 'application/pdf',
contentType: "application/pdf",
body: Buffer.alloc(128),
})
}),
);
}
@@ -72,8 +68,8 @@ async function mockParticipantApis(page: Page) {
// the matching option — Mantine renders a custom combobox, not a native select.
// ---------------------------------------------------------------------------
async function selectCertType(page: Page, label: string) {
await page.getByTestId('cert-type-select').click();
await page.getByRole('option', { name: label }).click();
await page.getByTestId("cert-type-select").click();
await page.getByRole("option", { name: label }).click();
}
// ---------------------------------------------------------------------------
@@ -82,7 +78,7 @@ async function selectCertType(page: Page, label: string) {
async function uploadCertFile(page: Page, filePath: string) {
// Mantine FileInput uses a visually hidden <input type="file">.
// We click the visible button to expose it, then set files via the hidden input.
const certFileInput = page.getByTestId('cert-file-input');
const certFileInput = page.getByTestId("cert-file-input");
await certFileInput.click();
// After click, the file chooser or the hidden input becomes interactive.
// Use the first file input on the page (Mantine places it near the button).
@@ -93,41 +89,41 @@ async function uploadCertFile(page: Page, filePath: string) {
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe('Certificate Validation — ParticipantView', () => {
test.describe("Certificate Validation — ParticipantView", () => {
test.beforeEach(async ({ page }) => {
await mockParticipantApis(page);
});
// 1. Happy path — valid P12
test('valid P12 cert shows green "Certificate valid until" feedback', async ({ page }) => {
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
await page.route("**/api/v1/workflow/participant/validate-certificate", (route) =>
route.fulfill({
json: {
valid: true,
subjectName: 'Test Signer',
notAfter: '2027-01-01T00:00:00Z',
notBefore: '2025-01-01T00:00:00Z',
subjectName: "Test Signer",
notAfter: "2027-01-01T00:00:00Z",
notBefore: "2025-01-01T00:00:00Z",
selfSigned: true,
error: null,
},
})
}),
);
await page.goto('/workflow/sign/test-token');
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, VALID_P12);
await page.getByTestId('cert-password-input').fill('testpass');
await page.getByTestId("cert-password-input").fill("testpass");
// Wait for debounce (600 ms) + network round-trip
const feedback = page.getByTestId('cert-validation-feedback');
await expect(feedback).toContainText('Certificate valid until', { timeout: 5000 });
await expect(feedback).toContainText('Test Signer');
const feedback = page.getByTestId("cert-validation-feedback");
await expect(feedback).toContainText("Certificate valid until", { timeout: 5000 });
await expect(feedback).toContainText("Test Signer");
});
// 2. Wrong password — red error
test('wrong password shows red error message', async ({ page }) => {
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
test("wrong password shows red error message", async ({ page }) => {
await page.route("**/api/v1/workflow/participant/validate-certificate", (route) =>
route.fulfill({
json: {
valid: false,
@@ -135,24 +131,24 @@ test.describe('Certificate Validation — ParticipantView', () => {
notAfter: null,
notBefore: null,
selfSigned: false,
error: 'Invalid certificate password or corrupt keystore file',
error: "Invalid certificate password or corrupt keystore file",
},
})
}),
);
await page.goto('/workflow/sign/test-token');
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, VALID_P12);
await page.getByTestId('cert-password-input').fill('wrongpass');
await page.getByTestId("cert-password-input").fill("wrongpass");
const feedback = page.getByTestId('cert-validation-feedback');
await expect(feedback).toContainText('Invalid certificate password', { timeout: 5000 });
const feedback = page.getByTestId("cert-validation-feedback");
await expect(feedback).toContainText("Invalid certificate password", { timeout: 5000 });
});
// 3. Expired certificate
test('expired cert shows "Certificate has expired" error', async ({ page }) => {
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
await page.route("**/api/v1/workflow/participant/validate-certificate", (route) =>
route.fulfill({
json: {
valid: false,
@@ -160,24 +156,24 @@ test.describe('Certificate Validation — ParticipantView', () => {
notAfter: null,
notBefore: null,
selfSigned: false,
error: 'Certificate has expired (expired: 2023-01-02 00:00:00 UTC)',
error: "Certificate has expired (expired: 2023-01-02 00:00:00 UTC)",
},
})
}),
);
await page.goto('/workflow/sign/test-token');
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, EXPIRED_P12);
await page.getByTestId('cert-password-input').fill('testpass');
await page.getByTestId("cert-password-input").fill("testpass");
const feedback = page.getByTestId('cert-validation-feedback');
await expect(feedback).toContainText('Certificate has expired', { timeout: 5000 });
const feedback = page.getByTestId("cert-validation-feedback");
await expect(feedback).toContainText("Certificate has expired", { timeout: 5000 });
});
// 4. Not-yet-valid certificate
test('not-yet-valid cert shows "not yet valid" error', async ({ page }) => {
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
await page.route("**/api/v1/workflow/participant/validate-certificate", (route) =>
route.fulfill({
json: {
valid: false,
@@ -185,46 +181,46 @@ test.describe('Certificate Validation — ParticipantView', () => {
notAfter: null,
notBefore: null,
selfSigned: false,
error: 'Certificate is not yet valid (valid from: 2027-01-01 00:00:00 UTC)',
error: "Certificate is not yet valid (valid from: 2027-01-01 00:00:00 UTC)",
},
})
}),
);
await page.goto('/workflow/sign/test-token');
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, NOT_YET_VALID_P12);
await page.getByTestId('cert-password-input').fill('testpass');
await page.getByTestId("cert-password-input").fill("testpass");
const feedback = page.getByTestId('cert-validation-feedback');
await expect(feedback).toContainText('not yet valid', { timeout: 5000 });
const feedback = page.getByTestId("cert-validation-feedback");
await expect(feedback).toContainText("not yet valid", { timeout: 5000 });
});
// 5. Submit button disabled while validating
test('submit button is disabled while validation is in flight', async ({ page }) => {
test("submit button is disabled while validation is in flight", async ({ page }) => {
// Slow response so we can assert the disabled state mid-flight
await page.route('**/api/v1/workflow/participant/validate-certificate', async (route) => {
await page.route("**/api/v1/workflow/participant/validate-certificate", async (route) => {
await new Promise((r) => setTimeout(r, 1500));
await route.fulfill({
json: {
valid: true,
subjectName: 'Test Signer',
notAfter: '2027-01-01T00:00:00Z',
notBefore: '2025-01-01T00:00:00Z',
subjectName: "Test Signer",
notAfter: "2027-01-01T00:00:00Z",
notBefore: "2025-01-01T00:00:00Z",
selfSigned: true,
error: null,
},
});
});
await page.goto('/workflow/sign/test-token');
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, VALID_P12);
await page.getByTestId('cert-password-input').fill('testpass');
await page.getByTestId("cert-password-input").fill("testpass");
// Shortly after typing, validation is in flight — button must be disabled
const submitBtn = page.getByTestId('submit-signature-button');
const submitBtn = page.getByTestId("submit-signature-button");
await expect(submitBtn).toBeDisabled({ timeout: 3000 });
// After validation completes the button should be re-enabled
@@ -232,49 +228,49 @@ test.describe('Certificate Validation — ParticipantView', () => {
});
// 6. SERVER type — no validation call made, button stays enabled
test('SERVER cert type skips validation and keeps submit enabled', async ({ page }) => {
test("SERVER cert type skips validation and keeps submit enabled", async ({ page }) => {
let validateCalled = false;
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) => {
await page.route("**/api/v1/workflow/participant/validate-certificate", (route) => {
validateCalled = true;
return route.fulfill({ json: { valid: true } });
});
await page.goto('/workflow/sign/test-token');
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await selectCertType(page, 'Server Certificate (if available)');
await selectCertType(page, "Server Certificate (if available)");
// Wait longer than debounce to confirm no call is made
await page.waitForTimeout(1000);
expect(validateCalled).toBe(false);
await expect(page.getByTestId('submit-signature-button')).toBeEnabled();
await expect(page.getByTestId("submit-signature-button")).toBeEnabled();
});
// 7. Bonus — valid JKS keystore
test('valid JKS keystore shows green feedback', async ({ page }) => {
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
test("valid JKS keystore shows green feedback", async ({ page }) => {
await page.route("**/api/v1/workflow/participant/validate-certificate", (route) =>
route.fulfill({
json: {
valid: true,
subjectName: 'JKS Signer',
notAfter: '2027-01-01T00:00:00Z',
notBefore: '2025-01-01T00:00:00Z',
subjectName: "JKS Signer",
notAfter: "2027-01-01T00:00:00Z",
notBefore: "2025-01-01T00:00:00Z",
selfSigned: true,
error: null,
},
})
}),
);
await page.goto('/workflow/sign/test-token');
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await selectCertType(page, 'JKS Keystore');
await selectCertType(page, "JKS Keystore");
await uploadCertFile(page, VALID_JKS);
await page.getByTestId('cert-password-input').fill('jkspass');
await page.getByTestId("cert-password-input").fill("jkspass");
const feedback = page.getByTestId('cert-validation-feedback');
await expect(feedback).toContainText('Certificate valid until', { timeout: 5000 });
await expect(feedback).toContainText('JKS Signer');
const feedback = page.getByTestId("cert-validation-feedback");
await expect(feedback).toContainText("Certificate valid until", { timeout: 5000 });
await expect(feedback).toContainText("JKS Signer");
});
});