diff --git a/.changeset/renovate-9363c67.md b/.changeset/renovate-9363c67.md new file mode 100644 index 0000000000..85115241de --- /dev/null +++ b/.changeset/renovate-9363c67.md @@ -0,0 +1,98 @@ +--- +'@backstage/backend-common': patch +'@backstage/backend-test-utils': patch +'@backstage/catalog-client': patch +'@backstage/cli': patch +'@backstage/config-loader': patch +'@backstage/core-app-api': patch +'@backstage/core-components': patch +'@backstage/core-plugin-api': patch +'@backstage/integration-react': patch +'@backstage/integration': patch +'@backstage/release-manifests': patch +'@backstage/test-utils': patch +'@backstage/plugin-adr-backend': patch +'@backstage/plugin-adr': patch +'@backstage/plugin-airbrake-backend': patch +'@backstage/plugin-airbrake': patch +'@backstage/plugin-allure': patch +'@backstage/plugin-analytics-module-ga': patch +'@backstage/plugin-apache-airflow': patch +'@backstage/plugin-api-docs': patch +'@backstage/plugin-app-backend': patch +'@backstage/plugin-auth-backend': patch +'@backstage/plugin-auth-node': patch +'@backstage/plugin-azure-devops-backend': patch +'@backstage/plugin-azure-devops': patch +'@backstage/plugin-badges': patch +'@backstage/plugin-bitrise': patch +'@backstage/plugin-catalog-backend-module-azure': patch +'@backstage/plugin-catalog-backend-module-bitbucket': patch +'@backstage/plugin-catalog-backend-module-gerrit': patch +'@backstage/plugin-catalog-backend-module-github': patch +'@backstage/plugin-catalog-backend-module-gitlab': patch +'@backstage/plugin-catalog-backend-module-msgraph': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-catalog-graphql': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-circleci': patch +'@backstage/plugin-cloudbuild': patch +'@backstage/plugin-code-climate': patch +'@backstage/plugin-code-coverage-backend': patch +'@backstage/plugin-code-coverage': patch +'@backstage/plugin-codescene': patch +'@backstage/plugin-config-schema': patch +'@backstage/plugin-cost-insights': patch +'@backstage/plugin-explore-react': patch +'@backstage/plugin-explore': patch +'@backstage/plugin-firehydrant': patch +'@backstage/plugin-fossa': patch +'@backstage/plugin-gcalendar': patch +'@backstage/plugin-gcp-projects': patch +'@backstage/plugin-git-release-manager': patch +'@backstage/plugin-github-actions': patch +'@backstage/plugin-github-deployments': patch +'@backstage/plugin-github-pull-requests-board': patch +'@backstage/plugin-gitops-profiles': patch +'@backstage/plugin-gocd': patch +'@backstage/plugin-graphiql': patch +'@backstage/plugin-graphql-backend': patch +'@backstage/plugin-home': patch +'@backstage/plugin-ilert': patch +'@backstage/plugin-jenkins-backend': patch +'@backstage/plugin-jenkins': patch +'@backstage/plugin-kafka': patch +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-lighthouse': patch +'@backstage/plugin-newrelic': patch +'@backstage/plugin-org': patch +'@backstage/plugin-pagerduty': patch +'@backstage/plugin-periskop-backend': patch +'@backstage/plugin-periskop': patch +'@backstage/plugin-permission-backend': patch +'@backstage/plugin-permission-common': patch +'@backstage/plugin-permission-node': patch +'@backstage/plugin-rollbar-backend': patch +'@backstage/plugin-rollbar': patch +'@backstage/plugin-scaffolder-backend-module-cookiecutter': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-search': patch +'@backstage/plugin-sentry': patch +'@backstage/plugin-shortcuts': patch +'@backstage/plugin-sonarqube': patch +'@backstage/plugin-splunk-on-call': patch +'@backstage/plugin-stack-overflow': patch +'@backstage/plugin-tech-insights': patch +'@backstage/plugin-tech-radar': patch +'@backstage/plugin-techdocs-addons-test-utils': patch +'@backstage/plugin-techdocs-backend': patch +'@backstage/plugin-techdocs-module-addons-contrib': patch +'@backstage/plugin-techdocs': patch +'@backstage/plugin-todo-backend': patch +'@backstage/plugin-todo': patch +'@backstage/plugin-user-settings': patch +'@backstage/plugin-xcmetrics': patch +--- + +Updated dependency `msw` to `^0.41.0`. diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 6114505127..d3c21e2945 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -112,7 +112,7 @@ "better-sqlite3": "^7.5.0", "http-errors": "^2.0.0", "mock-fs": "^5.1.0", - "msw": "^0.35.0", + "msw": "^0.42.0", "mysql2": "^2.2.5", "recursive-readdir": "^2.2.2", "supertest": "^6.1.3" diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 80bab74f52..9a7f8fc189 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -39,7 +39,7 @@ "@backstage/config": "^1.0.1", "better-sqlite3": "^7.5.0", "knex": "^1.0.2", - "msw": "^0.35.0", + "msw": "^0.42.0", "mysql2": "^2.2.5", "pg": "^8.3.0", "testcontainers": "^8.1.2", diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 0aa0f3a220..9fe812926e 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -40,7 +40,7 @@ "devDependencies": { "@backstage/cli": "^0.17.2-next.0", "@types/jest": "^26.0.7", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/packages/cli/package.json b/packages/cli/package.json index f562cc8772..76c31ca724 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -152,7 +152,7 @@ "@types/yarnpkg__lockfile": "^1.1.4", "del": "^6.0.0", "mock-fs": "^5.1.0", - "msw": "^0.35.0", + "msw": "^0.42.0", "nodemon": "^2.0.2", "ts-node": "^10.0.0" }, diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs index dacc1d601a..2416fd75cb 100644 --- a/packages/cli/templates/default-backend-plugin/package.json.hbs +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -41,7 +41,7 @@ "@backstage/cli": "{{versionQuery '@backstage/cli'}}", "@types/supertest": "{{versionQuery '@types/supertest' '2.0.8'}}", "supertest": "{{versionQuery 'supertest' '4.0.2'}}", - "msw": "{{versionQuery 'msw' '0.35.0'}}" + "msw": "{{versionQuery 'msw' '0.42.0'}}" }, "files": [ "dist" diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index a174c1bafd..34a0548b29 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -49,7 +49,7 @@ "@testing-library/user-event": "{{versionQuery '@testing-library/user-event' '14.0.0'}}", "@types/jest": "{{versionQuery '@types/jest' '26.0.7'}}", "@types/node": "{{versionQuery '@types/node' '16.11.26'}}", - "msw": "{{versionQuery 'msw' '0.35.0'}}", + "msw": "{{versionQuery 'msw' '0.42.0'}}", "cross-fetch": "{{versionQuery 'cross-fetch' '3.1.5'}}" }, "files": [ diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 00629bf2fb..818a7b0eb6 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -57,7 +57,7 @@ "@types/node": "^16.11.26", "@types/yup": "^0.29.13", "mock-fs": "^5.1.0", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index c9f12431e9..38f3f2663d 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -59,7 +59,7 @@ "@types/node": "^16.11.26", "@types/zen-observable": "^0.8.0", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 8ccadf7db5..e09cec6fdf 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -101,7 +101,7 @@ "@types/react-window": "^1.8.5", "@types/zen-observable": "^0.8.0", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index e0ea4b425a..ae220e6e09 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -58,7 +58,7 @@ "@types/prop-types": "^15.7.3", "@types/zen-observable": "^0.8.0", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/packages/integration-react/package.json b/packages/integration-react/package.json index ff0646dbcf..6f3db876c0 100644 --- a/packages/integration-react/package.json +++ b/packages/integration-react/package.json @@ -46,7 +46,7 @@ "@testing-library/user-event": "^14.0.0", "@types/jest": "^26.0.7", "@types/node": "^16.11.26", - "msw": "^0.35.0", + "msw": "^0.42.0", "cross-fetch": "^3.1.5" }, "files": [ diff --git a/packages/integration/package.json b/packages/integration/package.json index a27e07d7a9..da5a033010 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -48,7 +48,7 @@ "@backstage/test-utils": "^1.1.0", "@types/jest": "^26.0.7", "@types/luxon": "^2.0.4", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/packages/release-manifests/package.json b/packages/release-manifests/package.json index ef968e7a2d..527740c85d 100644 --- a/packages/release-manifests/package.json +++ b/packages/release-manifests/package.json @@ -37,7 +37,7 @@ }, "devDependencies": { "@backstage/test-utils": "^1.1.0", - "msw": "^0.35.0", + "msw": "^0.42.0", "@types/jest": "^26.0.7", "@types/node": "^16.0.0" }, diff --git a/packages/release-manifests/src/manifest.test.ts b/packages/release-manifests/src/manifest.test.ts index 70894e5431..272555df70 100644 --- a/packages/release-manifests/src/manifest.test.ts +++ b/packages/release-manifests/src/manifest.test.ts @@ -14,73 +14,75 @@ * limitations under the License. */ -import { setupRequestMockHandlers } from '@backstage/test-utils'; -import { getManifestByReleaseLine, getManifestByVersion } from './manifest'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { getManifestByReleaseLine, getManifestByVersion } from './manifest'; -describe('getManifestByVersion', () => { +describe('Release Manifests', () => { const worker = setupServer(); setupRequestMockHandlers(worker); - it('should return a list of packages in a release', async () => { - worker.use( - rest.get('*/v1/releases/0.0.0/manifest.json', (_, res, ctx) => - res( - ctx.status(200), - ctx.json({ - packages: [{ name: '@backstage/core', version: '1.2.3' }], - }), + describe('getManifestByVersion', () => { + it('should return a list of packages in a release', async () => { + worker.use( + rest.get('*/v1/releases/0.0.0/manifest.json', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + packages: [{ name: '@backstage/core', version: '1.2.3' }], + }), + ), ), - ), - rest.get('*/v1/releases/999.0.1/manifest.json', (_, res, ctx) => - res(ctx.status(404), ctx.json({})), - ), - ); + rest.get('*/v1/releases/999.0.1/manifest.json', (_, res, ctx) => + res(ctx.status(404), ctx.json({})), + ), + ); - const pkgs = await getManifestByVersion({ version: '0.0.0' }); - expect(pkgs.packages).toEqual([ - { - name: '@backstage/core', - version: '1.2.3', - }, - ]); + const pkgs = await getManifestByVersion({ version: '0.0.0' }); + expect(pkgs.packages).toEqual([ + { + name: '@backstage/core', + version: '1.2.3', + }, + ]); - await expect(getManifestByVersion({ version: '999.0.1' })).rejects.toThrow( - 'No release found for 999.0.1 version', - ); - }); -}); - -describe('getManifestByReleaseLine', () => { - const worker = setupServer(); - setupRequestMockHandlers(worker); - - it('should return a list of packages in a release', async () => { - worker.use( - rest.get('*/v1/tags/main/manifest.json', (_, res, ctx) => - res( - ctx.status(200), - ctx.json({ - packages: [{ name: '@backstage/core', version: '1.2.3' }], - }), - ), - ), - rest.get('*/v1/tags/foo/manifest.json', (_, res, ctx) => - res(ctx.status(404), ctx.json({})), - ), - ); - - const pkgs = await getManifestByReleaseLine({ releaseLine: 'main' }); - expect(pkgs.packages).toEqual([ - { - name: '@backstage/core', - version: '1.2.3', - }, - ]); - - await expect( - getManifestByReleaseLine({ releaseLine: 'foo' }), - ).rejects.toThrow("No 'foo' release line found"); + await expect( + getManifestByVersion({ version: '999.0.1' }), + ).rejects.toThrow('No release found for 999.0.1 version'); + }); + }); + + describe('getManifestByReleaseLine', () => { + it('should return a list of packages in a release', async () => { + worker.use( + rest.get( + 'https://versions.backstage.io/v1/tags/main/manifest.json', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + packages: [{ name: '@backstage/core', version: '1.2.3' }], + }), + ), + ), + rest.get( + 'https://versions.backstage.io/v1/tags/foo/manifest.json', + (_, res, ctx) => res(ctx.status(404), ctx.json({})), + ), + ); + + const pkgs = await getManifestByReleaseLine({ releaseLine: 'main' }); + expect(pkgs.packages).toEqual([ + { + name: '@backstage/core', + version: '1.2.3', + }, + ]); + + await expect( + getManifestByReleaseLine({ releaseLine: 'foo' }), + ).rejects.toThrow("No 'foo' release line found"); + }); }); }); diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index a532b78ccb..24b446d055 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -58,7 +58,7 @@ "@backstage/cli": "^0.17.2-next.0", "@types/jest": "^26.0.7", "@types/node": "^16.11.26", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/adr-backend/package.json b/plugins/adr-backend/package.json index ecdd9dce29..c7a47b69a9 100644 --- a/plugins/adr-backend/package.json +++ b/plugins/adr-backend/package.json @@ -48,7 +48,7 @@ "@types/marked": "^4.0.0", "@types/supertest": "^2.0.8", "supertest": "^6.1.3", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/adr/package.json b/plugins/adr/package.json index b63f5818ed..b645c77daf 100644 --- a/plugins/adr/package.json +++ b/plugins/adr/package.json @@ -55,7 +55,7 @@ "@types/jest": "*", "@types/node": "*", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/airbrake-backend/package.json b/plugins/airbrake-backend/package.json index 5b788ecfff..a87c73799a 100644 --- a/plugins/airbrake-backend/package.json +++ b/plugins/airbrake-backend/package.json @@ -37,7 +37,7 @@ "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "supertest": "^6.1.6", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/airbrake/package.json b/plugins/airbrake/package.json index b2e8d21eca..6ced2e37a7 100644 --- a/plugins/airbrake/package.json +++ b/plugins/airbrake/package.json @@ -51,7 +51,7 @@ "@types/node": "^16.11.26", "@types/object-hash": "^2.2.1", "cross-fetch": "^3.1.5", - "msw": "^0.35.0", + "msw": "^0.42.0", "react-router": "6.0.0-beta.0" }, "files": [ diff --git a/plugins/allure/package.json b/plugins/allure/package.json index 60a27654c1..668912cb04 100644 --- a/plugins/allure/package.json +++ b/plugins/allure/package.json @@ -50,7 +50,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index 2c764e740c..0dbc3fd70f 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -48,7 +48,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/apache-airflow/package.json b/plugins/apache-airflow/package.json index 32bc6e21dd..e22796fdd0 100644 --- a/plugins/apache-airflow/package.json +++ b/plugins/apache-airflow/package.json @@ -45,7 +45,7 @@ "@testing-library/user-event": "^14.0.0", "@types/jest": "^26.0.7", "@types/node": "^16.11.26", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index dcace08715..690926d2bd 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -68,7 +68,7 @@ "@types/node": "^16.11.26", "@types/swagger-ui-react": "^4.1.1", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 68aa3301a4..abce3493d2 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -55,7 +55,7 @@ "@backstage/types": "^1.0.0", "@types/supertest": "^2.0.8", "mock-fs": "^5.1.0", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.3" }, "files": [ diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 99ce0ff6bd..16d85acfa3 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -88,7 +88,7 @@ "@types/passport-saml": "^1.1.3", "@types/passport-strategy": "^0.2.35", "@types/xml2js": "^0.4.7", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.3" }, "files": [ diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index 27d0575417..f8dd6af0b3 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -33,7 +33,7 @@ "devDependencies": { "@backstage/cli": "^0.17.2-next.0", "lodash": "^4.17.21", - "msw": "^0.35.0", + "msw": "^0.42.0", "uuid": "^8.0.0" }, "files": [ diff --git a/plugins/azure-devops-backend/package.json b/plugins/azure-devops-backend/package.json index 03c91c6394..8d63228430 100644 --- a/plugins/azure-devops-backend/package.json +++ b/plugins/azure-devops-backend/package.json @@ -38,7 +38,7 @@ "@backstage/cli": "^0.17.2-next.0", "@types/supertest": "^2.0.8", "supertest": "^6.1.6", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/azure-devops/package.json b/plugins/azure-devops/package.json index 5990a9501a..aa0544d120 100644 --- a/plugins/azure-devops/package.json +++ b/plugins/azure-devops/package.json @@ -59,7 +59,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/badges/package.json b/plugins/badges/package.json index b7be771e19..a0a0320fbd 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -56,7 +56,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index 81bd158b7d..15d4e40ab2 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -53,7 +53,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "@types/recharts": "^1.8.15", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/catalog-backend-module-azure/package.json b/plugins/catalog-backend-module-azure/package.json index 2fd95ef97b..fb2e30d875 100644 --- a/plugins/catalog-backend-module-azure/package.json +++ b/plugins/catalog-backend-module-azure/package.json @@ -41,7 +41,7 @@ "@backstage/plugin-catalog-backend": "^1.2.0-next.0", "@backstage/types": "^1.0.0", "lodash": "^4.17.21", - "msw": "^0.35.0", + "msw": "^0.42.0", "node-fetch": "^2.6.7", "winston": "^3.2.1" }, diff --git a/plugins/catalog-backend-module-bitbucket/package.json b/plugins/catalog-backend-module-bitbucket/package.json index 4e40fa9400..d313555ab3 100644 --- a/plugins/catalog-backend-module-bitbucket/package.json +++ b/plugins/catalog-backend-module-bitbucket/package.json @@ -42,7 +42,7 @@ "@backstage/plugin-catalog-backend": "^1.2.0-next.0", "@backstage/types": "^1.0.0", "lodash": "^4.17.21", - "msw": "^0.35.0", + "msw": "^0.42.0", "node-fetch": "^2.6.7", "winston": "^3.2.1" }, diff --git a/plugins/catalog-backend-module-gerrit/package.json b/plugins/catalog-backend-module-gerrit/package.json index 3d8c51a9c3..354aae55fa 100644 --- a/plugins/catalog-backend-module-gerrit/package.json +++ b/plugins/catalog-backend-module-gerrit/package.json @@ -36,7 +36,7 @@ "@backstage/integration": "^1.2.1-next.0", "@backstage/plugin-catalog-backend": "^1.2.0-next.0", "fs-extra": "10.1.0", - "msw": "^0.35.0", + "msw": "^0.42.0", "node-fetch": "^2.6.7", "uuid": "^8.0.0", "winston": "^3.2.1" diff --git a/plugins/catalog-backend-module-github/package.json b/plugins/catalog-backend-module-github/package.json index 12dfaf8e4e..224e86ab07 100644 --- a/plugins/catalog-backend-module-github/package.json +++ b/plugins/catalog-backend-module-github/package.json @@ -43,7 +43,7 @@ "@backstage/types": "^1.0.0", "@octokit/graphql": "^4.5.8", "lodash": "^4.17.21", - "msw": "^0.35.0", + "msw": "^0.42.0", "node-fetch": "^2.6.7", "uuid": "^8.0.0", "winston": "^3.2.1" diff --git a/plugins/catalog-backend-module-gitlab/package.json b/plugins/catalog-backend-module-gitlab/package.json index a273f936cf..1aa8f4dc06 100644 --- a/plugins/catalog-backend-module-gitlab/package.json +++ b/plugins/catalog-backend-module-gitlab/package.json @@ -41,7 +41,7 @@ "@backstage/plugin-catalog-backend": "^1.2.0-next.0", "@backstage/types": "^1.0.0", "lodash": "^4.17.21", - "msw": "^0.35.0", + "msw": "^0.42.0", "node-fetch": "^2.6.7", "winston": "^3.2.1" }, diff --git a/plugins/catalog-backend-module-msgraph/package.json b/plugins/catalog-backend-module-msgraph/package.json index c9c9b75962..a7c737b3fe 100644 --- a/plugins/catalog-backend-module-msgraph/package.json +++ b/plugins/catalog-backend-module-msgraph/package.json @@ -52,7 +52,7 @@ "@backstage/backend-test-utils": "^0.1.25-next.0", "@backstage/cli": "^0.17.2-next.0", "@types/lodash": "^4.14.151", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index e35590838f..33a345734a 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -78,7 +78,7 @@ "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", "better-sqlite3": "^7.5.0", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.3", "wait-for-expect": "^3.0.2", "luxon": "^2.0.2" diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 848037bf53..e5ea11ebff 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -53,7 +53,7 @@ "@graphql-codegen/typescript": "^2.4.2", "@graphql-codegen/typescript-resolvers": "^2.4.3", "@graphql-tools/schema": "^8.3.1", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index b15faada11..02cee7f016 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -70,7 +70,7 @@ "@testing-library/user-event": "^14.0.0", "@types/jest": "^26.0.7", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index ee8ee6d27f..3fa3812939 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -66,7 +66,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 924b9ce21e..0c7b4caf4a 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -62,7 +62,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/code-climate/package.json b/plugins/code-climate/package.json index 0765c7aec4..8053169b1b 100644 --- a/plugins/code-climate/package.json +++ b/plugins/code-climate/package.json @@ -51,7 +51,7 @@ "@types/luxon": "^2.0.9", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json index f3bd764000..797861d948 100644 --- a/plugins/code-coverage-backend/package.json +++ b/plugins/code-coverage-backend/package.json @@ -42,7 +42,7 @@ "@backstage/cli": "^0.17.2-next.0", "@types/express-xml-bodyparser": "^0.3.2", "@types/supertest": "^2.0.8", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.6", "xml2js": "^0.4.23" }, diff --git a/plugins/code-coverage/package.json b/plugins/code-coverage/package.json index b01de562eb..98a2b152d2 100644 --- a/plugins/code-coverage/package.json +++ b/plugins/code-coverage/package.json @@ -58,7 +58,7 @@ "@types/node": "^16.11.26", "@types/recharts": "^1.8.15", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/codescene/package.json b/plugins/codescene/package.json index 670231908b..a57d96b469 100644 --- a/plugins/codescene/package.json +++ b/plugins/codescene/package.json @@ -47,7 +47,7 @@ "@testing-library/user-event": "^14.0.0", "@types/jest": "*", "@types/node": "*", - "msw": "^0.35.0", + "msw": "^0.42.0", "cross-fetch": "^3.1.5" }, "files": [ diff --git a/plugins/config-schema/package.json b/plugins/config-schema/package.json index 4c2b64eb2b..2662a52cde 100644 --- a/plugins/config-schema/package.json +++ b/plugins/config-schema/package.json @@ -51,7 +51,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 46fd3f4bd8..b6402ff9fc 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -75,7 +75,7 @@ "@types/yup": "^0.29.13", "canvas": "^2.6.1", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/example-todo-list-backend/package.json b/plugins/example-todo-list-backend/package.json index e9df66e183..b32dbf48c4 100644 --- a/plugins/example-todo-list-backend/package.json +++ b/plugins/example-todo-list-backend/package.json @@ -36,7 +36,7 @@ "@backstage/cli": "^0.17.2-next.0", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.6" }, "files": [ diff --git a/plugins/example-todo-list-common/package.json b/plugins/example-todo-list-common/package.json index ca91f926ed..2ad8e71eb0 100644 --- a/plugins/example-todo-list-common/package.json +++ b/plugins/example-todo-list-common/package.json @@ -31,7 +31,7 @@ "@backstage/dev-utils": "^1.0.3-next.0", "@backstage/test-utils": "^1.1.0", "@types/node": "^16.11.26", - "msw": "^0.35.0", + "msw": "^0.42.0", "cross-fetch": "^3.1.5" }, "files": [ diff --git a/plugins/example-todo-list/package.json b/plugins/example-todo-list/package.json index 36b9a72048..6781312fd4 100644 --- a/plugins/example-todo-list/package.json +++ b/plugins/example-todo-list/package.json @@ -42,7 +42,7 @@ "@testing-library/user-event": "^14.0.0", "@types/jest": "^26.0.7", "@types/node": "^16.11.26", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/explore-react/package.json b/plugins/explore-react/package.json index c925e024d2..16f56cb853 100644 --- a/plugins/explore-react/package.json +++ b/plugins/explore-react/package.json @@ -45,7 +45,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/explore/package.json b/plugins/explore/package.json index a4114d9cee..8fad2b98ab 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -63,7 +63,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/firehydrant/package.json b/plugins/firehydrant/package.json index 6af1ec094c..d290c6fa83 100644 --- a/plugins/firehydrant/package.json +++ b/plugins/firehydrant/package.json @@ -49,7 +49,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 6763f32711..11a0503055 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -62,7 +62,7 @@ "@testing-library/user-event": "^14.0.0", "@types/jest": "^26.0.7", "@types/node": "^16.11.26", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/gcalendar/package.json b/plugins/gcalendar/package.json index 3bdad9c3b7..6b77165b7a 100644 --- a/plugins/gcalendar/package.json +++ b/plugins/gcalendar/package.json @@ -55,7 +55,7 @@ "@types/node": "^16.11.26", "@types/sanitize-html": "^2.6.2", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 2edff98b4f..409746e4d4 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -57,7 +57,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/git-release-manager/package.json b/plugins/git-release-manager/package.json index d811a20779..163cf91da9 100644 --- a/plugins/git-release-manager/package.json +++ b/plugins/git-release-manager/package.json @@ -55,7 +55,7 @@ "@types/node": "^16.11.26", "@types/recharts": "^1.8.15", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index c86dd6b8a0..0b26f90bf5 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -65,7 +65,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index 03ce5d840e..c78fa62971 100644 --- a/plugins/github-deployments/package.json +++ b/plugins/github-deployments/package.json @@ -53,7 +53,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/github-pull-requests-board/package.json b/plugins/github-pull-requests-board/package.json index 402e21f94e..f9ef5ef854 100644 --- a/plugins/github-pull-requests-board/package.json +++ b/plugins/github-pull-requests-board/package.json @@ -58,7 +58,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 3f35eaee17..a55c7afa05 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -58,7 +58,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/gocd/package.json b/plugins/gocd/package.json index d4dd8e1c87..b7f56267d2 100644 --- a/plugins/gocd/package.json +++ b/plugins/gocd/package.json @@ -61,7 +61,7 @@ "@types/luxon": "^2.0.4", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 0a33313b28..2637e86d49 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -60,7 +60,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0", + "msw": "^0.42.0", "react-router-dom": "6.0.0-beta.0" }, "files": [ diff --git a/plugins/graphql-backend/package.json b/plugins/graphql-backend/package.json index 4987de1b23..723900e86f 100644 --- a/plugins/graphql-backend/package.json +++ b/plugins/graphql-backend/package.json @@ -53,7 +53,7 @@ "devDependencies": { "@backstage/cli": "^0.17.2-next.0", "@types/supertest": "^2.0.8", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.3" }, "files": [ diff --git a/plugins/home/package.json b/plugins/home/package.json index 48b92442db..05fa728b13 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -63,7 +63,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/ilert/package.json b/plugins/ilert/package.json index b428b2213f..cb3cd7e77b 100644 --- a/plugins/ilert/package.json +++ b/plugins/ilert/package.json @@ -53,7 +53,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json index 05a1564cae..8f51cf6217 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -44,7 +44,7 @@ "@backstage/cli": "^0.17.2-next.0", "@types/jenkins": "^0.23.1", "@types/supertest": "^2.0.8", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.6" }, "files": [ diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index c58eac65af..9d7eceb156 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -65,7 +65,7 @@ "@types/node": "^16.11.26", "@types/testing-library__jest-dom": "^5.9.1", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index 492bbe48d5..63d153e409 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -51,7 +51,7 @@ "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", "jest-when": "^3.1.0", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 341a5c086d..014528dacd 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -68,7 +68,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 1a02356fe2..59858282a7 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -63,7 +63,7 @@ "@types/node": "^16.11.26", "@types/react": "^16.13.1 || ^17.0.0", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 056447c90b..9e66da99be 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -57,7 +57,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/org/package.json b/plugins/org/package.json index abe9ce76e7..6389854bf1 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -59,7 +59,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index fa8f15e35e..45f022acca 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -62,7 +62,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/periskop-backend/package.json b/plugins/periskop-backend/package.json index e569d1cca2..4cfda542ac 100644 --- a/plugins/periskop-backend/package.json +++ b/plugins/periskop-backend/package.json @@ -37,7 +37,7 @@ "devDependencies": { "@backstage/cli": "^0.17.2-next.0", "@types/supertest": "^2.0.8", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.6" }, "files": [ diff --git a/plugins/periskop/package.json b/plugins/periskop/package.json index 32ed926e56..329164ddf0 100644 --- a/plugins/periskop/package.json +++ b/plugins/periskop/package.json @@ -53,7 +53,7 @@ "@types/luxon": "^2.0.4", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/permission-backend/package.json b/plugins/permission-backend/package.json index 1ac11779c4..fa43e6a263 100644 --- a/plugins/permission-backend/package.json +++ b/plugins/permission-backend/package.json @@ -43,7 +43,7 @@ "@types/lodash": "^4.14.151", "@types/supertest": "^2.0.8", "supertest": "^6.1.6", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/permission-common/package.json b/plugins/permission-common/package.json index 19b0742a93..cecdd2e03a 100644 --- a/plugins/permission-common/package.json +++ b/plugins/permission-common/package.json @@ -50,6 +50,6 @@ "devDependencies": { "@backstage/cli": "^0.17.2-next.0", "@types/jest": "^26.0.7", - "msw": "^0.35.0" + "msw": "^0.42.0" } } diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json index 670b79806d..1137b7859b 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -46,7 +46,7 @@ "devDependencies": { "@backstage/cli": "^0.17.2-next.0", "@types/supertest": "^2.0.8", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.3" }, "files": [ diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 14fdbc3b78..5e9d6f5382 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -53,7 +53,7 @@ "@backstage/backend-test-utils": "^0.1.25-next.0", "@backstage/cli": "^0.17.2-next.0", "@types/supertest": "^2.0.8", - "msw": "^0.36.3", + "msw": "^0.42.0", "supertest": "^6.1.3" }, "files": [ diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 06cf4c5f65..38a344e759 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -65,7 +65,7 @@ "@types/node": "^16.11.26", "@types/react": "^16.13.1 || ^17.0.0", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/scaffolder-backend-module-cookiecutter/package.json b/plugins/scaffolder-backend-module-cookiecutter/package.json index 05d4eb4511..16271f1046 100644 --- a/plugins/scaffolder-backend-module-cookiecutter/package.json +++ b/plugins/scaffolder-backend-module-cookiecutter/package.json @@ -41,7 +41,7 @@ "@types/jest": "^26.0.7", "@types/command-exists": "^1.2.0", "mock-fs": "^5.1.0", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 1c333eb85f..f26c242f97 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -40,6 +40,7 @@ export type ActionContext = { output(name: string, value: JsonValue): void; createTemporaryDirectory(): Promise; templateInfo?: TemplateInfo; + isDryRun?: boolean; }; // @public @@ -554,6 +555,8 @@ export interface TaskContext { // (undocumented) getWorkspaceName(): Promise; // (undocumented) + isDryRun?: boolean; + // (undocumented) secrets?: TaskSecrets; // (undocumented) spec: TaskSpec; @@ -671,6 +674,7 @@ export class TaskWorker { export type TemplateAction = { id: string; description?: string; + supportsDryRun?: boolean; schema?: { input?: Schema; output?: Schema; diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index b2ba72dc2a..acaf18a8d4 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -67,11 +67,13 @@ "nunjucks": "^3.2.3", "octokit": "^1.7.1", "octokit-plugin-create-pull-request": "^3.10.0", + "p-limit": "^3.1.0", "uuid": "^8.2.0", "winston": "^3.2.1", "yaml": "^1.10.0", "vm2": "^3.9.6", - "zen-observable": "^0.8.15" + "zen-observable": "^0.8.15", + "zod": "^3.11.6" }, "devDependencies": { "@backstage/backend-test-utils": "^0.1.25-next.0", @@ -86,7 +88,7 @@ "esbuild": "^0.14.1", "jest-when": "^3.1.0", "mock-fs": "^5.1.0", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.3", "yaml": "^1.10.0" }, diff --git a/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.test.ts b/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.test.ts new file mode 100644 index 0000000000..0e7de7f88d --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import mockFs from 'mock-fs'; +import { deserializeDirectoryContents } from './deserializeDirectoryContents'; +import { serializeDirectoryContents } from './serializeDirectoryContents'; + +describe('deserializeDirectoryContents', () => { + beforeEach(() => { + mockFs({ + root: {}, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('deserializes contents into a directory', async () => { + await deserializeDirectoryContents('root', [ + { + path: 'a.txt', + content: Buffer.from('a', 'utf8'), + }, + ]); + await expect(serializeDirectoryContents('root')).resolves.toEqual([ + { + path: 'a.txt', + content: Buffer.from('a', 'utf8'), + executable: false, + }, + ]); + }); + + it('deserializes contents into a deep directory structure', async () => { + await deserializeDirectoryContents('root', [ + { + path: 'a.txt', + content: Buffer.from('a', 'utf8'), + }, + { + path: 'a/b.txt', + content: Buffer.from('b', 'utf8'), + }, + { + path: 'a/b/c.txt', + content: Buffer.from('c', 'utf8'), + }, + ]); + await expect(serializeDirectoryContents('root')).resolves.toEqual([ + { + path: 'a.txt', + content: Buffer.from('a', 'utf8'), + executable: false, + }, + { + path: 'a/b.txt', + content: Buffer.from('b', 'utf8'), + executable: false, + }, + { + path: 'a/b/c.txt', + content: Buffer.from('c', 'utf8'), + executable: false, + }, + ]); + }); +}); diff --git a/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.ts b/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.ts new file mode 100644 index 0000000000..cb567eed9d --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import { dirname } from 'path'; +import { resolveSafeChildPath } from '@backstage/backend-common'; +import { SerializedFile } from './types'; + +/** + * Deserializes a list of serialized files into the target directory. + * + * This method uses `resolveSafeChildPath` to make sure that files are + * not written outside of the target directory. + * + * @internal + */ +export async function deserializeDirectoryContents( + targetPath: string, + files: SerializedFile[], +): Promise { + for (const file of files) { + const filePath = resolveSafeChildPath(targetPath, file.path); + await fs.ensureDir(dirname(filePath)); + await fs.writeFile(filePath, file.content); // Ignore file mode + } +} diff --git a/plugins/scaffolder-backend/src/lib/files/index.ts b/plugins/scaffolder-backend/src/lib/files/index.ts new file mode 100644 index 0000000000..ab6975532f --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { serializeDirectoryContents } from './serializeDirectoryContents'; +export { deserializeDirectoryContents } from './deserializeDirectoryContents'; +export type { SerializedFile } from './types'; diff --git a/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.test.ts b/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.test.ts new file mode 100644 index 0000000000..e1ea676ec5 --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { serializeDirectoryContents } from './serializeDirectoryContents'; +import mockFs from 'mock-fs'; + +describe('serializeDirectoryContents', () => { + afterEach(() => { + mockFs.restore(); + }); + + it('should list files in this directory', async () => { + await expect(serializeDirectoryContents(__dirname)).resolves.toEqual( + expect.arrayContaining([ + { + path: 'index.ts', + executable: false, + content: expect.any(Buffer), + }, + { + path: 'types.ts', + executable: false, + content: expect.any(Buffer), + }, + { + path: 'serializeDirectoryContents.ts', + executable: false, + content: expect.any(Buffer), + }, + { + path: 'serializeDirectoryContents.test.ts', + executable: false, + content: expect.any(Buffer), + }, + ]), + ); + }); + + it('should list files in a mock directory', async () => { + mockFs({ + root: { + 'a.txt': 'a', + b: { + 'b1.txt': 'b1', + 'b2.txt': 'b2', + }, + c: { + c1: { + 'c11.txt': 'c11', + c11: { + 'c111.txt': 'c111', + }, + }, + }, + }, + }); + + await expect(serializeDirectoryContents('root')).resolves.toEqual([ + { + path: 'a.txt', + executable: false, + content: Buffer.from('a', 'utf8'), + }, + { + path: 'b/b1.txt', + executable: false, + content: Buffer.from('b1', 'utf8'), + }, + { + path: 'b/b2.txt', + executable: false, + content: Buffer.from('b2', 'utf8'), + }, + { + path: 'c/c1/c11.txt', + executable: false, + content: Buffer.from('c11', 'utf8'), + }, + { + path: 'c/c1/c11/c111.txt', + executable: false, + content: Buffer.from('c111', 'utf8'), + }, + ]); + }); + + it('should ignore gitignored files', async () => { + mockFs({ + root: { + '.gitignore': '*.txt', + 'a.txt': 'a', + 'a.log': 'a', + }, + }); + + await expect( + serializeDirectoryContents('root', { + gitignore: true, + }), + ).resolves.toEqual([ + { + path: '.gitignore', + executable: false, + content: Buffer.from('*.txt', 'utf8'), + }, + { + path: 'a.log', + executable: false, + content: Buffer.from('a', 'utf8'), + }, + ]); + }); + + it('should use custom glob patterns', async () => { + mockFs({ + root: { + '.a': 'a', + 'a.log': 'a', + 'a.txt': 'a', + b: { + '.b': 'b', + 'b.log': 'b', + 'b.txt': 'b', + }, + c: { + '.c': 'c', + 'c.log': 'c', + 'c.txt': 'c', + }, + }, + }); + + await expect( + serializeDirectoryContents('root', { + gitignore: true, + globPatterns: ['**/*.txt', '*/.?', '*/*.log', '!c/**/.*', '!b/*.log'], + }).then(files => files.sort((a, b) => a.path.localeCompare(b.path))), + ).resolves.toEqual([ + { + path: 'a.txt', + executable: false, + content: Buffer.from('a', 'utf8'), + }, + { + path: 'b/.b', + executable: false, + content: Buffer.from('b', 'utf8'), + }, + { + path: 'b/b.txt', + executable: false, + content: Buffer.from('b', 'utf8'), + }, + { + path: 'c/c.log', + executable: false, + content: Buffer.from('c', 'utf8'), + }, + { + path: 'c/c.txt', + executable: false, + content: Buffer.from('c', 'utf8'), + }, + ]); + }); +}); diff --git a/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.ts b/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.ts new file mode 100644 index 0000000000..608b9e71a5 --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import globby from 'globby'; +import limiterFactory from 'p-limit'; +import { join as joinPath } from 'path'; +import { SerializedFile } from './types'; + +const DEFAULT_GLOB_PATTERNS = ['./**', '!.git']; + +export const isExecutable = (fileMode: number | undefined) => { + if (!fileMode) { + return false; + } + + const executeBitMask = 0o000111; + const res = fileMode & executeBitMask; + return res > 0; +}; + +export async function serializeDirectoryContents( + sourcePath: string, + options?: { + gitignore?: boolean; + globPatterns?: string[]; + }, +): Promise { + const paths = await globby(options?.globPatterns ?? DEFAULT_GLOB_PATTERNS, { + cwd: sourcePath, + dot: true, + gitignore: options?.gitignore, + followSymbolicLinks: false, + objectMode: true, + stats: true, + }); + + const limiter = limiterFactory(10); + + return Promise.all( + paths.map(async ({ path, stats }) => ({ + path, + content: await limiter(async () => + fs.readFile(joinPath(sourcePath, path)), + ), + executable: isExecutable(stats?.mode), + })), + ); +} diff --git a/plugins/scaffolder-backend/src/lib/files/types.ts b/plugins/scaffolder-backend/src/lib/files/types.ts new file mode 100644 index 0000000000..390d6791ee --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface SerializedFile { + path: string; + content: Buffer; + executable?: boolean; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts index 1b30268d9d..4b9f883706 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts @@ -84,6 +84,18 @@ export function createCatalogRegisterAction(options: { }, ], }, + output: { + type: 'object', + required: ['catalogInfoUrl'], + properties: { + entityRef: { + type: 'string', + }, + catalogInfoUrl: { + type: 'string', + }, + }, + }, }, async handler(ctx) { const { input } = ctx; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts index b55e521784..da758898dc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts @@ -46,6 +46,7 @@ export function createCatalogWriteAction() { }, }, }, + supportsDryRun: true, async handler(ctx) { ctx.logStream.write(`Writing catalog-info.yaml`); const { filePath, entity } = ctx.input; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts index 59d4c333c6..99d030b772 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts @@ -51,6 +51,7 @@ export function createDebugLogAction() { }, }, }, + supportsDryRun: true, async handler(ctx) { ctx.logger.info(JSON.stringify(ctx.input, null, 2)); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts index 6dbe08f61b..9dc82068c0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts @@ -54,6 +54,7 @@ export function createFetchPlainAction(options: { }, }, }, + supportsDryRun: true, async handler(ctx) { ctx.logger.info('Fetching plain content from remote URL'); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index 3d3d319c57..c6d6b86e7b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -101,6 +101,7 @@ export function createFetchTemplateAction(options: { }, }, }, + supportsDryRun: true, async handler(ctx) { ctx.logger.info('Fetching template content from remote URL'); @@ -153,6 +154,7 @@ export function createFetchTemplateAction(options: { dot: true, onlyFiles: false, markDirectories: true, + followSymbolicLinks: false, }); const nonTemplatedEntries = new Set( @@ -164,6 +166,7 @@ export function createFetchTemplateAction(options: { dot: true, onlyFiles: false, markDirectories: true, + followSymbolicLinks: false, }), ), ) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts index bf2953e75a..757e9c682e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts @@ -42,6 +42,7 @@ export const createFilesystemDeleteAction = () => { }, }, }, + supportsDryRun: true, async handler(ctx) { if (!Array.isArray(ctx.input?.files)) { throw new InputError('files must be an Array'); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts index 7c8dad7559..654ee7b150 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts @@ -66,6 +66,7 @@ export const createFilesystemRenameAction = () => { }, }, }, + supportsDryRun: true, async handler(ctx) { if (!Array.isArray(ctx.input?.files)) { throw new InputError('files must be an Array'); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts index 3bf90d1e30..f8f9b22d61 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -14,21 +14,19 @@ * limitations under the License. */ -import fs from 'fs-extra'; -import { parseRepoUrl, isExecutable } from './util'; - +import path from 'path'; +import { parseRepoUrl } from './util'; import { GithubCredentialsProvider, ScmIntegrationRegistry, } from '@backstage/integration'; -import { zipObject } from 'lodash'; import { createTemplateAction } from '../../createTemplateAction'; import { Octokit } from 'octokit'; import { InputError, CustomErrorBase } from '@backstage/errors'; import { createPullRequest } from 'octokit-plugin-create-pull-request'; -import globby from 'globby'; import { resolveSafeChildPath } from '@backstage/backend-common'; import { getOctokitOptions } from '../github/helpers'; +import { serializeDirectoryContents } from '../../../../lib/files'; export type Encoding = 'utf-8' | 'base64'; @@ -219,56 +217,39 @@ export const createPublishGithubPullRequestAction = ({ ? resolveSafeChildPath(ctx.workspacePath, sourcePath) : ctx.workspacePath; - const localFilePaths = await globby(['./**', './**/.*', '!.git'], { - cwd: fileRoot, + const directoryContents = await serializeDirectoryContents(fileRoot, { gitignore: true, - dot: true, }); - - const fileContents = await Promise.all( - localFilePaths.map(filePath => { - const absPath = resolveSafeChildPath(fileRoot, filePath); - const base64EncodedContent = fs - .readFileSync(absPath) - .toString('base64'); - const fileStat = fs.statSync(absPath); - // See the properties of tree items - // in https://docs.github.com/en/rest/reference/git#trees - const githubTreeItemMode = isExecutable(fileStat.mode) - ? '100755' - : '100644'; - // Always use base64 encoding to avoid doubling a binary file in size - // due to interpreting a binary file as utf-8 and sending github - // the utf-8 encoded content. - // - // For example, the original gradle-wrapper.jar is 57.8k in https://github.com/kennethzfeng/pull-request-test/pull/5/files. - // Its size could be doubled to 98.3K (See https://github.com/kennethzfeng/pull-request-test/pull/4/files) - const encoding: Encoding = 'base64'; - return { - encoding: encoding, - content: base64EncodedContent, - mode: githubTreeItemMode, - }; - }), + const files = Object.fromEntries( + directoryContents.map(file => [ + targetPath ? path.posix.join(targetPath, file.path) : file.path, + { + // See the properties of tree items + // in https://docs.github.com/en/rest/reference/git#trees + mode: file.executable ? '100755' : '100644', + // Always use base64 encoding to avoid doubling a binary file in size + // due to interpreting a binary file as utf-8 and sending github + // the utf-8 encoded content. + // + // For example, the original gradle-wrapper.jar is 57.8k in https://github.com/kennethzfeng/pull-request-test/pull/5/files. + // Its size could be doubled to 98.3K (See https://github.com/kennethzfeng/pull-request-test/pull/4/files) + encoding: 'base64' as const, + content: file.content.toString('base64'), + }, + ]), ); - const repoFilePaths = localFilePaths.map(repoFilePath => { - return targetPath ? `${targetPath}/${repoFilePath}` : repoFilePath; - }); - - const changes = [ - { - files: zipObject(repoFilePaths, fileContents), - commit: title, - }, - ]; - try { const response = await client.createPullRequest({ owner, repo, title, - changes, + changes: [ + { + files, + commit: title, + }, + ], body: description, head: branchName, draft, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts index 63cfeaa8b3..7259336b23 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts @@ -14,15 +14,14 @@ * limitations under the License. */ import { createTemplateAction } from '../../createTemplateAction'; -import { readFile } from 'fs-extra'; import { Gitlab } from '@gitbeaker/node'; -import globby from 'globby'; import { Types } from '@gitbeaker/core'; - +import path from 'path'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { InputError } from '@backstage/errors'; import { parseRepoUrl } from './util'; import { resolveSafeChildPath } from '@backstage/backend-common'; +import { serializeDirectoryContents } from '../../../../lib/files'; /** * Create a new action that creates a gitlab merge request. @@ -106,8 +105,6 @@ export const createPublishGitlabMergeRequestAction = (options: { const { host } = parseRepoUrl(repoUrl, integrations); const integrationConfig = integrations.gitlab.byHost(host); - const actions: Types.CommitAction[] = []; - const destinationBranch = ctx.input.branchName; if (!integrationConfig) { @@ -128,28 +125,21 @@ export const createPublishGitlabMergeRequestAction = (options: { [tokenType]: token, }); - const fileRoot = ctx.workspacePath; - const localFilePaths = await globby([`${ctx.input.targetPath}/**`], { - cwd: fileRoot, - gitignore: true, - dot: true, - }); - - const fileContents = await Promise.all( - localFilePaths.map(p => readFile(resolveSafeChildPath(fileRoot, p))), + const targetPath = resolveSafeChildPath( + ctx.workspacePath, + ctx.input.targetPath, ); - - const repoFilePaths = localFilePaths.map(repoFilePath => { - return repoFilePath; + const fileContents = await serializeDirectoryContents(targetPath, { + gitignore: true, }); - for (let i = 0; i < repoFilePaths.length; i++) { - actions.push({ - action: 'create', - filePath: repoFilePaths[i], - content: fileContents[i].toString(), - }); - } + const actions: Types.CommitAction[] = fileContents.map(file => ({ + action: 'create', + filePath: path.posix.join(ctx.input.targetPath, file.path), + encoding: 'base64', + content: file.content.toString('base64'), + execute_filemode: file.executable, + })); const projects = await api.Projects.show(ctx.input.projectid); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts index 9a4ae8b2bf..ff15300b4a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts @@ -39,12 +39,19 @@ export type ActionContext = { createTemporaryDirectory(): Promise; templateInfo?: TemplateInfo; + + /** + * Whether this action invocation is a dry-run or not. + * This will only ever be true if the actions as marked as supporting dry-runs. + */ + isDryRun?: boolean; }; /** @public */ export type TemplateAction = { id: string; description?: string; + supportsDryRun?: boolean; schema?: { input?: Schema; output?: Schema; diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts new file mode 100644 index 0000000000..6f11d3b6e8 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonObject } from '@backstage/types'; +import { TemplateAction, TemplateActionRegistry } from '../actions'; + +/** @internal */ +export class DecoratedActionsRegistry extends TemplateActionRegistry { + constructor( + private readonly innerRegistry: TemplateActionRegistry, + extraActions: Array>, + ) { + super(); + for (const action of extraActions) { + this.register(action); + } + } + + get(actionId: string): TemplateAction { + try { + return super.get(actionId); + } catch { + return this.innerRegistry.get(actionId); + } + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts new file mode 100644 index 0000000000..f2c3c13509 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ScmIntegrations } from '@backstage/integration'; +import { TaskSpec } from '@backstage/plugin-scaffolder-common'; +import { JsonObject } from '@backstage/types'; +import { v4 as uuid } from 'uuid'; +import { pathToFileURL } from 'url'; +import { Logger } from 'winston'; +import { + deserializeDirectoryContents, + SerializedFile, + serializeDirectoryContents, +} from '../../lib/files'; +import { TemplateFilter } from '../../lib/templating'; +import { createTemplateAction, TemplateActionRegistry } from '../actions'; +import { NunjucksWorkflowRunner } from '../tasks/NunjucksWorkflowRunner'; +import { TaskSecrets } from '../tasks/types'; +import { DecoratedActionsRegistry } from './DecoratedActionsRegistry'; +import fs from 'fs-extra'; +import { resolveSafeChildPath } from '@backstage/backend-common'; + +interface DryRunInput { + spec: TaskSpec; + secrets?: TaskSecrets; + directoryContents: SerializedFile[]; +} + +interface DryRunResult { + log: Array<{ body: JsonObject }>; + directoryContents: SerializedFile[]; + output: JsonObject; +} + +/** @internal */ +export type TemplateTesterCreateOptions = { + logger: Logger; + integrations: ScmIntegrations; + actionRegistry: TemplateActionRegistry; + workingDirectory: string; + additionalTemplateFilters?: Record; +}; + +/** + * Executes a dry-run of the provided template. + * + * The provided content will be extracted into a temporary directory + * which is then use as the base for any relative file fetch paths. + * + * @internal + */ +export function createDryRunner(options: TemplateTesterCreateOptions) { + return async function dryRun(input: DryRunInput): Promise { + let contentPromise; + + const workflowRunner = new NunjucksWorkflowRunner({ + ...options, + actionRegistry: new DecoratedActionsRegistry(options.actionRegistry, [ + createTemplateAction({ + id: 'dry-run:extract', + supportsDryRun: true, + async handler(ctx) { + contentPromise = serializeDirectoryContents(ctx.workspacePath); + await contentPromise.catch(() => {}); + }, + }), + ]), + }); + + const dryRunId = uuid(); + const log = new Array<{ body: JsonObject }>(); + const contentsPath = resolveSafeChildPath( + options.workingDirectory, + `dry-run-content-${dryRunId}`, + ); + + try { + await deserializeDirectoryContents(contentsPath, input.directoryContents); + + const result = await workflowRunner.execute({ + spec: { + ...input.spec, + steps: [ + ...input.spec.steps, + { + id: dryRunId, + name: 'dry-run:extract', + action: 'dry-run:extract', + }, + ], + templateInfo: { + entityRef: 'template:default/dry-run', + baseUrl: pathToFileURL( + resolveSafeChildPath(contentsPath, 'template.yaml'), + ).toString(), + }, + }, + secrets: input.secrets, + // No need to update this at the end of the run, so just hard-code it + done: false, + isDryRun: true, + getWorkspaceName: async () => `dry-run-${dryRunId}`, + async emitLog(message: string, logMetadata?: JsonObject) { + if (logMetadata?.stepId === dryRunId) { + return; + } + log.push({ + body: { + ...logMetadata, + message, + }, + }); + }, + async complete() { + throw new Error('Not implemented'); + }, + }); + + if (!contentPromise) { + throw new Error('Content extraction step was skipped'); + } + const directoryContents = await contentPromise; + + return { + log, + directoryContents, + output: result.output, + }; + } finally { + await fs.remove(contentsPath); + } + }; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/index.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/index.ts new file mode 100644 index 0000000000..9ae38e6df2 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { createDryRunner } from './createDryRunner'; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index c9d0714392..97e37fd3eb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -23,7 +23,7 @@ import nunjucks from 'nunjucks'; import { JsonObject, JsonValue } from '@backstage/types'; import { InputError } from '@backstage/errors'; import { PassThrough } from 'stream'; -import { isTruthy } from './helper'; +import { generateExampleOutput, isTruthy } from './helper'; import { validate as validateJsonSchema } from 'jsonschema'; import { parseRepoUrl } from '../actions/builtin/publish/util'; import { TemplateActionRegistry } from '../actions'; @@ -236,6 +236,27 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const action = this.options.actionRegistry.get(step.action); const { taskLogger, streamLogger } = createStepLogger({ task, step }); + if (task.isDryRun && !action.supportsDryRun) { + task.emitLog( + `Skipping because ${action.id} does not support dry-run`, + { + stepId: step.id, + status: 'skipped', + }, + ); + const outputSchema = action.schema?.output; + if (outputSchema) { + context.steps[step.id] = { + output: generateExampleOutput(outputSchema) as { + [name in string]: JsonValue; + }, + }; + } else { + context.steps[step.id] = { output: {} }; + } + continue; + } + // Secrets are only passed when templating the input to actions for security reasons const input = (step.input && diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts index 2058ce917f..76951afde4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts @@ -15,6 +15,7 @@ */ import { isArray } from 'lodash'; +import { Schema } from 'jsonschema'; /** * Returns true if the input is not `false`, `undefined`, `null`, `""`, `0`, or @@ -24,3 +25,31 @@ import { isArray } from 'lodash'; export function isTruthy(value: any): boolean { return isArray(value) ? value.length > 0 : !!value; } + +export function generateExampleOutput(schema: Schema): unknown { + const { examples } = schema as { examples?: unknown }; + if (examples && Array.isArray(examples)) { + return examples[0]; + } + if (schema.type === 'object') { + return Object.fromEntries( + Object.entries(schema.properties ?? {}).map(([key, value]) => [ + key, + generateExampleOutput(value), + ]), + ); + } else if (schema.type === 'array') { + const [firstSchema] = [schema.items]?.flat(); + if (firstSchema) { + return [generateExampleOutput(firstSchema)]; + } + return []; + } else if (schema.type === 'string') { + return ''; + } else if (schema.type === 'number') { + return 0; + } else if (schema.type === 'boolean') { + return false; + } + return ''; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index e56d1b682a..a9d3e3ae5b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -111,6 +111,7 @@ export interface TaskContext { secrets?: TaskSecrets; createdBy?: string; done: boolean; + isDryRun?: boolean; emitLog(message: string, logMetadata?: JsonObject): Promise; complete(result: TaskCompletionState, metadata?: JsonObject): Promise; getWorkspaceName(): Promise; diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index c1a9b50e0d..553ff2b8bc 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -22,18 +22,20 @@ import { UserEntity, } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; -import { Config } from '@backstage/config'; +import { Config, JsonObject } from '@backstage/config'; import { InputError, NotFoundError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import { TemplateEntityV1beta3, TaskSpec, + templateEntityV1beta3Validator, } from '@backstage/plugin-scaffolder-common'; import express from 'express'; import Router from 'express-promise-router'; import { validate } from 'jsonschema'; import { Logger } from 'winston'; import { TemplateFilter } from '../lib'; +import { z } from 'zod'; import { createBuiltinActions, DatabaseTaskStore, @@ -42,6 +44,7 @@ import { TemplateAction, TemplateActionRegistry, } from '../scaffolder'; +import { createDryRunner } from '../scaffolder/dryrun'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; import { getEntityBaseUrl, getWorkingDirectory, findTemplate } from './helpers'; @@ -129,6 +132,14 @@ export async function createRouter( actionsToRegister.forEach(action => actionRegistry.register(action)); workers.forEach(worker => worker.start()); + const dryRunner = createDryRunner({ + actionRegistry, + integrations, + logger, + workingDirectory, + additionalTemplateFilters, + }); + router .get( '/v2/templates/:namespace/:kind/:name/parameter-schema', @@ -333,6 +344,67 @@ export async function createRouter( subscription.unsubscribe(); clearTimeout(timeout); }); + }) + .post('/v2/dry-run', async (req, res) => { + const bodySchema = z.object({ + template: z.unknown(), + values: z.record(z.unknown()), + secrets: z.record(z.string()).optional(), + directoryContents: z.array( + z.object({ path: z.string(), base64Content: z.string() }), + ), + }); + const body = await bodySchema.parseAsync(req.body).catch(e => { + throw new InputError(`Malformed request: ${e}`); + }); + + const template = body.template as TemplateEntityV1beta3; + if (!(await templateEntityV1beta3Validator.check(template))) { + throw new InputError('Input template is not a template'); + } + + const { token } = parseBearerToken(req.headers.authorization); + + for (const parameters of [template.spec.parameters ?? []].flat()) { + const result = validate(body.values, parameters); + if (!result.valid) { + res.status(400).json({ errors: result.errors }); + return; + } + } + + const steps = template.spec.steps.map((step, index) => ({ + ...step, + id: step.id ?? `step-${index + 1}`, + name: step.name ?? step.action, + })); + + const result = await dryRunner({ + spec: { + apiVersion: template.apiVersion, + steps, + output: template.spec.output ?? {}, + parameters: body.values as JsonObject, + }, + directoryContents: (body.directoryContents ?? []).map(file => ({ + path: file.path, + content: Buffer.from(file.base64Content, 'base64'), + })), + secrets: { + ...body.secrets, + ...(token && { backstageToken: token }), + }, + }); + + res.status(200).json({ + ...result, + steps, + directoryContents: result.directoryContents.map(file => ({ + path: file.path, + executable: file.executable, + base64Content: file.content.toString('base64'), + })), + }); }); const app = express(); diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 2469450f93..6fbcd78df1 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -26,6 +26,7 @@ import { default as React_2 } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; +import { TaskStep } from '@backstage/plugin-scaffolder-common'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; // @public @@ -227,6 +228,8 @@ export type RouterProps = { // @public export interface ScaffolderApi { + // (undocumented) + dryRun?(options: ScaffolderDryRunOptions): Promise; // (undocumented) getIntegrationsList( options: ScaffolderGetIntegrationsListOptions, @@ -264,6 +267,8 @@ export class ScaffolderClient implements ScaffolderApi { useLongPollingLogs?: boolean; }); // (undocumented) + dryRun(options: ScaffolderDryRunOptions): Promise; + // (undocumented) getIntegrationsList( options: ScaffolderGetIntegrationsListOptions, ): Promise; @@ -286,6 +291,37 @@ export class ScaffolderClient implements ScaffolderApi { streamLogs(options: ScaffolderStreamLogsOptions): Observable; } +// @public (undocumented) +export interface ScaffolderDryRunOptions { + // (undocumented) + directoryContents: { + path: string; + base64Content: string; + }[]; + // (undocumented) + secrets?: Record; + // (undocumented) + template: JsonValue; + // (undocumented) + values: JsonObject; +} + +// @public (undocumented) +export interface ScaffolderDryRunResponse { + // (undocumented) + directoryContents: Array<{ + path: string; + base64Content: string; + executable: boolean; + }>; + // (undocumented) + log: Array>; + // (undocumented) + output: ScaffolderTaskOutput; + // (undocumented) + steps: TaskStep[]; +} + // @public export const ScaffolderFieldExtensions: React_2.ComponentType; diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 8bb88b28bb..ade5da45d0 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -55,6 +55,7 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", + "@react-hookz/web": "^13.0.0", "@rjsf/core": "^3.2.1", "@material-table/core": "^3.1.0", "@rjsf/material-ui": "^3.2.1", @@ -94,7 +95,7 @@ "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", "event-source-polyfill": "1.0.25", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index c19853b5f1..6d236bb8fd 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -38,6 +38,8 @@ import { ScaffolderGetIntegrationsListResponse, ScaffolderTask, TasksOwnerFilterKind, + ScaffolderDryRunOptions, + ScaffolderDryRunResponse, } from './types'; import queryString from 'qs'; @@ -198,6 +200,30 @@ export class ScaffolderClient implements ScaffolderApi { return this.streamLogsEventStream(options); } + async dryRun( + options: ScaffolderDryRunOptions, + ): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); + const response = await this.fetchApi.fetch(`${baseUrl}/v2/dry-run`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + template: options.template, + values: options.values, + secrets: options.secrets, + directoryContents: options.directoryContents, + }), + }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return response.json(); + } + private streamLogsEventStream({ taskId, after, diff --git a/plugins/scaffolder/src/components/FileBrowser/FileBrowser.test.tsx b/plugins/scaffolder/src/components/FileBrowser/FileBrowser.test.tsx new file mode 100644 index 0000000000..67d0034ba3 --- /dev/null +++ b/plugins/scaffolder/src/components/FileBrowser/FileBrowser.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FileEntry, parseFileEntires } from './FileBrowser'; + +function dir(path: string, ...children: FileEntry[]): FileEntry { + return { + type: 'directory', + path: path, + name: path.split('/').pop()!, + children: children, + }; +} + +function file(path: string): FileEntry { + return { + type: 'file', + path: path, + name: path.split('/').pop()!, + }; +} + +describe('parseFileEntires', () => { + it('parses an empty list', () => { + expect(parseFileEntires([])).toEqual([]); + }); + + it('parses a single file', () => { + expect(parseFileEntires(['a.txt'])).toEqual([file('a.txt')]); + expect(parseFileEntires(['a/b.txt'])).toEqual([dir('a', file('a/b.txt'))]); + expect(parseFileEntires(['a/b/c.txt'])).toEqual([ + dir('a', dir('a/b', file('a/b/c.txt'))), + ]); + }); + + it('parses multiple files', () => { + expect(parseFileEntires(['a.txt', 'b.txt'])).toEqual([ + file('a.txt'), + file('b.txt'), + ]); + expect(parseFileEntires(['a.txt', 'a/b.txt'])).toEqual([ + dir('a', file('a/b.txt')), + file('a.txt'), + ]); + expect(parseFileEntires(['a.txt', 'a/b.txt', 'a/c.txt'])).toEqual([ + dir('a', file('a/b.txt'), file('a/c.txt')), + file('a.txt'), + ]); + expect(parseFileEntires(['a.txt', 'a/b/c.txt', 'a/b/d.txt'])).toEqual([ + dir('a', dir('a/b', file('a/b/c.txt'), file('a/b/d.txt'))), + file('a.txt'), + ]); + }); + + it('throws an error on invalid filenames', () => { + expect(() => parseFileEntires([''])).toThrow(`Invalid path part: ''`); + expect(() => parseFileEntires(['/'])).toThrow(`Invalid path part: ''`); + expect(() => parseFileEntires(['a/'])).toThrow(`Invalid path part: ''`); + expect(() => parseFileEntires(['/a.txt'])).toThrow(`Invalid path part: ''`); + expect(() => parseFileEntires(['a//a.txt'])).toThrow( + `Invalid path part: ''`, + ); + }); + + it('throws an error on conflicting directory and filenames', () => { + expect(() => parseFileEntires(['a', 'a'])).toThrow( + `Duplicate filename at 'a'`, + ); + expect(() => parseFileEntires(['a', 'a/b'])).toThrow( + `Duplicate filename at 'a'`, + ); + expect(() => parseFileEntires(['a/b', 'a'])).toThrow( + `Duplicate filename at 'a'`, + ); + expect(() => parseFileEntires(['a/b', 'a/b/c'])).toThrow( + `Duplicate filename at 'a/b'`, + ); + expect(() => parseFileEntires(['a/b/c', 'a/b/c'])).toThrow( + `Duplicate filename at 'a/b/c'`, + ); + }); +}); diff --git a/plugins/scaffolder/src/components/FileBrowser/FileBrowser.tsx b/plugins/scaffolder/src/components/FileBrowser/FileBrowser.tsx new file mode 100644 index 0000000000..1273b8ad09 --- /dev/null +++ b/plugins/scaffolder/src/components/FileBrowser/FileBrowser.tsx @@ -0,0 +1,145 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useMemo } from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import TreeView from '@material-ui/lab/TreeView'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import TreeItem from '@material-ui/lab/TreeItem'; + +const useStyles = makeStyles({ + root: { + whiteSpace: 'nowrap', + overflowY: 'auto', + }, +}); + +export type FileEntry = + | { + type: 'file'; + name: string; + path: string; + } + | { + type: 'directory'; + name: string; + path: string; + children: FileEntry[]; + }; + +export function parseFileEntires(paths: string[]): FileEntry[] { + const root: FileEntry = { + type: 'directory', + name: '', + path: '', + children: [], + }; + + for (const path of paths.slice().sort()) { + const parts = path.split('/'); + + let current = root; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part === '') { + throw new Error(`Invalid path part: ''`); + } + + const entryPath = parts.slice(0, i + 1).join('/'); + + const existing = current.children.find(child => child.name === part); + if (existing?.type === 'file') { + throw new Error(`Duplicate filename at '${entryPath}'`); + } else if (existing) { + current = existing; + } else { + if (i < parts.length - 1) { + const newEntry: FileEntry = { + type: 'directory', + name: part, + path: entryPath, + children: [], + }; + const firstFileIndex = current.children.findIndex( + child => child.type === 'file', + ); + current.children.splice(firstFileIndex, 0, newEntry); + current = newEntry; + } else { + current.children.push({ + type: 'file', + name: part, + path: entryPath, + }); + } + } + } + } + + return root.children; +} + +function FileTreeItem({ entry }: { entry: FileEntry }) { + if (entry.type === 'file') { + return ; + } + + return ( + + {entry.children.map(child => ( + + ))} + + ); +} + +interface FileBrowserProps { + /** A list of all filepaths to show, directories are separated with a `/` */ + filePaths: string[]; + /** The currently selected file */ + selected?: string; + /** Callback for when a file is selected */ + onSelect?(filePath: string): void; +} + +/** A simple file browser that allows you to select individual files */ +export function FileBrowser(props: FileBrowserProps) { + const classes = useStyles(); + + const fileTree = useMemo( + () => parseFileEntires(props.filePaths), + [props.filePaths], + ); + + return ( + } + defaultExpandIcon={} + onNodeSelect={(_e: unknown, nodeId: string) => { + if (props.onSelect && props.filePaths.includes(nodeId)) { + props.onSelect(nodeId); + } + }} + > + {fileTree.map(entry => ( + + ))} + + ); +} diff --git a/plugins/scaffolder/src/components/FileBrowser/index.ts b/plugins/scaffolder/src/components/FileBrowser/index.ts new file mode 100644 index 0000000000..5d18109a9c --- /dev/null +++ b/plugins/scaffolder/src/components/FileBrowser/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { FileBrowser } from './FileBrowser'; diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index fb1de5f46d..33e274934e 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -54,6 +54,7 @@ type Props = { onFinish?: () => Promise; widgets?: FormProps['widgets']; fields?: FormProps['fields']; + finishButtonLabel?: string; }; export function getUiSchemasFromSteps(steps: Step[]): UiSchema[] { @@ -110,7 +111,15 @@ export function getReviewData(formData: Record, steps: Step[]) { } export const MultistepJsonForm = (props: Props) => { - const { formData, onChange, onReset, onFinish, fields, widgets } = props; + const { + formData, + onChange, + onReset, + onFinish, + fields, + widgets, + finishButtonLabel, + } = props; const [activeStep, setActiveStep] = useState(0); const [disableButtons, setDisableButtons] = useState(false); const errorApi = useApi(errorApiRef); @@ -171,8 +180,9 @@ export const MultistepJsonForm = (props: Props) => { try { await onFinish(); } catch (err) { - setDisableButtons(false); errorApi.post(err); + } finally { + setDisableButtons(false); } }; @@ -239,7 +249,7 @@ export const MultistepJsonForm = (props: Props) => { onClick={handleCreate} disabled={!onFinish || disableButtons} > - Create + {finishButtonLabel ?? 'Create'} diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 27d89cb6a4..0662b0300f 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -161,16 +161,16 @@ function TaskStepIconComponent(props: StepIconProps) { } export const TaskStatusStepper = memo( - ({ - steps, - currentStepId, - onUserStepChange, - }: { + (props: { steps: TaskStep[]; currentStepId: string | undefined; onUserStepChange: (id: string) => void; + classes?: { + root?: string; + }; }) => { - const classes = useStyles(); + const { steps, currentStepId, onUserStepChange } = props; + const classes = useStyles(props); return (
diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx index cb77689b6e..78d6a452ea 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx @@ -41,7 +41,10 @@ export const TaskPageLinks = ({ output }: TaskPageLinksProps) => { .filter(({ url, entityRef }) => url || entityRef) .map(({ url, entityRef, title, icon }) => { if (entityRef) { - const entityName = parseEntityRef(entityRef); + const entityName = parseEntityRef(entityRef, { + defaultKind: '', + defaultNamespace: '', + }); const target = entityRoute(entityName); return { title, icon, url: target }; } diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx new file mode 100644 index 0000000000..0edf390bfe --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx @@ -0,0 +1,243 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ErrorPanel, Progress } from '@backstage/core-components'; +import { useAsync, useRerender } from '@react-hookz/web'; +import React, { createContext, ReactNode, useContext, useEffect } from 'react'; +import { + TemplateDirectoryAccess, + TemplateFileAccess, +} from '../../lib/filesystem'; + +const MAX_SIZE = 1024 * 1024; +const MAX_SIZE_MESSAGE = 'This file is too large to be displayed'; + +interface DirectoryEditorFile { + /** The path of the file relative to the root directory */ + path: string; + /** The staged content of the file */ + content: string; + /** Whether the staged content matches what is on disk */ + dirty: boolean; + + /** Update the staged content of the file without saving */ + updateContent(content: string): void; + /** Save the staged content of the file to disk */ + save(): Promise; + /** Reload the staged content of the file from disk */ + reload(): Promise; +} + +interface DirectoryEditor { + /** A list of all files in the edited directory */ + files: Array; + + /** The currently selected file */ + selectedFile: DirectoryEditorFile | undefined; + /** Switch the selected file */ + setSelectedFile(path: string | undefined): void; + + /** Save all files to disk */ + save(): Promise; + /** Reload all files from disk */ + reload(): Promise; + + subscribe(listener: () => void): () => void; +} + +class DirectoryEditorFileManager implements DirectoryEditorFile { + readonly #access: TemplateFileAccess; + readonly #signalUpdate: () => void; + + #content?: string; + #savedContent?: string; + + constructor(access: TemplateFileAccess, signalUpdate: () => void) { + this.#access = access; + this.#signalUpdate = signalUpdate; + } + + get path() { + return this.#access.path; + } + + get content() { + return this.#content ?? MAX_SIZE_MESSAGE; + } + + updateContent(content: string): void { + if (this.#content === undefined) { + return; + } + this.#content = content; + this.#signalUpdate(); + } + + get dirty() { + return this.#content !== this.#savedContent; + } + + async save(): Promise { + if (this.#content !== undefined) { + await this.#access.save(this.#content); + this.#savedContent = this.#content; + this.#signalUpdate(); + } + } + + async reload(): Promise { + const file = await this.#access.file(); + if (file.size > MAX_SIZE) { + if (this.#content !== undefined) { + this.#content = undefined; + this.#savedContent = undefined; + this.#signalUpdate(); + } + return; + } + + const content = await file.text(); + if (this.#content !== content) { + this.#content = content; + this.#savedContent = content; + this.#signalUpdate(); + } + } +} + +class DirectoryEditorManager implements DirectoryEditor { + readonly #access: TemplateDirectoryAccess; + readonly #listeners = new Set<() => void>(); + + #files: DirectoryEditorFile[] = []; + #selectedFile: DirectoryEditorFile | undefined; + + constructor(access: TemplateDirectoryAccess) { + this.#access = access; + } + + get files() { + return this.#files; + } + + get selectedFile() { + return this.#selectedFile; + } + + setSelectedFile = (path: string | undefined): void => { + const prev = this.#selectedFile; + const next = this.#files.find(file => file.path === path); + if (prev !== next) { + this.#selectedFile = next; + this.#signalUpdate(); + } + }; + + get dirty() { + return this.#files.some(file => file.dirty); + } + + async save(): Promise { + await Promise.all(this.#files.map(file => file.save())); + } + + async reload(): Promise { + const selectedPath = this.#selectedFile?.path; + + const files = await this.#access.listFiles(); + const fileManagers = await Promise.all( + files.map(async file => { + const manager = new DirectoryEditorFileManager( + file, + this.#signalUpdate, + ); + await manager.reload(); + return manager; + }), + ); + this.#files.length = 0; + this.#files.push(...fileManagers); + + this.setSelectedFile(selectedPath); + this.#signalUpdate(); + } + + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + #signalUpdate = () => { + this.#listeners.forEach(listener => listener()); + }; +} + +const DirectoryEditorContext = createContext( + undefined, +); + +export function useDirectoryEditor(): DirectoryEditor { + const value = useContext(DirectoryEditorContext); + const rerender = useRerender(); + + useEffect(() => value?.subscribe(rerender), [value, rerender]); + + if (!value) { + throw new Error('must be used within a DirectoryEditorProvider'); + } + return value; +} + +interface DirectoryEditorProviderProps { + directory: TemplateDirectoryAccess; + children?: ReactNode; +} + +export function DirectoryEditorProvider(props: DirectoryEditorProviderProps) { + const { directory } = props; + + const [{ result, error }, { execute }] = useAsync( + async (dir: TemplateDirectoryAccess) => { + const manager = new DirectoryEditorManager(dir); + await manager.reload(); + + const firstYaml = manager.files.find(file => file.path.match(/\.ya?ml$/)); + if (firstYaml) { + manager.setSelectedFile(firstYaml.path); + } + + return manager; + }, + ); + + useEffect(() => { + execute(directory); + }, [execute, directory]); + + if (error) { + return ; + } else if (!result) { + return ; + } + + return ( + + {props.children} + + ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx new file mode 100644 index 0000000000..b1fe8a19ff --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx @@ -0,0 +1,153 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import yaml from 'yaml'; +import { useApi } from '@backstage/core-plugin-api'; +import { JsonObject } from '@backstage/types'; +import React, { + createContext, + ReactNode, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from 'react'; +import { scaffolderApiRef } from '../../api'; +import { ScaffolderDryRunResponse } from '../../types'; + +interface DryRunOptions { + templateContent: string; + values: JsonObject; + files: Array<{ path: string; content: string }>; +} + +interface DryRunResult extends ScaffolderDryRunResponse { + id: number; +} + +interface DryRun { + results: DryRunResult[]; + selectedResult: DryRunResult | undefined; + + selectResult(id: number): void; + deleteResult(id: number): void; + execute(options: DryRunOptions): Promise; +} + +const DryRunContext = createContext(undefined); + +interface DryRunProviderProps { + children: ReactNode; +} + +export function DryRunProvider(props: DryRunProviderProps) { + const scaffolderApi = useApi(scaffolderApiRef); + + const [state, setState] = useState< + Pick + >({ + results: [], + selectedResult: undefined, + }); + const idRef = useRef(1); + + const selectResult = useCallback((id: number) => { + setState(prevState => { + const result = prevState.results.find(r => r.id === id); + if (result === prevState.selectedResult) { + return prevState; + } + return { + results: prevState.results, + selectedResult: result, + }; + }); + }, []); + + const deleteResult = useCallback((id: number) => { + setState(prevState => { + const index = prevState.results.findIndex(r => r.id === id); + if (index === -1) { + return prevState; + } + const newResults = prevState.results.slice(); + const [deleted] = newResults.splice(index, 1); + return { + results: newResults, + selectedResult: + prevState.selectedResult?.id === deleted.id + ? newResults[0] + : prevState.selectedResult, + }; + }); + }, []); + + const execute = useCallback( + async (options: DryRunOptions) => { + if (!scaffolderApi.dryRun) { + throw new Error('Scaffolder API does not support dry-run'); + } + + const parsed = yaml.parse(options.templateContent); + + const response = await scaffolderApi.dryRun({ + template: parsed, + values: options.values, + secrets: {}, + directoryContents: options.files.map(file => ({ + path: file.path, + base64Content: btoa(file.content), + })), + }); + + const result = { + ...response, + id: idRef.current++, + }; + + setState(prevState => ({ + results: [...prevState.results, result], + selectedResult: prevState.selectedResult ?? result, + })); + }, + [scaffolderApi], + ); + + const dryRun = useMemo( + () => ({ + ...state, + selectResult, + deleteResult, + execute, + }), + [state, selectResult, deleteResult, execute], + ); + + return ( + + {props.children} + + ); +} + +export function useDryRun(): DryRun { + const value = useContext(DryRunContext); + if (!value) { + throw new Error('must be used within a DryRunProvider'); + } + return value; +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx new file mode 100644 index 0000000000..bc3354cc46 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { useEffect } from 'react'; +import { scaffolderApiRef } from '../../../api'; +import { DryRunProvider, useDryRun } from '../DryRunContext'; +import { DryRunResults } from './DryRunResults'; + +function DryRunRemote({ + execute, + remove, +}: { + execute?: boolean; + remove?: boolean; +}) { + const dryRun = useDryRun(); + + useEffect(() => { + if (execute) { + dryRun.execute({ + templateContent: '', + values: {}, + files: [], + }); + } + if (remove) { + if (dryRun.selectedResult) { + dryRun.deleteResult(dryRun.selectedResult.id); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [execute, remove]); + + return null; +} + +const mockScaffolderApi = { + dryRun: async () => ({ + directoryContents: [], + log: [], + output: {}, + steps: [], + }), +}; + +describe('DryRunResults', () => { + it('renders without exploding', async () => { + await renderInTestApp( + + + + + , + ); + expect(screen.getByText('Dry-run results')).toBeInTheDocument(); + }); + + it('expands when dry-run result is added and toggles on click, and disappears when results are gone', async () => { + const { rerender } = await renderInTestApp( + + + + + + , + ); + + expect(screen.getByText('Files')).not.toBeVisible(); + + await act(async () => { + rerender( + + + + + + , + ); + }); + + expect(screen.getByText('Files')).toBeVisible(); + + await userEvent.click(screen.getByText('Dry-run results')); + await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible()); + + await userEvent.click(screen.getByText('Dry-run results')); + expect(screen.getByText('Files')).toBeVisible(); + + await act(async () => { + rerender( + + + + + + , + ); + }); + + await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible()); + }); +}); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.tsx new file mode 100644 index 0000000000..ae9374438f --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.tsx @@ -0,0 +1,91 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BackstageTheme } from '@backstage/theme'; +import Accordion from '@material-ui/core/Accordion'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import Divider from '@material-ui/core/Divider'; +import { makeStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import ExpandMoreIcon from '@material-ui/icons/ExpandLess'; +import { usePrevious } from '@react-hookz/web'; +import React, { useEffect, useState } from 'react'; +import { useDryRun } from '../DryRunContext'; +import { DryRunResultsList } from './DryRunResultsList'; +import { DryRunResultsView } from './DryRunResultsView'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + header: { + height: 48, + minHeight: 0, + '&.Mui-expanded': { + height: 48, + minHeight: 0, + }, + }, + content: { + display: 'grid', + background: theme.palette.background.default, + gridTemplateColumns: '180px auto 1fr', + gridTemplateRows: '1fr', + padding: 0, + height: 400, + }, +})); + +export function DryRunResults() { + const classes = useStyles(); + const dryRun = useDryRun(); + const [expanded, setExpanded] = useState(false); + const [hidden, setHidden] = useState(true); + + const resultsLength = dryRun.results.length; + const prevResultsLength = usePrevious(resultsLength); + useEffect(() => { + if (prevResultsLength === 0 && resultsLength === 1) { + setHidden(false); + setExpanded(true); + } else if (prevResultsLength === 1 && resultsLength === 0) { + setExpanded(false); + } + }, [prevResultsLength, resultsLength]); + + return ( + <> + + + ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx new file mode 100644 index 0000000000..65eb59c84a --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { act, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { useEffect } from 'react'; +import { scaffolderApiRef } from '../../../api'; +import { DryRunProvider, useDryRun } from '../DryRunContext'; +import { DryRunResultsList } from './DryRunResultsList'; + +function DryRunRemote({ execute }: { execute?: number }) { + const dryRun = useDryRun(); + + useEffect(() => { + if (execute) { + dryRun.execute({ + templateContent: '', + values: {}, + files: [], + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [execute]); + + return null; +} + +const mockScaffolderApi = { + dryRun: async () => ({ + directoryContents: [], + log: [], + output: {}, + steps: [], + }), +}; + +describe('DryRunResultsList', () => { + it('renders without exploding', async () => { + const rendered = await renderInTestApp( + + + + + , + ); + expect(rendered.baseElement.querySelector('ul')).toBeEmptyDOMElement(); + }); + + it('adds new result items and deletes them', async () => { + const { rerender } = await renderInTestApp( + + + + + + , + ); + + expect(screen.queryByText('Result 1')).toBeInTheDocument(); + expect(screen.queryByText('Result 2')).not.toBeInTheDocument(); + + await act(async () => { + rerender( + + + + + + , + ); + }); + + expect(screen.queryByText('Result 1')).toBeInTheDocument(); + expect(screen.queryByText('Result 2')).toBeInTheDocument(); + + await userEvent.click(screen.getAllByLabelText('delete')[0]); + + expect(screen.queryByText('Result 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Result 2')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx new file mode 100644 index 0000000000..462c3b58fd --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BackstageTheme } from '@backstage/theme'; +import IconButton from '@material-ui/core/IconButton'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; +import ListItemText from '@material-ui/core/ListItemText'; +import { makeStyles } from '@material-ui/core/styles'; +import CancelIcon from '@material-ui/icons/Cancel'; +import CheckIcon from '@material-ui/icons/Check'; +import DeleteIcon from '@material-ui/icons/Delete'; +import React from 'react'; +import { useDryRun } from '../DryRunContext'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + root: { + overflowY: 'auto', + background: theme.palette.background.default, + }, + iconSuccess: { + minWidth: 0, + marginRight: theme.spacing(1), + color: theme.palette.status.ok, + }, + iconFailure: { + minWidth: 0, + marginRight: theme.spacing(1), + color: theme.palette.status.error, + }, +})); + +export function DryRunResultsList() { + const classes = useStyles(); + const dryRun = useDryRun(); + + return ( + + {dryRun.results.map(result => { + const failed = result.log.some(l => l.body.status === 'failed'); + return ( + dryRun.selectResult(result.id)} + > + + {failed ? : } + + + + dryRun.deleteResult(result.id)} + > + + + + + ); + })} + + ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx new file mode 100644 index 0000000000..b554a1c0c0 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { makeStyles } from '@material-ui/core/styles'; +import Divider from '@material-ui/core/Divider'; +import React, { Children, ReactNode } from 'react'; +import classNames from 'classnames'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'grid', + gridTemplateColumns: '280px auto 3fr', + gridTemplateRows: '1fr', + }, + child: { + overflowY: 'auto', + height: '100%', + minHeight: 0, + }, + firstChild: { + background: theme.palette.background.paper, + }, +})); + +export function DryRunResultsSplitView(props: { children: ReactNode }) { + const classes = useStyles(); + const childArray = Children.toArray(props.children); + + if (childArray.length !== 2) { + throw new Error('must have exactly 2 children'); + } + + return ( +
+
+ {childArray[0]} +
+ +
{childArray[1]}
+
+ ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx new file mode 100644 index 0000000000..bc6b5d8f6f --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { ReactNode, useEffect } from 'react'; +import { scaffolderApiRef } from '../../../api'; +import { DryRunProvider, useDryRun } from '../DryRunContext'; +import { DryRunResultsView } from './DryRunResultsView'; + +// The inside needs mocking to render in jsdom +jest.mock('react-virtualized-auto-sizer', () => ({ + __esModule: true, + default: (props: { + children: (size: { width: number; height: number }) => ReactNode; + }) => <>{props.children({ width: 400, height: 200 })}, +})); + +function DryRunRemote() { + const dryRun = useDryRun(); + + useEffect(() => { + dryRun.execute({ + templateContent: '', + values: {}, + files: [], + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +} + +describe('DryRunResultsView', () => { + it('renders a result', async () => { + await renderInTestApp( + ({ + directoryContents: [ + { + path: 'foo.txt', + base64Content: btoa('Foo Content'), + executable: false, + }, + ], + log: [{ body: { message: 'Foo Message', stepId: 'foo' } }], + output: { + links: [{ title: 'Foo Link', url: 'http://example.com' }], + }, + steps: [{ id: 'foo', action: 'foo', name: 'Foo' }], + }), + }, + ], + ]} + > + + + + + , + { + mountedRoutes: { + '/catalog/:kind/:namespace/:name': entityRouteRef, + }, + }, + ); + + expect(screen.getByText('foo.txt')).toBeInTheDocument(); + expect(screen.getByText('Foo Content')).toBeInTheDocument(); + + expect(screen.queryByText('Foo Message')).not.toBeInTheDocument(); + expect(screen.queryByText('Foo Link')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('Log')); + expect(screen.getByText('Foo Message')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Output')); + expect(screen.getByText('Foo Link')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx new file mode 100644 index 0000000000..9c56039808 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx @@ -0,0 +1,195 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LogViewer } from '@backstage/core-components'; +import { StreamLanguage } from '@codemirror/language'; +import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; +import Box from '@material-ui/core/Box'; +import Divider from '@material-ui/core/Divider'; +import { makeStyles } from '@material-ui/core/styles'; +import Tab from '@material-ui/core/Tab'; +import Tabs from '@material-ui/core/Tabs'; +import CodeMirror from '@uiw/react-codemirror'; +import React, { useEffect, useMemo, useState } from 'react'; +import { TaskStatusStepper } from '../../TaskPage/TaskPage'; +import { TaskPageLinks } from '../../TaskPage/TaskPageLinks'; +import { useDryRun } from '../DryRunContext'; +import { FileBrowser } from '../../FileBrowser'; +import { DryRunResultsSplitView } from './DryRunResultsSplitView'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexFlow: 'column nowrap', + }, + contentWrapper: { + flex: 1, + position: 'relative', + }, + content: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + + display: 'flex', + '& > *': { + flex: 1, + }, + }, + codeMirror: { + height: '100%', + overflowY: 'auto', + }, +}); + +function FilesContent() { + const classes = useStyles(); + const { selectedResult } = useDryRun(); + const [selectedPath, setSelectedPath] = useState(''); + const selectedFile = selectedResult?.directoryContents.find( + f => f.path === selectedPath, + ); + + useEffect(() => { + if (selectedResult) { + const [firstFile] = selectedResult.directoryContents; + if (firstFile) { + setSelectedPath(firstFile.path); + } else { + setSelectedPath(''); + } + } + return undefined; + }, [selectedResult]); + + if (!selectedResult) { + return null; + } + return ( + + file.path)} + /> + + + ); +} +function LogContent() { + const { selectedResult } = useDryRun(); + const [currentStepId, setUserSelectedStepId] = useState(); + + const steps = useMemo(() => { + if (!selectedResult) { + return []; + } + return ( + selectedResult.steps.map(step => { + const stepLog = selectedResult.log.filter( + l => l.body.stepId === step.id, + ); + return { + id: step.id, + name: step.name, + logString: stepLog.map(l => l.body.message).join('\n'), + status: stepLog[stepLog.length - 1]?.body.status ?? 'completed', + }; + }) ?? [] + ); + }, [selectedResult]); + + if (!selectedResult) { + return null; + } + + const selectedStep = steps.find(s => s.id === currentStepId) ?? steps[0]; + + return ( + + + + + ); +} + +function OutputContent() { + const classes = useStyles(); + const { selectedResult } = useDryRun(); + + if (!selectedResult) { + return null; + } + + return ( + + + {selectedResult.output?.links?.length && ( + + )} + + + + ); +} + +export function DryRunResultsView() { + const classes = useStyles(); + const [selectedTab, setSelectedTab] = useState<'files' | 'log' | 'output'>( + 'files', + ); + + return ( +
+ setSelectedTab(v)}> + + + + + + +
+
+ {selectedTab === 'files' && } + {selectedTab === 'log' && } + {selectedTab === 'output' && } +
+
+
+ ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/index.ts b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/index.ts new file mode 100644 index 0000000000..af8ef5214f --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { DryRunResults } from './DryRunResults'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx new file mode 100644 index 0000000000..cc4dd8c9a1 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { makeStyles } from '@material-ui/core'; +import React, { useState } from 'react'; +import { FieldExtensionOptions } from '../../extensions'; +import { TemplateDirectoryAccess } from '../../lib/filesystem'; +import { DirectoryEditorProvider } from './DirectoryEditorContext'; +import { DryRunProvider } from './DryRunContext'; +import { DryRunResults } from './DryRunResults'; +import { TemplateEditorBrowser } from './TemplateEditorBrowser'; +import { TemplateEditorForm } from './TemplateEditorForm'; +import { TemplateEditorTextArea } from './TemplateEditorTextArea'; + +const useStyles = makeStyles({ + // Reset and fix sizing to make sure scrolling behaves correctly + root: { + gridArea: 'pageContent', + + display: 'grid', + gridTemplateAreas: ` + "browser editor preview" + "results results results" + `, + gridTemplateColumns: '1fr 3fr 2fr', + gridTemplateRows: '1fr auto', + }, + browser: { + gridArea: 'browser', + overflow: 'auto', + }, + editor: { + gridArea: 'editor', + overflow: 'auto', + }, + preview: { + gridArea: 'preview', + overflow: 'auto', + }, + results: { + gridArea: 'results', + }, +}); + +export const TemplateEditor = (props: { + directory: TemplateDirectoryAccess; + fieldExtensions?: FieldExtensionOptions[]; + onClose?: () => void; +}) => { + const classes = useStyles(); + + const [errorText, setErrorText] = useState(); + + return ( + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +}; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.test.tsx new file mode 100644 index 0000000000..cec311a5cd --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { MockFileSystemAccess } from '../../lib/filesystem/MockFileSystemAccess'; +import { DirectoryEditorProvider } from './DirectoryEditorContext'; +import { TemplateEditorBrowser } from './TemplateEditorBrowser'; + +Blob.prototype.text = async function text() { + return new Promise(resolve => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsText(this); + }); +}; + +describe('TemplateEditorBrowser', () => { + it('should render files and expand dirs without exploding', async () => { + await renderInTestApp( + + + , + ); + + await expect(screen.findByText('foo.txt')).resolves.toBeInTheDocument(); + expect(screen.getByText('dir')).toBeInTheDocument(); + expect(screen.queryByText('bar.txt')).not.toBeInTheDocument(); + expect(screen.queryByText('baz.txt')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('dir')); + expect(screen.getByText('bar.txt')).toBeInTheDocument(); + expect(screen.getByText('baz.txt')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx new file mode 100644 index 0000000000..177ad11c2b --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Divider, IconButton, makeStyles, Tooltip } from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import RefreshIcon from '@material-ui/icons/Refresh'; +import SaveIcon from '@material-ui/icons/Save'; +import React from 'react'; +import { FileBrowser } from '../FileBrowser'; +import { useDirectoryEditor } from './DirectoryEditorContext'; + +const useStyles = makeStyles(theme => ({ + button: { + padding: theme.spacing(1), + }, + buttons: { + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + justifyContent: 'flex-start', + }, + buttonsGap: { + flex: '1 1 auto', + }, + buttonsDivider: { + marginBottom: theme.spacing(1), + }, +})); + +/** The local file browser for the template editor */ +export function TemplateEditorBrowser(props: { onClose?: () => void }) { + const classes = useStyles(); + const directoryEditor = useDirectoryEditor(); + const changedFiles = directoryEditor.files.filter(file => file.dirty); + + const handleClose = () => { + if (!props.onClose) { + return; + } + if (changedFiles.length > 0) { + // eslint-disable-next-line no-alert + const accepted = window.confirm( + 'Are you sure? Unsaved changes will be lost', + ); + if (!accepted) { + return; + } + } + props.onClose(); + }; + + return ( + <> +
+ + !file.dirty)} + onClick={() => directoryEditor.save()} + > + + + + + directoryEditor.reload()} + > + + + +
+ + + + + +
+ + file.path)} + /> + + ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx new file mode 100644 index 0000000000..11b4da246d --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx @@ -0,0 +1,240 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useApiHolder } from '@backstage/core-plugin-api'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { Component, ReactNode, useMemo, useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import yaml from 'yaml'; +import { FieldExtensionOptions } from '../../extensions'; +import { TemplateParameterSchema } from '../../types'; +import { MultistepJsonForm } from '../MultistepJsonForm'; +import { createValidator } from '../TemplatePage'; +import { useDirectoryEditor } from './DirectoryEditorContext'; +import { useDryRun } from './DryRunContext'; + +const useStyles = makeStyles({ + containerWrapper: { + position: 'relative', + width: '100%', + height: '100%', + }, + container: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + overflow: 'auto', + }, +}); + +interface ErrorBoundaryProps { + invalidator: unknown; + setErrorText(errorText: string | undefined): void; + children: ReactNode; +} + +interface ErrorBoundaryState { + shouldRender: boolean; +} + +class ErrorBoundary extends Component { + state = { + shouldRender: true, + }; + + componentDidUpdate(prevProps: { invalidator: unknown }) { + if (prevProps.invalidator !== this.props.invalidator) { + this.setState({ shouldRender: true }); + } + } + + componentDidCatch(error: Error) { + this.props.setErrorText(error.message); + this.setState({ shouldRender: false }); + } + + render() { + return this.state.shouldRender ? this.props.children : null; + } +} + +interface TemplateEditorFormProps { + content?: string; + /** Setting this to true will cause the content to be parsed as if it is the template entity spec */ + contentIsSpec?: boolean; + data: JsonObject; + onUpdate: (data: JsonObject) => void; + setErrorText: (errorText?: string) => void; + + onDryRun?: (data: JsonObject) => Promise; + fieldExtensions?: FieldExtensionOptions[]; +} + +function isJsonObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Shows the a template form that is parsed from the provided content */ +export function TemplateEditorForm(props: TemplateEditorFormProps) { + const { + content, + contentIsSpec, + data, + onUpdate, + onDryRun, + setErrorText, + fieldExtensions = [], + } = props; + const classes = useStyles(); + const apiHolder = useApiHolder(); + + const [steps, setSteps] = useState(); + + const fields = useMemo(() => { + return Object.fromEntries( + fieldExtensions.map(({ name, component }) => [name, component]), + ); + }, [fieldExtensions]); + + useDebounce( + () => { + try { + if (!content) { + setSteps(undefined); + return; + } + const parsed: JsonValue = yaml.parse(content); + + if (!isJsonObject(parsed)) { + setSteps(undefined); + return; + } + + let rootObj = parsed; + if (!contentIsSpec) { + const isTemplate = + String(parsed.kind).toLocaleLowerCase('en-US') === 'template'; + if (!isTemplate) { + setSteps(undefined); + return; + } + + rootObj = isJsonObject(parsed.spec) ? parsed.spec : {}; + } + + const { parameters } = rootObj; + if (!Array.isArray(parameters)) { + setErrorText('Template parameters must be an array'); + setSteps(undefined); + return; + } + + const fieldValidators = Object.fromEntries( + fieldExtensions.map(({ name, validation }) => [name, validation]), + ); + + setErrorText(); + setSteps( + parameters.flatMap(param => + isJsonObject(param) + ? [ + { + title: String(param.title), + schema: param, + validate: createValidator(param, fieldValidators, { + apiHolder, + }), + }, + ] + : [], + ), + ); + } catch (e) { + setErrorText(e.message); + } + }, + 250, + [contentIsSpec, content, apiHolder], + ); + + if (!steps) { + return null; + } + + return ( +
+
+ + onUpdate(e.formData)} + onReset={() => onUpdate({})} + finishButtonLabel={onDryRun && 'Try It'} + onFinish={onDryRun && (() => onDryRun(data))} + /> + +
+
+ ); +} + +/** A version of the TemplateEditorForm that is connected to the DirectoryEditor and DryRun contexts */ +export function TemplateEditorFormDirectoryEditorDryRun( + props: Pick, +) { + const { setErrorText, fieldExtensions = [] } = props; + const dryRun = useDryRun(); + + const directoryEditor = useDirectoryEditor(); + const { selectedFile } = directoryEditor; + + const [data, setData] = useState({}); + + const handleDryRun = async () => { + if (!selectedFile) { + return; + } + + await dryRun.execute({ + templateContent: selectedFile.content, + values: data, + files: directoryEditor.files, + }); + }; + + const content = + selectedFile && selectedFile.path.match(/\.ya?ml$/) + ? selectedFile.content + : undefined; + + return ( + + ); +} + +TemplateEditorForm.DirectoryEditorDryRun = + TemplateEditorFormDirectoryEditorDryRun; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx new file mode 100644 index 0000000000..dc411ee965 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import Card from '@material-ui/core/Card'; +import CardActionArea from '@material-ui/core/CardActionArea'; +import CardContent from '@material-ui/core/CardContent'; +import Tooltip from '@material-ui/core/Tooltip'; +import Typography from '@material-ui/core/Typography'; +import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import { makeStyles } from '@material-ui/core/styles'; +import { WebFileSystemAccess } from '../../lib/filesystem'; + +const useStyles = makeStyles(theme => ({ + introText: { + textAlign: 'center', + marginTop: theme.spacing(2), + }, + card: { + position: 'relative', + maxWidth: 340, + marginTop: theme.spacing(4), + margin: theme.spacing(0, 2), + }, + infoIcon: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), + }, +})); + +interface EditorIntroProps { + style?: JSX.IntrinsicElements['div']['style']; + onSelect?: (option: 'local' | 'form') => void; +} + +export function TemplateEditorIntro(props: EditorIntroProps) { + const classes = useStyles(); + const supportsLoad = WebFileSystemAccess.isSupported(); + + const cardLoadLocal = ( + + props.onSelect?.('local')} + > + + + Load Template Directory + + + Load a local template directory, allowing you to both edit and try + executing your own template. + + + + {!supportsLoad && ( +
+ + + +
+ )} +
+ ); + + const cardFormEditor = ( + + props.onSelect?.('form')}> + + + Edit Template Form + + + Preview and edit a template form, either using a sample template or + by loading a template from the catalog. + + + + + ); + + return ( +
+ + Get started by choosing one of the options below + +
+ {supportsLoad && cardLoadLocal} + {cardFormEditor} + {!supportsLoad && cardLoadLocal} +
+
+ ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.test.tsx new file mode 100644 index 0000000000..bf9cd8c464 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { TemplateEditorPage } from './TemplateEditorPage'; + +describe('TemplateEditorPage', () => { + it('renders without exploding', async () => { + await renderInTestApp(); + + expect(screen.getByText('Load Template Directory')).toBeInTheDocument(); + expect(screen.getByText('Edit Template Form')).toBeInTheDocument(); + }); + + it('template directory loading should not be supported in Jest', async () => { + await renderInTestApp(); + + expect( + screen.getByRole('button', { name: /Load Template Directory/ }), + ).toBeDisabled(); + }); + + it('should be able to continue to form preview', async () => { + await renderInTestApp( + + + , + ); + + await userEvent.click(screen.getByText('Edit Template Form')); + + expect(screen.getByLabelText('Load Existing Template')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx index 71c64a9fa4..fce2881190 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx @@ -13,258 +13,76 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useCallback, useState } from 'react'; -import useAsync from 'react-use/lib/useAsync'; -import useDebounce from 'react-use/lib/useDebounce'; -import { Entity } from '@backstage/catalog-model'; -import { Content, Header, InfoCard, Page } from '@backstage/core-components'; -import { alertApiRef, useApi, useApiHolder } from '@backstage/core-plugin-api'; +import React, { useState } from 'react'; +import { Content, Header, Page } from '@backstage/core-components'; import { - catalogApiRef, - humanizeEntityRef, -} from '@backstage/plugin-catalog-react'; -import { JsonObject } from '@backstage/types'; -import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; -import { showPanel } from '@codemirror/view'; -import { StreamLanguage } from '@codemirror/language'; - -import { - FormControl, - Grid, - InputLabel, - LinearProgress, - makeStyles, - MenuItem, - Select, -} from '@material-ui/core'; -import { IChangeEvent } from '@rjsf/core'; -import CodeMirror from '@uiw/react-codemirror'; -import yaml from 'yaml'; + TemplateDirectoryAccess, + WebFileSystemAccess, +} from '../../lib/filesystem'; +import { TemplateEditorIntro } from './TemplateEditorIntro'; +import { TemplateEditor } from './TemplateEditor'; +import { TemplateFormPreviewer } from './TemplateFormPreviewer'; import { FieldExtensionOptions } from '../../extensions'; -import { TemplateParameterSchema } from '../../types'; -import { MultistepJsonForm } from '../MultistepJsonForm'; -import { createValidator } from '../TemplatePage'; -const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI -parameters: - - title: Fill in some steps - required: - - name - properties: - name: - title: Name - type: string - description: Unique name of the component - owner: - title: Owner - type: string - description: Owner of the component - ui:field: OwnerPicker - ui:options: - allowedKinds: - - Group - - title: Choose a location - required: - - repoUrl - properties: - repoUrl: - title: Repository Location - type: string - ui:field: RepoUrlPicker - ui:options: - allowedHosts: - - github.com -`; +type Selection = + | { + type: 'local'; + directory: TemplateDirectoryAccess; + } + | { + type: 'form'; + }; -type TemplateOption = { - label: string; - value: Entity; -}; - -const useStyles = makeStyles({ - templateSelect: { - marginBottom: '10px', - }, - grid: { - height: '100%', - }, - codeMirror: { - height: '95%', - }, -}); - -export const TemplateEditorPage = ({ - defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML, - customFieldExtensions = [], -}: { +interface TemplateEditorPageProps { defaultPreviewTemplate?: string; customFieldExtensions?: FieldExtensionOptions[]; -}) => { - const classes = useStyles(); - const alertApi = useApi(alertApiRef); - const catalogApi = useApi(catalogApiRef); - const apiHolder = useApiHolder(); - const [selectedTemplate, setSelectedTemplate] = useState(''); - const [schema, setSchema] = useState({ - title: '', - steps: [], - }); - const [templateOptions, setTemplateOptions] = useState([]); - const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate); - const [formState, setFormState] = useState({}); +} - const { loading } = useAsync( - () => - catalogApi - .getEntities({ - filter: { kind: 'template' }, - fields: [ - 'kind', - 'metadata.namespace', - 'metadata.name', - 'metadata.title', - 'spec.parameters', - ], - }) - .then(({ items }) => - setTemplateOptions( - items.map(template => ({ - label: - template.metadata.title ?? - humanizeEntityRef(template, { defaultKind: 'template' }), - value: template, - })), - ), - ) - .catch(e => - alertApi.post({ - message: `Error loading exisiting templates: ${e.message}`, - severity: 'error', - }), - ), - [catalogApi], - ); +export function TemplateEditorPage(props: TemplateEditorPageProps) { + const [selection, setSelection] = useState(); - const errorPanel = document.createElement('div'); - errorPanel.style.color = 'red'; - - useDebounce( - () => { - try { - const parsedTemplate = yaml.parse(templateYaml); - - setSchema({ - title: 'Preview', - steps: parsedTemplate.parameters.map((param: JsonObject) => ({ - title: param.title, - schema: param, - })), - }); - setFormState({}); - } catch (e) { - errorPanel.textContent = e.message; - } - }, - 250, - [setFormState, setSchema, templateYaml], - ); - - const handleSelectChange = useCallback( - selected => { - setSelectedTemplate(selected); - setTemplateYaml(yaml.stringify(selected.spec)); - }, - [setTemplateYaml], - ); - - const handleFormReset = () => setFormState({}); - const handleFormChange = useCallback( - (e: IChangeEvent) => setFormState(e.formData), - [setFormState], - ); - - const handleCodeChange = useCallback( - (code: string) => { - setTemplateYaml(code); - }, - [setTemplateYaml], - ); - - const customFieldComponents = Object.fromEntries( - customFieldExtensions.map(({ name, component }) => [name, component]), - ); - - const customFieldValidators = Object.fromEntries( - customFieldExtensions.map(({ name, validation }) => [name, validation]), - ); + let content: JSX.Element | null = null; + if (selection?.type === 'local') { + content = ( + setSelection(undefined)} + /> + ); + } else if (selection?.type === 'form') { + content = ( + setSelection(undefined)} + /> + ); + } else { + content = ( + + { + if (option === 'local') { + WebFileSystemAccess.requestDirectoryAccess() + .then(directory => setSelection({ type: 'local', directory })) + .catch(() => {}); + } else if (option === 'form') { + setSelection({ type: 'form' }); + } + }} + /> + + ); + } return (
- - {loading && } - - - - - Load Existing Template - - - - ({ dom: errorPanel, top: true })), - ]} - onChange={handleCodeChange} - /> - - - {schema && ( - - { - return { - ...step, - validate: createValidator( - step.schema, - customFieldValidators, - { apiHolder }, - ), - }; - })} - /> - - )} - - - + {content} ); -}; +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorTextArea.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorTextArea.tsx new file mode 100644 index 0000000000..588b0cd4ba --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorTextArea.tsx @@ -0,0 +1,153 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StreamLanguage } from '@codemirror/language'; +import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; +import { showPanel } from '@codemirror/view'; +import { IconButton, makeStyles, Paper, Tooltip } from '@material-ui/core'; +import RefreshIcon from '@material-ui/icons/Refresh'; +import SaveIcon from '@material-ui/icons/Save'; +import { useKeyboardEvent } from '@react-hookz/web'; +import CodeMirror from '@uiw/react-codemirror'; +import React, { useMemo } from 'react'; +import { useDirectoryEditor } from './DirectoryEditorContext'; + +const useStyles = makeStyles(theme => ({ + container: { + position: 'relative', + width: '100%', + height: '100%', + }, + codeMirror: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + errorPanel: { + color: theme.palette.error.main, + lineHeight: 2, + margin: theme.spacing(0, 1), + }, + floatingButtons: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(3), + }, + floatingButton: { + padding: theme.spacing(1), + }, +})); + +/** A wrapper around CodeMirror with an error panel and extra actions available */ +export function TemplateEditorTextArea(props: { + content?: string; + onUpdate?: (content: string) => void; + errorText?: string; + onSave?: () => void; + onReload?: () => void; +}) { + const { errorText } = props; + const classes = useStyles(); + + const panelExtension = useMemo(() => { + if (!errorText) { + return showPanel.of(null); + } + + const dom = document.createElement('div'); + dom.classList.add(classes.errorPanel); + dom.textContent = errorText; + return showPanel.of(() => ({ dom, bottom: true })); + }, [classes, errorText]); + + useKeyboardEvent( + e => e.key === 's' && (e.ctrlKey || e.metaKey), + e => { + e.preventDefault(); + if (props.onSave) { + props.onSave(); + } + }, + ); + + return ( +
+ + {(props.onSave || props.onReload) && ( +
+ + {props.onSave && ( + + props.onSave?.()} + > + + + + )} + {props.onReload && ( + + props.onReload?.()} + > + + + + )} + +
+ )} +
+ ); +} + +/** A version of the TemplateEditorTextArea that is connected to the DirectoryEditor context */ +export function TemplateEditorDirectoryEditorTextArea(props: { + errorText?: string; +}) { + const directoryEditor = useDirectoryEditor(); + + const actions = directoryEditor.selectedFile?.dirty + ? { + onSave: () => directoryEditor.save(), + onReload: () => directoryEditor.reload(), + } + : { + onReload: () => directoryEditor.reload(), + }; + + return ( + directoryEditor.selectedFile?.updateContent(content)} + {...actions} + /> + ); +} + +TemplateEditorTextArea.DirectoryEditor = TemplateEditorDirectoryEditorTextArea; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx new file mode 100644 index 0000000000..bfa7071d48 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -0,0 +1,216 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Entity } from '@backstage/catalog-model'; +import { alertApiRef, useApi } from '@backstage/core-plugin-api'; +import { + catalogApiRef, + humanizeEntityRef, +} from '@backstage/plugin-catalog-react'; +import { + FormControl, + IconButton, + InputLabel, + LinearProgress, + makeStyles, + MenuItem, + Select, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import React, { useCallback, useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import yaml from 'yaml'; +import { FieldExtensionOptions } from '../../extensions'; +import { TemplateEditorForm } from './TemplateEditorForm'; +import { TemplateEditorTextArea } from './TemplateEditorTextArea'; + +const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI +parameters: + - title: Fill in some steps + required: + - name + properties: + name: + title: Name + type: string + description: Unique name of the component + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + allowedKinds: + - Group + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com +steps: + - id: fetch-base + name: Fetch Base + action: fetch:template + input: + url: ./template + values: + name: \${{parameters.name}} +`; + +type TemplateOption = { + label: string; + value: Entity; +}; + +const useStyles = makeStyles(theme => ({ + root: { + gridArea: 'pageContent', + display: 'grid', + gridTemplateAreas: ` + "controls controls" + "textArea preview" + `, + gridTemplateRows: 'auto 1fr', + gridTemplateColumns: '1fr 1fr', + }, + controls: { + gridArea: 'controls', + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + margin: theme.spacing(1), + }, + textArea: { + gridArea: 'textArea', + }, + preview: { + gridArea: 'preview', + }, +})); + +export const TemplateFormPreviewer = ({ + defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML, + customFieldExtensions = [], + onClose, +}: { + defaultPreviewTemplate?: string; + customFieldExtensions?: FieldExtensionOptions[]; + onClose?: () => void; +}) => { + const classes = useStyles(); + const alertApi = useApi(alertApiRef); + const catalogApi = useApi(catalogApiRef); + const [selectedTemplate, setSelectedTemplate] = useState(''); + const [errorText, setErrorText] = useState(); + const [templateOptions, setTemplateOptions] = useState([]); + const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate); + const [formState, setFormState] = useState({}); + + const { loading } = useAsync( + () => + catalogApi + .getEntities({ + filter: { kind: 'template' }, + fields: [ + 'kind', + 'metadata.namespace', + 'metadata.name', + 'metadata.title', + 'spec.parameters', + 'spec.steps', + 'spec.output', + ], + }) + .then(({ items }) => + setTemplateOptions( + items.map(template => ({ + label: + template.metadata.title ?? + humanizeEntityRef(template, { defaultKind: 'template' }), + value: template, + })), + ), + ) + .catch(e => + alertApi.post({ + message: `Error loading exisiting templates: ${e.message}`, + severity: 'error', + }), + ), + [catalogApi], + ); + + const handleSelectChange = useCallback( + selected => { + setSelectedTemplate(selected); + setTemplateYaml(yaml.stringify(selected.spec)); + }, + [setTemplateYaml], + ); + + return ( + <> + {loading && } +
+
+ + + Load Existing Template + + + + + + + +
+
+ +
+
+ +
+
+ + ); +}; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/index.ts b/plugins/scaffolder/src/components/TemplateEditorPage/index.ts index 506ff08f24..7ec6bddb64 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/index.ts +++ b/plugins/scaffolder/src/components/TemplateEditorPage/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { TemplateEditorPage } from './TemplateEditorPage'; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 4f4c650f1c..d98fd96e55 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -25,6 +25,8 @@ export type { ListActionsResponse, LogEvent, ScaffolderApi, + ScaffolderDryRunOptions, + ScaffolderDryRunResponse, ScaffolderGetIntegrationsListOptions, ScaffolderGetIntegrationsListResponse, ScaffolderOutputLink, diff --git a/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts b/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts new file mode 100644 index 0000000000..d466543dbd --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TemplateDirectoryAccess, TemplateFileAccess } from './types'; + +class MockFileAccess implements TemplateFileAccess { + constructor(readonly path: string, private content: string) {} + + async file(): Promise { + const blob = new Blob([this.content]); + return Object.assign(blob, { + name: this.path.split('/').pop()!, + lastModified: Date.now(), + webkitRelativePath: this.path, + }); + } + + async save(data: string | Blob | BufferSource): Promise { + this.content = await new Response(data).text(); + } +} + +class MockDirectoryAccess implements TemplateDirectoryAccess { + private readonly files = new Array(); + + constructor(inputFiles: Record) { + this.files = Object.entries(inputFiles).map( + ([path, content]) => new MockFileAccess(path, content), + ); + } + + async listFiles(): Promise { + return this.files; + } +} + +/** @internal */ +export class MockFileSystemAccess { + private constructor() {} + + static createMockDirectory(files: Record) { + return new MockDirectoryAccess(files); + } +} diff --git a/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts new file mode 100644 index 0000000000..36e7b11de2 --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TemplateDirectoryAccess, TemplateFileAccess } from './types'; + +type WritableFileHandle = FileSystemFileHandle & { + createWritable(): Promise<{ + write(data: string | Blob | BufferSource): Promise; + close(): Promise; + }>; +}; + +// A nicer type than the one from the TS lib +interface IterableDirectoryHandle extends FileSystemDirectoryHandle { + values(): AsyncIterable< + | ({ kind: 'file' } & WritableFileHandle) + | ({ kind: 'directory' } & IterableDirectoryHandle) + >; +} + +const showDirectoryPicker = (window as any).showDirectoryPicker as + | (() => Promise) + | undefined; + +class WebFileAccess implements TemplateFileAccess { + constructor( + readonly path: string, + private readonly handle: WritableFileHandle, + ) {} + + file(): Promise { + return this.handle.getFile(); + } + + async save(data: string | Blob | BufferSource): Promise { + const writable = await this.handle.createWritable(); + await writable.write(data); + await writable.close(); + } +} + +class WebDirectoryAccess implements TemplateDirectoryAccess { + constructor(private readonly handle: IterableDirectoryHandle) {} + + async listFiles(): Promise { + const content = []; + for await (const entry of this.listDirectoryContents(this.handle)) { + content.push(entry); + } + return content; + } + + private async *listDirectoryContents( + dirHandle: IterableDirectoryHandle, + basePath: string[] = [], + ): AsyncIterable { + for await (const handle of dirHandle.values()) { + if (handle.kind === 'file') { + yield new WebFileAccess([...basePath, handle.name].join('/'), handle); + } else if (handle.kind === 'directory') { + yield* this.listDirectoryContents(handle, [...basePath, handle.name]); + } + } + } +} + +/** @internal */ +export class WebFileSystemAccess { + static isSupported(): boolean { + return Boolean(showDirectoryPicker); + } + + static async requestDirectoryAccess(): Promise { + if (!showDirectoryPicker) { + throw new Error('File system access is not supported'); + } + const handle = await showDirectoryPicker(); + return new WebDirectoryAccess(handle); + } + + private constructor() {} +} diff --git a/plugins/scaffolder/src/lib/filesystem/helpers.ts b/plugins/scaffolder/src/lib/filesystem/helpers.ts new file mode 100644 index 0000000000..530cf87277 --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/helpers.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export async function blobToBase64(file: Blob) { + const reader = new FileReader(); + reader.readAsDataURL(file); + return new Promise((resolve, reject) => { + reader.onload = () => { + const uri = reader.result as string; + resolve(uri.slice(uri.indexOf(',') + 1)); + }; + reader.onerror = reject; + }); +} diff --git a/plugins/scaffolder/src/lib/filesystem/index.ts b/plugins/scaffolder/src/lib/filesystem/index.ts new file mode 100644 index 0000000000..1f485d0743 --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { TemplateFileAccess, TemplateDirectoryAccess } from './types'; +export { blobToBase64 } from './helpers'; +export { WebFileSystemAccess } from './WebFileSystemAccess'; diff --git a/plugins/scaffolder/src/lib/filesystem/types.ts b/plugins/scaffolder/src/lib/filesystem/types.ts new file mode 100644 index 0000000000..11de159b2a --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface TemplateFileAccess { + path: string; + file(): Promise; + save(data: string | BufferSource | Blob): Promise; +} + +export interface TemplateDirectoryAccess { + listFiles(): Promise>; +} diff --git a/plugins/scaffolder/src/setupTests.ts b/plugins/scaffolder/src/setupTests.ts index 10252413a7..5499f969dc 100644 --- a/plugins/scaffolder/src/setupTests.ts +++ b/plugins/scaffolder/src/setupTests.ts @@ -19,3 +19,16 @@ import 'cross-fetch/polyfill'; const { EventSourcePolyfill } = jest.requireMock('event-source-polyfill'); global.EventSource = EventSourcePolyfill; + +// Patch jsdom to add feature used by CodeMirror +document.createRange = () => { + const range = new Range(); + + range.getClientRects = () => ({ + item: () => null, + length: 0, + [Symbol.iterator]: jest.fn(), + }); + + return range; +}; diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 608d586b09..df7afe065f 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TaskSpec } from '@backstage/plugin-scaffolder-common'; +import { TaskSpec, TaskStep } from '@backstage/plugin-scaffolder-common'; import { JsonObject, JsonValue, Observable } from '@backstage/types'; import { JSONSchema7 } from 'json-schema'; @@ -156,6 +156,27 @@ export interface ScaffolderStreamLogsOptions { taskId: string; after?: number; } + +/** @public */ +export interface ScaffolderDryRunOptions { + template: JsonValue; + values: JsonObject; + secrets?: Record; + directoryContents: { path: string; base64Content: string }[]; +} + +/** @public */ +export interface ScaffolderDryRunResponse { + directoryContents: Array<{ + path: string; + base64Content: string; + executable: boolean; + }>; + log: Array>; + steps: TaskStep[]; + output: ScaffolderTaskOutput; +} + /** * An API to interact with the scaffolder backend. * @@ -194,4 +215,6 @@ export interface ScaffolderApi { listActions(): Promise; streamLogs(options: ScaffolderStreamLogsOptions): Observable; + + dryRun?(options: ScaffolderDryRunOptions): Promise; } diff --git a/plugins/search/package.json b/plugins/search/package.json index 5724c2af0c..57c8cacfa9 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -68,7 +68,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 40566e1f2c..fecf38c165 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -65,7 +65,7 @@ "@types/node": "^16.11.26", "@types/react": "^16.13.1 || ^17.0.0", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/shortcuts/package.json b/plugins/shortcuts/package.json index 987be389ef..6b007c4261 100644 --- a/plugins/shortcuts/package.json +++ b/plugins/shortcuts/package.json @@ -52,7 +52,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index 8f872c4e1c..b6640c2551 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -62,7 +62,7 @@ "@testing-library/user-event": "^14.0.0", "@types/jest": "^26.0.7", "@types/node": "^16.11.26", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json index 2bcd01dc9c..79d6147407 100644 --- a/plugins/splunk-on-call/package.json +++ b/plugins/splunk-on-call/package.json @@ -62,7 +62,7 @@ "@types/luxon": "^2.0.4", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "configSchema": "config.d.ts", "files": [ diff --git a/plugins/stack-overflow/package.json b/plugins/stack-overflow/package.json index 2632d113af..ce500e7886 100644 --- a/plugins/stack-overflow/package.json +++ b/plugins/stack-overflow/package.json @@ -50,7 +50,7 @@ "@testing-library/user-event": "^14.0.0", "@types/jest": "^26.0.7", "@types/node": "^16.11.26", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/tech-insights/package.json b/plugins/tech-insights/package.json index 461151659c..da889a6a79 100644 --- a/plugins/tech-insights/package.json +++ b/plugins/tech-insights/package.json @@ -57,7 +57,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 99975b5542..050f9a9877 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -62,7 +62,7 @@ "@types/node": "^16.11.26", "@types/react": "^16.13.1 || ^17.0.0", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/techdocs-addons-test-utils/package.json b/plugins/techdocs-addons-test-utils/package.json index 81e05d18c2..26449881a0 100644 --- a/plugins/techdocs-addons-test-utils/package.json +++ b/plugins/techdocs-addons-test-utils/package.json @@ -63,7 +63,7 @@ "@testing-library/user-event": "^14.0.0", "@types/jest": "^26.0.7", "@types/node": "^16.11.26", - "msw": "^0.35.0", + "msw": "^0.42.0", "cross-fetch": "^3.1.5" }, "files": [ diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 9bca7b7a81..d3563f5165 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -60,7 +60,7 @@ "@backstage/cli": "^0.17.2-next.0", "@backstage/plugin-search-backend-node": "0.6.2-next.0", "@types/dockerode": "^3.3.0", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.3" }, "files": [ diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts index 684d5729a2..3f1a857c4d 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts @@ -74,144 +74,145 @@ const expectedEntities: Entity[] = [ }, ]; -describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { - let mockDiscoveryApi: jest.Mocked; - let mockTokenManager: jest.Mocked; - let collator: DefaultTechDocsCollator; - +describe('TechDocs Collator', () => { const worker = setupServer(); setupRequestMockHandlers(worker); - beforeEach(() => { - mockDiscoveryApi = { - getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'), - getExternalBaseUrl: jest.fn(), - }; - mockTokenManager = { - getToken: jest.fn().mockResolvedValue({ token: '' }), - authenticate: jest.fn(), - }; - const mockConfig = new ConfigReader({ - techdocs: { - legacyUseCaseSensitiveTripletPaths: true, - }, - }); - collator = DefaultTechDocsCollator.fromConfig(mockConfig, { - discovery: mockDiscoveryApi, - tokenManager: mockTokenManager, - logger, - legacyPathCasing: true, - }); - worker.use( - rest.get( - 'http://test-backend/static/docs/default/Component/test-entity-with-docs/search/search_index.json', - (_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)), - ), - rest.get('http://test-backend/entities', (_, res, ctx) => - res(ctx.status(200), ctx.json(expectedEntities)), - ), - ); - }); + describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { + let mockDiscoveryApi: jest.Mocked; + let mockTokenManager: jest.Mocked; + let collator: DefaultTechDocsCollator; - it('fetches from the configured catalog and tech docs services', async () => { - const documents = await collator.execute(); - expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog'); - expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('techdocs'); - expect(documents).toHaveLength(mockSearchDocIndex.docs.length); - }); - - it('should create documents for each tech docs search index', async () => { - const documents = await collator.execute(); - const entity = expectedEntities[0]; - documents.forEach((document, idx) => { - expect(document).toMatchObject({ - title: mockSearchDocIndex.docs[idx].title, - location: `/docs/default/Component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`, - text: mockSearchDocIndex.docs[idx].text, - namespace: 'default', - entityTitle: entity!.metadata.title, - componentType: entity!.spec!.type, - lifecycle: entity!.spec!.lifecycle, - owner: '', - kind: entity.kind, - name: entity.metadata.name, - }); - }); - }); -}); - -describe('DefaultTechDocsCollator', () => { - let mockDiscoveryApi: jest.Mocked; - let mockTokenManager: jest.Mocked; - let collator: DefaultTechDocsCollator; - - const worker = setupServer(); - setupRequestMockHandlers(worker); - beforeEach(() => { - mockDiscoveryApi = { - getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'), - getExternalBaseUrl: jest.fn(), - }; - mockTokenManager = { - getToken: jest.fn().mockResolvedValue({ token: '' }), - authenticate: jest.fn(), - }; - collator = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), { - discovery: mockDiscoveryApi, - tokenManager: mockTokenManager, - logger, - }); - - worker.use( - rest.get( - 'http://test-backend/static/docs/default/component/test-entity-with-docs/search/search_index.json', - (_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)), - ), - rest.get('http://test-backend/entities', (_, res, ctx) => - res(ctx.status(200), ctx.json(expectedEntities)), - ), - ); - }); - - it('should create documents for each tech docs search index', async () => { - const documents = await collator.execute(); - const entity = expectedEntities[0]; - documents.forEach((document, idx) => { - expect(document).toMatchObject({ - title: mockSearchDocIndex.docs[idx].title, - location: `/docs/default/component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`, - text: mockSearchDocIndex.docs[idx].text, - namespace: 'default', - entityTitle: entity!.metadata.title, - componentType: entity!.spec!.type, - lifecycle: entity!.spec!.lifecycle, - owner: '', - kind: entity.kind.toLocaleLowerCase('en-US'), - name: entity.metadata.name.toLocaleLowerCase('en-US'), - authorization: { - resourceRef: `component:default/${entity.metadata.name}`, + beforeEach(() => { + mockDiscoveryApi = { + getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'), + getExternalBaseUrl: jest.fn(), + }; + mockTokenManager = { + getToken: jest.fn().mockResolvedValue({ token: '' }), + authenticate: jest.fn(), + }; + const mockConfig = new ConfigReader({ + techdocs: { + legacyUseCaseSensitiveTripletPaths: true, }, }); + collator = DefaultTechDocsCollator.fromConfig(mockConfig, { + discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, + logger, + legacyPathCasing: true, + }); + + worker.use( + rest.get( + 'http://test-backend/static/docs/default/Component/test-entity-with-docs/search/search_index.json', + (_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)), + ), + rest.get('http://test-backend/entities', (_, res, ctx) => + res(ctx.status(200), ctx.json(expectedEntities)), + ), + ); + }); + + it('fetches from the configured catalog and tech docs services', async () => { + const documents = await collator.execute(); + expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('catalog'); + expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('techdocs'); + expect(documents).toHaveLength(mockSearchDocIndex.docs.length); + }); + + it('should create documents for each tech docs search index', async () => { + const documents = await collator.execute(); + const entity = expectedEntities[0]; + documents.forEach((document, idx) => { + expect(document).toMatchObject({ + title: mockSearchDocIndex.docs[idx].title, + location: `/docs/default/Component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`, + text: mockSearchDocIndex.docs[idx].text, + namespace: 'default', + entityTitle: entity!.metadata.title, + componentType: entity!.spec!.type, + lifecycle: entity!.spec!.lifecycle, + owner: '', + kind: entity.kind, + name: entity.metadata.name, + }); + }); }); }); - it('maps a returned entity with a custom locationTemplate', async () => { - const mockConfig = new ConfigReader({ - techdocs: { - legacyUseCaseSensitiveTripletPaths: true, - }, - }); - // Provide an alternate location template. - collator = DefaultTechDocsCollator.fromConfig(mockConfig, { - discovery: mockDiscoveryApi, - tokenManager: mockTokenManager, - locationTemplate: '/software/:name', - logger, + describe('DefaultTechDocsCollator', () => { + let mockDiscoveryApi: jest.Mocked; + let mockTokenManager: jest.Mocked; + let collator: DefaultTechDocsCollator; + + beforeEach(() => { + mockDiscoveryApi = { + getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'), + getExternalBaseUrl: jest.fn(), + }; + mockTokenManager = { + getToken: jest.fn().mockResolvedValue({ token: '' }), + authenticate: jest.fn(), + }; + collator = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), { + discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, + logger, + }); + + worker.use( + rest.get( + 'http://test-backend/static/docs/default/component/test-entity-with-docs/search/search_index.json', + (_, res, ctx) => res(ctx.status(200), ctx.json(mockSearchDocIndex)), + ), + rest.get('http://test-backend/entities', (_, res, ctx) => + res(ctx.status(200), ctx.json(expectedEntities)), + ), + ); }); - const documents = await collator.execute(); - expect(documents[0]).toMatchObject({ - location: '/software/test-entity-with-docs', + it('should create documents for each tech docs search index', async () => { + const documents = await collator.execute(); + const entity = expectedEntities[0]; + documents.forEach((document, idx) => { + expect(document).toMatchObject({ + title: mockSearchDocIndex.docs[idx].title, + location: `/docs/default/component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`, + text: mockSearchDocIndex.docs[idx].text, + namespace: 'default', + entityTitle: entity!.metadata.title, + componentType: entity!.spec!.type, + lifecycle: entity!.spec!.lifecycle, + owner: '', + kind: entity.kind.toLocaleLowerCase('en-US'), + name: entity.metadata.name.toLocaleLowerCase('en-US'), + authorization: { + resourceRef: `component:default/${entity.metadata.name}`, + }, + }); + }); + }); + + it('maps a returned entity with a custom locationTemplate', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + legacyUseCaseSensitiveTripletPaths: true, + }, + }); + // Provide an alternate location template. + collator = DefaultTechDocsCollator.fromConfig(mockConfig, { + discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, + locationTemplate: '/software/:name', + logger, + }); + + const documents = await collator.execute(); + expect(documents[0]).toMatchObject({ + location: '/software/test-entity-with-docs', + }); }); }); }); diff --git a/plugins/techdocs-module-addons-contrib/package.json b/plugins/techdocs-module-addons-contrib/package.json index eb6f4c9c2d..28eb02c0ca 100644 --- a/plugins/techdocs-module-addons-contrib/package.json +++ b/plugins/techdocs-module-addons-contrib/package.json @@ -62,7 +62,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "@types/react": "^16.13.1 || ^17.0.0", - "msw": "^0.35.0", + "msw": "^0.42.0", "cross-fetch": "^3.1.5" }, "files": [ diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 49dd1a61fb..368fcb538e 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -81,7 +81,7 @@ "@types/node": "^16.11.26", "canvas": "^2.6.1", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist", diff --git a/plugins/todo-backend/package.json b/plugins/todo-backend/package.json index c050bbf6ca..0120940fe0 100644 --- a/plugins/todo-backend/package.json +++ b/plugins/todo-backend/package.json @@ -45,7 +45,7 @@ "devDependencies": { "@backstage/cli": "^0.17.2-next.0", "@types/supertest": "^2.0.8", - "msw": "^0.35.0", + "msw": "^0.42.0", "supertest": "^6.1.3" }, "files": [ diff --git a/plugins/todo/package.json b/plugins/todo/package.json index 8df4c8b31b..f5a0c512b5 100644 --- a/plugins/todo/package.json +++ b/plugins/todo/package.json @@ -55,7 +55,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0", + "msw": "^0.42.0", "react-router": "6.0.0-beta.0" }, "files": [ diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index dae4e4b360..acc3c211ef 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -58,7 +58,7 @@ "@types/jest": "^26.0.7", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/plugins/xcmetrics/package.json b/plugins/xcmetrics/package.json index da57f71fef..f5fe1e0f2a 100644 --- a/plugins/xcmetrics/package.json +++ b/plugins/xcmetrics/package.json @@ -51,7 +51,7 @@ "@types/luxon": "^2.0.4", "@types/node": "^16.11.26", "cross-fetch": "^3.1.5", - "msw": "^0.35.0" + "msw": "^0.42.0" }, "files": [ "dist" diff --git a/yarn.lock b/yarn.lock index 4ed8ee03bf..aeac084923 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4176,14 +4176,6 @@ resolved "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.1.tgz#155ef21065427901994e765da8a0ba0eaae8b8bd" integrity sha512-6Wci+Tp3CgPt/B9B0a3J4s3yMgLNSku6w5TV6mN+61C71UqsRBv2FUibBf3tPGlNxebgPHMEUzKpb1ggE8KCKw== -"@mswjs/cookies@^0.1.6", "@mswjs/cookies@^0.1.7": - version "0.1.7" - resolved "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.1.7.tgz#d334081b2c51057a61c1dd7b76ca3cac02251651" - integrity sha512-bDg1ReMBx+PYDB4Pk7y1Q07Zz1iKIEUWQpkEXiA2lEWg9gvOZ8UBmGXilCEUvyYoRFlmr/9iXTRR69TrgSwX/Q== - dependencies: - "@types/set-cookie-parser" "^2.4.0" - set-cookie-parser "^2.4.6" - "@mswjs/cookies@^0.2.0": version "0.2.0" resolved "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.2.0.tgz#7ef2b5d7e444498bb27cf57720e61f76a4ce9f23" @@ -4192,22 +4184,10 @@ "@types/set-cookie-parser" "^2.4.0" set-cookie-parser "^2.4.6" -"@mswjs/interceptors@^0.12.6", "@mswjs/interceptors@^0.12.7": - version "0.12.7" - resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.12.7.tgz#0d1cd4cd31a0f663e0455993951201faa09d0909" - integrity sha512-eGjZ3JRAt0Fzi5FgXiV/P3bJGj0NqsN7vBS0J0FO2AQRQ0jCKQS4lEFm4wvlSgKQNfeuc/Vz6d81VtU3Gkx/zg== - dependencies: - "@open-draft/until" "^1.0.3" - "@xmldom/xmldom" "^0.7.2" - debug "^4.3.2" - headers-utils "^3.0.2" - outvariant "^1.2.0" - strict-event-emitter "^0.2.0" - "@mswjs/interceptors@^0.15.1": - version "0.15.1" - resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.15.1.tgz#4a0009f56e51bc2cd3176f1507065c7d2f6c0d5e" - integrity sha512-D5B+ZJNlfvBm6ZctAfRBdNJdCHYAe2Ix4My5qfbHV5WH+3lkt3mmsjiWJzEh5ZwGDauzY487TldI275If7DJVw== + version "0.15.3" + resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.15.3.tgz#bcd17b5d7558d4f598007a4bb383b42dc9264f8d" + integrity sha512-GJ1qzBq82EQ3bwhsvw5nScbrLzOSI5H/TyB2CGd1K7dDqX58DJDLJHexiN+S5Ucvl6/84FjRdIysz0RxE/L8MA== dependencies: "@open-draft/until" "^1.0.3" "@xmldom/xmldom" "^0.7.5" @@ -4216,6 +4196,18 @@ outvariant "^1.2.1" strict-event-emitter "^0.2.0" +"@mswjs/interceptors@^0.16.3": + version "0.16.4" + resolved "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.16.4.tgz#fa41de240dd094e6ad19e3a6829651589551d159" + integrity sha512-McPKUFlZNS/wo+OAor15k0fv2skK+EdWl9CEcdxAqsN4vKajlxCxDU4B5W/pn1y0TJPSAOmxR6LYFe/8esePrg== + dependencies: + "@open-draft/until" "^1.0.3" + "@xmldom/xmldom" "^0.7.5" + debug "^4.3.3" + headers-polyfill "^3.0.4" + outvariant "^1.2.1" + strict-event-emitter "^0.2.4" + "@n1ru4l/graphql-live-query@^0.9.0": version "0.9.0" resolved "https://registry.npmjs.org/@n1ru4l/graphql-live-query/-/graphql-live-query-0.9.0.tgz#defaebdd31f625bee49e6745934f36312532b2bc" @@ -5975,14 +5967,6 @@ resolved "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.1.tgz#f14740d1f585a0a8e3f46359b62fda8b0eaa31e7" integrity sha512-K3e+NZlpCKd6Bd/EIdqjFJRFHbrq5TzPPLwREk5Iv/YoIjQrs6ljdAUCo+Lb2xFlGNOjGSE0dqsVD19cZL137w== -"@types/inquirer@^7.3.3": - version "7.3.3" - resolved "https://registry.npmjs.org/@types/inquirer/-/inquirer-7.3.3.tgz#92e6676efb67fa6925c69a2ee638f67a822952ac" - integrity sha512-HhxyLejTHMfohAuhRun4csWigAMjXTmRyiJTU1Y/I1xmggikFMkOUoMQRlFm+zQcPEGHSs3io/0FAmNZf8EymQ== - dependencies: - "@types/through" "*" - rxjs "^6.4.0" - "@types/inquirer@^8.1.3": version "8.2.1" resolved "https://registry.npmjs.org/@types/inquirer/-/inquirer-8.2.1.tgz#28a139be3105a1175e205537e8ac10830e38dbf4" @@ -6059,11 +6043,6 @@ resolved "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f" integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw== -"@types/js-levenshtein@^1.1.0": - version "1.1.0" - resolved "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.0.tgz#9541eec4ad6e3ec5633270a3a2b55d981edc44a9" - integrity sha512-14t0v1ICYRtRVcHASzes0v/O+TIeASb8aD55cWF1PidtInhFWSXcmhzhHqGjUWf9SUq1w70cvd1cWKUULubAfQ== - "@types/js-levenshtein@^1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz#ba05426a43f9e4e30b631941e0aa17bf0c890ed5" @@ -7184,7 +7163,7 @@ "@webassemblyjs/ast" "1.11.1" "@xtuc/long" "4.2.2" -"@xmldom/xmldom@^0.7.0", "@xmldom/xmldom@^0.7.2", "@xmldom/xmldom@^0.7.5": +"@xmldom/xmldom@^0.7.0", "@xmldom/xmldom@^0.7.5": version "0.7.5" resolved "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.5.tgz#09fa51e356d07d0be200642b0e4f91d8e6dd408d" integrity sha512-V3BIhmY36fXZ1OtVcI9W+FxQqxVLsPKcNjWigIaa81dLC9IolJl5Mt4Cvhmr0flUnjSpTdrbMTSbXqYqV5dT6A== @@ -9848,7 +9827,7 @@ cookie@0.4.1: resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== -cookie@0.4.2, cookie@^0.4.1, cookie@^0.4.2, cookie@~0.4.1: +cookie@0.4.2, cookie@^0.4.2, cookie@~0.4.1: version "0.4.2" resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== @@ -13577,11 +13556,6 @@ graphql-ws@^5.4.1: resolved "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.5.5.tgz#f375486d3f196e2a2527b503644693ae3a8670a9" integrity sha512-hvyIS71vs4Tu/yUYHPvGXsTgo0t3arU820+lT5VjZS2go0ewp2LqyCgxEN56CzOG7Iys52eRhHBiD1gGRdiQtw== -graphql@^15.5.1: - version "15.8.0" - resolved "https://registry.npmjs.org/graphql/-/graphql-15.8.0.tgz#33410e96b012fa3bdb1091cc99a94769db212b38" - integrity sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw== - graphql@^16.0.0, graphql@^16.3.0: version "16.5.0" resolved "https://registry.npmjs.org/graphql/-/graphql-16.5.0.tgz#41b5c1182eaac7f3d47164fb247f61e4dfb69c85" @@ -13808,11 +13782,6 @@ headers-polyfill@^3.0.4: resolved "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.0.7.tgz#725c4f591e6748f46b036197eae102c92b959ff4" integrity sha512-JoLCAdCEab58+2/yEmSnOlficyHFpIl0XJqwu3l+Unkm1gXpFUYsThz6Yha3D6tNhocWkCPfyW0YVIGWFqTi7w== -headers-utils@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/headers-utils/-/headers-utils-3.0.2.tgz#dfc65feae4b0e34357308aefbcafa99c895e59ef" - integrity sha512-xAxZkM1dRyGV2Ou5bzMxBPNLoRCjcX+ya7KSWybQD2KwLphxsapUVK6x/02o7f4VU6GPSXch9vNY2+gkU8tYWQ== - helmet@^5.0.2: version "5.0.2" resolved "https://registry.npmjs.org/helmet/-/helmet-5.0.2.tgz#3264ec6bab96c82deaf65e3403c369424cb2366c" @@ -14392,7 +14361,7 @@ inquirer@^7.3.3: strip-ansi "^6.0.0" through "^2.3.6" -inquirer@^8.0.0, inquirer@^8.1.1, inquirer@^8.2.0: +inquirer@^8.0.0, inquirer@^8.2.0: version "8.2.4" resolved "https://registry.npmjs.org/inquirer/-/inquirer-8.2.4.tgz#ddbfe86ca2f67649a67daa6f1051c128f684f0b4" integrity sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg== @@ -18177,58 +18146,6 @@ ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -msw@^0.35.0: - version "0.35.0" - resolved "https://registry.npmjs.org/msw/-/msw-0.35.0.tgz#18a4ceb6c822ef226a30421d434413bc45030d38" - integrity sha512-V7A6PqaS31F1k//fPS0OnO7vllfaqBUFsMEu3IpYixyWpiUInfyglodnbXhhtDyytkQikpkPZv8TZi/CvZzv/w== - dependencies: - "@mswjs/cookies" "^0.1.6" - "@mswjs/interceptors" "^0.12.6" - "@open-draft/until" "^1.0.3" - "@types/cookie" "^0.4.1" - "@types/inquirer" "^7.3.3" - "@types/js-levenshtein" "^1.1.0" - chalk "^4.1.1" - chokidar "^3.4.2" - cookie "^0.4.1" - graphql "^15.5.1" - headers-utils "^3.0.2" - inquirer "^8.1.1" - is-node-process "^1.0.1" - js-levenshtein "^1.1.6" - node-fetch "^2.6.1" - node-match-path "^0.6.3" - statuses "^2.0.0" - strict-event-emitter "^0.2.0" - type-fest "^1.2.2" - yargs "^17.0.1" - -msw@^0.36.3: - version "0.36.8" - resolved "https://registry.npmjs.org/msw/-/msw-0.36.8.tgz#33ff8bfb0299626a95f43d0e4c3dc2c73c17f1ba" - integrity sha512-K7lOQoYqhGhTSChsmHMQbf/SDCsxh/m0uhN6Ipt206lGoe81fpTmaGD0KLh4jUxCONMOUnwCSj0jtX2CM4pEdw== - dependencies: - "@mswjs/cookies" "^0.1.7" - "@mswjs/interceptors" "^0.12.7" - "@open-draft/until" "^1.0.3" - "@types/cookie" "^0.4.1" - "@types/inquirer" "^8.1.3" - "@types/js-levenshtein" "^1.1.0" - chalk "4.1.1" - chokidar "^3.4.2" - cookie "^0.4.1" - graphql "^15.5.1" - headers-utils "^3.0.2" - inquirer "^8.2.0" - is-node-process "^1.0.1" - js-levenshtein "^1.1.6" - node-fetch "^2.6.7" - path-to-regexp "^6.2.0" - statuses "^2.0.0" - strict-event-emitter "^0.2.0" - type-fest "^1.2.2" - yargs "^17.3.0" - msw@^0.39.2: version "0.39.2" resolved "https://registry.npmjs.org/msw/-/msw-0.39.2.tgz#832e9274db62c43cb79854d5a69dce031c700de8" @@ -18254,6 +18171,32 @@ msw@^0.39.2: type-fest "^1.2.2" yargs "^17.3.1" +msw@^0.42.0: + version "0.42.0" + resolved "https://registry.npmjs.org/msw/-/msw-0.42.0.tgz#2286aefad82808888914e2bc5e40666e82b2824b" + integrity sha512-vB9rzgiGHoQGfkKpp3QZHxobzfuuQOJk+0bff0wtbK8k3P3CaUSt8bCwvExours682AY4mUfTjIkCsxy0JoS3w== + dependencies: + "@mswjs/cookies" "^0.2.0" + "@mswjs/interceptors" "^0.16.3" + "@open-draft/until" "^1.0.3" + "@types/cookie" "^0.4.1" + "@types/js-levenshtein" "^1.1.1" + chalk "4.1.1" + chokidar "^3.4.2" + cookie "^0.4.2" + graphql "^16.3.0" + headers-polyfill "^3.0.4" + inquirer "^8.2.0" + is-node-process "^1.0.1" + js-levenshtein "^1.1.6" + node-fetch "^2.6.7" + outvariant "^1.3.0" + path-to-regexp "^6.2.0" + statuses "^2.0.0" + strict-event-emitter "^0.2.0" + type-fest "^1.2.2" + yargs "^17.3.1" + multicast-dns@^7.2.4: version "7.2.4" resolved "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.4.tgz#cf0b115c31e922aeb20b64e6556cbeb34cf0dd19" @@ -18547,11 +18490,6 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-match-path@^0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/node-match-path/-/node-match-path-0.6.3.tgz#55dd8443d547f066937a0752dce462ea7dc27551" - integrity sha512-fB1reOHKLRZCJMAka28hIxCwQLxGmd7WewOCBDYKpyA1KXi68A7vaGgdZAPhY2E6SXoYt3KqYCCvXLJ+O0Fu/Q== - node-modules-regexp@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" @@ -19161,12 +19099,7 @@ outdent@^0.5.0: resolved "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz#9e10982fdc41492bb473ad13840d22f9655be2ff" integrity sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q== -outvariant@^1.2.0: - version "1.2.1" - resolved "https://registry.npmjs.org/outvariant/-/outvariant-1.2.1.tgz#e630f6cdc1dbf398ed857e36f219de4a005ccd35" - integrity sha512-bcILvFkvpMXh66+Ubax/inxbKRyWTUiiFIW2DWkiS79wakrLGn3Ydy+GvukadiyfZjaL6C7YhIem4EZSM282wA== - -outvariant@^1.2.1: +outvariant@^1.2.1, outvariant@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/outvariant/-/outvariant-1.3.0.tgz#c39723b1d2cba729c930b74bf962317a81b9b1c9" integrity sha512-yeWM9k6UPfG/nzxdaPlJkB2p08hCg4xP6Lx99F+vP8YF7xyZVfTmJjrrNalkmzudD4WFvNLVudQikqUmF8zhVQ== @@ -22200,7 +22133,7 @@ rxjs@7.5.5, rxjs@^7.1.0, rxjs@^7.2.0, rxjs@^7.5.1, rxjs@^7.5.5: dependencies: tslib "^2.1.0" -rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.6.0, rxjs@^6.6.3: +rxjs@^6.3.3, rxjs@^6.6.0, rxjs@^6.6.3: version "6.6.7" resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== @@ -23256,6 +23189,13 @@ strict-event-emitter@^0.2.0: dependencies: events "^3.3.0" +strict-event-emitter@^0.2.4: + version "0.2.4" + resolved "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.2.4.tgz#365714f0c95f059db31064ca745d5b33e5b30f6e" + integrity sha512-xIqTLS5azUH1djSUsLH9DbP6UnM/nI18vu8d43JigCQEoVsnY+mrlE+qv6kYqs6/1OkMnMIiL6ffedQSZStuoQ== + dependencies: + events "^3.3.0" + strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" @@ -25815,7 +25755,7 @@ yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yargs@^17.0.0, yargs@^17.0.1, yargs@^17.1.1, yargs@^17.2.1, yargs@^17.3.0: +yargs@^17.0.0, yargs@^17.1.1, yargs@^17.2.1: version "17.3.1" resolved "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz#da56b28f32e2fd45aefb402ed9c26f42be4c07b9" integrity sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA==