diff --git a/apps/site/next-data/generators/__tests__/partners.test.mjs b/apps/site/next-data/generators/__tests__/partners.test.mjs new file mode 100644 index 0000000000000..f614e11690e19 --- /dev/null +++ b/apps/site/next-data/generators/__tests__/partners.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; + +// The shuffle reads a different hash byte depending on the size of the weighted +// list, so these timestamps only apply to a list of exactly 20 entries. This +// fixture keeps them meaningful when the real partner list changes. +const PARTNERS = [ + { id: 'A', weight: 3 }, + { id: 'B', weight: 2 }, + { id: 'C', weight: 3 }, + { id: 'D', weight: 3 }, + { id: 'E', weight: 2 }, + { id: 'F', weight: 2 }, + { id: 'G', weight: 3 }, + { id: 'H', weight: 2 }, +]; + +mock.module('#site/next.json.mjs', { + namedExports: { partners: PARTNERS }, +}); + +const { default: generatePartners } = + await import('#site/next-data/generators/partners.mjs'); + +// The shuffle seed is `Math.floor(Date.now() / 300000)`, so the result can only +// change on a 5-minute boundary. Each incident below is a real build failure, +// and each neighbouring time is the same code on either side of that window. +const TIMES = [ + ['2026-09-09T19:59:59.999Z', 'just before the 20:00 window'], + ['2026-09-09T20:00:00.000Z', 'start of the 20:00 window'], + ['2026-09-09T20:00:26.000Z', 'failed build: Windows, /en/download'], + ['2026-09-09T20:01:26.000Z', 'failed build: Ubuntu, /en'], + ['2026-09-09T20:04:59.999Z', 'end of the 20:00 window'], + ['2026-09-09T20:05:00.000Z', 'just after the 20:00 window'], + ['2026-11-06T17:14:51.000Z', 'passed build: Ubuntu, same commit as below'], + ['2026-11-06T17:14:59.999Z', 'just before the 17:15 window'], + ['2026-11-06T17:15:00.000Z', 'start of the 17:15 window'], + ['2026-11-06T17:17:26.000Z', 'failed build: Windows, /en'], + ['2026-11-06T17:19:59.999Z', 'end of the 17:15 window'], + ['2026-11-06T17:20:00.000Z', 'just after the 17:15 window'], +]; + +describe('generatePartners', () => { + for (const [now, description] of TIMES) { + it(`returns every partner at ${now} (${description})`, async t => { + t.mock.timers.enable({ apis: ['Date'], now: new Date(now) }); + + const partners = await generatePartners(); + + assert.ok(!partners.includes(undefined), 'result contains undefined'); + assert.equal(partners.length, PARTNERS.length); + assert.deepEqual(new Set(partners), new Set(PARTNERS)); + }); + } +}); diff --git a/apps/site/util/__tests__/array.test.mjs b/apps/site/util/__tests__/array.test.mjs new file mode 100644 index 0000000000000..7dbe672297f4c --- /dev/null +++ b/apps/site/util/__tests__/array.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { shuffle } from '#site/util/array'; + +const range = length => Array.from({ length }, (_, i) => i); + +const sorted = array => [...array].sort((a, b) => a - b); + +describe('shuffle', () => { + it('returns the same order for the same seed', async () => { + const input = range(20); + + assert.deepEqual(await shuffle(input, 42), await shuffle(input, 42)); + }); + + it('does not mutate the input', async () => { + const input = range(20); + + await shuffle(input, 42); + + assert.deepEqual(input, range(20)); + }); + + // In these seeds, the first hash byte that the shuffle reads is 255 + for (const seed of [5963280, 5979951]) { + it(`keeps every entry when the hash byte is 255 (seed ${seed})`, async () => { + const input = range(20); + + assert.deepEqual(sorted(await shuffle(input, seed)), input); + }); + } + + // About 1 in 256 seeds has a 255 byte at any given position, so this range + // covers that case for each length without relying on hand-picked seeds + for (const length of [2, 3, 20, 32, 33, 100]) { + it(`returns a permutation of ${length} entries for any seed`, async () => { + const input = range(length); + + for (let seed = 5979000; seed < 5981000; seed++) { + assert.deepEqual(sorted(await shuffle(input, seed)), input); + } + }); + } +}); diff --git a/apps/site/util/array.ts b/apps/site/util/array.ts index f6aec1ed9c97b..21e3b8beb211f 100644 --- a/apps/site/util/array.ts +++ b/apps/site/util/array.ts @@ -12,8 +12,8 @@ export const shuffle = async ( for (let i = shuffled.length - 1; i > 0; i--) { // Use hash bytes to generate deterministic "random" index const hashIndex = (i + seed) % 32; - // Normalize to 0-1 - const randomValue = hash[hashIndex] / 255; + // Normalize to [0, 1), since a value of 1 would put `j` out of bounds + const randomValue = hash[hashIndex] / 256; const j = Math.floor(randomValue * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];