├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── feature_request.md │ └── support_question.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── issue.yml │ ├── pull_request.yml │ └── push.yml ├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Procfile ├── README.md ├── codecov.yml ├── config ├── api-config.js ├── copyright.txt ├── fixtures-base.js ├── fixtures-large.js ├── fixtures.js ├── jest.js ├── mariadb.js ├── mysql.js └── postgres.js ├── demo ├── .eslintrc.js ├── README.md ├── build-tracker-cli.config.js ├── build-tracker.config.js ├── build.sh └── run.sh ├── docs ├── .gitignore ├── README.md ├── blog │ └── 2019-03-22-version-1.md ├── docs │ ├── .eslintrc.js │ ├── budgets.md │ ├── guides │ │ ├── advanced-ci.md │ │ ├── contributing.md │ │ ├── github-actions.md │ │ ├── guides.md │ │ └── heroku.md │ ├── installation.md │ ├── packages │ │ ├── api-client.md │ │ ├── api-errors.md │ │ ├── app.md │ │ ├── build.md │ │ ├── cli.md │ │ ├── comparator.md │ │ ├── formatting.md │ │ ├── server.md │ │ └── types.md │ └── plugins │ │ ├── index.md │ │ ├── with-mariadb.md │ │ ├── with-mysql.md │ │ └── with-postgres.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.js │ │ └── styles.module.css └── static │ ├── CNAME │ └── img │ ├── favicon.png │ ├── favicon │ └── favicon.ico │ ├── github-action.png │ ├── logo-animated.svg │ ├── logo.png │ ├── logo.svg │ ├── ogImage.png │ ├── undraw_completed_ngx6.svg │ ├── undraw_deliveries_131a.svg │ └── undraw_server_down_s4lk.svg ├── greenkeeper.json ├── husky.config.js ├── jest.config.js ├── lerna.json ├── lint-staged.config.js ├── package.json ├── plugins ├── .eslintrc.js ├── with-mariadb │ ├── README.md │ ├── index.js │ ├── index.ts │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ ├── index.test.ts │ │ │ ├── queries.test.ts │ │ │ └── setup.test.ts │ │ ├── index.ts │ │ ├── queries.ts │ │ └── setup.ts │ └── tsconfig.json ├── with-mysql │ ├── README.md │ ├── index.js │ ├── index.ts │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ ├── index.test.ts │ │ │ ├── queries.test.ts │ │ │ └── setup.test.ts │ │ ├── index.ts │ │ ├── queries.ts │ │ └── setup.ts │ └── tsconfig.json └── with-postgres │ ├── README.md │ ├── index.js │ ├── index.ts │ ├── jest.config.js │ ├── package.json │ ├── src │ ├── __tests__ │ │ ├── index.test.ts │ │ ├── queries.test.ts │ │ └── setup.test.ts │ ├── index.ts │ ├── queries.ts │ └── setup.ts │ └── tsconfig.json ├── src ├── .eslintrc.js ├── api-client │ ├── README.md │ ├── index.js │ ├── index.ts │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── modules │ │ │ ├── __tests__ │ │ │ ├── config.test.ts │ │ │ ├── create-build.test.ts │ │ │ ├── get-build.test.ts │ │ │ ├── git.test.ts │ │ │ ├── readfile.test.ts │ │ │ ├── spawn.test.ts │ │ │ ├── stat-artifacts.test.ts │ │ │ └── upload-build.test.ts │ │ │ ├── config.ts │ │ │ ├── create-build.ts │ │ │ ├── get-build.ts │ │ │ ├── git.ts │ │ │ ├── readfile.ts │ │ │ ├── spawn.ts │ │ │ ├── stat-artifacts.ts │ │ │ └── upload-build.ts │ └── tsconfig.json ├── api-errors │ ├── README.md │ ├── index.js │ ├── index.ts │ ├── jest.config.js │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── app │ ├── README.md │ ├── config │ │ ├── .eslintrc.js │ │ ├── constants.js │ │ ├── devserver.config.js │ │ ├── jest │ │ │ ├── fileMock.js │ │ │ └── setup.js │ │ ├── webpack-client.config.js │ │ ├── webpack-progress.js │ │ ├── webpack-server.config.js │ │ └── webpack.config.js │ ├── index.js │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── app.d.ts │ │ ├── client │ │ │ ├── Routes.tsx │ │ │ ├── history.ts │ │ │ ├── index.tsx │ │ │ └── routes │ │ │ │ ├── Builds.tsx │ │ │ │ ├── Dates.tsx │ │ │ │ ├── Latest.tsx │ │ │ │ └── __tests__ │ │ │ │ ├── Builds.test.tsx │ │ │ │ ├── Dates.test.tsx │ │ │ │ └── Latest.test.tsx │ │ ├── components │ │ │ ├── AppBar.tsx │ │ │ ├── BuildInfo.tsx │ │ │ ├── Button.tsx │ │ │ ├── ColorScalePicker │ │ │ │ ├── ColorScale.tsx │ │ │ │ ├── __tests__ │ │ │ │ │ ├── ColorScale.test.tsx │ │ │ │ │ └── index.test.tsx │ │ │ │ └── index.tsx │ │ │ ├── ComparisonTable │ │ │ │ ├── ArtifactCell.tsx │ │ │ │ ├── BodyRow.tsx │ │ │ │ ├── ComparisonTable.tsx │ │ │ │ ├── DeltaCell.tsx │ │ │ │ ├── GroupCell.tsx │ │ │ │ ├── GroupRow.tsx │ │ │ │ ├── HeaderRow.tsx │ │ │ │ ├── RevisionCell.tsx │ │ │ │ ├── RevisionDeltaCell.tsx │ │ │ │ ├── TextCell.tsx │ │ │ │ ├── TotalCell.tsx │ │ │ │ ├── __tests__ │ │ │ │ │ ├── ArtifactCell.test.tsx │ │ │ │ │ ├── BodyRow.test.tsx │ │ │ │ │ ├── ComparisonTable.test.tsx │ │ │ │ │ ├── DeltaCell.test.tsx │ │ │ │ │ ├── GroupCell.test.tsx │ │ │ │ │ ├── GroupRow.test.tsx │ │ │ │ │ ├── HeaderRow.test.tsx │ │ │ │ │ ├── RevisionCell.test.tsx │ │ │ │ │ ├── RevisionDeltaCell.test.tsx │ │ │ │ │ ├── TextCell.test.tsx │ │ │ │ │ └── TotalCell.test.tsx │ │ │ │ └── index.tsx │ │ │ ├── DatePicker.tsx │ │ │ ├── DateTextField.tsx │ │ │ ├── Divider.tsx │ │ │ ├── Drawer.tsx │ │ │ ├── DrawerLink.tsx │ │ │ ├── EmptyState.tsx │ │ │ ├── Graph │ │ │ │ ├── Area.tsx │ │ │ │ ├── HoverOverlay.tsx │ │ │ │ ├── Offset.ts │ │ │ │ ├── StackedBar.tsx │ │ │ │ ├── XAxis.tsx │ │ │ │ ├── YAxis.tsx │ │ │ │ ├── __tests__ │ │ │ │ │ ├── Area.test.tsx │ │ │ │ │ ├── HoverOverlay.test.tsx │ │ │ │ │ ├── StackedBar.test.tsx │ │ │ │ │ ├── XAxis.test.tsx │ │ │ │ │ ├── YAxis.test.tsx │ │ │ │ │ └── index.test.tsx │ │ │ │ └── index.tsx │ │ │ ├── Hoverable.tsx │ │ │ ├── Menu.tsx │ │ │ ├── MenuItem.tsx │ │ │ ├── RadioSelect.tsx │ │ │ ├── RelativeModal.tsx │ │ │ ├── RelativeTooltip.tsx │ │ │ ├── Ripple.tsx │ │ │ ├── SizeKeyPicker │ │ │ │ ├── Key.tsx │ │ │ │ ├── __tests__ │ │ │ │ │ ├── Key.test.tsx │ │ │ │ │ └── index.test.tsx │ │ │ │ └── index.tsx │ │ │ ├── Snackbar.tsx │ │ │ ├── Subtitle.tsx │ │ │ ├── Table.tsx │ │ │ ├── TabularMetadata.tsx │ │ │ ├── TextField.tsx │ │ │ ├── TextLink.tsx │ │ │ ├── Tooltip.tsx │ │ │ └── __tests__ │ │ │ │ ├── AppBar.test.tsx │ │ │ │ ├── BuildInfo.test.tsx │ │ │ │ ├── Button.test.tsx │ │ │ │ ├── DatePicker.test.tsx │ │ │ │ ├── DateTextField.test.tsx │ │ │ │ ├── Divider.test.tsx │ │ │ │ ├── Drawer.test.tsx │ │ │ │ ├── DrawerLink.test.tsx │ │ │ │ ├── EmptyState.test.tsx │ │ │ │ ├── Hoverable.test.tsx │ │ │ │ ├── Menu.test.tsx │ │ │ │ ├── MenuItem.test.tsx │ │ │ │ ├── RadioSelect.test.tsx │ │ │ │ ├── RelativeModal.test.tsx │ │ │ │ ├── RelativeTooltip.test.tsx │ │ │ │ ├── Ripple.test.tsx │ │ │ │ ├── Snackbar.test.tsx │ │ │ │ ├── Subtitle.test.tsx │ │ │ │ ├── TabularMetadata.test.tsx │ │ │ │ ├── TextField.test.tsx │ │ │ │ ├── TextLink.test.tsx │ │ │ │ └── Tooltip.test.tsx │ │ ├── icons │ │ │ ├── .eslintrc.js │ │ │ ├── ArrowLeft.tsx │ │ │ ├── ArrowRight.tsx │ │ │ ├── BarChart.tsx │ │ │ ├── Clear.tsx │ │ │ ├── Close.tsx │ │ │ ├── Collapse.tsx │ │ │ ├── Document.tsx │ │ │ ├── Error.tsx │ │ │ ├── Folder.tsx │ │ │ ├── Hash.tsx │ │ │ ├── Heart.tsx │ │ │ ├── Info.tsx │ │ │ ├── LineChart.tsx │ │ │ ├── Link.tsx │ │ │ ├── ListBulleted.tsx │ │ │ ├── Logo.tsx │ │ │ ├── Menu.tsx │ │ │ ├── More.tsx │ │ │ ├── OpenInExternal.tsx │ │ │ ├── Remove.tsx │ │ │ ├── Table.tsx │ │ │ ├── Warning.tsx │ │ │ └── styles.ts │ │ ├── images │ │ │ └── favicon.png │ │ ├── modules │ │ │ └── ColorScale.ts │ │ ├── screens │ │ │ ├── Main.tsx │ │ │ └── __tests__ │ │ │ │ └── Main.test.tsx │ │ ├── server │ │ │ ├── __tests__ │ │ │ │ └── index.test.tsx │ │ │ └── index.tsx │ │ ├── store │ │ │ ├── __tests__ │ │ │ │ ├── reducer.test.ts │ │ │ │ └── utils.test.ts │ │ │ ├── actions.ts │ │ │ ├── index.ts │ │ │ ├── mock.ts │ │ │ ├── reducer.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── theme.ts │ │ └── views │ │ │ ├── AppBar.tsx │ │ │ ├── Comparison.tsx │ │ │ ├── Drawer.tsx │ │ │ ├── Graph.tsx │ │ │ ├── Snacks.tsx │ │ │ └── __tests__ │ │ │ ├── AppBar.test.tsx │ │ │ └── Graph.test.tsx │ └── tsconfig.json ├── build │ ├── README.md │ ├── index.js │ ├── index.ts │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ └── index.test.ts │ │ └── index.ts │ └── tsconfig.json ├── cli │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── bin.ts │ │ └── commands │ │ │ ├── __tests__ │ │ │ ├── create-build.test.ts │ │ │ ├── stat-artifacts.test.ts │ │ │ ├── upload-build.test.ts │ │ │ └── version.test.ts │ │ │ ├── create-build.ts │ │ │ ├── get-build.ts │ │ │ ├── stat-artifacts.ts │ │ │ ├── upload-build.ts │ │ │ └── version.ts │ └── tsconfig.json ├── comparator │ ├── README.md │ ├── index.js │ ├── index.ts │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── ArtifactDelta.ts │ │ ├── BuildDelta.ts │ │ ├── __tests__ │ │ │ ├── ArtifactDelta.test.ts │ │ │ ├── BuildDelta.test.ts │ │ │ ├── artifact-math.test.ts │ │ │ └── index.test.ts │ │ ├── artifact-math.ts │ │ └── index.ts │ └── tsconfig.json ├── fixtures │ ├── .eslintrc.js │ ├── README.md │ ├── builds-large │ │ ├── 02bdf313fa99040980ed2a83667fa133b8e5c03f.json │ │ ├── 02f7aaa689a3b50de1c9a9816528ca216d7c62a5.json │ │ ├── 038af502b8663341d3f39fdb51472efca16d692e.json │ │ ├── 042e66da793d071a1938737827417f840e1d04be.json │ │ ├── 05392199a9ab764c9f6733ccc438394ce4960faa.json │ │ ├── 059e66cbf5487d0228f0f0fb69eba4b69ca0f3de.json │ │ ├── 0a651288e67cfdf935d410b414b62edbcab31c97.json │ │ ├── 0d0e6a13380539f0ebd7460bb5ee3ac64dd12704.json │ │ ├── 0d7dda5ced7c88b30fc61ab6b7aa95e00b44a051.json │ │ ├── 0dc5dd7ddb4188432785101b14d4d0843d09f77b.json │ │ ├── 0f66e01e2f03c959bb2c4bb0220a9fe853b1f7ca.json │ │ ├── 108d099e3073c347dd4f59a24c5ecb18a3534e48.json │ │ ├── 109ed16292d5507b8a19c57653f7bafd010653be.json │ │ ├── 12d7b29e44d9876cdf12c57319ddf83f10811c62.json │ │ ├── 15b436cd9a6c26ccb5a29c42cc72fcd236ab68db.json │ │ ├── 16851b9b198253cea1fdb4660c698f947b2562b1.json │ │ ├── 16a80d93b835cd91d66e578bd427bc11e70f077f.json │ │ ├── 174c202f085a6589dce828b3f81396b8ce37909c.json │ │ ├── 19110d41fe39332b259b02c673eb49139c45fb89.json │ │ ├── 1df5a4c4b3dfeb3e3c78ef03cc6b006cd6a9ca5f.json │ │ ├── 1dff4e067686e52a5052b7420f307528300516c8.json │ │ ├── 1edc8aae8820bb7f717323873d08c082a1fb2d92.json │ │ ├── 1f52b1fc8a1a036f63bed0ee21eb8d91f4c43e12.json │ │ ├── 1f918739ce05149c3ee7603a28102cbe268cd0b3.json │ │ ├── 20724544d271f98ff71435a6c48f239649e82310.json │ │ ├── 20d4e8e78265f93d80f1fb52ee4076367ef3fe15.json │ │ ├── 214fe0cd7545840e133ec645df3057cf96d14390.json │ │ ├── 242c11b770dc9d2750843ea1e4f19b41af7c65b6.json │ │ ├── 24a6110b01bd5681d4e4827b09ed924ea9f2e701.json │ │ ├── 24dd1c12bba08a6bd667541156211b7ab3198c2c.json │ │ ├── 265c153c5cbeeee681442d0050f41d4b8f92ce56.json │ │ ├── 26c8cc84ae382aed064e94fdfb10d91b5d2cc560.json │ │ ├── 27055696a6fe26cf3ae2eaf6faa98ab5192045cb.json │ │ ├── 2802f95211524614860a60e41021aadfcdbcd2dd.json │ │ ├── 283a64e194bfaa21fe2cbf05e2dbb95094bd4d2c.json │ │ ├── 2888ba5e296ca4b64e19393e5a4c16bcab0b4fd3.json │ │ ├── 2bc5bd7eab37ca9a18f2c05f1aadec8573c81505.json │ │ ├── 2ca236bcd08a660df1e8143a2f86a2f9a6a3c7c3.json │ │ ├── 313b3fa1785585c8b81ab268564424a0c2f4d9c9.json │ │ ├── 32befedefb354547dc8c9d3c94cc2c98075a1d71.json │ │ ├── 32c27eff6715a60fbd30c7c7aa3d14e3e43aeb33.json │ │ ├── 336e8182d372f24445dbd997ed795134455b8bfd.json │ │ ├── 35321ff75d9a7a76a5a458660e10875909364832.json │ │ ├── 35bcb71a6bec2c88e963fcf63026922cc3696a54.json │ │ ├── 363f622f8c0575362acf0f1f49fafd9edfd28e6f.json │ │ ├── 37b03d0035c13e255ccdd8caf52b0e66545df891.json │ │ ├── 391bb1d4f36a4ecf5472a1a77b6f9a8268d6eac1.json │ │ ├── 3d89368e2e21f165789d5e0e6446a2ae2ac8c0c3.json │ │ ├── 3eaa932131748311a8ce7a9725780b638b4b52f4.json │ │ ├── 40e4f7c278c6348c2b3686dd72666628dea7a999.json │ │ ├── 4989d24b399d2fd5b87ad907098ed9aabaddd288.json │ │ ├── 4b937dd21ad3616608cffca7eaa5ab8b0dbf0f7c.json │ │ ├── 4e4447b40ad1c916eceab77c11d6d14b01906712.json │ │ ├── 4e6e79ac145b7c99c1b2cef04dd0109443618202.json │ │ ├── 4fa802dba4c0afbef437083fa45c740368390e67.json │ │ ├── 533b7a39a6a6a92eff92449a1510ad9d72fef9ed.json │ │ ├── 55bd5b55e0e47ae1e4a372419d9d334c0f74d030.json │ │ ├── 56b2b9a027d4fdd31665d770e75b7a833f8eaa7c.json │ │ ├── 58a972c65b72c2215fb3d1906bc4b5b62d573eb3.json │ │ ├── 59c922c7d91785e6a5543cf4d14b51330ec0a4ce.json │ │ ├── 5bc11f734f68d51ecba5f47af433bdce7276cee2.json │ │ ├── 5bc45a245fb800350fdb7af23a770f0667d5faa7.json │ │ ├── 5c07a5e47f1370f345473dae38a546b4bc27f199.json │ │ ├── 5cf390f3beb154d224a7bc1725d8c810e25fc0f2.json │ │ ├── 5e539a67af6f3e7471ced7ebec262ab484fbe282.json │ │ ├── 5e98f70b2c315fcfb9328b4c7d9371372a3c80ac.json │ │ ├── 617931a8ea1859ab363e2c227b444d9a62572d3b.json │ │ ├── 61efd704f3a2a8a8be3c3c385f62ca113c444a84.json │ │ ├── 62cc3de51d22037b84f07d4543b3618880328f89.json │ │ ├── 6314a3142b62d25b23798a9b5de88418cf42abca.json │ │ ├── 6534f190f68cce2caab8aa14bfd9583984061343.json │ │ ├── 6b5d85b67180173ebd805851913150a8233b9d6a.json │ │ ├── 6e5eabea0748ac674a69f367f331ccb07703efaf.json │ │ ├── 6eb4aed2084f1aca83506741b5901b924d6aa31f.json │ │ ├── 7189e4f619856d2c9e7e03568ab0f56765a5f77a.json │ │ ├── 7224fb31d6e3e147478907de24c7fa2078ba2d1b.json │ │ ├── 7295bc43757e41a742a90a99a9d45dde4f3e09c2.json │ │ ├── 74ae1f09be1bb45beef7b5b0f6c0092bf6113d42.json │ │ ├── 7676a13f64c2c4b616aa31fd732fd54119be7f94.json │ │ ├── 768d64b7ff38e8b9e5368700aaf35d254e7f8d0e.json │ │ ├── 78a61348337e7a3440734dd6e7554e8a1e6d874a.json │ │ ├── 79a9122d368214b628344ce260c8ce9f16cb9e0d.json │ │ ├── 7aadeb749a17a06b6abb0fad49de392645930126.json │ │ ├── 7b1c5069953363cee3e624ecb19fbf4e5a1496af.json │ │ ├── 7d4ae9984071aad0d436dc9dca425ece070ceaec.json │ │ ├── 7f6961eb1fce989788c717d7ed7cadbcde086857.json │ │ ├── 804201b3fbc7021863992877b5cc0548a35c19ea.json │ │ ├── 8052a710c2befdf8fe2c78635da546794fbb790a.json │ │ ├── 827f0e70cea43e076bcbec03a16f1d7c70aa7347.json │ │ ├── 835f2e50f95baf967798327eec4fc3ab0e0ee83f.json │ │ ├── 8464307d13c834f643017c440fcf2b38ef894deb.json │ │ ├── 859b00da16f32007f373faa3f7880a3eee44ca95.json │ │ ├── 85d5aa0db580042c36031007b1d7fad1d263a1cf.json │ │ ├── 87c8684055979726c0095fa1c6ccd7253e02ff04.json │ │ ├── 89147300968ef369a29402ec0b465c367ffceaf0.json │ │ ├── 899b6868d68898a3f9be17d0e9f9efb210c8c22b.json │ │ ├── 8af1672267e07074e3623113b895e32902f8b8e9.json │ │ ├── 8b11a9a770fba7f0e72ecba6b34b263416429e15.json │ │ ├── 8ba674395f1abc702bdfe1840f6a78e3d506d031.json │ │ ├── 8cbd4d15efc07a6ba447404745decde216bcbc2b.json │ │ ├── 8e3a06e1076d54237871418b394c4574b30810b1.json │ │ ├── 8fbf45e985ed42188dbd686960e378d9c4f13ab5.json │ │ ├── 916993b92e20bc6530b748176168e86fc2ba3f15.json │ │ ├── 929fdf0ab2ae9e9b532fb81cf1267f93d9c74ee5.json │ │ ├── 934772440ab687076c0605e5413213aba25347ed.json │ │ ├── 935af3902229bbb6f14d05d31f2b01a50816e7ba.json │ │ ├── 93de650c4242d7ef84cbc69c9d7675ae541338e4.json │ │ ├── 98755a05c40c55b4c894521cee29b43ae3266d4a.json │ │ ├── 997a86636f0f73c57b14bb5c235c4f5961202002.json │ │ ├── 9a0b36550bfe7d5192c6e9f99843ee377c59e045.json │ │ ├── 9acccc33dc58beeaafb0a99aa8f58d4b9f1b3838.json │ │ ├── 9b2d6bc10b558d031faa598d22697becc6477395.json │ │ ├── a46f4355d9b446f44f9f9fd3eb35ed863792657c.json │ │ ├── a6f47eeb26012161426922c3e060ddbbbe87d96d.json │ │ ├── a76f84c9eb8cdd0cc3107e092df44321dc79cf9b.json │ │ ├── a778a7ab5ae6d149b6069c58fcec74aaf3069e2b.json │ │ ├── a9070407f23a90dd90b28dfd37ff41e6625875ed.json │ │ ├── a9bb0fbb37f13c431420585c5d25ae835e938e5f.json │ │ ├── a9d3d21f41295d454f6b93e493ac05ca95e6f55a.json │ │ ├── abb299d0699a72c0fcf3ed56514de3218b279d25.json │ │ ├── ac5a2cba0fb7a28b15a7290b43a4630566251e47.json │ │ ├── acaa24eb071762729b0ef50c5860431a33e6fb6d.json │ │ ├── acbd72dc7d59e9102c591533620676be2d96f3d1.json │ │ ├── ad118e2efd7dc8a7106018ce4253a38639fa2ecd.json │ │ ├── ad6e9725f3e1d4e2067c00632d8b9b6992652545.json │ │ ├── ad8d0eb665196afe9ad66d7d40c514380fda55c2.json │ │ ├── af1e4d8b772e51abb953a0e2d9e210ba861670d6.json │ │ ├── af7aed7c8c0e85456f5c5365412b6066f5499a14.json │ │ ├── b38bfb7db2c77720bd83ef061d1d3376513484a0.json │ │ ├── b4bcdcd6f1fd0dae33895b3046d3850bc8bad479.json │ │ ├── b930efa9b71f80f278a1e8a77b3fb59dcb072781.json │ │ ├── ba02e05709726c04073dd6b0e3cdaad63f7e4a70.json │ │ ├── ba6f3f3d53448bb365d0bd9d086aba57d36b32e9.json │ │ ├── bac06a515b5c4b3421916d103214567438c17aa6.json │ │ ├── bb979ad75cfb903de9721810585ac785de1cd4b6.json │ │ ├── be5313924b2d90e89e5dc419a86d44516d5a96b2.json │ │ ├── c28e7503bae87904c47dad46b2ba71a6099202e9.json │ │ ├── c30fe207650d6cf0d101c14eefde946ddf04833c.json │ │ ├── c3c21aa17a38aba6067e0d32e19948c0808d558f.json │ │ ├── c4dc5e627a47576a62d026a68c731d34f26fd392.json │ │ ├── c74618cd6065a4204d092babd452b1df5c515d11.json │ │ ├── c7b5fdcc7cde4fb909f75c6b2c36e38b4763f026.json │ │ ├── c7b655c42c797d97e14834f222880c01afbaee92.json │ │ ├── cb2061c144c1db6d9399cbe7451c8a89c94256f2.json │ │ ├── cd45c648992c33f2c38bd9d91714e9d3dee011bb.json │ │ ├── ce91bb8414321c4205cf5e959ab21a02483dfc59.json │ │ ├── cff0db25bc18fcb24600af2681d9f158eadf0172.json │ │ ├── cff8b1a0fe1b44c00a36961e59f198818fe02af9.json │ │ ├── d07ede0507e371503e1f5debb72ad7d6771fe943.json │ │ ├── d11d9cca61c2970ed931acea3c63ccc1b997d78f.json │ │ ├── d2b39a334c041166b2dec3ee47ab7ef399d39390.json │ │ ├── d4887ae61b0f1a3cca31f5e182d7911af89a223a.json │ │ ├── d5c1e481ee5a88cc3c357770d214ed39168ad274.json │ │ ├── d60995e666a191680af2fbd2d077c369717f47e4.json │ │ ├── d929ffe845c39f1a0ba67ff410ffe597288cac18.json │ │ ├── e0f48a8d0433fe2a37bd0d065f1f5b3712a431d2.json │ │ ├── e4de5ffcb8341d3f3cc563c7c71ebecd195335eb.json │ │ ├── e60ce95f883ff6256a0b214d3700c7455ae10bd9.json │ │ ├── e7404028794a630d65fe02474dd9c318cf582078.json │ │ ├── e92478cb2aa4c0996ffc1aa481acb66dc37eac99.json │ │ ├── e94e2fb286cd5d8505a745a54ee5b82f1def3a09.json │ │ ├── e9e01c86de760a03ea713c0f41be6bab99be13e9.json │ │ ├── ed16ec1c51119cdd13d909f2fa4e7c97b9f7302d.json │ │ ├── ee9735e8a642d6a02398276178d7a1df9b5f82de.json │ │ ├── ef69c1bccc295531ef21d75086fa0a28861ec5c6.json │ │ ├── f030863898f4b3dee0ce8811ce5de9dca0b6bce9.json │ │ ├── f147ec9d9c6f18ed33cbeb87c9661077eb2bf9da.json │ │ ├── f2d1d162fc6b42c9e2c86662bcc1d9b644539dcc.json │ │ ├── f47cdafdc6fb4eec8faaac00afb0f9dbcef8561b.json │ │ ├── f63d52e046a5ff0f06fd366dfb7e12c026ea6594.json │ │ ├── f9b21fbd65fe5b2c3418544c7ecd697ae0493394.json │ │ ├── fa0bf6d9ebfcc5c6dd2d5e542204723289034cc1.json │ │ ├── fa1024039ac1478c343ae849830ac99089c65098.json │ │ ├── fa57cd0e94b9472d171953ce5e1fe374ec622a9c.json │ │ ├── faada294394bf7abf762dd439772f18350e56f7f.json │ │ ├── fccbaa06e5aa7a34016a275604c52de7c6062505.json │ │ ├── fd06ace7fe7c4ed6ecd03437c5839f72c3be04da.json │ │ ├── fe540d67a3b60dd98d681e7b47245c36184c366e.json │ │ └── fe541551877fc3b8512e96ed8e343cec705c6193.json │ ├── builds-medium │ │ ├── 01141f29743fb2bdd7e176cf919fc964025cea5a.json │ │ ├── 1327ec715a0a2dd204eb7bede5f86bcf9c78f324.json │ │ ├── 188aa19da077a0db3972b52f0f1b580fafe75f49.json │ │ ├── 19868a0432f039d45783bca1845cede313fbfbe1.json │ │ ├── 1bf811ec249c2b13a314e127312b2d760f6658e2.json │ │ ├── 1d088ea7d0aa3a93fa0d5c0497a3fc96b33ce1c9.json │ │ ├── 20fd519231b53d27e4ce7c3914f815e0b02684b4.json │ │ ├── 22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04.json │ │ ├── 243024909db66ac3c3e48d2ffe4015f049609834.json │ │ ├── 298dfe16af18f420dfbb192d2f38b1ac6022abb8.json │ │ ├── 30af629d1d4c9f2f199cec5f572a019d4198004c.json │ │ ├── 3d7d22d51db1369426c4ae1a300180c325e0b61d.json │ │ ├── 4a8882483a664401a602f64a882d0ed7fb1763cb.json │ │ ├── 4c9d2a8fc24d37c967cf0e596f293d3168b7696e.json │ │ ├── 4d06472b21aef812ffb2ce70dbd41a08529fbfe6.json │ │ ├── 5113828d6b0e628a78fa383a53933c413b6bdbef.json │ │ ├── 6666692feabaa56483f2d21eaec994bf75ac2583.json │ │ ├── 703baac11f1be4b2d02ddb89de6789c7605183b0.json │ │ ├── 71a383fdf7ae287aa1f18ebe7e270b0520da2536.json │ │ ├── 7f68d84c59b4604ecd88943eae4a88a41b137ed6.json │ │ ├── 8de1726f26559b4191f4826817df64baff07711d.json │ │ ├── 90aedcf89f12d2013f58105e0b1493e85c72e92c.json │ │ ├── 946f0519038552aac5d36b99dc1e5dbd23bba6bc.json │ │ ├── 99649d52f3bf158766c90120b7c3a60d34d6f76c.json │ │ ├── 9d5afc23011f42b7794f137e78af45527cb3b58b.json │ │ ├── a7f9952a68c69bbdfd73e7f8dee230d281b0c2b8.json │ │ ├── b4098b3a46c207924e1e9edfa22fc21960524c72.json │ │ ├── bd691addbd384df852e116f4452c8e0e615d1b6f.json │ │ ├── bf4c66a3ee2a8c8355f8bd1c207593d32fa29d6b.json │ │ ├── c00dc16fb85a6de1e51e9eb0f13056443614769e.json │ │ ├── c0bdda3979f0badcbc3338c66b826ee1d5903e2a.json │ │ ├── c74d7997da43e8b51b66b09a75cb24f2b49fe645.json │ │ ├── d167fec4ee6a0bec23a06ce6085a93af3379f287.json │ │ ├── d48a6bde107f8663818fd5d2460ee059365cc5e1.json │ │ ├── d691af0754ce7dcc08c76a1ed2d8259962cb9d1d.json │ │ ├── f7cb67d8312045fe3526cd7c8039c31a3183b03d.json │ │ ├── f90b4831ad2bf921d5ccccfffeb61f6486567777.json │ │ ├── f915ee2df9a9d3db4a8fb1a8b8c0e82f30338e13.json │ │ ├── fa752a9ce64a440f16e2c3670aa97a28be25c71f.json │ │ └── fc55ce1d311aa85bd0499c51e230255bbde19ef3.json │ ├── cli-configs │ │ ├── commonjs │ │ │ └── build-tracker.config.js │ │ ├── fakedist │ │ │ ├── main.1234567.js │ │ │ ├── test-folder │ │ │ │ └── test-no-extension │ │ │ └── vendor.js │ │ └── rc │ │ │ ├── .build-tracker-build-url-rc.js │ │ │ ├── .build-tracker-http-rc.js │ │ │ ├── .build-trackerrc-invalid.js │ │ │ └── .build-trackerrc.js │ ├── index.js │ ├── index.ts │ ├── package.json │ └── server-configs │ │ └── build-tracker.config.js ├── formatting │ ├── README.md │ ├── index.js │ ├── index.ts │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ └── index.test.ts │ │ └── index.ts │ └── tsconfig.json ├── server │ ├── README.md │ ├── index.js │ ├── index.ts │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── __tests__ │ │ │ ├── csp.test.ts │ │ │ └── server.test.ts │ │ ├── api │ │ │ ├── __tests__ │ │ │ │ ├── insert.test.ts │ │ │ │ ├── protected.test.ts │ │ │ │ └── read.test.ts │ │ │ ├── index.ts │ │ │ ├── insert.ts │ │ │ ├── protected.ts │ │ │ └── read.ts │ │ ├── commands │ │ │ ├── .eslintrc.js │ │ │ ├── __tests__ │ │ │ │ ├── run.test.ts │ │ │ │ ├── setup.test.ts │ │ │ │ └── version.test.ts │ │ │ ├── run.ts │ │ │ ├── seed.ts │ │ │ ├── setup.ts │ │ │ └── version.ts │ │ ├── csp.ts │ │ ├── index.ts │ │ ├── server.ts │ │ └── types.ts │ └── tsconfig.json └── types │ ├── README.md │ ├── index.js │ ├── index.ts │ ├── package.json │ ├── src │ └── index.ts │ └── tsconfig.json ├── tsconfig.json ├── tsconfig.test.json ├── typings └── react-native │ ├── LICENSE │ ├── index.d.ts │ └── package.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | docs/src/theme 4 | docs/build 5 | typings 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: 4 | - paularmstrong 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | about: If something isn't working as expected 🤔. 4 | --- 5 | 6 | 14 | 15 | ## Problem 16 | 17 | 23 | 24 | ## Steps to Reproduce 25 | 26 | 35 | 36 | ## Expected Result 37 | 38 | 44 | 45 | ## Actual Result 46 | 47 | 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature Request 3 | about: I have a suggestion (and I might like to implement it myself 😀)! 4 | --- 5 | 6 | 14 | 15 | ## Problem 16 | 17 | 26 | 27 | ## Proposed solution 28 | 29 | 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support_question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤗 Support or Question 3 | about: If you have a question 💬, please check for help on StackOverflow! 4 | --- 5 | 6 | # General support is not provided in GitHub issues! 7 | 8 | Issues on GitHub are intended to be related to problems with Build Tracker itself. It's often seen as rude to ask basic usage questions here. Please make sure you read the documentation thoroughly and try searching the web for your problem. You are not likely to receive support with how to use it here 😁. 9 | 10 | --- 11 | 12 | If you can't find an answer, please submit them to one of this resources: 13 | 14 | - StackOverflow: https://stackoverflow.com/questions/tagged/build-tracker using the tag `build-tracker` 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | # Problem 6 | 7 | 10 | 11 | # Solution 12 | 13 | 22 | 23 | # TODO 24 | 25 | - [ ] 🤓 Add & update tests (always try to _increase_ test coverage) 26 | - [ ] 🔬 Ensure CI is passing (`yarn lint:ci`, `yarn test`, `yarn tsc:ci`) 27 | - [ ] 📖 Update relevant documentation 28 | -------------------------------------------------------------------------------- /.github/workflows/issue.yml: -------------------------------------------------------------------------------- 1 | name: Issue checker 2 | 3 | on: 4 | issues: 5 | types: [opened, edited] 6 | 7 | jobs: 8 | auto_close_issues: 9 | name: Verify well-formed 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v1 14 | - name: Automatically close issues that don't follow the issue template 15 | uses: lucasbento/auto-close-issues@v1.0.2 16 | with: 17 | github-token: ${{ secrets.GITHUB_TOKEN }} 18 | issue-close-message: "Hello @${issue.user.login} :wave: Thanks for helping out with Build Tracker!\n\nThis issue is being automatically closed because it does not follow the issue template. Please follow the template provided, do not delete any of the template contents, and include as much information as possible.\n\nYou can edit this issue and include the template to re-open or start with a [New Issue](https://github.com/paularmstrong/build-tracker/issues/new/choose)." 19 | closed-issues-label: '🙁 Issue template broken' 20 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: On push 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | run_tests: 10 | name: Typecheck, Test, Lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | - uses: actions/setup-node@master 15 | with: 16 | node-version: 12.x 17 | - run: npm install -g yarn 18 | - run: yarn install 19 | - name: Typecheck 20 | run: yarn tsc 21 | - name: Tests 22 | run: yarn test:ci --coverage 23 | - name: Lint 24 | run: yarn lint:ci 25 | - uses: codecov/codecov-action@v1 26 | 27 | upload-build: 28 | name: Upload build 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@master 32 | - uses: actions/setup-node@master 33 | with: 34 | node-version: 12.x 35 | - run: npm install -g yarn 36 | - run: yarn install 37 | - name: Build all 38 | run: yarn build 39 | - name: Upload build 40 | run: yarn ts-node src/cli/src/bin.ts upload-build -c demo/build-tracker-cli.config.js -b main 41 | env: 42 | BT_API_AUTH_TOKEN: ${{ secrets.BT_API_AUTH_TOKEN }} 43 | 44 | deploy: 45 | name: Deploy docs 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@master 49 | - uses: actions/setup-node@master 50 | with: 51 | node-version: 12.x 52 | - run: npm install -g yarn 53 | - run: yarn install 54 | - name: Build docs 55 | run: yarn workspace @build-tracker/docs build 56 | - name: Deploy documentation 57 | uses: peaceiris/actions-gh-pages@v3 58 | with: 59 | github_token: ${{ secrets.GITHUB_TOKEN }} 60 | publish_dir: ./docs/build 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules 3 | /**/node_modules 4 | *.log 5 | *.cache 6 | dist 7 | .eslintcache 8 | coverage 9 | .env 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please refer to the contributing guidelines at https://buildtracker.dev/docs/guides/contributing 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Paul Armstrong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: demo/build.sh 2 | web: demo/run.sh 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build Tracker [![Action status](https://github.com/paularmstrong/build-tracker/workflows/On%20push/badge.svg)](https://github.com/paularmstrong/build-tracker/actions) 2 | 3 | > **Warning** 4 | > Build Tracker needs some love. While the functionality is all present and works great, it's pretty out of date and would greatly benefit from your help. 5 | 6 | Build Tracker logo 7 | 8 | ## Documentation 9 | 10 | All documentation can be found at [buildtracker.dev](https://buildtracker.dev). 11 | 12 | ## Contributing 13 | 14 | ### [Code of Conduct](https://github.com/paularmstrong/build-tracker/blob/main/CODE_OF_CONDUCT.md) 15 | 16 | All project participants are expected to adhere to the repository's Code of Conduct. Please read [the full text](https://github.com/paularmstrong/build-tracker/blob/main/CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated. 17 | 18 | ### [Contributing Guide](https://buildtracker.dev/docs/guides/contributing) 19 | 20 | Read the contributing guide to learn about the development process, how to propose fixes and improvements, and how to build and test your changes. 21 | 22 | ## License 23 | 24 | Build Tracker is [MIT licensed](https://github.com/paularmstrong/build-tracker/blob/main/LICENSE). 25 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | branch: main 3 | require_ci_to_pass: no 4 | 5 | coverage: 6 | precision: 2 7 | round: down 8 | range: '90...100' 9 | 10 | comment: 11 | layout: 'diff,files' 12 | behavior: default 13 | require_changes: no 14 | -------------------------------------------------------------------------------- /config/api-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | applicationUrl: 'http://localhost:3000', 3 | artifacts: ['src/*/dist/**/*', 'plugins/*/dist/**/*'], 4 | }; 5 | -------------------------------------------------------------------------------- /config/copyright.txt: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | -------------------------------------------------------------------------------- /config/fixtures-large.js: -------------------------------------------------------------------------------- 1 | const makeFixtureConfig = require('./fixtures-base'); 2 | const { BudgetLevel, BudgetType } = require('@build-tracker/types'); 3 | 4 | module.exports = makeFixtureConfig('large', { 5 | artifacts: { 6 | budgets: { 7 | '*': [{ level: BudgetLevel.WARN, sizeKey: 'gzip', type: BudgetType.PERCENT_DELTA, maximum: 0.5 }], 8 | }, 9 | groups: [ 10 | { artifactMatch: /^web\//, name: 'Web' }, 11 | { artifactMatch: /^serviceworker\//, name: 'Service Worker' }, 12 | ], 13 | filters: [ 14 | // Ignore all but english 15 | /web\/i18n-\w+\/(?!en\.).+/, 16 | // Ignore all but english 17 | /web\/ondemand\.emoji\.(?!en\.).+/, 18 | /web\/ondemand\.countries-(?!en\.).+/, 19 | ], 20 | }, 21 | budgets: [{ level: BudgetLevel.WARN, sizeKey: 'gzip', type: BudgetType.DELTA, maximum: 1000 }], 22 | defaultSizeKey: 'gzip', 23 | name: 'Static Fixtures - Large Set', 24 | }); 25 | -------------------------------------------------------------------------------- /config/fixtures.js: -------------------------------------------------------------------------------- 1 | const makeFixtureConfig = require('./fixtures-base'); 2 | const { BudgetLevel, BudgetType } = require('@build-tracker/types'); 3 | 4 | module.exports = makeFixtureConfig('medium', { 5 | artifacts: { 6 | groups: [ 7 | { 8 | name: 'Entries', 9 | artifactMatch: /^\w+$/, 10 | budgets: [{ level: BudgetLevel.ERROR, sizeKey: 'gzip', type: BudgetType.SIZE, maximum: 250000 }], 11 | }, 12 | { 13 | name: 'Home', 14 | artifactNames: ['main', 'vendor', 'shared', 'runtime', 'bundle.HomeTimeline'], 15 | budgets: [{ level: BudgetLevel.ERROR, sizeKey: 'gzip', type: BudgetType.SIZE, maximum: 350000 }], 16 | }, 17 | ], 18 | }, 19 | defaultSizeKey: 'gzip', 20 | name: 'Static Fixtures', 21 | }); 22 | -------------------------------------------------------------------------------- /config/jest.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: ['/src/**/*.{ts,tsx}'], 3 | globals: { 4 | 'ts-jest': { 5 | isolatedModules: true, 6 | }, 7 | }, 8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], 9 | modulePathIgnorePatterns: ['dist', 'lib'], 10 | resetMocks: true, 11 | restoreMocks: true, 12 | testPathIgnorePatterns: ['node_modules'], 13 | testURL: 'https://build-tracker.local', 14 | timers: 'fake', 15 | transform: { 16 | '^.+\\.tsx?$': 'ts-jest', 17 | '^.+\\.jsx?$': 'babel-jest', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /config/mariadb.js: -------------------------------------------------------------------------------- 1 | const withMaria = require('@build-tracker/plugin-with-mariadb').default; 2 | const { BudgetLevel, BudgetType } = require('@build-tracker/types'); 3 | 4 | /** 5 | * To run a mariadb docker container: 6 | * docker run -p 3307:3306 --name bt-mariadb -e MYSQL_ROOT_PASSWORD=tacos -e MYSQL_ROOT_HOST=% -e MYSQL_DATABASE=buildtracker -d mariadb --default-authentication-plugin=mysql_native_password 7 | * yarn ts-node src/server/src/index.ts setup -c ./config/mariadb.js 8 | * yarn ts-node src/server/src/index.ts seed -c ./config/mariadb.js 9 | */ 10 | 11 | module.exports = withMaria({ 12 | defaultBranch: 'main', 13 | dev: true, 14 | artifacts: { 15 | groups: [ 16 | { 17 | name: 'Web App', 18 | artifactMatch: /^app\/client/, 19 | budgets: [{ level: BudgetLevel.ERROR, sizeKey: 'gzip', type: BudgetType.SIZE, maximum: 150000 }], 20 | }, 21 | ], 22 | }, 23 | mariadb: { 24 | user: 'root', 25 | password: 'tacos', 26 | database: 'buildtracker', 27 | host: '127.0.0.1', 28 | port: 3307, 29 | }, 30 | url: 'http://localhost:3000', 31 | }); 32 | -------------------------------------------------------------------------------- /config/mysql.js: -------------------------------------------------------------------------------- 1 | const withMysql = require('@build-tracker/plugin-with-mysql').default; 2 | const { BudgetLevel, BudgetType } = require('@build-tracker/types'); 3 | 4 | /** 5 | * To run a mysql docker container: 6 | * docker run -p 3306:3306 --name bt-mysql -e MYSQL_ROOT_PASSWORD=tacos -e MYSQL_ROOT_HOST=% -e MYSQL_DATABASE=buildtracker -d mysql --default-authentication-plugin=mysql_native_password 7 | * yarn ts-node src/server/src/index.ts setup -c ./config/mysql.js 8 | * yarn ts-node src/server/src/index.ts seed -c ./config/mysql.js 9 | */ 10 | 11 | module.exports = withMysql({ 12 | defaultBranch: 'main', 13 | dev: true, 14 | artifacts: { 15 | groups: [ 16 | { 17 | name: 'Web App', 18 | artifactMatch: /^app\/client/, 19 | budgets: [{ level: BudgetLevel.ERROR, sizeKey: 'gzip', type: BudgetType.SIZE, maximum: 150000 }], 20 | }, 21 | ], 22 | }, 23 | mysql: { 24 | user: 'root', 25 | password: 'tacos', 26 | database: 'buildtracker', 27 | host: '127.0.0.1', 28 | port: 3306, 29 | }, 30 | url: 'http://localhost:3000', 31 | }); 32 | -------------------------------------------------------------------------------- /config/postgres.js: -------------------------------------------------------------------------------- 1 | const withPostgres = require('@build-tracker/plugin-with-postgres').default; 2 | const { BudgetLevel, BudgetType } = require('@build-tracker/types'); 3 | 4 | const { config } = require('dotenv'); 5 | config(); 6 | 7 | /** 8 | * To run a mysql docker container: 9 | * docker run --name pg -e POSTGRES_PASSWORD=mysecretpassword -e POSTGRES_DB=buildtracker -p 54320:5432 -d postgres 10 | * yarn ts-node src/server/src/index.ts setup -c ./config/postgres.js 11 | * yarn ts-node src/server/src/index.ts seed -c ./config/postgres.js 12 | */ 13 | 14 | module.exports = withPostgres({ 15 | defaultBranch: 'main', 16 | dev: true, 17 | artifacts: { 18 | groups: [ 19 | { 20 | name: 'Web App', 21 | artifactMatch: /^app\/client/, 22 | budgets: [{ level: BudgetLevel.ERROR, sizeKey: 'gzip', type: BudgetType.SIZE, maximum: 150000 }], 23 | }, 24 | ], 25 | }, 26 | pg: { 27 | connectionString: 'postgresql://postgres:mysecretpassword@127.0.0.1:54320/buildtracker', 28 | ssl: false, 29 | }, 30 | url: 'http://localhost:3000', 31 | }); 32 | -------------------------------------------------------------------------------- /demo/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'no-console': 'off' 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Not a code demo 2 | 3 | > This is not a demo that you should use to learn how to set up your own Build Tracker instance. This is code that deploys and runs the un-published source code from this repository in our online demo application https://build-tracker-demo.herokuapp.com 4 | 5 | Please read the [Getting Started documentation](https://buildtracker.dev/docs/installation) to learn how to set up your own instance. 6 | -------------------------------------------------------------------------------- /demo/build-tracker-cli.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const repoRoot = path.join(__dirname, '..'); 4 | 5 | const filenameHash = (fileName) => { 6 | const parts = path.basename(fileName, '.js').split('.'); 7 | return parts.length > 1 ? parts[parts.length - 1] : null; 8 | }; 9 | 10 | module.exports = { 11 | applicationUrl: 'https://build-tracker-demo.herokuapp.com', 12 | artifacts: ['src/*/dist/**/*.js', 'plugins/*/dist/**/*.js'], 13 | baseDir: repoRoot, 14 | cwd: repoRoot, 15 | filenameHash, 16 | buildUrlFormat: 'https://github.com/paularmstrong/build-tracker/commit/:revision', 17 | nameMapper: (fileName) => { 18 | const hash = filenameHash(fileName); 19 | let out = fileName.replace(/\.js$/, '').replace(/(plugins|src|dist)\//g, ''); 20 | return hash ? out.replace(`.${hash}`, '') : out; 21 | }, 22 | onCompare: ({ markdown }) => { 23 | console.log(markdown); 24 | return Promise.resolve(); 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /demo/build-tracker.config.js: -------------------------------------------------------------------------------- 1 | const withPostgres = require('@build-tracker/plugin-with-postgres').default; 2 | const { BudgetLevel, BudgetType } = require('@build-tracker/types'); 3 | 4 | module.exports = withPostgres({ 5 | artifacts: { 6 | groups: [ 7 | { 8 | name: 'Web App', 9 | artifactMatch: /^app\/client/, 10 | budgets: [{ level: BudgetLevel.WARN, sizeKey: 'gzip', type: BudgetType.SIZE, maximum: 153600 }], 11 | }, 12 | ], 13 | }, 14 | defaultBranch: 'main', 15 | pg: { 16 | connectionString: process.env.DATABASE_URL, 17 | ssl: true, 18 | }, 19 | port: process.env.PORT, 20 | url: 'https://build-tracker-demo.herokuapp.com', 21 | }); 22 | -------------------------------------------------------------------------------- /demo/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd `dirname $0`/../ 4 | 5 | node src/server/dist/index.js setup -c demo/build-tracker.config.js 6 | -------------------------------------------------------------------------------- /demo/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd `dirname $0`/../ 4 | 5 | node src/server/dist/index.js run -c demo/build-tracker.config.js 6 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Production 2 | /build 3 | 4 | # Generated files 5 | .docusaurus 6 | .cache-loader 7 | 8 | # Misc 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local 13 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | ``` 30 | $ GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /docs/docs/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | plugins: ['header'], 4 | overrides: [ 5 | { 6 | files: ['*.md'], 7 | rules: { 8 | '@typescript-eslint/explicit-function-return-type': 'off', 9 | '@typescript-eslint/no-unused-vars': 'off' 10 | } 11 | } 12 | ] 13 | }; 14 | -------------------------------------------------------------------------------- /docs/docs/guides/guides.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: guides 3 | title: Guides 4 | sidebar_label: Guides 5 | --- 6 | 7 | > This page is currently just a placeholder. 8 | > It is a jumping point for guides and ideas for doing more with Build Tracker. If you have ideas for more, please help by [contributing](contributing)! 9 | 10 | - [Deploy to Heroku](heroku) 11 | - [GitHub Actions](github-actions) 12 | - [Continuous Integration](advanced-ci) 13 | - [Contributing](contributing) 14 | -------------------------------------------------------------------------------- /docs/docs/guides/heroku.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: heroku 3 | title: Deploy to Heroku 4 | sidebar_label: Deploy to Heroku 5 | --- 6 | 7 | ## Prerequisites 8 | 9 | This documentation assumes that you have an active Heroku account. 10 | 11 | ## 1. Create a new repository from the template 12 | 13 | On GitHub, [create a new repository using the template](https://github.com/paularmstrong/build-tracker-heroku/generate) from [paularmstrong/build-tracker-heroku](https://github.com/paularmstrong/build-tracker-heroku). 14 | 15 | ## 2. Update the `build-tracker.config.js` file 16 | 17 | This is the `@build-tracker/server` configuration. The most important thing to do here is to set up your [performance budgets](/docs/budgets). There are also [more options](/docs/packages/server#configuration) available that you may want to set as well. It is not recommended to change any of the presets other than the `artifacts` option. 18 | 19 | ## 3. Deploy to Heroku 20 | 21 | If you are set up as a public GitHub repository, simply click the _Deploy to Heroku_ button at the top of the README.md file while viewing it on GitHub. 22 | 23 | If you are using a private repository, update the `README.md` link to use the `?template=` argument, replacing the repository `my-org/my-repository` in this string with the correct organization and repository path: 24 | 25 | ``` 26 | https://heroku.com/deploy?template=https://github.com/my-org/my-repository/tree/master 27 | ``` 28 | 29 | Once that URL is updated, you may click it any time. 30 | 31 | ## 4. Configure on Heroku 32 | 33 | Follow the instructions provided on Heroku to deploy your Build Tracker application. Take special note that the `BT_URL` option must be provided with the same application name if you plan on using a herokuapp.com domain. If you are going to configure any other domain, enter that instead. 34 | -------------------------------------------------------------------------------- /docs/docs/packages/api-errors.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: api-errors 3 | title: '@build-tracker/api-errors' 4 | sidebar_label: '@build-tracker/api-errors' 5 | --- 6 | 7 | This is a shared package for creating and comparing API errors returned from the Build Tracker server's API. 8 | 9 | ## 400 `ValidationError` 10 | 11 | The build that you are trying to insert into the database does not meet requirements. See the specific error message for more information. 12 | 13 | ## 401 `AuthError` 14 | 15 | If your server's API is protected with an API key and you do not provide it with requests requiring authentication, a 401 unauthorized response will be returned. 16 | 17 | ## 404 `NotFoundError` 18 | 19 | When querying one or more builds, you may find that they do not exist. This will result in a 404 not found error. 20 | 21 | ## 501 `UnimplementedError` 22 | 23 | If your API or API plugin does not support a method, a 501 unimplemented error will be returned. 24 | -------------------------------------------------------------------------------- /docs/docs/packages/app.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: app 3 | title: '@build-tracker/app' 4 | sidebar_label: '@build-tracker/app' 5 | --- 6 | 7 | This package includes a pre-compiled client and server-side application that is intended for use only with the Build Tracker Server. 8 | 9 | To use the Build Tracker Application, please install [`@build-tracker/server`](packages/server.md). 10 | -------------------------------------------------------------------------------- /docs/docs/packages/formatting.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: formatting 3 | title: '@build-tracker/formatting' 4 | sidebar_label: '@build-tracker/formatting' 5 | --- 6 | 7 | This package includes default formatting options for Build and Comparator data. nder most circumstances, these are internally shared and may not be needed for external integration purposes. 8 | -------------------------------------------------------------------------------- /docs/docs/packages/types.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: types 3 | title: '@build-tracker/types' 4 | sidebar_label: '@build-tracker/types' 5 | --- 6 | 7 | This package includes types and enums for working with other Build Tracker packages. 8 | 9 | # Exports 10 | 11 | ## `BudgetType` 12 | 13 | ```js 14 | import { BudgetType } from '@build-tracker/types'; 15 | ``` 16 | 17 | ### `BudgetType.SIZE` 18 | 19 | A size budget is an absolute limite on the total size of an artifact. This is the most simple budget, because passing or failing can be seen with a single build and no math. 20 | 21 | ### `BudgetType.DELTA` 22 | 23 | A budget delta is a limit on the amount of change allowed for one or more artifacts from one build to the next. 24 | 25 | | | First build | Second build | Δ (delta) | 26 | | ------- | ----------- | ------------ | --------- | 27 | | main.js | 40 KiB | 48 KiB | +8 KiB | 28 | 29 | ### `BudgetType.PERCENT_DELTA` 30 | 31 | The percent delta budget is a limit on the artifacts total size percent change from one build to the next. For this, we divide the delta by the size of the first build: 32 | 33 | > Note: this is a simplified version of the formula that does not account for edge cases 34 | 35 | ```js 36 | function percentDelta(firstSize, secondSize) { 37 | return (secondSize - firstSize) / firstSize; 38 | } 39 | 40 | percentDelta(40, 48); 41 | ``` 42 | 43 | | | First build | Second build | Δ% (percent delta) | 44 | | ------- | ----------- | ------------ | ------------------ | 45 | | main.js | 40 KiB | 48 KiB | + 20% | 46 | 47 | ## `BudgetType` 48 | 49 | ```js 50 | import { BudgetType } from '@build-tracker/types'; 51 | ``` 52 | 53 | ### `BudgetLevel.ERROR` 54 | 55 | ### `BudgetLevel.WARN` 56 | -------------------------------------------------------------------------------- /docs/docs/plugins/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: plugins 3 | title: Plugins 4 | --- 5 | 6 | Plugins are community-developed config composers that make running your server simple. Most plugins remove the need to develop custom database integrations. 7 | 8 | - Database Integrations 9 | - Postgres 10 | - [@build-tracker/plugin-with-postgres](/docs/plugins/withPostgres) 11 | - MariaDB 12 | - [@build-tracker/plugin-with-mariadb](/docs/plugins/withMariadb) 13 | - MySQL 14 | - [@build-tracker/plugin-with-mysql](/docs/plugins/withMysql) 15 | -------------------------------------------------------------------------------- /docs/docs/plugins/with-mariadb.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: withMariadb 3 | title: MariaDB 4 | --- 5 | 6 | Connecting your Build Tracker application to a Maria database is easy with the help of `@build-tracker/plugin-with-mariadb` 7 | 8 | ## Installation 9 | 10 | ```sh 11 | yarn add @build-tracker/plugin-with-mariadb@latest 12 | # or 13 | npm install --save @build-tracker/plugin-with-mariadb@latest 14 | ``` 15 | 16 | ## Configuration 17 | 18 | Edit your `build-tracker.config.js` file and compose your output configuration: 19 | 20 | ```js 21 | const withMariadb = require('@build-tracker/plugin-with-mariadb'); 22 | 23 | module.exports = withMariadb({ 24 | mariadb: { 25 | user: '', // default: process.env.MARIAUSER 26 | host: '', // default: process.env.MARIAHOST 27 | database: '', // default: process.env.MARIADATABASE 28 | password: '', // default: process.env.MARIAPASSWORD 29 | port: 3306, // default: process.env.MARIAPORT 30 | }, 31 | }); 32 | ``` 33 | 34 | All configuration options that are able to fall back on `process.env` environment variables can be written to your systems `ENV` or to a local `.env` file via [dotenv](https://github.com/motdotla/dotenv#readme). 35 | 36 | ### `host: string = process.env.MARIAHOST` 37 | 38 | Database host. 39 | 40 | ### `database: string = process.env.MARIAPASSWORD` 41 | 42 | Database name. 43 | 44 | ### `user: string = process.env.MARIAUSER` 45 | 46 | Database username with read access. 47 | 48 | ### `password: string = process.env.MARIADATABASE` 49 | 50 | Password for the given database username. 51 | 52 | ### `port: number = process.env.MARIAPORT = 3306` 53 | 54 | Database host port. 55 | -------------------------------------------------------------------------------- /docs/docs/plugins/with-mysql.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: withMysql 3 | title: MySQL 4 | --- 5 | 6 | Connecting your Build Tracker application to a MySQL database is easy with the help of `@build-tracker/plugin-with-mysql` 7 | 8 | ## Installation 9 | 10 | ```sh 11 | yarn add @build-tracker/plugin-with-mysql@latest 12 | # or 13 | npm install --save @build-tracker/plugin-with-mysql@latest 14 | ``` 15 | 16 | ## Configuration 17 | 18 | Edit your `build-tracker.config.js` file and compose your output configuration: 19 | 20 | ```js 21 | const withMysql = require('@build-tracker/plugin-with-mysql'); 22 | 23 | module.exports = withMysql({ 24 | mysql: { 25 | user: '', // default: process.env.MYSQLUSER 26 | host: '', // default: process.env.MYSQLHOST 27 | database: '', // default: process.env.MYSQLDATABASE 28 | password: '', // default: process.env.MYSQLPASSWORD 29 | port: 3306, // default: process.env.MYSQLPORT 30 | }, 31 | }); 32 | ``` 33 | 34 | All configuration options that are able to fall back on `process.env` environment variables can be written to your systems `ENV` or to a local `.env` file via [dotenv](https://github.com/motdotla/dotenv#readme). 35 | 36 | ### `host: string = process.env.MYSQLHOST` 37 | 38 | Database host. 39 | 40 | ### `database: string = process.env.MYSQLPASSWORD` 41 | 42 | Database name. 43 | 44 | ### `user: string = process.env.MYSQLUSER` 45 | 46 | Database username with read access. 47 | 48 | ### `password: string = process.env.MYSQLDATABASE` 49 | 50 | Password for the given database username. 51 | 52 | ### `port: number = process.env.MYSQLPORT = 3306` 53 | 54 | Database host port. 55 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@build-tracker/docs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "docusaurus start", 7 | "build": "docusaurus build", 8 | "swizzle": "docusaurus swizzle", 9 | "deploy": "docusaurus deploy" 10 | }, 11 | "dependencies": { 12 | "@docusaurus/core": "2.0.0-alpha.66", 13 | "@docusaurus/preset-classic": "2.0.0-alpha.66", 14 | "@mdx-js/react": "^1.5.8", 15 | "clsx": "^1.1.1", 16 | "react": "^16.8.4", 17 | "react-dom": "^16.8.4" 18 | }, 19 | "browserslist": { 20 | "production": [ 21 | ">0.2%", 22 | "not dead", 23 | "not op_mini all" 24 | ], 25 | "development": [ 26 | "last 1 chrome version", 27 | "last 1 firefox version", 28 | "last 1 safari version" 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | docs: { 3 | 'Getting Started': ['installation', 'budgets'], 4 | Guides: ['guides/guides', 'guides/heroku', 'guides/github-actions', 'guides/advanced-ci', 'guides/contributing'], 5 | Packages: [ 6 | 'packages/api-client', 7 | 'packages/api-errors', 8 | 'packages/app', 9 | 'packages/build', 10 | 'packages/cli', 11 | 'packages/comparator', 12 | 'packages/formatting', 13 | 'packages/server', 14 | 'packages/types', 15 | ], 16 | Plugins: ['plugins/plugins', 'plugins/withPostgres', 'plugins/withMariadb', 'plugins/withMysql'], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #12346c; 10 | --ifm-color-primary-dark: #0f2b59; 11 | --ifm-color-primary-darker: #0c2245; 12 | --ifm-color-primary-darkest: #091832; 13 | --ifm-color-primary-light: #3d5886; 14 | --ifm-color-primary-lighter: #687da1; 15 | --ifm-color-primary-lightest: #93a2bc; 16 | --ifm-code-font-size: 95%; 17 | 18 | --ifm-color-secondary: #a50011; 19 | --ifm-color-secondary-dark: #88000e; 20 | --ifm-color-secondary-darker: #6a000b; 21 | --ifm-color-secondary-darkest: #4b0008; 22 | --ifm-color-secondary-light: #b52e3c; 23 | --ifm-color-secondary-lighter: #c55c67; 24 | --ifm-color-secondary-lightest: #d68b92; 25 | } 26 | 27 | [data-theme='dark'] { 28 | --ifm-color-primary: var(--ifm-color-primary-lighter); 29 | } 30 | 31 | [data-theme='dark'] .hero.hero--primary { 32 | background-color: #12346c; 33 | } 34 | 35 | .docusaurus-highlight-code-line { 36 | background-color: rgb(72, 77, 91); 37 | display: block; 38 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 39 | padding: 0 var(--ifm-pre-padding); 40 | } 41 | -------------------------------------------------------------------------------- /docs/src/pages/styles.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | .heroBanner { 6 | padding: 4rem 0; 7 | text-align: start; 8 | position: relative; 9 | overflow: hidden; 10 | } 11 | 12 | @media screen and (max-width: 966px) { 13 | .heroBanner { 14 | padding: 2rem; 15 | } 16 | .heroContainer { 17 | flex-direction: column; 18 | } 19 | } 20 | 21 | .buttons { 22 | display: flex; 23 | justify-content: space-around; 24 | } 25 | 26 | .features { 27 | display: flex; 28 | align-items: center; 29 | padding: 2rem 0; 30 | width: 100%; 31 | } 32 | 33 | .featureImage { 34 | height: 200px; 35 | width: 200px; 36 | } 37 | 38 | .heroFirstLine { 39 | word-wrap: nowrap; 40 | display: flex; 41 | } 42 | 43 | .heroAmpersand { 44 | color: var(--ifm-color-primary-lightest); 45 | } 46 | 47 | .heroContainer { 48 | display: flex; 49 | } 50 | 51 | .heroBloat { 52 | color: var(--ifm-color-secondary-lighter); 53 | font-weight: 900; 54 | } 55 | 56 | .heroBoxes { 57 | flex-grow: 0.5; 58 | display: flex; 59 | justify-content: center; 60 | } 61 | -------------------------------------------------------------------------------- /docs/static/CNAME: -------------------------------------------------------------------------------- 1 | buildtracker.dev 2 | -------------------------------------------------------------------------------- /docs/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paularmstrong/build-tracker/863d80755ea9188c5c8b9fc6f1e6ab1bfe062313/docs/static/img/favicon.png -------------------------------------------------------------------------------- /docs/static/img/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paularmstrong/build-tracker/863d80755ea9188c5c8b9fc6f1e6ab1bfe062313/docs/static/img/favicon/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/github-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paularmstrong/build-tracker/863d80755ea9188c5c8b9fc6f1e6ab1bfe062313/docs/static/img/github-action.png -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paularmstrong/build-tracker/863d80755ea9188c5c8b9fc6f1e6ab1bfe062313/docs/static/img/logo.png -------------------------------------------------------------------------------- /docs/static/img/ogImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paularmstrong/build-tracker/863d80755ea9188c5c8b9fc6f1e6ab1bfe062313/docs/static/img/ogImage.png -------------------------------------------------------------------------------- /greenkeeper.json: -------------------------------------------------------------------------------- 1 | { 2 | "groups": { 3 | "repo": { 4 | "packages": ["package.json"] 5 | }, 6 | "packages": { 7 | "packages": [ 8 | "src/app/package.json", 9 | "src/build/package.json", 10 | "src/comparator/package.json", 11 | "src/formatting/package.json", 12 | "src/server/package.json", 13 | "src/types/package.json" 14 | ] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /husky.config.js: -------------------------------------------------------------------------------- 1 | const runYarnLock = 'yarn install --frozen-lockfile'; 2 | 3 | module.exports = { 4 | hooks: { 5 | 'post-checkout': `if [[ $HUSKY_GIT_PARAMS =~ 1$ ]]; then ${runYarnLock}; fi`, 6 | 'post-merge': runYarnLock, 7 | 'post-rebase': 'yarn install', 8 | 'pre-commit': 'yarn tsc && yarn lint-staged', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: ['/src/*', '/plugins/*'], 3 | testPathIgnorePatterns: ['src'], 4 | }; 5 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmClient": "yarn", 3 | "useWorkspaces": true, 4 | "version": "1.0.0", 5 | "packages": [ 6 | "src/*", 7 | "plugins/*" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{ts,tsx,js,jsx,json,md}': ['yarn lint', 'git add'], 3 | '*.{ts,tsx}': ['jest --bail --findRelatedTests'], 4 | }; 5 | -------------------------------------------------------------------------------- /plugins/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | plugins: ['header'], 4 | rules: { 5 | 'header/header': ['error', path.join(__dirname, '../config/copyright.txt')] 6 | }, 7 | overrides: [{ files: ['*.md', '*.json'], rules: { 'header/header': 'off' } }] 8 | }; 9 | -------------------------------------------------------------------------------- /plugins/with-mariadb/README.md: -------------------------------------------------------------------------------- 1 | # @build-tracker/plugin-with-mariadb 2 | 3 | > A server-configuration plugin for Build Tracker to enable reading build data from a MariaDB database. 4 | 5 | Read the [documentation online](https://buildtracker.dev/docs/plugins/withMariadb) for more information on getting started with Build Tracker. 6 | -------------------------------------------------------------------------------- /plugins/with-mariadb/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import withMariadb from './dist'; 5 | export * from './dist'; 6 | export default withMariadb; 7 | -------------------------------------------------------------------------------- /plugins/with-mariadb/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import withMariadb from './src'; 5 | export * from './src'; 6 | export default withMariadb; 7 | -------------------------------------------------------------------------------- /plugins/with-mariadb/jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | module.exports = { 5 | preset: '../../config/jest.js', 6 | displayName: 'with-mariadb', 7 | testEnvironment: 'node', 8 | rootDir: './', 9 | roots: ['/src'], 10 | }; 11 | -------------------------------------------------------------------------------- /plugins/with-mariadb/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@build-tracker/plugin-with-mariadb", 3 | "version": "1.0.0", 4 | "description": "Build Tracker server plugin for MariaDB", 5 | "author": "Paul Armstrong ", 6 | "repository": "git@github.com:paularmstrong/build-tracker.git", 7 | "license": "MIT", 8 | "main": "dist", 9 | "module": "./", 10 | "types": "dist/index.d.ts", 11 | "sideEffects": false, 12 | "files": [ 13 | "index.js", 14 | "dist" 15 | ], 16 | "scripts": { 17 | "build": "tsc", 18 | "clean": "rimraf dist", 19 | "tsc": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@build-tracker/api-errors": "^1.0.0", 23 | "@build-tracker/build": "^1.0.0", 24 | "@build-tracker/server": "^1.0.0", 25 | "@types/dotenv": "^6.1.0", 26 | "dotenv": "^7.0.0", 27 | "mariadb": "^2.0.5" 28 | }, 29 | "gitHead": "156788a6a199fc25e21adf354c106defd002afbf", 30 | "publishConfig": { 31 | "access": "public" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /plugins/with-mariadb/src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import withMariadb from '../'; 5 | 6 | const url = 'https://build-tracker.local'; 7 | 8 | jest.mock('mariadb'); 9 | 10 | describe('withMariadb', () => { 11 | test('preserves user-set config', () => { 12 | expect(withMariadb({ mariadb: {}, port: 1234, url })).toMatchObject({ port: 1234 }); 13 | }); 14 | 15 | test('adds setup', () => { 16 | expect(withMariadb({ mariadb: {}, url })).toHaveProperty('setup'); 17 | }); 18 | 19 | test('adds queries', () => { 20 | expect(withMariadb({ mariadb: {}, url })).toMatchObject({ 21 | queries: { 22 | build: { 23 | byRevision: expect.any(Function), 24 | insert: expect.any(Function), 25 | }, 26 | builds: { 27 | byRevisions: expect.any(Function), 28 | byRevisionRange: expect.any(Function), 29 | byTimeRange: expect.any(Function), 30 | }, 31 | }, 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /plugins/with-mariadb/src/__tests__/setup.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Mariadb from 'mariadb'; 5 | import setup from '../setup'; 6 | 7 | describe('withMariadb setup', () => { 8 | let query, release, setupFn; 9 | beforeEach(() => { 10 | query = jest.fn(); 11 | release = jest.fn(); 12 | // @ts-ignore 13 | jest.spyOn(Mariadb, 'createPool').mockImplementation(() => ({ 14 | // @ts-ignore 15 | getConnection: () => Promise.resolve({ query, release }), 16 | })); 17 | 18 | setupFn = setup(Mariadb.createPool({})); 19 | }); 20 | 21 | test('creates the table if not exists', async () => { 22 | await expect(setupFn()).resolves.toBe(true); 23 | expect(query).toHaveBeenCalledWith(expect.stringMatching('CREATE TABLE IF NOT EXISTS builds')); 24 | }); 25 | 26 | test('creates a multi-index', async () => { 27 | await expect(setupFn()).resolves.toBe(true); 28 | expect(query).toHaveBeenCalledWith( 29 | 'CREATE INDEX IF NOT EXISTS parent ON builds (revision, parentRevision, branch)' 30 | ); 31 | }); 32 | 33 | test('creates an index on timestamp', async () => { 34 | await expect(setupFn()).resolves.toBe(true); 35 | expect(query).toHaveBeenCalledWith('CREATE INDEX IF NOT EXISTS timestamp ON builds (timestamp)'); 36 | }); 37 | 38 | test('alters the branch column to length 256', async () => { 39 | await expect(setupFn()).resolves.toBe(true); 40 | expect(query).toHaveBeenCalledWith('ALTER TABLE builds MODIFY branch VARCHAR(256)'); 41 | }); 42 | 43 | test('releases the client on complete', async () => { 44 | await expect(setupFn()).resolves.toBe(true); 45 | expect(release).toHaveBeenCalled(); 46 | }); 47 | 48 | test('releases the client on error', async () => { 49 | const error = new Error('tacos'); 50 | query.mockReturnValueOnce(Promise.reject(error)); 51 | await expect(setupFn()).rejects.toThrow(error); 52 | expect(release).toHaveBeenCalled(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /plugins/with-mariadb/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { config } from 'dotenv'; 5 | import Queries from './queries'; 6 | import { ServerConfig } from '@build-tracker/server'; 7 | import setup from './setup'; 8 | import { createPool, PoolConfig } from 'mariadb'; 9 | 10 | config(); 11 | type Omit = Pick>; 12 | 13 | export default function withMariadb(config: Omit & { mariadb: PoolConfig }): ServerConfig { 14 | const { mariadb: mariaConfig } = config; 15 | const database = mariaConfig.database || process.env.MARIADATABASE || 'buildtracker'; 16 | 17 | const pool = createPool({ 18 | user: process.env.MARIAUSER, 19 | host: process.env.MARIAHOST, 20 | password: process.env.MARIAPASSWORD, 21 | port: process.env.MARIAPORT ? parseInt(process.env.MARIAPORT, 10) : 3306, 22 | ...mariaConfig, 23 | database, 24 | }); 25 | 26 | const queries = new Queries(pool); 27 | 28 | return { 29 | ...config, 30 | setup: setup(pool), 31 | queries: { 32 | build: { 33 | byRevision: queries.getByRevision, 34 | insert: queries.insert, 35 | }, 36 | builds: { 37 | byRevisions: queries.getByRevisions, 38 | byRevisionRange: queries.getByRevisionRange, 39 | byTimeRange: queries.getByTimeRange, 40 | recent: queries.getRecent, 41 | }, 42 | }, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /plugins/with-mariadb/src/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { Pool } from 'mariadb'; 5 | 6 | export default function setup(pool: Pool): () => Promise { 7 | return async function setup(): Promise { 8 | const client = await pool.getConnection(); 9 | try { 10 | await client.query(` 11 | CREATE TABLE IF NOT EXISTS builds( 12 | revision VARCHAR(64) PRIMARY KEY NOT NULL, 13 | branch VARCHAR(256) NOT NULL, 14 | parentRevision VARCHAR(64), 15 | timestamp INT NOT NULL, 16 | meta VARCHAR(1024), 17 | artifacts BLOB, 18 | CHECK (meta IS NULL OR JSON_VALID(meta)) 19 | )`); 20 | await client.query('CREATE INDEX IF NOT EXISTS parent ON builds (revision, parentRevision, branch)'); 21 | await client.query('CREATE INDEX IF NOT EXISTS timestamp ON builds (timestamp)'); 22 | await client.query('ALTER TABLE builds MODIFY branch VARCHAR(256)'); 23 | } catch (err) { 24 | client.release(); 25 | throw err; 26 | } 27 | client.release(); 28 | return Promise.resolve(true); 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /plugins/with-mariadb/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /plugins/with-mysql/README.md: -------------------------------------------------------------------------------- 1 | # @build-tracker/plugin-with-mysql 2 | 3 | > A server-configuration plugin for Build Tracker to enable reading build data from a MySQL database. 4 | 5 | Read the [documentation online](https://buildtracker.dev/docs/plugins/withMysql) for more information on getting started with Build Tracker. 6 | -------------------------------------------------------------------------------- /plugins/with-mysql/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import withMariaDB from './dist'; 5 | export * from './dist'; 6 | export default withMariaDB; 7 | -------------------------------------------------------------------------------- /plugins/with-mysql/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import withMysql from './src'; 5 | export * from './src'; 6 | export default withMysql; 7 | -------------------------------------------------------------------------------- /plugins/with-mysql/jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | module.exports = { 5 | preset: '../../config/jest.js', 6 | displayName: 'with-mysql', 7 | testEnvironment: 'node', 8 | rootDir: './', 9 | roots: ['/src'], 10 | }; 11 | -------------------------------------------------------------------------------- /plugins/with-mysql/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@build-tracker/plugin-with-mysql", 3 | "version": "1.0.0", 4 | "description": "Build Tracker server plugin for MariaDB", 5 | "author": "Paul Armstrong ", 6 | "repository": "git@github.com:paularmstrong/build-tracker.git", 7 | "license": "MIT", 8 | "main": "dist", 9 | "module": "./", 10 | "types": "dist/index.d.ts", 11 | "sideEffects": false, 12 | "files": [ 13 | "index.js", 14 | "dist" 15 | ], 16 | "scripts": { 17 | "build": "tsc", 18 | "clean": "rimraf dist", 19 | "tsc": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@build-tracker/api-errors": "^1.0.0", 23 | "@build-tracker/build": "^1.0.0", 24 | "@build-tracker/server": "^1.0.0", 25 | "@types/dotenv": "^6.1.0", 26 | "@types/mysql": "^2.15.7", 27 | "dotenv": "^7.0.0", 28 | "mysql": "^2.17.1" 29 | }, 30 | "gitHead": "156788a6a199fc25e21adf354c106defd002afbf", 31 | "publishConfig": { 32 | "access": "public" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /plugins/with-mysql/src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import withMysql from '../'; 5 | 6 | const url = 'https://build-tracker.local'; 7 | 8 | jest.mock('mysql'); 9 | 10 | describe('withMysql', () => { 11 | test('preserves user-set config', () => { 12 | expect(withMysql({ mysql: {}, port: 1234, url })).toMatchObject({ port: 1234 }); 13 | }); 14 | 15 | test('adds setup', () => { 16 | expect(withMysql({ mysql: {}, url })).toHaveProperty('setup'); 17 | }); 18 | 19 | test('adds queries', () => { 20 | expect(withMysql({ mysql: {}, url })).toMatchObject({ 21 | queries: { 22 | build: { 23 | byRevision: expect.any(Function), 24 | insert: expect.any(Function), 25 | }, 26 | builds: { 27 | byRevisions: expect.any(Function), 28 | byRevisionRange: expect.any(Function), 29 | byTimeRange: expect.any(Function), 30 | }, 31 | }, 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /plugins/with-mysql/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { config } from 'dotenv'; 5 | import Queries from './queries'; 6 | import { ServerConfig } from '@build-tracker/server'; 7 | import setup from './setup'; 8 | import { createPool, PoolConfig } from 'mysql'; 9 | 10 | config(); 11 | type Omit = Pick>; 12 | 13 | export default function withMysql(config: Omit & { mysql: PoolConfig }): ServerConfig { 14 | const { mysql: mysqlConfig } = config; 15 | const database = mysqlConfig.database || process.env.MYSQLDATABASE || 'buildtracker'; 16 | 17 | const pool = createPool({ 18 | user: process.env.MYSQLUSER, 19 | host: process.env.MYSQLHOST, 20 | password: process.env.MYSQLPASSWORD, 21 | port: process.env.MYSQLPORT ? parseInt(process.env.MYSQLPORT, 10) : 3306, 22 | ...mysqlConfig, 23 | database, 24 | }); 25 | 26 | const queries = new Queries(pool); 27 | 28 | return { 29 | ...config, 30 | setup: setup(pool), 31 | queries: { 32 | build: { 33 | byRevision: queries.getByRevision, 34 | insert: queries.insert, 35 | }, 36 | builds: { 37 | byRevisions: queries.getByRevisions, 38 | byRevisionRange: queries.getByRevisionRange, 39 | byTimeRange: queries.getByTimeRange, 40 | recent: queries.getRecent, 41 | }, 42 | }, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /plugins/with-mysql/src/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { Pool } from 'mysql'; 5 | 6 | export default function setup(pool: Pool): () => Promise { 7 | return async function setup(): Promise { 8 | return new Promise((resolve) => { 9 | pool.getConnection((err, client) => { 10 | if (err) { 11 | if (client) { 12 | client.release(); 13 | } 14 | throw err; 15 | } 16 | client.query( 17 | ` 18 | CREATE TABLE IF NOT EXISTS builds( 19 | revision VARCHAR(64) PRIMARY KEY NOT NULL, 20 | branch VARCHAR(256) NOT NULL, 21 | parentRevision VARCHAR(64), 22 | timestamp INT NOT NULL, 23 | meta VARCHAR(1024), 24 | artifacts BLOB, 25 | CHECK (meta IS NULL OR JSON_VALID(meta)) 26 | )`, 27 | (err) => { 28 | if (err) { 29 | client.release(); 30 | throw err; 31 | } 32 | client.query('ALTER TABLE builds ADD INDEX (revision, parentRevision, branch)', (err) => { 33 | if (err) { 34 | client.release(); 35 | throw err; 36 | } 37 | client.query('ALTER TABLE builds ADD INDEX (timestamp)', (err) => { 38 | if (err) { 39 | client.release(); 40 | throw err; 41 | } 42 | client.query('ALTER TABLE builds MODIFY branch VARCHAR(256)', (err) => { 43 | if (err) { 44 | client.release(); 45 | throw err; 46 | } 47 | client.release(); 48 | resolve(true); 49 | }); 50 | }); 51 | }); 52 | } 53 | ); 54 | }); 55 | }); 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /plugins/with-mysql/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /plugins/with-postgres/README.md: -------------------------------------------------------------------------------- 1 | # @build-tracker/plugin-with-postgres 2 | 3 | > A server-configuration plugin for Build Tracker to enable reading build data from a PostgreSQL database. 4 | 5 | Read the [documentation online](https://buildtracker.dev/docs/plugins/withPostgres) for more information on getting started with Build Tracker. 6 | -------------------------------------------------------------------------------- /plugins/with-postgres/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import withPostgres from './dist'; 5 | export * from './dist'; 6 | export default withPostgres; 7 | -------------------------------------------------------------------------------- /plugins/with-postgres/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import withPostgres from './src'; 5 | export * from './src'; 6 | export default withPostgres; 7 | -------------------------------------------------------------------------------- /plugins/with-postgres/jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | module.exports = { 5 | preset: '../../config/jest.js', 6 | displayName: 'with-postgres', 7 | testEnvironment: 'node', 8 | rootDir: './', 9 | roots: ['/src'], 10 | }; 11 | -------------------------------------------------------------------------------- /plugins/with-postgres/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@build-tracker/plugin-with-postgres", 3 | "version": "1.0.0", 4 | "description": "Build Tracker server plugin for PostgreSQL", 5 | "author": "Paul Armstrong ", 6 | "repository": "git@github.com:paularmstrong/build-tracker.git", 7 | "license": "MIT", 8 | "main": "dist", 9 | "module": "./", 10 | "types": "dist/index.d.ts", 11 | "sideEffects": false, 12 | "files": [ 13 | "index.js", 14 | "dist" 15 | ], 16 | "scripts": { 17 | "build": "tsc", 18 | "clean": "rimraf dist", 19 | "tsc": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@build-tracker/api-errors": "^1.0.0", 23 | "@build-tracker/build": "^1.0.0", 24 | "@build-tracker/server": "^1.0.0", 25 | "@types/dotenv": "^6.1.0", 26 | "@types/pg": "^7.4.13", 27 | "dotenv": "^7.0.0", 28 | "pg": "^7.8.2" 29 | }, 30 | "gitHead": "156788a6a199fc25e21adf354c106defd002afbf", 31 | "publishConfig": { 32 | "access": "public" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /plugins/with-postgres/src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { Pool } from 'pg'; 5 | import withPostgres from '../'; 6 | 7 | jest.mock('pg'); 8 | 9 | const url = 'https://build-tracker.local'; 10 | 11 | describe('withPostgres', () => { 12 | beforeAll(() => { 13 | // @ts-ignore 14 | Pool.mockImplementation(() => { 15 | return {}; 16 | }); 17 | }); 18 | 19 | test('preserves user-set config', () => { 20 | expect(withPostgres({ pg: {}, port: 1234, url })).toMatchObject({ port: 1234 }); 21 | }); 22 | 23 | test('adds setup', () => { 24 | expect(withPostgres({ pg: {}, url })).toHaveProperty('setup'); 25 | }); 26 | 27 | test('adds queries', () => { 28 | expect(withPostgres({ pg: {}, url })).toMatchObject({ 29 | queries: { 30 | build: { 31 | byRevision: expect.any(Function), 32 | insert: expect.any(Function), 33 | }, 34 | builds: { 35 | byRevisions: expect.any(Function), 36 | byRevisionRange: expect.any(Function), 37 | byTimeRange: expect.any(Function), 38 | }, 39 | }, 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /plugins/with-postgres/src/__tests__/setup.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { Pool } from 'pg'; 5 | import setup from '../setup'; 6 | 7 | jest.mock('pg'); 8 | 9 | describe('withPostgres', () => { 10 | let query, release, setupFn; 11 | beforeEach(() => { 12 | query = jest.fn(); 13 | release = jest.fn(); 14 | // @ts-ignore 15 | Pool.mockImplementation(() => ({ 16 | connect: () => ({ query, release }), 17 | })); 18 | 19 | setupFn = setup(new Pool()); 20 | }); 21 | 22 | test('creates the table if not exists', async () => { 23 | await expect(setupFn()).resolves.toBe(true); 24 | expect(query).toHaveBeenCalledWith(expect.stringMatching('CREATE TABLE IF NOT EXISTS builds')); 25 | }); 26 | 27 | test('creates a multi-index', async () => { 28 | await expect(setupFn()).resolves.toBe(true); 29 | expect(query).toHaveBeenCalledWith( 30 | 'CREATE INDEX IF NOT EXISTS parent ON builds (revision, parentRevision, branch)' 31 | ); 32 | }); 33 | 34 | test('creates an index on timestamp', async () => { 35 | await expect(setupFn()).resolves.toBe(true); 36 | expect(query).toHaveBeenCalledWith('CREATE INDEX IF NOT EXISTS timestamp ON builds (timestamp)'); 37 | }); 38 | 39 | test('alters the branch column to length 256', async () => { 40 | await expect(setupFn()).resolves.toBe(true); 41 | expect(query).toHaveBeenCalledWith('ALTER TABLE builds ALTER COLUMN branch TYPE VARCHAR(256)'); 42 | }); 43 | 44 | test('releases the client on complete', async () => { 45 | await expect(setupFn()).resolves.toBe(true); 46 | expect(release).toHaveBeenCalled(); 47 | }); 48 | 49 | test('releases the client on error', async () => { 50 | const error = new Error('tacos'); 51 | query.mockReturnValueOnce(Promise.reject(error)); 52 | await expect(setupFn()).rejects.toEqual(error); 53 | expect(release).toHaveBeenCalled(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /plugins/with-postgres/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { config } from 'dotenv'; 5 | import Queries from './queries'; 6 | import { ServerConfig } from '@build-tracker/server'; 7 | import setup from './setup'; 8 | import { Pool, PoolConfig } from 'pg'; 9 | 10 | config(); 11 | 12 | type Omit = Pick>; 13 | 14 | export default function withPostgres(config: Omit & { pg: PoolConfig }): ServerConfig { 15 | const { pg: pgConfig } = config; 16 | 17 | const pool = new Pool(pgConfig); 18 | 19 | const queries = new Queries(pool); 20 | 21 | return { 22 | ...config, 23 | setup: setup(pool), 24 | queries: { 25 | build: { 26 | byRevision: queries.getByRevision, 27 | insert: queries.insert, 28 | }, 29 | builds: { 30 | byRevisions: queries.getByRevisions, 31 | byRevisionRange: queries.getByRevisionRange, 32 | byTimeRange: queries.getByTimeRange, 33 | recent: queries.getRecent, 34 | }, 35 | }, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /plugins/with-postgres/src/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { Pool } from 'pg'; 5 | 6 | export default function setup(pool: Pool): () => Promise { 7 | return async function setup(): Promise { 8 | const client = await pool.connect(); 9 | try { 10 | await client.query(`CREATE TABLE IF NOT EXISTS builds( 11 | revision char(64) PRIMARY KEY NOT NULL, 12 | branch char(256) NOT NULL, 13 | parentRevision char(64), 14 | timestamp int NOT NULL, 15 | meta jsonb NOT NULL, 16 | artifacts jsonb NOT NULL 17 | )`); 18 | await client.query('CREATE INDEX IF NOT EXISTS parent ON builds (revision, parentRevision, branch)'); 19 | await client.query('CREATE INDEX IF NOT EXISTS timestamp ON builds (timestamp)'); 20 | await client.query('ALTER TABLE builds ALTER COLUMN branch TYPE VARCHAR(256)'); 21 | } catch (err) { 22 | client.release(); 23 | throw err; 24 | } 25 | client.release(); 26 | return Promise.resolve(true); 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /plugins/with-postgres/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | plugins: ['header'], 4 | rules: { 5 | 'header/header': ['error', path.join(__dirname, '../config/copyright.txt')] 6 | }, 7 | overrides: [{ files: ['*.md', '*.json'], rules: { 'header/header': 'off' } }] 8 | }; 9 | -------------------------------------------------------------------------------- /src/api-client/README.md: -------------------------------------------------------------------------------- 1 | # @build-tracker/api-client 2 | 3 | > A suite of tools for interfacing with a [Build Tracker](https://buildtracker.dev) instance's API. 4 | 5 | Read the [documentation online](https://buildtracker.dev/docs/packages/api-client) for more information on getting started with Build Tracker. 6 | -------------------------------------------------------------------------------- /src/api-client/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | export * from './dist'; 5 | -------------------------------------------------------------------------------- /src/api-client/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | export * from './src'; 5 | -------------------------------------------------------------------------------- /src/api-client/jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | module.exports = { 5 | preset: '../../config/jest.js', 6 | displayName: 'api-client', 7 | testEnvironment: 'node', 8 | rootDir: './', 9 | roots: ['/src'], 10 | }; 11 | -------------------------------------------------------------------------------- /src/api-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@build-tracker/api-client", 3 | "version": "1.0.0", 4 | "description": "Build Tracker tool for reading build artifact sizes and uploading to your server", 5 | "author": "Paul Armstrong ", 6 | "repository": "git@github.com:paularmstrong/build-tracker.git", 7 | "license": "MIT", 8 | "main": "dist", 9 | "module": "./", 10 | "types": "dist/index.d.ts", 11 | "sideEffects": false, 12 | "files": [ 13 | "index.js", 14 | "dist" 15 | ], 16 | "scripts": { 17 | "build": "tsc", 18 | "clean": "rimraf dist", 19 | "tsc": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@types/glob": "^7.1.1", 23 | "@types/node": "^11.10.4", 24 | "@types/yargs": "^15.0.0", 25 | "brotli-size": "^4.0.0", 26 | "cosmiconfig": "^7.0.0", 27 | "glob": "^7.1.3", 28 | "gzip-size": "^5.0.0", 29 | "path-to-regexp": "3.0.0", 30 | "yargs": "^15.0.0" 31 | }, 32 | "devDependencies": { 33 | "mock-spawn": "^0.2.6", 34 | "nock": "^10.0.6" 35 | }, 36 | "gitHead": "156788a6a199fc25e21adf354c106defd002afbf", 37 | "publishConfig": { 38 | "access": "public" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/api-client/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | 5 | export { default as createBuild } from './modules/create-build'; 6 | export { default as getBuild } from './modules/get-build'; 7 | export { Config, default as getConfig } from './modules/config'; 8 | export { default as statArtifacts } from './modules/stat-artifacts'; 9 | export { default as uploadBuild } from './modules/upload-build'; 10 | -------------------------------------------------------------------------------- /src/api-client/src/modules/__tests__/config.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as path from 'path'; 5 | import getConfig from '../config'; 6 | 7 | const commonjsConfig = require(path.join( 8 | path.dirname(require.resolve('@build-tracker/fixtures')), 9 | 'cli-configs/commonjs/build-tracker.config.js' 10 | )); 11 | const rcConfig = require(path.join( 12 | path.dirname(require.resolve('@build-tracker/fixtures')), 13 | 'cli-configs/rc/.build-trackerrc.js' 14 | )); 15 | 16 | describe('getConfig', () => { 17 | test('found via cosmiconfig when not provided', () => { 18 | jest 19 | .spyOn(process, 'cwd') 20 | .mockReturnValue(path.join(path.dirname(require.resolve('@build-tracker/fixtures')), 'cli-configs/commonjs')); 21 | return getConfig().then((result) => { 22 | expect(result).toEqual(commonjsConfig); 23 | }); 24 | }); 25 | 26 | test('loaded via cosmiconfig when provided', () => { 27 | return getConfig( 28 | path.join(path.dirname(require.resolve('@build-tracker/fixtures')), 'cli-configs/rc/.build-trackerrc.js') 29 | ).then((result) => { 30 | expect(result).toMatchObject(rcConfig); 31 | }); 32 | }); 33 | 34 | test('propagate errors', () => { 35 | return getConfig( 36 | path.join(path.dirname(require.resolve('@build-tracker/fixtures')), 'cli-configs/rc/.build-trackerrc-invalid.js') 37 | ).catch((e) => { 38 | expect(e.message).toMatch('test'); 39 | }); 40 | }); 41 | 42 | test('throws if no configuration found', () => { 43 | return getConfig('tacos').catch((e) => { 44 | expect(e.message).toMatch('Could not find configuration file'); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/api-client/src/modules/__tests__/get-build.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import config from '@build-tracker/fixtures/cli-configs/rc/.build-trackerrc.js'; 5 | const fakeBuild = require('@build-tracker/fixtures/builds-medium/1bf811ec249c2b13a314e127312b2d760f6658e2.json'); 6 | import getBuild from '../get-build'; 7 | import nock from 'nock'; 8 | import { NotFoundError } from '@build-tracker/api-errors'; 9 | 10 | describe('getBuild', () => { 11 | afterEach(() => { 12 | nock.cleanAll(); 13 | }); 14 | 15 | test('exist', () => { 16 | expect(getBuild).toBeDefined(); 17 | }); 18 | 19 | test('when found returns build', async () => { 20 | nock(`${config.applicationUrl}/`).get('/api/build/1bf811ec249c2b13a314e127312b2d760f6658e2').reply(200, fakeBuild); 21 | 22 | await expect(getBuild(config, '1bf811ec249c2b13a314e127312b2d760f6658e2')).resolves.toMatchObject(fakeBuild); 23 | }); 24 | 25 | test('when missing', async () => { 26 | nock(`${config.applicationUrl}/`).get('/api/build/blah').reply(404, new NotFoundError()); 27 | 28 | await expect(getBuild(config, 'blah')).rejects.toThrow(Error); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/api-client/src/modules/__tests__/readfile.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as brotliSize from 'brotli-size'; 5 | import fs from 'fs'; 6 | import gzipSize from 'gzip-size'; 7 | import path from 'path'; 8 | import readfile from '../readfile'; 9 | 10 | const fixturePath = require.resolve('@build-tracker/fixtures'); 11 | const main = path.join(path.dirname(fixturePath), 'cli-configs/fakedist/main.1234567.js'); 12 | 13 | describe('readfile', () => { 14 | let brotliSizeMock; 15 | beforeEach(() => { 16 | // @ts-ignore 17 | jest.spyOn(fs, 'statSync').mockImplementationOnce(() => ({ size: 64 })); 18 | brotliSizeMock = jest.spyOn(brotliSize, 'sync').mockImplementationOnce(() => 49); 19 | jest.spyOn(gzipSize, 'sync').mockImplementationOnce(() => 73); 20 | }); 21 | 22 | describe('hash', () => { 23 | test('defaults to an MD5 of the file', () => { 24 | expect(readfile(main).hash).toMatchInlineSnapshot(`"764196c430cf8a94c698b74b6dfdad71"`); 25 | }); 26 | 27 | test('can get the hash from filename function', () => { 28 | expect( 29 | readfile(main, (fileName: string): string => { 30 | return fileName.split('.')[1]; 31 | }) 32 | ).toMatchObject({ 33 | hash: '1234567', 34 | }); 35 | }); 36 | }); 37 | 38 | describe('sizes', () => { 39 | test('returns a stat size', () => { 40 | expect(readfile(main).stat).toEqual(64); 41 | }); 42 | 43 | test('returns a gzip size', () => { 44 | expect(readfile(main).gzip).toEqual(73); 45 | }); 46 | 47 | test('returns a brotli size', () => { 48 | expect(readfile(main).brotli).toEqual(49); 49 | }); 50 | 51 | test('if brotli throws, does not fail', () => { 52 | brotliSizeMock.mockReset().mockImplementationOnce(() => { 53 | throw new Error('not implemented'); 54 | }); 55 | expect(readfile(main)).not.toMatchObject({ brotli: expect.any(Number) }); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/api-client/src/modules/__tests__/stat-artifacts.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as brotliSize from 'brotli-size'; 5 | import config from '@build-tracker/fixtures/cli-configs/rc/.build-trackerrc.js'; 6 | import statArtifacts from '../stat-artifacts'; 7 | 8 | describe('statArtifacts', () => { 9 | test('gets artifact sizes', () => { 10 | jest.spyOn(brotliSize, 'sync').mockImplementation(() => { 11 | throw new Error('disabled in test'); 12 | }); 13 | const artifacts = statArtifacts(config); 14 | expect(artifacts).toBeInstanceOf(Map); 15 | expect(artifacts.size).toEqual(3); 16 | expect(artifacts.get('../../fakedist/main.1234567.js')).toMatchInlineSnapshot(` 17 | Object { 18 | "gzip": 74, 19 | "hash": "764196c430cf8a94c698b74b6dfdad71", 20 | "stat": 65, 21 | } 22 | `); 23 | expect(artifacts.get('../../fakedist/test-folder/test-no-extension')).toMatchInlineSnapshot(` 24 | Object { 25 | "gzip": 54, 26 | "hash": "415dec15fc798bb79f499aeff00258fb", 27 | "stat": 34, 28 | } 29 | `); 30 | expect(artifacts.get('../../fakedist/vendor.js')).toMatchInlineSnapshot(` 31 | Object { 32 | "gzip": 83, 33 | "hash": "0b3bb1892728da2a8a5af73335a51f35", 34 | "stat": 83, 35 | } 36 | `); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/api-client/src/modules/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { cosmiconfig } from 'cosmiconfig'; 5 | 6 | export interface ApiReturn { 7 | comparatorData: string; 8 | summary: Array; 9 | } 10 | 11 | export interface Config { 12 | applicationUrl: string; 13 | artifacts: Array; 14 | baseDir?: string; 15 | cwd?: string; 16 | buildUrlFormat?: string; 17 | getFilenameHash?: (filename: string) => string | null; 18 | nameMapper?: (filename: string) => string; 19 | onCompare?: (data: ApiReturn) => Promise; 20 | } 21 | 22 | export default async function getConfig(path?: string): Promise { 23 | const explorer = cosmiconfig('build-tracker'); 24 | let result; 25 | try { 26 | result = !path ? await explorer.search() : await explorer.load(path); 27 | } catch (e) { 28 | if (e.code === 'ENOENT') { 29 | throw new Error('Could not find configuration file'); 30 | } else { 31 | throw e; 32 | } 33 | } 34 | 35 | return { 36 | baseDir: process.cwd(), 37 | cwd: process.cwd(), 38 | ...result.config, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/api-client/src/modules/readfile.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as brotliSize from 'brotli-size'; 5 | import crypto from 'crypto'; 6 | import fs from 'fs'; 7 | import gzipSize from 'gzip-size'; 8 | 9 | interface SizeDef { 10 | brotli?: number; 11 | gzip: number; 12 | hash: string; 13 | stat: number; 14 | } 15 | 16 | type GetFileNameHash = (fileName: string) => string | void; 17 | const defaultFilenameHash: GetFileNameHash = (): string | void => null; 18 | 19 | export default function readfile(filePath: string, getFilenameHash: GetFileNameHash = defaultFilenameHash): SizeDef { 20 | const stat = fs.statSync(filePath).size; 21 | const contents = fs.readFileSync(filePath); 22 | const gzip = gzipSize.sync(contents); 23 | 24 | let hash = getFilenameHash(filePath); 25 | if (!hash) { 26 | const md5sum = crypto.createHash('md5'); 27 | md5sum.update(contents); 28 | hash = md5sum.digest('hex'); 29 | } 30 | 31 | const output: SizeDef = { stat, gzip, hash }; 32 | 33 | try { 34 | output.brotli = brotliSize.sync(contents); 35 | } catch (e) {} 36 | 37 | return output; 38 | } 39 | -------------------------------------------------------------------------------- /src/api-client/src/modules/spawn.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { spawn as nodeSpawn, SpawnOptions } from 'child_process'; 5 | 6 | export default function spawn(command: string, args?: ReadonlyArray, options?: SpawnOptions): Promise { 7 | return new Promise((resolve, reject) => { 8 | const proc = nodeSpawn(command, args, options); 9 | const errors = { 10 | spawn: null, 11 | stdout: null, 12 | stderr: null, 13 | }; 14 | 15 | const stderr: Array = []; 16 | const stdout: Array = []; 17 | 18 | proc.on('error', (error) => { 19 | errors.spawn = error; 20 | }); 21 | 22 | proc.stdout.on('error', (error) => { 23 | errors.stdout = error; 24 | }); 25 | 26 | proc.stderr.on('error', (error) => { 27 | errors.stderr = error; 28 | }); 29 | 30 | proc.stderr.on('data', (data) => { 31 | stderr.push(data); 32 | }); 33 | 34 | proc.stdout.on('data', (data) => { 35 | stdout.push(data); 36 | }); 37 | 38 | proc.on('close', (code) => { 39 | if (code !== 0) { 40 | const invocation = `${command} ${(args || []).join(' ')}`; 41 | return reject({ code, stderr: Buffer.concat(stderr).toString(), errors, invocation }); 42 | } 43 | resolve(Buffer.concat(stdout)); 44 | }); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/api-client/src/modules/stat-artifacts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { Config } from './config'; 5 | import glob from 'glob'; 6 | import path from 'path'; 7 | import readfile from './readfile'; 8 | 9 | export interface Stat { 10 | hash: string; 11 | stat: number; 12 | gzip: number; 13 | brotli: number; 14 | } 15 | 16 | const defaultNameMapper = (fileName: string): string => fileName; 17 | 18 | export default function statArtifacts(config: Config): Map { 19 | const { artifacts: artifactGlobs, baseDir, cwd, getFilenameHash, nameMapper = defaultNameMapper } = config; 20 | 21 | const artifacts = new Map(); 22 | 23 | artifactGlobs.forEach((fileGlob) => { 24 | glob.sync(path.resolve(cwd, fileGlob), { nodir: true }).forEach((filePath) => { 25 | const sizes = readfile(filePath, getFilenameHash); 26 | artifacts.set(nameMapper(path.relative(baseDir, filePath).replace(`.${sizes.hash}`, '')), sizes); 27 | }); 28 | }, []); 29 | 30 | return artifacts; 31 | } 32 | -------------------------------------------------------------------------------- /src/api-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /src/api-errors/README.md: -------------------------------------------------------------------------------- 1 | # @build-tracker/api-errors 2 | 3 | > A comprehensive list of error types that may be returned from a [Build Tracker](https://buildtracker.dev) instance's API. 4 | 5 | Read the [documentation online](https://buildtracker.dev/docs/packages/api-errors) for more information on getting started with Build Tracker. 6 | -------------------------------------------------------------------------------- /src/api-errors/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | export * from './dist'; 5 | -------------------------------------------------------------------------------- /src/api-errors/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | export * from './src'; 5 | -------------------------------------------------------------------------------- /src/api-errors/jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | module.exports = { 5 | displayName: 'api-errors', 6 | testEnvironment: 'node', 7 | resetMocks: true, 8 | rootDir: './', 9 | roots: ['/src'], 10 | transform: { 11 | '^.+\\.ts$': 'ts-jest', 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/api-errors/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@build-tracker/api-errors", 3 | "version": "1.0.0", 4 | "description": "Build Tracker API errors", 5 | "author": "Paul Armstrong ", 6 | "repository": "git@github.com:paularmstrong/build-tracker.git", 7 | "license": "MIT", 8 | "main": "dist", 9 | "module": "./", 10 | "types": "dist/index.d.ts", 11 | "sideEffects": false, 12 | "files": [ 13 | "index.js", 14 | "dist" 15 | ], 16 | "scripts": { 17 | "build": "tsc", 18 | "clean": "rimraf dist", 19 | "tsc": "tsc --noEmit" 20 | }, 21 | "gitHead": "156788a6a199fc25e21adf354c106defd002afbf", 22 | "publishConfig": { 23 | "access": "public" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/api-errors/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | 5 | export class AuthError extends Error { 6 | public readonly status = 401; 7 | 8 | public constructor(message?: string) { 9 | super(`Unauthorized access${message ? `: ${message}` : ''}`); 10 | Object.setPrototypeOf(this, AuthError.prototype); 11 | } 12 | } 13 | 14 | export class NotFoundError extends Error { 15 | public readonly status = 404; 16 | 17 | public constructor(message?: string) { 18 | super(`No builds found${message ? `: ${message}` : ''}`); 19 | Object.setPrototypeOf(this, NotFoundError.prototype); 20 | } 21 | } 22 | 23 | export class UnimplementedError extends Error { 24 | public readonly status = 501; 25 | 26 | public constructor(message?: string) { 27 | super(`Method not implemented${message ? `: ${message}` : ''}`); 28 | Object.setPrototypeOf(this, UnimplementedError.prototype); 29 | } 30 | } 31 | 32 | export class ValidationError extends Error { 33 | public readonly status = 400; 34 | 35 | public constructor(field: string, expected: string, received: string | number | void) { 36 | super(`"${field}" expected to receive "${expected}", but value was "${received}"`); 37 | Object.setPrototypeOf(this, ValidationError.prototype); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/api-errors/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /src/app/README.md: -------------------------------------------------------------------------------- 1 | # @build-tracker/app 2 | 3 | > A React.js client-side application for [Build Tracker](https://buildtracker.dev). 4 | 5 | Read the [documentation online](https://buildtracker.dev/docs/packages/app) for more information on getting started with Build Tracker. 6 | -------------------------------------------------------------------------------- /src/app/config/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'no-console': 'off' 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /src/app/config/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | const path = require('path'); 5 | 6 | module.exports = { 7 | SRC_ROOT: path.join(__dirname, '..', 'src'), 8 | DIST_ROOT: path.join(__dirname, '..', 'dist'), 9 | IS_PROD: process.env.NODE_ENV === 'production', 10 | }; 11 | -------------------------------------------------------------------------------- /src/app/config/devserver.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | const fakeBuild = require('@build-tracker/fixtures/builds-medium/01141f29743fb2bdd7e176cf919fc964025cea5a.json'); 5 | 6 | module.exports = { 7 | dev: true, 8 | port: 3000, 9 | queries: { 10 | build: { 11 | byRevision: () => Promise.resolve(fakeBuild), 12 | }, 13 | builds: { 14 | byRevisions: () => Promise.resolve([fakeBuild]), 15 | byRevisionRange: () => Promise.resolve([fakeBuild]), 16 | byTimeRange: () => Promise.resolve([fakeBuild]), 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/app/config/jest/fileMock.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | module.exports = 'test-file-stub'; 5 | -------------------------------------------------------------------------------- /src/app/config/jest/setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | process.on('unhandledRejection', (error) => { 5 | console.error(error); 6 | }); 7 | 8 | const ignoredErrors = [ 9 | /An update to [^ ]+ inside a test was not wrapped in act/, 10 | /Warning: useLayoutEffect does nothing on the server/, 11 | ]; 12 | 13 | console.error = (...args) => { 14 | if (ignoredErrors.some((err) => err.test(args[0]))) { 15 | return; 16 | } 17 | throw new Error(`Console error, ${args.join(' ')}`); 18 | }; 19 | 20 | const ignoredWarnings = [/Warning: \w+ has been renamed, and is not recommended for use. See https:\/\/fb\.me/]; 21 | const consoleWarn = console.warn.bind(console); 22 | 23 | console.warn = (...args) => { 24 | if (ignoredWarnings.some((warning) => warning.test(args[0]))) { 25 | return; 26 | } 27 | consoleWarn(...args); 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/config/webpack-progress.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | const chalk = require('chalk'); 5 | 6 | const clearOutput = () => { 7 | process.stdout.write('\x1B[2J\x1B[0f'); 8 | }; 9 | 10 | module.exports = (port) => ({ 11 | change: (context, { shortPath }) => { 12 | clearOutput(); 13 | console.log(`⏱ ${shortPath} changed. Rebuilding…`); 14 | }, 15 | 16 | done: (context, { stats }) => { 17 | const info = stats.toJson(); 18 | 19 | if (stats.hasErrors()) { 20 | info.errors.forEach((error) => { 21 | console.error(error); 22 | }); 23 | } 24 | }, 25 | 26 | afterAllDone: (context) => { 27 | const { hasErrors, message, name } = context.state; 28 | 29 | if (hasErrors) { 30 | console.log(`\n 🚨 Server is ready on https://localhost:${port}, but may not work correctly\n`); 31 | console.error(`${chalk.red(name)}: ${message}`); 32 | } else { 33 | clearOutput(); 34 | console.log(`\n 🚀 Server is ready on https://localhost:${port}\n`); 35 | } 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /src/app/config/webpack-server.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | const path = require('path'); 5 | const webpack = require('webpack'); 6 | const WebpackBar = require('webpackbar'); 7 | 8 | const { DIST_ROOT, IS_PROD, SRC_ROOT } = require('./constants'); 9 | 10 | module.exports = (env, reporter) => ({ 11 | name: 'server', 12 | target: 'node', 13 | entry: path.join(SRC_ROOT, 'server/index.tsx'), 14 | mode: IS_PROD ? 'production' : 'development', 15 | devServer: { 16 | contentBase: SRC_ROOT, 17 | hot: true, 18 | noInfo: true, 19 | }, 20 | devtool: IS_PROD ? false : 'cheap-module-eval-source-map', 21 | module: { 22 | rules: [ 23 | { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ }, 24 | { 25 | test: /\.(png)$/i, 26 | loader: require.resolve('file-loader'), 27 | options: { outputPath: '../client/static', publicPath: '/client/static', name: '[name].[hash:8].[ext]' }, 28 | }, 29 | ], 30 | }, 31 | resolve: { 32 | alias: { 33 | 'react-native$': 'react-native-web', 34 | }, 35 | extensions: ['.tsx', '.ts', '.js'], 36 | }, 37 | node: { 38 | global: false, 39 | __dirname: true, 40 | }, 41 | optimization: { 42 | minimize: false, 43 | sideEffects: true, 44 | splitChunks: {}, 45 | }, 46 | output: { 47 | filename: '[name].js', 48 | libraryTarget: 'commonjs2', 49 | path: path.join(DIST_ROOT, 'server'), 50 | }, 51 | plugins: [ 52 | !IS_PROD && 53 | reporter && 54 | new WebpackBar({ 55 | name: 'Server', 56 | color: 'blue', 57 | reporters: ['fancy', reporter], 58 | }), 59 | !IS_PROD && new webpack.HotModuleReplacementPlugin(), 60 | ].filter(Boolean), 61 | }); 62 | -------------------------------------------------------------------------------- /src/app/config/webpack.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | const clientConfig = require('./webpack-client.config'); 5 | const serverConfig = require('./webpack-server.config'); 6 | const createReporter = require('./webpack-progress'); 7 | 8 | module.exports = (env = {}) => { 9 | const reporter = env.port && createReporter(env.port); 10 | return [clientConfig(env, reporter), serverConfig(env, reporter)]; 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | -------------------------------------------------------------------------------- /src/app/jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | module.exports = { 5 | preset: '../../config/jest.js', 6 | displayName: 'app', 7 | moduleNameMapper: { 8 | '\\.png$': '/config/jest/fileMock.js', 9 | '^react-native$': require.resolve('react-native-web'), 10 | }, 11 | rootDir: './', 12 | roots: ['/src'], 13 | setupFiles: ['react-native-web/jest/setup.js', '/config/jest/setup.js'], 14 | testEnvironment: 'jsdom', 15 | }; 16 | -------------------------------------------------------------------------------- /src/app/src/app.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | 5 | /* eslint-disable @typescript-eslint/no-explicit-any */ 6 | 7 | declare module '*.png' { 8 | const value: any; 9 | export = value; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/src/client/Routes.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import Builds from './routes/Builds'; 5 | import Dates from './routes/Dates'; 6 | import Latest from './routes/Latest'; 7 | import React, { FunctionComponent } from 'react'; 8 | import { Route, Switch } from 'react-router'; 9 | 10 | const Routes: FunctionComponent<{}> = (): React.ReactElement => { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default Routes; 22 | -------------------------------------------------------------------------------- /src/app/src/client/history.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment'; 5 | import { createBrowserHistory, createMemoryHistory } from 'history'; 6 | 7 | export default canUseDOM ? createBrowserHistory() : createMemoryHistory(); 8 | -------------------------------------------------------------------------------- /src/app/src/client/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { AppRegistry } from 'react-native'; 5 | import history from './history'; 6 | import Main from '../screens/Main'; 7 | import makeStore from '../store'; 8 | import { Provider } from 'react-redux'; 9 | import React from 'react'; 10 | import { Router } from 'react-router'; 11 | import Routes from './Routes'; 12 | import { searchParamsToStore } from '../store/utils'; 13 | 14 | // @ts-ignore 15 | const initialProps = window.__PROPS__ || {}; 16 | 17 | const store = makeStore({ ...initialProps, ...searchParamsToStore(window.location.search) }); 18 | 19 | const App = (): React.ReactElement => ( 20 | 21 | 22 | 23 |
24 | 25 | 26 | ); 27 | 28 | AppRegistry.registerComponent('App', () => App); 29 | AppRegistry.runApplication('App', { 30 | hydrate: true, 31 | initialProps: {}, 32 | rootTag: document.getElementById('root'), 33 | }); 34 | -------------------------------------------------------------------------------- /src/app/src/client/routes/Builds.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import Build from '@build-tracker/build'; 5 | import { fetch } from 'cross-fetch'; 6 | import { RouteComponentProps } from 'react-router'; 7 | import { FetchState, State } from '../../store/types'; 8 | import React, { FunctionComponent } from 'react'; 9 | import { setBuilds, setFetchState } from '../../store/actions'; 10 | import { useDispatch, useSelector } from 'react-redux'; 11 | 12 | const Builds: FunctionComponent> = (props): React.ReactElement => { 13 | const { revisions } = props.match.params; 14 | const url = useSelector((state: State) => state.url); 15 | const dispatch = useDispatch(); 16 | 17 | React.useEffect(() => { 18 | dispatch(setFetchState(FetchState.FETCHING)); 19 | fetch(`${url}/api/builds/list/${revisions}`) 20 | .then((response) => response.json()) 21 | .then((builds) => { 22 | if (!Array.isArray(builds)) { 23 | throw new Error('Bad response'); 24 | } 25 | dispatch(setBuilds(builds.map((buildStruct) => new Build(buildStruct.meta, buildStruct.artifacts)))); 26 | dispatch(setFetchState(FetchState.FETCHED)); 27 | }) 28 | .catch(() => { 29 | dispatch(setFetchState(FetchState.ERROR)); 30 | }); 31 | }, [dispatch, revisions, url]); 32 | return null; 33 | }; 34 | 35 | export default Builds; 36 | -------------------------------------------------------------------------------- /src/app/src/client/routes/Dates.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import Build from '@build-tracker/build'; 5 | import { fetch } from 'cross-fetch'; 6 | import { RouteComponentProps } from 'react-router'; 7 | import { FetchState, State } from '../../store/types'; 8 | import React, { FunctionComponent } from 'react'; 9 | import { setBuilds, setFetchState } from '../../store/actions'; 10 | import { useDispatch, useSelector } from 'react-redux'; 11 | 12 | const Dates: FunctionComponent> = ( 13 | props 14 | ): React.ReactElement => { 15 | const { startTimestamp, endTimestamp } = props.match.params; 16 | const url = useSelector((state: State) => state.url); 17 | const dispatch = useDispatch(); 18 | 19 | React.useEffect(() => { 20 | dispatch(setFetchState(FetchState.FETCHING)); 21 | fetch(`${url}/api/builds/time/${startTimestamp}...${endTimestamp}`) 22 | .then((response) => response.json()) 23 | .then((builds) => { 24 | if (!Array.isArray(builds)) { 25 | throw new Error('Bad response'); 26 | } 27 | dispatch(setBuilds(builds.map((buildStruct) => new Build(buildStruct.meta, buildStruct.artifacts)))); 28 | dispatch(setFetchState(FetchState.FETCHED)); 29 | }) 30 | .catch(() => { 31 | dispatch(setFetchState(FetchState.ERROR)); 32 | }); 33 | }, [dispatch, endTimestamp, startTimestamp, url]); 34 | return null; 35 | }; 36 | 37 | export default Dates; 38 | -------------------------------------------------------------------------------- /src/app/src/client/routes/Latest.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import Build from '@build-tracker/build'; 5 | import { fetch } from 'cross-fetch'; 6 | import { RouteComponentProps } from 'react-router'; 7 | import { FetchState, State } from '../../store/types'; 8 | import React, { FunctionComponent } from 'react'; 9 | import { setBuilds, setFetchState } from '../../store/actions'; 10 | import { useDispatch, useSelector } from 'react-redux'; 11 | 12 | const DEFAULT_LIMIT = '30'; 13 | 14 | const Latest: FunctionComponent> = (props): React.ReactElement => { 15 | const { limit = DEFAULT_LIMIT } = props.match.params; 16 | const url = useSelector((state: State) => state.url); 17 | const dispatch = useDispatch(); 18 | 19 | React.useEffect(() => { 20 | dispatch(setFetchState(FetchState.FETCHING)); 21 | fetch(`${url}/api/builds/${limit}`) 22 | .then((response) => response.json()) 23 | .then((builds) => { 24 | if (!Array.isArray(builds)) { 25 | throw new Error('Bad response'); 26 | } 27 | dispatch(setBuilds(builds.map((buildStruct) => new Build(buildStruct.meta, buildStruct.artifacts)))); 28 | dispatch(setFetchState(FetchState.FETCHED)); 29 | }) 30 | .catch(() => { 31 | dispatch(setFetchState(FetchState.ERROR)); 32 | }); 33 | }, [dispatch, limit, url]); 34 | return null; 35 | }; 36 | 37 | export default Latest; 38 | -------------------------------------------------------------------------------- /src/app/src/components/ColorScalePicker/ColorScale.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Theme from '../../theme'; 5 | import ColorScales from '../../modules/ColorScale'; 6 | import RadioSelect from '../RadioSelect'; 7 | import React from 'react'; 8 | import { setColorScale } from '../../store/actions'; 9 | import { useDispatch } from 'react-redux'; 10 | import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; 11 | 12 | interface Props { 13 | isSelected?: boolean; 14 | name: keyof typeof ColorScales; 15 | style?: StyleProp; 16 | } 17 | 18 | export const ColorScale = (props: Props): React.ReactElement => { 19 | const { isSelected, name, style } = props; 20 | 21 | const dispatch = useDispatch(); 22 | const handleSelect = React.useCallback( 23 | (checked: boolean) => { 24 | if (checked) { 25 | dispatch(setColorScale(name)); 26 | } 27 | }, 28 | [dispatch, name] 29 | ); 30 | 31 | const nativeID = `radio${`${name}`.replace(/^\w/g, '')}`; 32 | 33 | return ( 34 | 35 | 36 | {name} 37 | 38 | ); 39 | }; 40 | 41 | const styles = StyleSheet.create({ 42 | root: { 43 | cursor: 'pointer', 44 | flexDirection: 'row', 45 | alignItems: 'center', 46 | }, 47 | radio: { 48 | marginEnd: Theme.Spacing.Small, 49 | }, 50 | }); 51 | 52 | export default ColorScale; 53 | -------------------------------------------------------------------------------- /src/app/src/components/ColorScalePicker/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { ColorScalePicker } from '../'; 5 | import mockStore from '../../../store/mock'; 6 | import { Provider } from 'react-redux'; 7 | import React from 'react'; 8 | import { render } from 'react-native-testing-library'; 9 | 10 | describe('ColorScalePicker', () => { 11 | test('sets the active scale to selected', () => { 12 | const { queryAllByProps } = render( 13 | 14 | 15 | 16 | ); 17 | const selected = queryAllByProps({ isSelected: true }); 18 | expect(selected).toHaveLength(1); 19 | expect(selected[0].props.name).toBe('Magma'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/src/components/ColorScalePicker/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Theme from '../../theme'; 5 | import ColorScale from './ColorScale'; 6 | import ColorScales from '../../modules/ColorScale'; 7 | import React from 'react'; 8 | import { State } from '../../store/types'; 9 | import { useSelector } from 'react-redux'; 10 | import { StyleSheet, View } from 'react-native'; 11 | 12 | export const ColorScalePicker = (): React.ReactElement => { 13 | const activeColorScale = useSelector((state: State) => ColorScales[state.colorScale]); 14 | return ( 15 | 16 | {Object.entries(ColorScales).map(([name, scale]) => { 17 | return ; 18 | })} 19 | 20 | ); 21 | }; 22 | 23 | const styles = StyleSheet.create({ 24 | root: { 25 | flexDirection: 'column', 26 | }, 27 | scale: { 28 | marginBottom: Theme.Spacing.Small, 29 | }, 30 | }); 31 | 32 | export default React.memo(ColorScalePicker); 33 | -------------------------------------------------------------------------------- /src/app/src/components/ComparisonTable/HeaderRow.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import React from 'react'; 5 | import RevisionCell from './RevisionCell'; 6 | import RevisionDeltaCell from './RevisionDeltaCell'; 7 | import { StyleSheet } from 'react-native'; 8 | import TextCell from './TextCell'; 9 | import { Tr } from './../Table'; 10 | import { CellType, HeaderRow as HRow } from '@build-tracker/comparator'; 11 | 12 | interface Props { 13 | onFocusRevision: (artifactName: string) => void; 14 | onRemoveRevision: (artifactName: string) => void; 15 | row: HRow; 16 | } 17 | 18 | export const HeaderRow = (props: Props): React.ReactElement => { 19 | const { onFocusRevision, onRemoveRevision, row } = props; 20 | 21 | const mapHeaderCell = (cell, i): React.ReactElement | void => { 22 | switch (cell.type) { 23 | case CellType.TEXT: 24 | return ; 25 | case CellType.REVISION: 26 | return ( 27 | 34 | ); 35 | case CellType.REVISION_DELTA: 36 | return ( 37 | 42 | ); 43 | } 44 | }; 45 | 46 | return {row.map(mapHeaderCell)}; 47 | }; 48 | 49 | const styles = StyleSheet.create({ 50 | headerCell: { 51 | backgroundColor: 'white', 52 | position: 'sticky', 53 | top: 0, 54 | zIndex: 4, 55 | height: 'calc(4rem - 1px)', 56 | }, 57 | }); 58 | 59 | export default React.memo(HeaderRow); 60 | -------------------------------------------------------------------------------- /src/app/src/components/ComparisonTable/RevisionDeltaCell.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { RevisionDeltaCell as Cell } from '@build-tracker/comparator'; 5 | import { formatSha } from '@build-tracker/formatting'; 6 | import Hoverable from '../Hoverable'; 7 | import React from 'react'; 8 | import RelativeTooltip from '../RelativeTooltip'; 9 | import { Th } from '../Table'; 10 | import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; 11 | 12 | interface Props { 13 | cell: Cell; 14 | style?: StyleProp; 15 | } 16 | 17 | export const RevisionDeltaCell = (props: Props): React.ReactElement => { 18 | const { againstRevision, deltaIndex, revision } = props.cell; 19 | const viewRef = React.useRef(null); 20 | 21 | return ( 22 | 23 | 24 | {(isHovered) => ( 25 | 26 | {`𝚫${deltaIndex}`} 27 | {isHovered ? ( 28 | 32 | ) : null} 33 | 34 | )} 35 | 36 | 37 | ); 38 | }; 39 | 40 | const styles = StyleSheet.create({ 41 | delta: { 42 | fontWeight: 'bold', 43 | }, 44 | }); 45 | 46 | export default React.memo(RevisionDeltaCell); 47 | -------------------------------------------------------------------------------- /src/app/src/components/ComparisonTable/TextCell.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { TextCell as Cell } from '@build-tracker/comparator'; 5 | import React from 'react'; 6 | import { StyleProp, Text, ViewStyle } from 'react-native'; 7 | import { Td, Th } from './../Table'; 8 | 9 | interface Props { 10 | cell: Cell; 11 | header?: boolean; 12 | style?: StyleProp; 13 | } 14 | 15 | export const TextCell = (props: Props): React.ReactElement => { 16 | const El = props.header ? Th : Td; 17 | return ( 18 | 19 | {props.cell.text} 20 | 21 | ); 22 | }; 23 | 24 | export default React.memo(TextCell); 25 | -------------------------------------------------------------------------------- /src/app/src/components/ComparisonTable/__tests__/RevisionDeltaCell.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { CellType } from '@build-tracker/comparator'; 5 | import React from 'react'; 6 | import { RevisionDeltaCell } from '../RevisionDeltaCell'; 7 | import { fireEvent, render } from 'react-native-testing-library'; 8 | 9 | describe('RevisionDeltaCell', () => { 10 | beforeEach(() => { 11 | // Enable the hover monitor 12 | document.dispatchEvent(new Event('mousemove')); 13 | }); 14 | 15 | describe('tooltip', () => { 16 | test('mouse enter shows a tooltip', () => { 17 | const { getByTestId, queryAllByProps } = render( 18 | 21 | ); 22 | fireEvent(getByTestId('delta'), 'mouseEnter'); 23 | expect(queryAllByProps({ accessibilityRole: 'tooltip' })).toHaveLength(1); 24 | }); 25 | 26 | test('mouse leave removes the tooltip', () => { 27 | const { getByTestId, queryAllByProps } = render( 28 | 31 | ); 32 | fireEvent(getByTestId('delta'), 'mouseEnter'); 33 | fireEvent(getByTestId('delta'), 'mouseLeave'); 34 | expect(queryAllByProps({ accessibilityRole: 'tooltip' })).toHaveLength(0); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/app/src/components/ComparisonTable/__tests__/TextCell.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { CellType } from '@build-tracker/comparator'; 5 | import React from 'react'; 6 | import { render } from 'react-native-testing-library'; 7 | import { TextCell } from '../TextCell'; 8 | import { Td, Th } from '../../Table'; 9 | 10 | describe('TextCell', () => { 11 | describe('header', () => { 12 | test('renders as Th when true', () => { 13 | const { queryAllByType } = render(); 14 | expect(queryAllByType(Th)).toHaveLength(1); 15 | expect(queryAllByType(Td)).toHaveLength(0); 16 | }); 17 | 18 | test('renders as Td when false', () => { 19 | const { queryAllByType } = render(); 20 | expect(queryAllByType(Th)).toHaveLength(0); 21 | expect(queryAllByType(Td)).toHaveLength(1); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/src/components/ComparisonTable/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | export * from './ComparisonTable'; 5 | export { default } from './ComparisonTable'; 6 | -------------------------------------------------------------------------------- /src/app/src/components/Divider.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Theme from '../theme'; 5 | import React from 'react'; 6 | import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; 7 | 8 | interface Props { 9 | color?: string; 10 | style?: StyleProp; 11 | } 12 | 13 | const Divider = (props: Props): React.ReactElement => { 14 | const { color, style } = props; 15 | return ; 16 | }; 17 | 18 | const styles = StyleSheet.create({ 19 | root: { 20 | height: StyleSheet.hairlineWidth, 21 | overflow: 'hidden', 22 | backgroundColor: Theme.Color.Gray20, 23 | }, 24 | }); 25 | 26 | export default Divider; 27 | -------------------------------------------------------------------------------- /src/app/src/components/DrawerLink.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Theme from '../theme'; 5 | import Hoverable from './Hoverable'; 6 | import React from 'react'; 7 | import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; 8 | 9 | interface Props { 10 | href: string; 11 | icon?: React.ComponentType<{ style?: StyleProp }>; 12 | style?: StyleProp; 13 | text: string; 14 | } 15 | 16 | const DrawerLink = (props: Props): React.ReactElement => { 17 | const { href, icon: Icon = null, style, text } = props; 18 | 19 | return ( 20 | 21 | {(isHovered) => { 22 | return ( 23 | 29 | 30 | <> 31 | {Icon ? : null} 32 | {text} 33 | 34 | 35 | 36 | ); 37 | }} 38 | 39 | ); 40 | }; 41 | 42 | const styles = StyleSheet.create({ 43 | root: { 44 | marginBottom: Theme.Spacing.Small, 45 | marginHorizontal: `calc(-1 * ${Theme.Spacing.Xsmall})`, 46 | borderRadius: Theme.BorderRadius.Normal, 47 | padding: Theme.Spacing.Xsmall, 48 | }, 49 | rootHovered: { 50 | backgroundColor: Theme.Color.Primary00, 51 | }, 52 | text: { 53 | fontSize: Theme.FontSize.Normal, 54 | }, 55 | textHovered: { 56 | color: Theme.Color.Primary40, 57 | }, 58 | icon: { 59 | marginEnd: Theme.Spacing.Small, 60 | }, 61 | }); 62 | 63 | export default DrawerLink; 64 | -------------------------------------------------------------------------------- /src/app/src/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import React from 'react'; 5 | import { StyleSheet, Text, View } from 'react-native'; 6 | 7 | interface Props { 8 | message: string; 9 | } 10 | 11 | const EmptyState = (props: Props): React.ReactElement => { 12 | const { message } = props; 13 | return ( 14 | 15 | {message} 16 | 17 | ); 18 | }; 19 | 20 | const styles = StyleSheet.create({ 21 | root: { 22 | flexGrow: 1, 23 | width: '100%', 24 | height: '100%', 25 | justifyContent: 'center', 26 | alignItems: 'center', 27 | }, 28 | }); 29 | 30 | export default EmptyState; 31 | -------------------------------------------------------------------------------- /src/app/src/components/Graph/Offset.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | export enum Offset { 5 | TOP = 20, 6 | RIGHT = 20, 7 | BOTTOM = 100, 8 | LEFT = 80, 9 | } 10 | -------------------------------------------------------------------------------- /src/app/src/components/Graph/XAxis.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { axisBottom } from 'd3-axis'; 5 | import { formatSha } from '@build-tracker/formatting'; 6 | import React from 'react'; 7 | import { ScalePoint } from 'd3-scale'; 8 | import { select } from 'd3-selection'; 9 | 10 | interface Props { 11 | height: number; 12 | scale: ScalePoint; 13 | } 14 | 15 | const MAX_TICK_LABELS = 50; 16 | 17 | const XAxis = (props: Props): React.ReactElement => { 18 | const { height, scale } = props; 19 | const ref = React.useRef(null); 20 | const domainLength = scale.domain().length; 21 | 22 | React.useEffect(() => { 23 | const axis = axisBottom(scale).tickFormat((d, i) => { 24 | if (domainLength <= MAX_TICK_LABELS || i % Math.floor(domainLength / (MAX_TICK_LABELS / 2)) === 0) { 25 | return formatSha(d); 26 | } 27 | return ''; 28 | }); 29 | 30 | select(ref.current) 31 | .call(axis) 32 | .selectAll('text') 33 | .attr('y', 3) 34 | .attr('x', 6) 35 | .attr('transform', 'rotate(24)') 36 | .style('text-anchor', 'start'); 37 | }, [domainLength, scale]); 38 | 39 | return ; 40 | }; 41 | 42 | export default XAxis; 43 | -------------------------------------------------------------------------------- /src/app/src/components/Graph/YAxis.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import { axisLeft } from 'd3-axis'; 5 | import { formatBytes } from '@build-tracker/formatting'; 6 | import React from 'react'; 7 | import { ScaleLinear } from 'd3-scale'; 8 | import { select } from 'd3-selection'; 9 | 10 | interface Props { 11 | scale: ScaleLinear; 12 | } 13 | 14 | const tickFormat = (d): string => 15 | formatBytes(d.valueOf(), { formatter: (bytes: number, units: number): number => Math.round(bytes / units) }); 16 | 17 | const YAxis = (props: Props): React.ReactElement => { 18 | const { scale } = props; 19 | const ref = React.useRef(null); 20 | 21 | React.useEffect(() => { 22 | const axis = axisLeft(scale).tickFormat(tickFormat); 23 | select(ref.current).call(axis); 24 | }, [scale]); 25 | 26 | return ; 27 | }; 28 | 29 | export default YAxis; 30 | -------------------------------------------------------------------------------- /src/app/src/components/Graph/__tests__/YAxis.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Axes from 'd3-axis'; 5 | import * as Selection from 'd3-selection'; 6 | import React from 'react'; 7 | import { render } from '@testing-library/react'; 8 | import { scaleLinear } from 'd3-scale'; 9 | import YAxis from '../YAxis'; 10 | 11 | const scale = scaleLinear().range([100, 0]).domain([0, 100]); 12 | 13 | describe('YAxis', () => { 14 | let mockCall, selectSpy; 15 | beforeEach(() => { 16 | mockCall = jest.fn(); 17 | // @ts-ignore Just want to make sure call is called 18 | selectSpy = jest.spyOn(Selection, 'select').mockReturnValue({ call: mockCall }); 19 | }); 20 | 21 | test('creates a left axis', () => { 22 | render( 23 | 24 | 25 | 26 | ); 27 | 28 | expect(selectSpy).toHaveBeenCalled(); 29 | expect(mockCall).toHaveBeenCalled(); 30 | }); 31 | 32 | test('rounds ticks', () => { 33 | const mockTickFormat = jest.fn(() => '1'); 34 | // @ts-ignore 35 | jest.spyOn(Axes, 'axisLeft').mockReturnValue({ tickFormat: mockTickFormat }); 36 | 37 | render( 38 | 39 | 40 | 41 | ); 42 | 43 | // @ts-ignore 44 | const formatter = mockTickFormat.mock.calls[0][0]; 45 | // @ts-ignore 46 | expect(formatter(1234)).toEqual('1 KiB'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/app/src/components/Menu.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import MenuItem from './MenuItem'; 5 | import React from 'react'; 6 | import RelativeModal from './RelativeModal'; 7 | 8 | interface Props extends React.ComponentProps { 9 | children: React.ReactElement | Array>; 10 | } 11 | 12 | const Menu = (props: Props): React.ReactElement => { 13 | return ; 14 | }; 15 | 16 | export default Menu; 17 | -------------------------------------------------------------------------------- /src/app/src/components/RelativeTooltip.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import React from 'react'; 5 | import Tooltip from './Tooltip'; 6 | import { View } from 'react-native'; 7 | 8 | interface Props { 9 | relativeTo: React.RefObject; 10 | text: string; 11 | } 12 | 13 | const RelativeTooltip = (props: Props): React.ReactElement => { 14 | const { relativeTo, text } = props; 15 | const [position, setPosition] = React.useState({ top: -999, left: 0 }); 16 | 17 | React.useEffect(() => { 18 | let mounted = true; 19 | if (relativeTo.current) { 20 | relativeTo.current.measureInWindow((x: number, y: number, width: number, height: number): void => { 21 | if (!mounted) { 22 | return; 23 | } 24 | 25 | setPosition({ left: x + Math.round(width / 2), top: y + Math.round(height / 2) }); 26 | }); 27 | } 28 | return () => { 29 | mounted = false; 30 | }; 31 | /* eslint-disable react-hooks/exhaustive-deps */ 32 | // Tests break if you pass the ref/relativeTo higher object in and not the actual value we're using 33 | }, [relativeTo.current]); 34 | /* eslint-enable react-hooks/exhaustive-deps */ 35 | 36 | return ; 37 | }; 38 | 39 | export default RelativeTooltip; 40 | -------------------------------------------------------------------------------- /src/app/src/components/SizeKeyPicker/Key.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Theme from '../../theme'; 5 | import RadioSelect from '../RadioSelect'; 6 | import React from 'react'; 7 | import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; 8 | 9 | interface Props { 10 | isSelected?: boolean; 11 | onSelect: (value: string) => void; 12 | style?: StyleProp; 13 | value: string; 14 | } 15 | 16 | const SizeKeyPicker = (props: Props): React.ReactElement => { 17 | const { isSelected, onSelect, style, value } = props; 18 | 19 | const handleSelect = React.useCallback( 20 | (checked: boolean): void => { 21 | if (checked) { 22 | onSelect(value); 23 | } 24 | }, 25 | [onSelect, value] 26 | ); 27 | 28 | const nativeID = `radio${value.replace(/^\w/g, '')}`; 29 | 30 | return ( 31 | 32 | 33 | 34 | {value} 35 | 36 | 37 | ); 38 | }; 39 | 40 | const styles = StyleSheet.create({ 41 | root: { 42 | flexDirection: 'row', 43 | alignItems: 'center', 44 | cursor: 'pointer', 45 | }, 46 | radio: { 47 | marginEnd: Theme.Spacing.Small, 48 | }, 49 | text: {}, 50 | }); 51 | 52 | export default SizeKeyPicker; 53 | -------------------------------------------------------------------------------- /src/app/src/components/SizeKeyPicker/__tests__/Key.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import RadioSelect from '../../RadioSelect'; 5 | import React from 'react'; 6 | import SizeKey from '../Key'; 7 | import { fireEvent, render } from 'react-native-testing-library'; 8 | 9 | describe('SizeKey', () => { 10 | describe('rendering', () => { 11 | test('wraps the radio in a label', () => { 12 | const { queryAllByProps } = render(); 13 | expect( 14 | queryAllByProps({ 15 | accessibilityRole: 'label', 16 | }) 17 | ).toHaveLength(1); 18 | }); 19 | }); 20 | 21 | describe('isSelected', () => { 22 | test('sets checkbox value when true', () => { 23 | const { getByType } = render(); 24 | expect(getByType(RadioSelect).props.value).toBe(true); 25 | }); 26 | 27 | test('does not set checkbox value when false', () => { 28 | const { getByType } = render(); 29 | expect(getByType(RadioSelect).props.value).toBeUndefined(); 30 | }); 31 | }); 32 | 33 | describe('onSelect', () => { 34 | test('fires onSelect with the value', () => { 35 | const handleOnSelect = jest.fn(); 36 | const { getByType } = render(); 37 | fireEvent(getByType(RadioSelect), 'valueChange', true); 38 | expect(handleOnSelect).toHaveBeenCalledWith('tacos'); 39 | }); 40 | 41 | test('does not fire onSelect when value changes to false', () => { 42 | const handleOnSelect = jest.fn(); 43 | const { getByType } = render(); 44 | fireEvent(getByType(RadioSelect), 'valueChange', false); 45 | expect(handleOnSelect).not.toHaveBeenCalled(); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/app/src/components/SizeKeyPicker/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Actions from '../../../store/actions'; 5 | import mockStore from '../../../store/mock'; 6 | import { Provider } from 'react-redux'; 7 | import React from 'react'; 8 | import SizeKeyPicker from '../'; 9 | import { fireEvent, render } from 'react-native-testing-library'; 10 | 11 | describe('SizeKeyPicker', () => { 12 | test('renders a button per key', () => { 13 | const { queryAllByProps } = render( 14 | 15 | 16 | 17 | ); 18 | expect(queryAllByProps({ isSelected: true, value: 'foo' })).toHaveLength(1); 19 | expect(queryAllByProps({ isSelected: false, value: 'bar' })).toHaveLength(1); 20 | }); 21 | 22 | test('passes the onSelect handler', () => { 23 | const handleSelect = jest.spyOn(Actions, 'setSizeKey'); 24 | const { queryAllByProps } = render( 25 | 26 | 27 | 28 | ); 29 | expect(queryAllByProps({ value: 'foo' })).toHaveLength(1); 30 | expect(queryAllByProps({ value: 'bar' })).toHaveLength(1); 31 | expect(queryAllByProps({ value: 'tacos' })).toHaveLength(1); 32 | fireEvent(queryAllByProps({ value: 'bar' })[0], 'select', 'foo'); 33 | expect(handleSelect).toHaveBeenCalledWith('foo'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/app/src/components/SizeKeyPicker/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import React from 'react'; 5 | import { setSizeKey } from '../../store/actions'; 6 | import SizeKey from './Key'; 7 | import { State } from '../../store/types'; 8 | import { StyleSheet, View } from 'react-native'; 9 | import { useDispatch, useSelector } from 'react-redux'; 10 | 11 | interface Props { 12 | keys: Array; 13 | } 14 | 15 | const SizeKeyPicker = (props: Props): React.ReactElement => { 16 | const { keys } = props; 17 | const selected = useSelector((state: State) => state.sizeKey); 18 | const dispatch = useDispatch(); 19 | const handleSelect = React.useCallback( 20 | (key: string): void => { 21 | dispatch(setSizeKey(key)); 22 | }, 23 | [dispatch] 24 | ); 25 | return ( 26 | 27 | {keys.map((key) => ( 28 | 29 | ))} 30 | 31 | ); 32 | }; 33 | 34 | const styles = StyleSheet.create({ 35 | root: {}, 36 | button: {}, 37 | }); 38 | 39 | export default SizeKeyPicker; 40 | -------------------------------------------------------------------------------- /src/app/src/components/Snackbar.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Theme from '../theme'; 5 | import React from 'react'; 6 | import { StyleSheet, Text, View } from 'react-native'; 7 | 8 | interface Props { 9 | text: string; 10 | } 11 | 12 | const Snackbar = (props: Props): React.ReactElement => { 13 | const { text } = props; 14 | return ( 15 | 16 | {text} 17 | 18 | ); 19 | }; 20 | 21 | const styles = StyleSheet.create({ 22 | root: { 23 | position: 'absolute', 24 | bottom: Theme.Spacing.Normal, 25 | left: Theme.Spacing.Normal, 26 | width: 'auto', 27 | paddingVertical: Theme.Spacing.Small, 28 | paddingHorizontal: Theme.Spacing.Normal, 29 | backgroundColor: Theme.Color.Black, 30 | borderRadius: Theme.BorderRadius.Normal, 31 | shadowOffset: { width: 0, height: 2 }, 32 | shadowColor: Theme.Color.Black, 33 | shadowOpacity: 0.3, 34 | shadowRadius: 5, 35 | elevation: 10, 36 | animationKeyframes: [ 37 | { 38 | from: { transform: [{ scale: 0.85 }], opacity: 0 }, 39 | to: { transform: [{ scale: 1 }], opacity: 1 }, 40 | }, 41 | ], 42 | animationDuration: '0.1s', 43 | animationTimingFunction: Theme.MotionTiming.Accelerate, 44 | animationIterationCount: 1, 45 | }, 46 | text: { 47 | color: Theme.TextColor.Black, 48 | }, 49 | }); 50 | 51 | export default Snackbar; 52 | -------------------------------------------------------------------------------- /src/app/src/components/Subtitle.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as React from 'react'; 5 | import * as Theme from '../theme'; 6 | import { StyleSheet, Text } from 'react-native'; 7 | 8 | interface Props { 9 | title: string; 10 | } 11 | 12 | const Subtitle = (props: Props): React.ReactElement => { 13 | const { title } = props; 14 | return {title}; 15 | }; 16 | 17 | const styles = StyleSheet.create({ 18 | subtitle: { 19 | color: Theme.Color.Gray50, 20 | fontSize: Theme.FontSize.Normal, 21 | marginBottom: Theme.Spacing.Xsmall, 22 | }, 23 | }); 24 | 25 | export default Subtitle; 26 | -------------------------------------------------------------------------------- /src/app/src/components/TextLink.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Theme from '../theme'; 5 | import Hoverable from './Hoverable'; 6 | import React from 'react'; 7 | import { StyleProp, StyleSheet, Text, ViewStyle } from 'react-native'; 8 | 9 | interface Props { 10 | href: string; 11 | style?: StyleProp; 12 | text: string; 13 | } 14 | 15 | const DrawerLink = (props: Props): React.ReactElement => { 16 | const { href, style, text } = props; 17 | 18 | return ( 19 | 20 | {(isHovered) => { 21 | return ( 22 | 28 | {text} 29 | 30 | ); 31 | }} 32 | 33 | ); 34 | }; 35 | 36 | const styles = StyleSheet.create({ 37 | root: { 38 | borderRadius: Theme.BorderRadius.Normal, 39 | color: Theme.Color.Primary40, 40 | }, 41 | rootHovered: { 42 | backgroundColor: Theme.Color.Primary00, 43 | }, 44 | }); 45 | 46 | export default DrawerLink; 47 | -------------------------------------------------------------------------------- /src/app/src/components/__tests__/BuildInfo.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Actions from '../../store/actions'; 5 | import Build from '@build-tracker/build'; 6 | import BuildInfo from '../BuildInfo'; 7 | import Comparator from '@build-tracker/comparator'; 8 | import mockStore from '../../store/mock'; 9 | import { Provider } from 'react-redux'; 10 | import React from 'react'; 11 | import { fireEvent, render } from 'react-native-testing-library'; 12 | 13 | const build = new Build({ branch: 'main', revision: '1234565', parentRevision: 'abcdef', timestamp: 123 }, []); 14 | 15 | describe('BuildInfo', () => { 16 | test('can be closed', () => { 17 | const focusRevisionSpy = jest.spyOn(Actions, 'setFocusedRevision'); 18 | const { getByProps } = render( 19 | 20 | 21 | 22 | ); 23 | fireEvent.press(getByProps({ title: 'Collapse details' })); 24 | expect(focusRevisionSpy).toHaveBeenCalledWith(undefined); 25 | }); 26 | 27 | test('removes the build focus on button press', () => { 28 | const removeComparedRevisionSpy = jest.spyOn(Actions, 'removeComparedRevision'); 29 | const { getByProps } = render( 30 | 31 | 32 | 33 | ); 34 | fireEvent.press(getByProps({ title: 'Remove build' })); 35 | expect(removeComparedRevisionSpy).toHaveBeenCalledWith('1234565'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/app/src/components/__tests__/Divider.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import * as Theme from '../../theme'; 5 | import Divider from '../Divider'; 6 | import React from 'react'; 7 | import { render } from 'react-native-testing-library'; 8 | import { StyleSheet, View } from 'react-native'; 9 | 10 | describe('Divider', () => { 11 | test('renders a simple divider', () => { 12 | const { getByType } = render(); 13 | expect(StyleSheet.flatten(getByType(View).props.style)).toMatchObject({ backgroundColor: Theme.Color.Gray20 }); 14 | }); 15 | 16 | test('renders a divider with the given color', () => { 17 | const { getByType } = render(); 18 | expect(StyleSheet.flatten(getByType(View).props.style)).toMatchObject({ backgroundColor: 'red' }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/app/src/components/__tests__/Drawer.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 Paul Armstrong 3 | */ 4 | import React from 'react'; 5 | import Drawer, { Handles } from '../Drawer'; 6 | import { fireEvent, render } from 'react-native-testing-library'; 7 | import { ScrollView, StyleSheet, TouchableOpacity } from 'react-native'; 8 | 9 | describe('Drawer', () => { 10 | describe('hidden', () => { 11 | test('is hidden initially', () => { 12 | const { getByType } = render( 13 |