verify-experience-baseline.mjs 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import { existsSync } from 'node:fs'
  2. import { readFile, stat } from 'node:fs/promises'
  3. import { join, resolve } from 'node:path'
  4. import { loadEcosystemReleaseContext } from './lib/ecosystem-packages.mjs'
  5. const sourceRoot = process.cwd()
  6. const baselinePath = join(sourceRoot, 'ecosystem', 'experience-baseline.json')
  7. const smokeMatrixPath = join(sourceRoot, 'ecosystem', 'smoke-matrix.json')
  8. function fail(message) {
  9. throw new Error(`[experience-baseline] ${message}`)
  10. }
  11. async function readJson(path) {
  12. return JSON.parse(await readFile(path, 'utf8'))
  13. }
  14. async function assertFile(path, label) {
  15. if (!existsSync(path)) {
  16. fail(`Missing ${label}: ${path}`)
  17. }
  18. const fileStat = await stat(path)
  19. if (!fileStat.isFile()) {
  20. fail(`${label} is not a file: ${path}`)
  21. }
  22. }
  23. function assertUnique(items, label, selectId = item => item.id) {
  24. const seen = new Set()
  25. for (const item of items || []) {
  26. const id = selectId(item)
  27. if (!id) {
  28. fail(`${label} contains an item without id`)
  29. }
  30. if (seen.has(id)) {
  31. fail(`${label} contains duplicate id ${id}`)
  32. }
  33. seen.add(id)
  34. }
  35. return seen
  36. }
  37. function assertPackageScript(packageJson, scriptName, label) {
  38. if (!packageJson.scripts?.[scriptName]) {
  39. fail(`${label} references missing package script ${scriptName}`)
  40. }
  41. }
  42. function packageEntryByName(entries, rootPackage, packageName) {
  43. if (rootPackage.name === packageName) {
  44. return {
  45. packageName,
  46. packageDir: '.',
  47. packageJson: rootPackage,
  48. absoluteDir: sourceRoot
  49. }
  50. }
  51. return entries.find(entry => entry.packageName === packageName)
  52. }
  53. const [baseline, smokeMatrix, releaseContext] = await Promise.all([
  54. readJson(baselinePath),
  55. readJson(smokeMatrixPath),
  56. loadEcosystemReleaseContext(sourceRoot)
  57. ])
  58. const { rootPackage, wrapperManifest, entries } = releaseContext
  59. if (baseline.schemaVersion !== 1) {
  60. fail(`Unsupported schemaVersion ${baseline.schemaVersion}`)
  61. }
  62. if (baseline.baseline?.sourceBranch !== wrapperManifest.sourceBranch) {
  63. fail(`Baseline sourceBranch must match wrappers.json sourceBranch ${wrapperManifest.sourceBranch}`)
  64. }
  65. if (baseline.baseline?.rootPackage !== rootPackage.name) {
  66. fail(`Baseline rootPackage must match package.json name ${rootPackage.name}`)
  67. }
  68. for (const scriptName of baseline.requiredScripts || []) {
  69. assertPackageScript(rootPackage, scriptName, 'requiredScripts')
  70. }
  71. const featureIds = assertUnique(baseline.featureGroups, 'featureGroups')
  72. for (const requiredFeature of [
  73. 'source-loading',
  74. 'options',
  75. 'toolbar-operations',
  76. 'lifecycle-events',
  77. 'document-navigation',
  78. 'layout-visual',
  79. 'print-export',
  80. 'wrapper-contract'
  81. ]) {
  82. if (!featureIds.has(requiredFeature)) {
  83. fail(`Missing required feature group ${requiredFeature}`)
  84. }
  85. }
  86. const evidence = new Set()
  87. for (const featureGroup of baseline.featureGroups || []) {
  88. if (!Array.isArray(featureGroup.evidence) || !featureGroup.evidence.length) {
  89. fail(`Feature group ${featureGroup.id} must declare evidence`)
  90. }
  91. for (const item of featureGroup.evidence) {
  92. evidence.add(item)
  93. }
  94. }
  95. for (const requiredEvidence of baseline.requiredEvidence || []) {
  96. if (!evidence.has(requiredEvidence) && !baseline.cases?.some(item => item.assertions?.includes(requiredEvidence))) {
  97. fail(`Required evidence ${requiredEvidence} is not covered by feature groups or cases`)
  98. }
  99. }
  100. const wrapperById = new Map(wrapperManifest.wrappers.map(wrapper => [wrapper.id, wrapper]))
  101. const surfaceIds = assertUnique(baseline.surfaces, 'surfaces')
  102. for (const requiredSurface of [
  103. 'vue3-current-component',
  104. 'compare-page',
  105. 'pure-web-native-viewer',
  106. 'react-native-wrapper',
  107. 'pure-js-native-wrapper',
  108. 'script-tag-iife'
  109. ]) {
  110. if (!surfaceIds.has(requiredSurface)) {
  111. fail(`Missing required baseline surface ${requiredSurface}`)
  112. }
  113. }
  114. for (const surface of baseline.surfaces || []) {
  115. const entry = packageEntryByName(entries, rootPackage, surface.packageName)
  116. if (!entry) {
  117. fail(`Surface ${surface.id} references unknown package ${surface.packageName}`)
  118. }
  119. if (surface.wrapperId) {
  120. const wrapper = wrapperById.get(surface.wrapperId)
  121. if (!wrapper) {
  122. fail(`Surface ${surface.id} references unknown wrapper ${surface.wrapperId}`)
  123. }
  124. if (
  125. surface.packageName !== wrapper.packageName &&
  126. !wrapper.historicalPackages.includes(surface.packageName)
  127. ) {
  128. fail(`Surface ${surface.id} package ${surface.packageName} is not ${wrapper.packageName} or one of its historical aliases`)
  129. }
  130. }
  131. for (const feature of surface.preserves || []) {
  132. if (!featureIds.has(feature)) {
  133. fail(`Surface ${surface.id} preserves unknown feature group ${feature}`)
  134. }
  135. }
  136. for (const scriptName of surface.smokeScripts || []) {
  137. assertPackageScript(rootPackage, scriptName, `Surface ${surface.id}`)
  138. }
  139. for (const entryFile of surface.entryFiles || []) {
  140. await assertFile(resolve(sourceRoot, entryFile), `surface ${surface.id} entry file`)
  141. }
  142. }
  143. const smokeCaseIds = new Set([
  144. ...(smokeMatrix.cases || []).map(item => item.id),
  145. ...(smokeMatrix.wrapperCases || []).map(item => item.id)
  146. ])
  147. const smokeSurfaces = new Set(smokeMatrix.surfaces || [])
  148. const smokeMatrixWrappers = new Set((smokeMatrix.wrappers || []).map(wrapper => wrapper.id))
  149. assertUnique(baseline.cases, 'cases')
  150. const coveredSurfaces = new Set()
  151. const coveredFeatures = new Set()
  152. for (const testCase of baseline.cases || []) {
  153. if (!surfaceIds.has(testCase.surface)) {
  154. fail(`Case ${testCase.id} references unknown surface ${testCase.surface}`)
  155. }
  156. coveredSurfaces.add(testCase.surface)
  157. if (testCase.smokeCaseId && !smokeCaseIds.has(testCase.smokeCaseId)) {
  158. fail(`Case ${testCase.id} references unknown smoke matrix case ${testCase.smokeCaseId}`)
  159. }
  160. if (testCase.wrapperId) {
  161. if (!wrapperById.has(testCase.wrapperId)) {
  162. fail(`Case ${testCase.id} references unknown wrapper ${testCase.wrapperId}`)
  163. }
  164. if (!smokeMatrixWrappers.has(testCase.wrapperId)) {
  165. fail(`Case ${testCase.id} wrapper ${testCase.wrapperId} is missing from smoke-matrix wrappers`)
  166. }
  167. }
  168. const surface = baseline.surfaces.find(item => item.id === testCase.surface)
  169. if (surface?.kind !== 'baseline-page' && surface?.kind !== 'baseline-component' && surface?.wrapperId) {
  170. const smokeSurface = smokeMatrix.wrappers?.find(wrapper => wrapper.id === surface.wrapperId)?.surface
  171. if (smokeSurface && !smokeSurfaces.has(smokeSurface)) {
  172. fail(`Case ${testCase.id} maps to unknown smoke surface ${smokeSurface}`)
  173. }
  174. }
  175. for (const scriptName of testCase.scripts || []) {
  176. assertPackageScript(rootPackage, scriptName, `Case ${testCase.id}`)
  177. }
  178. if (!Array.isArray(testCase.features) || !testCase.features.length) {
  179. fail(`Case ${testCase.id} must declare feature coverage`)
  180. }
  181. for (const feature of testCase.features) {
  182. if (!featureIds.has(feature)) {
  183. fail(`Case ${testCase.id} references unknown feature group ${feature}`)
  184. }
  185. coveredFeatures.add(feature)
  186. }
  187. if (!Array.isArray(testCase.assertions) || !testCase.assertions.length) {
  188. fail(`Case ${testCase.id} must declare assertions`)
  189. }
  190. }
  191. for (const surfaceId of surfaceIds) {
  192. if (!coveredSurfaces.has(surfaceId)) {
  193. fail(`No baseline case covers surface ${surfaceId}`)
  194. }
  195. }
  196. for (const featureId of featureIds) {
  197. if (!coveredFeatures.has(featureId)) {
  198. fail(`No baseline case covers feature group ${featureId}`)
  199. }
  200. }
  201. console.log(
  202. `[experience-baseline] Verified ${baseline.surfaces.length} surfaces, ${baseline.cases.length} baseline cases, ${baseline.featureGroups.length} feature groups, and ${baseline.requiredScripts.length} required scripts.`
  203. )