From 52cc4956248f98d500389a5177c7ce31f0b2b4d4 Mon Sep 17 00:00:00 2001 From: Sarah Young Date: Thu, 15 Oct 2026 10:53:12 -0400 Subject: [PATCH] update build number validation --- docs/format/report.md | 15 ++++++- schemas/report/v3.json | 4 +- schemas/report/v4.json | 4 +- test/unit/report.test.js | 86 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 5 deletions(-) diff --git a/docs/format/report.md b/docs/format/report.md index e4b8ae87..42616461 100644 --- a/docs/format/report.md +++ b/docs/format/report.md @@ -10,6 +10,19 @@ stored in [AWS Timestream], please see [Storage Schema]. > All JSON below is simply a pseudo representation of the actual schemas which > are stored in the [`schemas`](../../schemas) folder. +## LMS build numbers + +Report versions 3 and 4 accept both release and CI build numbers. The +prefix has two digits, a dot, two digits, a dot, and one or two digits. +Release builds append a dot and exactly five digits, including leading zeros +(for example, `20.26.9.00001` or `20.26.11.16663`). +CI builds append lowercase `-ci` and an unpadded integer from 1 to 99999 +(for example, `20.26.9-ci1` or `20.26.11-ci16663`). +The build number is optional and is preserved unchanged, including the `-ci` +label, when reports are loaded. Legacy report versions 1 and 2 accept only +dotted release build numbers, including when LMS metadata is injected before +upgrading. + ## Current ```json @@ -30,7 +43,7 @@ stored in [AWS Timestream], please see [Storage Schema]. "sha": "" }, "lms": { - "buildNumber": "", + "buildNumber": "", "instanceUrl": "" }, "framework": "'", diff --git a/schemas/report/v3.json b/schemas/report/v3.json index fd8fe01e..79cb5251 100644 --- a/schemas/report/v3.json +++ b/schemas/report/v3.json @@ -26,9 +26,9 @@ "properties": { "buildNumber": { "type": "string", - "pattern": "^([0-9]{2}\\.){2}[0-9]{1,2}\\.[0-9]{5}$", + "pattern": "^([0-9]{2}\\.){2}[0-9]{1,2}(\\.[0-9]{5}|-ci[1-9][0-9]{0,4})$", "errorMessage": { - "pattern": "should be a valid LMS build number (XX.XX.XX.XXXXX)" + "pattern": "should be a valid LMS build number (release: XX.XX.X.XXXXX or XX.XX.XX.XXXXX; CI: XX.XX.X-ciN or XX.XX.XX-ciN, where N is an unpadded integer from 1 to 99999)" } }, "instanceUrl": { diff --git a/schemas/report/v4.json b/schemas/report/v4.json index a6ac6757..1db3ff5b 100644 --- a/schemas/report/v4.json +++ b/schemas/report/v4.json @@ -26,9 +26,9 @@ "properties": { "buildNumber": { "type": "string", - "pattern": "^([0-9]{2}\\.){2}[0-9]{1,2}\\.[0-9]{5}$", + "pattern": "^([0-9]{2}\\.){2}[0-9]{1,2}(\\.[0-9]{5}|-ci[1-9][0-9]{0,4})$", "errorMessage": { - "pattern": "should be a valid LMS build number (XX.XX.XX.XXXXX)" + "pattern": "should be a valid LMS build number (release: XX.XX.X.XXXXX or XX.XX.XX.XXXXX; CI: XX.XX.X-ciN or XX.XX.XX-ciN, where N is an unpadded integer from 1 to 99999)" } }, "instanceUrl": { diff --git a/test/unit/report.test.js b/test/unit/report.test.js index 603ddf8d..4dff5bec 100644 --- a/test/unit/report.test.js +++ b/test/unit/report.test.js @@ -429,7 +429,46 @@ describe('report', () => { }, { name: 'v3', report: testReportLatestFull + }, { + name: 'v4', + report: testReportV4Full }]; + const releaseBuildNumbers = [ + '20.26.9.12345', + '20.26.11.16663', + '20.26.9.00000', + '20.26.11.00001', + '20.26.11.99999' + ]; + const ciBuildNumbers = [ + '20.26.9-ci1', + '20.26.11-ci16663', + '20.26.11-ci99999' + ]; + const invalidBuildNumbers = [ + '20.26.11-ci0', + '20.26.11-ci01', + '20.26.11-ci00001', + '20.26.11-ci100000', + '20.26.11-ci', + '20.26.11-ciabc', + '20.26.11-ci-1', + '20.26.11-ci1.5', + '20.26.11-CI16663', + '20.26.11.ci16663', + '20.26.11ci16663', + '20.26.11-ci.16663', + '2.26.11-ci16663', + '200.26.11-ci16663', + '20.2.11-ci16663', + '20.260.11-ci16663', + '20.26.-ci16663', + '20.26.111-ci16663', + ' 20.26.11-ci16663', + '20.26.11-ci16663 ', + '20.26.11.1234', + '20.26.11.123456' + ]; for (const { name, report: reportData } of reports) { it(`adds LMS information to ${name} reports`, () => { @@ -439,6 +478,53 @@ describe('report', () => { expect(report.toJSON().summary.lms).to.deep.equal(lmsInfo); }); + + for (const source of ['injected', 'existing']) { + describe(`${name} ${source} LMS build numbers`, () => { + const originalVersion = reportData.reportVersion ?? reportData.version; + const supportsCi = originalVersion >= 3; + const validBuildNumbers = supportsCi ? [...releaseBuildNumbers, ...ciBuildNumbers] : releaseBuildNumbers; + const rejectedBuildNumbers = supportsCi ? invalidBuildNumbers : [...invalidBuildNumbers, ...ciBuildNumbers]; + const constructReport = (buildNumber) => { + const data = structuredClone(reportData); + const options = source === 'injected' ? { lmsInfo: { buildNumber } } : {}; + + if (source === 'existing') { + if (originalVersion === 1) { + data.summary.lmsBuildNumber = buildNumber; + } else { + data.summary.lms = { buildNumber }; + } + } + + mock.method(fs, 'readFileSync', () => JSON.stringify(data)); + + return new Report(testReportPath, options); + }; + + for (const buildNumber of validBuildNumbers) { + it(`preserves ${buildNumber}`, () => { + const report = constructReport(buildNumber); + + expect(report.getVersionOriginal()).to.equal(originalVersion); + expect(report.getVersion()).to.equal(originalVersion === 4 ? 4 : latestReportVersion); + expect(report.toJSON().summary.lms.buildNumber).to.equal(buildNumber); + }); + } + + for (const buildNumber of rejectedBuildNumbers) { + it(`rejects ${JSON.stringify(buildNumber)}`, () => { + const field = originalVersion === 1 ? 'lmsBuildNumber' : 'lms.buildNumber'; + + expect(() => constructReport(buildNumber)).to.throw( + `[report (v${originalVersion})].summary.${field}: should be a valid LMS build number` + ).with.property('message').that.includes( + supportsCi ? 'unpadded integer from 1 to 99999' : '(XX.XX.XX.XXXXX)' + ); + }); + } + }); + } } });