diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index c8af6282b..b850d436c 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -167,6 +167,13 @@ jobs: if: runner.os != 'Windows' - name: Run e2e playwright tests run: npm run test:e2e + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report-${{ matrix.os }} + path: frontend/playwright-report/ + retention-days: 30 deploy: name: Deploy to abacus-test.nl diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d97d3562..70c42c45a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,14 +59,12 @@ jobs: path: ${{ matrix.target.binary }} playwright-e2e: - name: Playwright e2e tests (${{ matrix.os }}, ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) + name: Playwright e2e tests (${{ matrix.os }} needs: - build strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - shardIndex: [1, 2, 3] - shardTotal: [3] runs-on: ${{ matrix.os }} defaults: run: @@ -90,9 +88,16 @@ jobs: run: chmod a+x ../builds/backend/abacus if: runner.os != 'Windows' - name: Run Playwright e2e tests - run: npm run test:e2e -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: npm run test:e2e env: BACKEND_BUILD: release + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report-release-${{ matrix.os }} + path: frontend/playwright-report/ + retention-days: 30 release: name: Release diff --git a/backend/openapi.json b/backend/openapi.json index 9d714688a..fdbaa9daf 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2089,12 +2089,12 @@ "properties": { "meets_surplus_threshold": { "type": "boolean", - "description": "Whether this group met the threshold for surplus seat assigment" + "description": "Whether this group met the threshold for surplus seat assignment" }, "pg_number": { "type": "integer", "format": "int32", - "description": "Political group number for which this assigment applies", + "description": "Political group number for which this assignment applies", "minimum": 0 }, "rest_seats": { diff --git a/backend/src/apportionment/mod.rs b/backend/src/apportionment/mod.rs index bcaf4e603..94e1b683f 100644 --- a/backend/src/apportionment/mod.rs +++ b/backend/src/apportionment/mod.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use tracing::{debug, info}; use utoipa::ToSchema; +use crate::election::PGNumber; use crate::{data_entry::PoliticalGroupVotes, summary::ElectionSummary}; pub use self::{api::*, fraction::*}; @@ -24,13 +25,14 @@ pub struct ApportionmentResult { /// Contains information about the final assignment of seats for a specific political group. #[derive(Debug, PartialEq, Serialize, Deserialize, ToSchema)] pub struct PoliticalGroupSeatAssignment { - /// Political group number for which this assigment applies - pg_number: u8, + /// Political group number for which this assignment applies + #[schema(value_type = u32)] + pg_number: PGNumber, /// The number of votes cast for this group votes_cast: u64, /// The surplus votes that were not used to get whole seats assigned to this political group surplus_votes: Fraction, - /// Whether this group met the threshold for surplus seat assigment + /// Whether this group met the threshold for surplus seat assignment meets_surplus_threshold: bool, /// The number of whole seats assigned to this group whole_seats: u64, @@ -59,7 +61,8 @@ impl From for PoliticalGroupSeatAssignment { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct PoliticalGroupStanding { /// Political group number for which this standing applies - pg_number: u8, + #[schema(value_type = u32)] + pg_number: PGNumber, /// The number of votes cast for this group votes_cast: u64, /// The surplus of votes that was not used to get whole seats (does not have to be a whole number of votes) @@ -312,7 +315,7 @@ fn allocate_remainder( } /// Assign the next remainder seat, and return which group that seat was assigned to. -/// This assigment is done according to the rules for elections with 19 seats or more. +/// This assignment is done according to the rules for elections with 19 seats or more. fn step_allocate_remainder_using_highest_averages( standing: &[PoliticalGroupStanding], remaining_seats: u64, @@ -360,7 +363,7 @@ fn political_groups_qualifying_for_unique_highest_average<'a>( } /// Assign the next remainder seat, and return which group that seat was assigned to. -/// This assigment is done according to the rules for elections with less than 19 seats. +/// This assignment is done according to the rules for elections with less than 19 seats. fn step_allocate_remainder_using_highest_surplus( assigned_seats: &[PoliticalGroupStanding], remaining_seats: u64, @@ -427,7 +430,7 @@ pub enum AssignedSeat { impl AssignedSeat { /// Get the political group number for the group this step has assigned a seat - fn political_group_number(&self) -> u8 { + fn political_group_number(&self) -> PGNumber { match self { AssignedSeat::HighestAverage(highest_average) => highest_average.selected_pg_number, AssignedSeat::HighestSurplus(highest_surplus) => highest_surplus.selected_pg_number, @@ -449,9 +452,11 @@ impl AssignedSeat { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] pub struct HighestAverageAssignedSeat { /// The political group that was selected for this seat has this political group number - selected_pg_number: u8, + #[schema(value_type = u32)] + selected_pg_number: PGNumber, /// The list from which the political group was selected, all of them having the same votes per seat - pg_options: Vec, + #[schema(value_type = Vec)] + pg_options: Vec, /// This is the votes per seat achieved by the selected political group votes_per_seat: Fraction, } @@ -460,9 +465,11 @@ pub struct HighestAverageAssignedSeat { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] pub struct HighestSurplusAssignedSeat { /// The political group that was selected for this seat has this political group number - selected_pg_number: u8, + #[schema(value_type = u32)] + selected_pg_number: PGNumber, /// The list from which the political group was selected, all of them having the same number of surplus votes - pg_options: Vec, + #[schema(value_type = Vec)] + pg_options: Vec, /// The number of surplus votes achieved by the selected political group surplus_votes: Fraction, } @@ -474,7 +481,7 @@ pub enum ApportionmentError { } /// Create a vector containing just the political group numbers from an iterator of the current standing -fn political_group_numbers(standing: &[&PoliticalGroupStanding]) -> Vec { +fn political_group_numbers(standing: &[&PoliticalGroupStanding]) -> Vec { standing.iter().map(|s| s.pg_number).collect() } @@ -493,6 +500,7 @@ mod tests { get_total_seats_from_apportionment_result, seat_allocation, ApportionmentError, }, data_entry::{Count, PoliticalGroupVotes, VotersCounts, VotesCounts}, + election::PGNumber, summary::{ElectionSummary, SummaryDifferencesCounts}, }; use test_log::test; @@ -502,7 +510,7 @@ mod tests { let mut political_group_votes: Vec = vec![]; for (index, votes) in pg_votes.iter().enumerate() { political_group_votes.push(PoliticalGroupVotes::from_test_data_auto( - u8::try_from(index + 1).unwrap(), + PGNumber::try_from(index + 1).unwrap(), *votes, &[], )) diff --git a/backend/src/data_entry/structs.rs b/backend/src/data_entry/structs.rs index 21b1ef06d..05b9b372d 100644 --- a/backend/src/data_entry/structs.rs +++ b/backend/src/data_entry/structs.rs @@ -1,4 +1,9 @@ -use crate::{data_entry::status::DataEntryStatus, error::ErrorReference, APIError}; +use crate::{ + data_entry::status::DataEntryStatus, + election::{CandidateNumber, PGNumber}, + error::ErrorReference, + APIError, +}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{types::Json, FromRow}; @@ -154,7 +159,8 @@ impl DifferencesCounts { #[derive(Serialize, Deserialize, ToSchema, Clone, Debug, Default, PartialEq, Eq, Hash)] pub struct PoliticalGroupVotes { - pub number: u8, + #[schema(value_type = u32)] + pub number: PGNumber, #[schema(value_type = u32)] pub total: Count, pub candidate_votes: Vec, @@ -193,7 +199,11 @@ impl PoliticalGroupVotes { /// Create `PoliticalGroupVotes` from test data. #[cfg(test)] - pub fn from_test_data(number: u8, total_count: Count, candidate_votes: &[(u8, Count)]) -> Self { + pub fn from_test_data( + number: PGNumber, + total_count: Count, + candidate_votes: &[(CandidateNumber, Count)], + ) -> Self { PoliticalGroupVotes { number, total: total_count, @@ -209,14 +219,18 @@ impl PoliticalGroupVotes { /// Create `PoliticalGroupVotes` from test data with candidate numbers automatically generated starting from 1. #[cfg(test)] - pub fn from_test_data_auto(number: u8, total_count: Count, candidate_votes: &[Count]) -> Self { + pub fn from_test_data_auto( + number: PGNumber, + total_count: Count, + candidate_votes: &[Count], + ) -> Self { Self::from_test_data( number, total_count, &candidate_votes .iter() .enumerate() - .map(|(i, votes)| (u8::try_from(i).unwrap() + 1, *votes)) + .map(|(i, votes)| (CandidateNumber::try_from(i + 1).unwrap(), *votes)) .collect::>(), ) } @@ -224,7 +238,8 @@ impl PoliticalGroupVotes { #[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq, Eq, Hash)] pub struct CandidateVotes { - pub number: u8, + #[schema(value_type = u32)] + pub number: CandidateNumber, #[schema(value_type = u32)] pub votes: Count, } diff --git a/backend/src/election/structs.rs b/backend/src/election/structs.rs index 538fa6b71..bdb00c613 100644 --- a/backend/src/election/structs.rs +++ b/backend/src/election/structs.rs @@ -60,18 +60,24 @@ pub enum ElectionStatus { DataEntryFinished, } +pub type PGNumber = u32; + /// Political group with its candidates #[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq, Eq, Hash)] pub struct PoliticalGroup { - pub number: u8, + #[schema(value_type = u32)] + pub number: PGNumber, pub name: String, pub candidates: Vec, } +pub type CandidateNumber = u32; + /// Candidate #[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq, Eq, Hash)] pub struct Candidate { - pub number: u8, + #[schema(value_type = u32)] + pub number: CandidateNumber, pub initials: String, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] @@ -108,12 +114,12 @@ pub(crate) mod tests { /// Create a test election with some political groups. /// The number of political groups is the length of the `political_groups_candidates` slice. /// The number of candidates in each political group is equal to the value in the slice at that index. - pub fn election_fixture(political_groups_candidates: &[u8]) -> Election { + pub fn election_fixture(political_groups_candidates: &[u32]) -> Election { let political_groups = political_groups_candidates .iter() .enumerate() .map(|(i, &candidates)| PoliticalGroup { - number: u8::try_from(i + 1).unwrap(), + number: u32::try_from(i + 1).unwrap(), name: format!("Political group {}", i + 1), candidates: (0..candidates) .map(|j| Candidate { diff --git a/backend/templates/model-na-31-2.typ b/backend/templates/model-na-31-2.typ index cb7f39d67..7120402e1 100644 --- a/backend/templates/model-na-31-2.typ +++ b/backend/templates/model-na-31-2.typ @@ -1,6 +1,7 @@ #import "common/style.typ": conf, title, mono #import "common/scripts.typ": * #let input = json("inputs/model-na-31-2.json") +#set text(lang: "nl") #show: doc => conf(input, doc, footer: [ #input.creation_date_time. Digitale vingerafdruk van EML-telbestand bij dit proces-verbaal (SHA-256): \ @@ -97,7 +98,7 @@ was. Indien er meerdere zittingslocaties waren, vermeld dan per lid de locatie.] grid.cell(text(size: 8pt, [Locatie])), grid.cell()[] )), - table.cell(align: horizon, stack(dir: ltr, spacing: 3pt, time_input(time: ""), align(top, [-]), time_input(time: ""))), + table.cell(align: horizon, stack(dir: ltr, spacing: 3pt, time_input(time: ""), "-", time_input(time: ""))), )}).flatten(), ) @@ -117,7 +118,7 @@ was. Indien er meerdere zittingslocaties waren, vermeld dan per lid de locatie.] ..input.polling_stations.map(polling_station => {( [#polling_station.number], [ - #if polling_station.polling_station_type == "Mobile" [ + #if "polling_station_type" in polling_station and polling_station.polling_station_type == "Mobile" [ _(Mobiel stembureau)_ ] else [ #polling_station.address \ @@ -340,8 +341,8 @@ Naam leden naam hebben genoteerd, ondertekenen het proces-verbaal. Houd hierbij de volgorde aan van rubriek 12. ] -#block(width: 100%, align(right + horizon, stack(dir: ltr, spacing: 15pt, - [Datum: ], +#block(width: 100%, align(right, stack(dir: ltr, spacing: 15pt, + align(horizon, [Datum: ]), date_input(date: none, top_label: ([Dag], [Maand], [Jaar])), time_input(time: none, top_label: "Tijd") ))) diff --git a/documentatie/verkiezingsproces/wie-doet-welke-stembureaus.md b/documentatie/verkiezingsproces/wie-doet-welke-stembureaus.md index db214a497..ebb26e55b 100644 --- a/documentatie/verkiezingsproces/wie-doet-welke-stembureaus.md +++ b/documentatie/verkiezingsproces/wie-doet-welke-stembureaus.md @@ -9,7 +9,7 @@ In de onderstaande tabel is aangegeven welke partijen of instanties fungeren als | Tweede kamer | 1-20 | gemeentes | Den Haag, ACStM[^1] | gemeente | kieskring | Kiesraad | | Europees Parlement | 1 (heel NL) | gemeentes | Den Haag, ACStM | gemeente | 20 HSBs | Kiesraad | | Provinciale Staten | 1-19 | gemeentes | nvt | gemeente | als meerdere kieskringen in één provincie | 1 gemeente per provincie | -| Waterschappen | per waterschap | gemeentes | nvt | gemeente | nvt | 1 gemeente per waterschap | +| Waterschappen | per waterschap | gemeentes | nvt | gemeente | nvt | Waterschap | | Kiescolleges Eerste Kamer | per openbaar lichaam | openbare lichamen | nvt | nvt | nvt | per eiland | | | buitenland | nvt | ja | nvt | nvt | Zuid-Holland | | Eerste Kamer | per provincie | statenvergadering | nvt | nvt | nvt | Kiesraad | diff --git a/frontend/.gitignore b/frontend/.gitignore index 8e24e898a..20de1624d 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -29,6 +29,7 @@ test-results coverage mock test-report.junit.xml +playwright-report/ # Automatically created: https://github.com/mswjs/msw/discussions/1015#discussioncomment-1747585 mockServiceWorker.js diff --git a/frontend/README.md b/frontend/README.md index bb9111c5a..c556ca8ab 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -60,6 +60,9 @@ npm run test:ladle npm run test:e2e # run tests, expect builds and database to be available: npm run test:e2e-dev + +# view reports and traces, e.g. the ones saved by our pipeline: +npx playwright show-report ``` ### UI Component development diff --git a/frontend/e2e-tests/authentication.e2e.ts b/frontend/e2e-tests/authentication.e2e.ts index 0fbb1ebe6..7347ae08f 100644 --- a/frontend/e2e-tests/authentication.e2e.ts +++ b/frontend/e2e-tests/authentication.e2e.ts @@ -31,6 +31,8 @@ test.describe("authentication", () => { await page.getByLabel("Wachtwoord").fill(password); await page.getByRole("button", { name: "Inloggen" }).click(); + await page.waitForURL("/account/setup"); + // TODO: use new page object when we know which page to render await expect(page.getByRole("alert")).toContainText("Inloggen gelukt"); }); diff --git a/frontend/playwright.common.config.ts b/frontend/playwright.common.config.ts index bb31c415b..966dfc381 100644 --- a/frontend/playwright.common.config.ts +++ b/frontend/playwright.common.config.ts @@ -12,11 +12,10 @@ const commonConfig: PlaywrightTestConfig = defineConfig({ workers: process.env.CI ? "100%" : undefined, // Increase the test timeout on CI, which is usually slower timeout: process.env.CI ? 30_000 : 10_000, - // Use the list reporter even on CI, to get immediate feedback - reporter: "list", fullyParallel: true, use: { - trace: "retain-on-failure", + // Local runs don't have retries, so we have a trace of each failure. On CI we do have retries, so keeping the trace of the first failure allows us to investigate flaky tests. + trace: "retain-on-first-failure", testIdAttribute: "id", }, projects: [ diff --git a/frontend/playwright.e2e.config.ts b/frontend/playwright.e2e.config.ts index f4199dc19..61e147520 100644 --- a/frontend/playwright.e2e.config.ts +++ b/frontend/playwright.e2e.config.ts @@ -20,6 +20,7 @@ function returnWebserverCommand(): string { const config: PlaywrightTestConfig = defineConfig({ ...commonConfig, + reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list", testDir: "./e2e-tests", outputDir: "./test-results/e2e-tests", testMatch: /\.e2e\.ts/, diff --git a/frontend/playwright.ladle.config.ts b/frontend/playwright.ladle.config.ts index 0e224122f..2780dc4e8 100644 --- a/frontend/playwright.ladle.config.ts +++ b/frontend/playwright.ladle.config.ts @@ -4,6 +4,8 @@ import commonConfig from "./playwright.common.config"; const config: PlaywrightTestConfig = defineConfig({ ...commonConfig, + // Use the list reporter even on CI, to get immediate feedback + reporter: "list", testDir: "./lib/ui", outputDir: "./test-results/ladle", testMatch: /\.e2e\.ts/, diff --git a/lefthook.yml b/lefthook.yml index d5a4adb07..276b9e496 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -19,15 +19,16 @@ pre-commit: backend-openapi: root: "backend/" glob: "*.rs" - run: cargo run --package abacus --bin gen-openapi && git add openapi.json + run: > + cargo run --package abacus --bin gen-openapi && + git add openapi.json && + cd ../frontend && + npm run gen:openapi && + git add lib/api/gen/openapi.ts frontend-formatter: root: "frontend/" run: npx prettier --ignore-unknown --write {staged_files} stage_fixed: true - frontend-typescript: - root: "frontend/" - glob: "*.{ts,tsx}" - run: npx tsc frontend-linter: root: "frontend/" glob: "*.{ts,tsx}"