diff --git a/.github/workflows/create-docker-image-job.yml b/.github/workflows/create-docker-image-job.yml index 2875e3ae8..9d3efee73 100644 --- a/.github/workflows/create-docker-image-job.yml +++ b/.github/workflows/create-docker-image-job.yml @@ -18,8 +18,6 @@ jobs: contents: read security-events: write steps: - - name: Print example variable's - run: echo "${{ inputs.example-variable}}" - uses: actions/checkout@v4 with: submodules: true diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index e3b508bf4..78debd759 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -6,9 +6,6 @@ on: type: string default: chromium -env: - E2E_TESTS_RUNNING: "true" - jobs: e2e-tests: runs-on: ubuntu-latest @@ -74,7 +71,8 @@ jobs: run: | echo "Running e2e tests 1 time" - npm run dev & + npm run build + npm run preview -- --port 5173 & DEV_PID=$! npm run test:e2e -- --project ${{ inputs.browser }} --repeat-each 1 pkill -P $DEV_PID diff --git a/.talismanrc b/.talismanrc index 9db6aff24..bd187bf53 100644 --- a/.talismanrc +++ b/.talismanrc @@ -20,7 +20,7 @@ fileignoreconfig: - filename: doc/adr/0009-renovate.md checksum: 172f8d22a6c8114b91ba4e430349c40599d1afa59fb96f49a651c1eac1e551dc - filename: frontend/src/main.ts - checksum: c7bf1cf4779f88563975f13217a367a4be2100cb52709f38ddb4c6f1c6e3a857 + checksum: fd82b62a209f40f1735e9bde784f76e47337ed6ede40335a864c74a529223a97 - filename: LegalDocML.de/*/fixtures/*.xml ignore_detectors: [filecontent] - filename: LegalDocML.de/*/samples/*.xml diff --git a/DockerfileApp b/DockerfileApp index 745690d57..4c55fec8d 100644 --- a/DockerfileApp +++ b/DockerfileApp @@ -3,7 +3,7 @@ FROM node:22.12.0 AS frontend WORKDIR /frontend COPY frontend . RUN npm ci -RUN npm run-script build +RUN npm run build FROM gradle:8.11-jdk21 AS backend ARG SENTRY_AUTH_TOKEN diff --git a/frontend/README.md b/frontend/README.md index 0dde25cc7..85b5aaf98 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -16,9 +16,11 @@ The frontend is the main entry point for users of _RIS norms_. - `npm run test:watch` runs the tests and automatically re-runs if something changes - `npm run test:e2e` runs the E2E tests (requires a running frontend and backend) - `npm run coverage` compiles a coverage report via `v8` +- `npm run typecheck` runs type checking through TypeScript - `npm run style:check` does linting and formatting - `npm run style:fix` will try to fix linting and formatting issues - `npm run build` builds the app +- `npm run preview` previews the app (requires a build first) ## E2E Tests @@ -62,8 +64,7 @@ npm run test:e2e -- --project firefox --repeat-each 1 npm run test:e2e -- --project msedge --repeat-each 1 ``` -Alternatively the [DEVELOPING.md](../DEVELOPING.md#how-to-run-locally) also explains how to run the e2e-tests inside a -docker container. +Alternatively the [DEVELOPING.md](../DEVELOPING.md#how-to-run-locally) also explains how to run the e2e-tests inside a docker container. ## Icons diff --git a/frontend/sonar-project.properties b/frontend/sonar-project.properties index 471659783..2e0b356f7 100644 --- a/frontend/sonar-project.properties +++ b/frontend/sonar-project.properties @@ -6,6 +6,6 @@ sonar.tests=src/ sonar.test.inclusions=**/*.spec.ts sonar.host.url=https://sonarcloud.io sonar.javascript.lcov.reportPaths=coverage/lcov.info -# see vite.config.ts +# see vitest.config.ts sonar.coverage.exclusions=**/*.d.ts, **/*.spec.ts, src/views/**/*, src/App.vue, src/router.ts, src/main.ts, src/**/*.story.vue diff --git a/frontend/src/lib/env.spec.ts b/frontend/src/lib/env.spec.ts new file mode 100644 index 000000000..b18f84614 --- /dev/null +++ b/frontend/src/lib/env.spec.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { detectEnv, RisEnvironment } from "./env" + +describe("env", () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + }) + + it("detects development", () => { + vi.stubEnv("DEV", true) + + expect(detectEnv()).toBe(RisEnvironment.DEVELOPMENT) + }) + + it("detects local environment", () => { + vi.stubEnv("DEV", false) + + vi.stubGlobal("window", { + ...window, + location: { hostname: "localhost" }, + }) + + expect(detectEnv()).toBe(RisEnvironment.LOCAL) + }) + + it("detects production environment", () => { + vi.stubEnv("DEV", false) + + vi.stubGlobal("window", { + ...window, + location: { hostname: "foo.bar.prod.example.dev" }, + }) + + expect(detectEnv()).toBe(RisEnvironment.PRODUCTION) + }) + + it("detects staging environment", () => { + vi.stubEnv("DEV", false) + + vi.stubGlobal("window", { + ...window, + location: { hostname: "foo.bar.dev.example.dev" }, + }) + + expect(detectEnv()).toBe(RisEnvironment.STAGING) + }) + + it("detects uat environment", () => { + vi.stubEnv("DEV", false) + + vi.stubGlobal("window", { + ...window, + location: { hostname: "foo.bar-uat.example.dev" }, + }) + + expect(detectEnv()).toBe(RisEnvironment.UAT) + }) + + it("returns a fallback environment", () => { + vi.stubEnv("DEV", false) + + vi.stubGlobal("window", { + ...window, + location: { hostname: "foo.bar.baz.dev.example.dev" }, + }) + + expect(detectEnv()).toBe(RisEnvironment.UNKNOWN) + }) +}) diff --git a/frontend/src/lib/env.ts b/frontend/src/lib/env.ts new file mode 100644 index 000000000..8b1cf86f0 --- /dev/null +++ b/frontend/src/lib/env.ts @@ -0,0 +1,36 @@ +export enum RisEnvironment { + /** Development mode = unbundled app served through dev server or unit tests */ + DEVELOPMENT = "development", + /** Preview mode = production bundle is served on the local machine */ + LOCAL = "local", + /** Production mode = production bundle is served from production environment */ + PRODUCTION = "prod", + /** Staging mode = production bundle is served from staging environment */ + STAGING = "staging", + /** UAT mode = production bundle is served from UAT environment */ + UAT = "uat", + /** Any other environment */ + UNKNOWN = "unknown", +} + +/** + * Infers the environment that the app is running in during runtime based on + * build metadata and the domain the app is served from. + * + * @returns The environment the app is running in + */ +export function detectEnv(): RisEnvironment { + if (import.meta.env.DEV) { + return RisEnvironment.DEVELOPMENT + } else if (window.location.hostname.includes("-uat.")) { + return RisEnvironment.UAT + } else if (window.location.hostname.includes(".prod.")) { + return RisEnvironment.PRODUCTION + } else if (window.location.hostname.includes(".dev.")) { + return window.location.hostname.split(".").length > 5 + ? RisEnvironment.UNKNOWN + : RisEnvironment.STAGING + } else if (window.location.hostname.includes("localhost")) { + return RisEnvironment.LOCAL + } else return RisEnvironment.UNKNOWN +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index d5a8bd8ef..f6c179dd0 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -7,28 +7,38 @@ import ConfirmationService from "primevue/confirmationservice" import ToastService from "primevue/toastservice" import { createApp } from "vue" import App from "./App.vue" +import { detectEnv, RisEnvironment } from "./lib/env" import router from "./router" import "./style.css" const app = createApp(App) + .use(PrimeVue, { + pt: RisUiTheme, + unstyled: true, + locale: RisUiLocale.deDE, + }) + .use(ToastService) + .use(ConfirmationService) + .use(router) + +const env = detectEnv() -app.use(PrimeVue, { - pt: RisUiTheme, - unstyled: true, - locale: RisUiLocale.deDE, -}) +const enableSentry = [ + RisEnvironment.PRODUCTION, + RisEnvironment.UAT, + RisEnvironment.STAGING, +].includes(env) -app.use(ToastService) -app.use(ConfirmationService) +console.info( + `Sentry reporting is ${enableSentry ? "enabled" : "disabled"} in environment "${env}"`, +) -if (import.meta.env.PROD && import.meta.env.E2E_TESTS_RUNNING !== "true") { +if (enableSentry) { Sentry.init({ app, - environment: "staging", + environment: env, dsn: "https://bc002a52fd187905497284bed2d771c1@o1248831.ingest.us.sentry.io/4507543284613120", - initialScope: { - tags: { source: "frontend" }, - }, + initialScope: { tags: { source: "frontend" } }, integrations: [ Sentry.browserTracingIntegration({ router }), Sentry.captureConsoleIntegration(), @@ -39,4 +49,4 @@ if (import.meta.env.PROD && import.meta.env.E2E_TESTS_RUNNING !== "true") { }) } -app.use(router).mount("#app") +app.mount("#app") diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0cc930004..db2f3d74a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,99 +1,50 @@ -/// +import { sentryVitePlugin } from "@sentry/vite-plugin" import vue from "@vitejs/plugin-vue" import { fileURLToPath, URL } from "node:url" import icons from "unplugin-icons/vite" import { defineConfig } from "vite" -import { configDefaults } from "vitest/dist/config" -import { sentryVitePlugin } from "@sentry/vite-plugin" - -const isTest = process.env.VITEST === "true" // https://vitejs.dev/config/ -export default defineConfig({ - build: { - sourcemap: true, - target: ["edge127", "firefox115", "chrome127"], - }, - plugins: [ - vue(), - icons({ - scale: 1.3333, // ~24px at the current default font size of 18px - compiler: "vue3", - }), - !isTest && - process.env.NODE_ENV === "production" && - sentryVitePlugin({ - authToken: process.env.SENTRY_AUTH_TOKEN, - org: "digitalservice", - project: "ris-norms", - telemetry: process.env.VITEST !== "true", - }), - ].filter(Boolean), - server: { - proxy: { - "/api": { - target: "http://localhost:8080", - changeOrigin: true, - secure: false, - }, - }, - }, - test: { - setupFiles: ["src/vitest-setup.ts"], - globals: true, - environment: "jsdom", - exclude: [...configDefaults.exclude, "e2e/**/*.spec.ts"], - css: { - // Needed so we can reliably test for class names for CSS modules. - // Otherwise scoped CSS classes would have an unreliable hash - // attached to the class name. - modules: { classNameStrategy: "non-scoped" }, - }, - coverage: { - provider: "v8", - reporter: ["lcov"], - // Changes to this also need to be reflected in the sonar-project.properties - exclude: [ - // Configuration and generated outputs - "**/[.]**", - "coverage/**/*", - "dist/**/*", - "**/.*rc.{?(c|m)js,yml}", - "*.config.{js,ts}", - - // Types - "**/*.d.ts", +export default defineConfig(({ mode }) => { + const enableSentry = mode === "production" && process.env.SENTRY_AUTH_TOKEN - // Tests - "test/**/*", - "e2e/**/*", + console.info( + `Sentry plugin is ${enableSentry ? "enabled" : "disabled"} in ${mode} mode`, + ) - // App content we're not interested in covering with unit tests. If you - // add something here, please also add a comment explaining why the - // exclusion is necessary. - - // Views are too complex too set up and mock in unit tests, we're covering - // those with E2E test instead. (App is also a view) - "src/views/**/*", - "src/App.vue", - - // If necessary to use e.g. guards, we'll have a router-guards file that - // then should be tested - "src/router.ts", - - // Just the init file, nothing much to test here. - "src/main.ts", - - // Stories are just for internal development use and don't need to be - // tested - "src/**/*.story.vue", - ], + return { + build: { + sourcemap: true, + target: ["edge127", "firefox115", "chrome127"], }, - }, - resolve: { - alias: { - "@": fileURLToPath(new URL("./src", import.meta.url)), - "@e2e": fileURLToPath(new URL("./e2e", import.meta.url)), + plugins: [ + vue(), + icons({ + scale: 1.3333, // ~24px at the current default font size of 18px + compiler: "vue3", + }), + enableSentry && + sentryVitePlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: "digitalservice", + project: "ris-norms", + telemetry: process.env.VITEST !== "true", + }), + ], + server: { + proxy: { + "/api": { + target: "http://localhost:8080", + changeOrigin: true, + secure: false, + }, + }, + }, + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + "@e2e": fileURLToPath(new URL("./e2e", import.meta.url)), + }, }, - }, + } }) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 000000000..0f1bcc903 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,63 @@ +import { defineConfig, mergeConfig } from "vitest/config" +import { configDefaults } from "vitest/dist/config" +import viteConfig from "./vite.config" + +export default defineConfig((context) => + mergeConfig( + viteConfig(context), + defineConfig({ + test: { + setupFiles: ["src/vitest-setup.ts"], + globals: true, + environment: "jsdom", + exclude: [...configDefaults.exclude, "e2e/**/*.spec.ts"], + css: { + // Needed so we can reliably test for class names for CSS modules. + // Otherwise scoped CSS classes would have an unreliable hash + // attached to the class name. + modules: { classNameStrategy: "non-scoped" }, + }, + coverage: { + provider: "v8", + reporter: ["lcov"], + // Changes to this also need to be reflected in the sonar-project.properties + exclude: [ + // Configuration and generated outputs + "**/[.]**", + "coverage/**/*", + "dist/**/*", + "**/.*rc.{?(c|m)js,yml}", + "*.config.{js,ts}", + + // Types + "**/*.d.ts", + + // Tests + "test/**/*", + "e2e/**/*", + + // App content we're not interested in covering with unit tests. If you + // add something here, please also add a comment explaining why the + // exclusion is necessary. + + // Views are too complex too set up and mock in unit tests, we're covering + // those with E2E test instead. (App is also a view) + "src/views/**/*", + "src/App.vue", + + // If necessary to use e.g. guards, we'll have a router-guards file that + // then should be tested + "src/router.ts", + + // Just the init file, nothing much to test here. + "src/main.ts", + + // Stories are just for internal development use and don't need to be + // tested + "src/**/*.story.vue", + ], + }, + }, + }), + ), +)