diff --git a/.github/actions/yarn-install/action.yml b/.github/actions/yarn-install/action.yml new file mode 100644 index 0000000000..2b2ca82324 --- /dev/null +++ b/.github/actions/yarn-install/action.yml @@ -0,0 +1,76 @@ +name: 'Install dependencies' +description: > + Set up the Node version from .nvmrc and get node_modules in place, restoring + it from cache when nothing that shapes the tree has changed. + +outputs: + os: + description: >- + The runner's OS release as used in this action's cache keys, e.g. + ubuntu24.04, so callers can key their own caches the same way. + value: ${{ steps.os.outputs.id }} + +runs: + using: composite + steps: + # The runner's OS release, not just `runner.os`. Every release reports + # "Linux", so keying on runner.os alone would let a tree installed on one + # image be restored on another, e.g. when ubuntu-latest moves to a new + # release, or if a job pins a different one again. All jobs use + # ubuntu-latest so they share one entry. The prebuilt native binaries we pull + # (rollup, lmdb, lightningcss, esbuild) target an old glibc and would + # survive a mismatch today, but nothing guarantees the next dependency will. + - id: os + shell: bash + run: | + if [ -r /etc/os-release ]; then + . /etc/os-release + echo "id=${ID}${VERSION_ID}" >> "$GITHUB_OUTPUT" + else + echo "id=${RUNNER_OS}" >> "$GITHUB_OUTPUT" + fi + + # First, because whether this hits decides if restoring the ~480 MB yarn + # download cache below is worth doing at all. + - id: node_modules + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: node_modules + # .nvmrc is in the key because the tree records the Node ABI it was + # built for, and yarn.lock because it is what the tree must match. + # + # No restore-keys on purpose. A tree from a different lockfile is not a + # useful starting point: yarn has to reconcile it, and an optional + # dependency that is no longer selected can survive the reconcile. + # Exact match or a clean install; there is no useful middle. + key: ${{ steps.os.outputs.id }}-${{ runner.arch }}-node-modules-${{ hashFiles('.nvmrc', 'yarn.lock') }} + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + # Deliberately no `cache: yarn`; the two steps below do it conditionally. + + # The yarn download cache only speeds up an install that actually downloads. + # It is ~480 MB in this repo, so restoring it on a run whose node_modules + # already hit costs more than the 2s install it would be accelerating. + - id: yarn_cache_dir + if: steps.node_modules.outputs.cache-hit != 'true' + shell: bash + run: echo "path=$(yarn cache dir)" >> "$GITHUB_OUTPUT" + + - if: steps.node_modules.outputs.cache-hit != 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.yarn_cache_dir.outputs.path }} + key: ${{ steps.os.outputs.id }}-${{ runner.arch }}-yarn-${{ hashFiles('yarn.lock') }} + # Here restore-keys IS right: a partial download cache is still a + # head start, and yarn verifies every package against the lockfile. + restore-keys: | + ${{ steps.os.outputs.id }}-${{ runner.arch }}-yarn- + + # Always, hit or miss. On a hit yarn checks node_modules/.yarn-integrity + # against the lockfile and exits in about two seconds with "Already + # up-to-date", and the `prepare` script still runs. The cache is an + # accelerator; yarn stays the source of truth. + - shell: bash + run: yarn --frozen-lockfile diff --git a/.github/workflows/bld_mvn.yml b/.github/workflows/bld_mvn.yml index dae347e5dc..669fd317b6 100644 --- a/.github/workflows/bld_mvn.yml +++ b/.github/workflows/bld_mvn.yml @@ -24,6 +24,11 @@ on: required: false default: "default" type: string + deploy: + description: 'Whether to upload the WAR to the artifact repository. Set false for a dry run.' + required: false + default: true + type: boolean workflow_dispatch: inputs: @@ -48,6 +53,11 @@ on: required: false default: "default" type: string + deploy: + description: 'Whether to upload the WAR to the artifact repository. Set false for a dry run.' + required: false + default: true + type: boolean jobs: bld_mvn: @@ -72,6 +82,46 @@ jobs: # NOTE: history does not include tags! fetch-depth: 100 + # Build the Angular app here rather than letting frontend-maven-plugin do + # it: this reuses the runner's Node and the cached node_modules instead of + # downloading Node and installing from cold inside maven. The maven build + # below skips the plugin's node/yarn goals and just packages ./dist. + - id: deps + uses: ./.github/actions/yarn-install + + # No formatting check here: formatting is a pull request gate (the + # `format` job), and a release build must not fail on whitespace. + + # The Angular build cache, same lineage as bld_yarn's (the comment there + # explains the key and when it refreshes). Restoring main's newest entry + # speeds up this release build; saving below is what gives every new pull + # request a warm first run. + - name: restore the Angular build cache + id: ng_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .angular/cache + key: ng-build-${{ steps.deps.outputs.os }}-${{ runner.arch }}-${{ hashFiles('yarn.lock', 'angular.json', '.nvmrc') }}-${{ github.sha }} + restore-keys: | + ng-build-${{ steps.deps.outputs.os }}-${{ runner.arch }}-${{ hashFiles('yarn.lock', 'angular.json', '.nvmrc') }}- + + - name: build the Angular app (prebuild + postbuild included) + run: yarn build + + # The WAR is a verbatim copy of ./dist, so validate it before packaging. + - run: yarn build:manifest:check + - run: yarn build:verify + + # Saved only after the build and both layout checks passed: steps run + # under `if: success()` unless told otherwise, so a failed build never + # saves. Skipped on an exact hit, i.e. a re-run of the same commit. + - name: save the Angular build cache + if: steps.ng_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .angular/cache + key: ng-build-${{ steps.deps.outputs.os }}-${{ runner.arch }}-${{ hashFiles('yarn.lock', 'angular.json', '.nvmrc') }}-${{ github.sha }} + - name: find next version id: version uses: ORCID/version-bump-action@main @@ -117,10 +167,15 @@ jobs: - name: build our project run: | + # skip.installyarn skips the install-node-and-yarn goal and skip.yarn + # skips both `yarn` goals of frontend-maven-plugin: ./dist was already + # built by the `yarn build` step above. pom.xml is left untouched so + # local `mvn` builds still run the full frontend chain. mvn -T 1C --batch-mode \ -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn \ --file "pom.xml" \ --activate-profiles prod -Dnodejs.workingDirectory=. \ + -Dskip.installyarn=true -Dskip.yarn=true \ package -Dmaven.test.skip echo "------------------------------------------------------" @@ -129,7 +184,10 @@ jobs: env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + # Skipped by the ci_only dry run: it proves the WAR still builds without + # publishing an artifact nobody asked for. - name: deploy same artifact for all environments + if: ${{ inputs.deploy }} run: | WAR_FILE="target/orcid-web-frontend-prod.war" REPO_URL="${ARTIFACT_URL}${ARTIFACT_REPO_PATH}" diff --git a/.github/workflows/bld_test_rel_tag.yml b/.github/workflows/bld_test_rel_tag.yml index c90a0531ef..cc2b7a8d31 100644 --- a/.github/workflows/bld_test_rel_tag.yml +++ b/.github/workflows/bld_test_rel_tag.yml @@ -28,6 +28,11 @@ on: type: boolean required: false default: false + deploy: + description: 'Whether to upload the WAR and push the i18n source. Set false for a dry run.' + type: boolean + required: false + default: true workflow_dispatch: inputs: @@ -62,6 +67,11 @@ on: type: boolean required: false default: false + deploy: + description: 'Whether to upload the WAR and push the i18n source. Set false for a dry run.' + type: boolean + required: false + default: true # cancel running job if another commit comes in concurrency: @@ -69,37 +79,31 @@ concurrency: cancel-in-progress: true jobs: - format_i18n: - uses: ./.github/workflows/format_i18n.yml - secrets: inherit # pass all secrets for pushing - - format_prettier: - uses: ./.github/workflows/format_prettier.yml - secrets: inherit # pass all secrets for pushing + # Extract messages.xlf from the code and push it to Transifex. Off the + # critical path: nothing downstream depends on it. + # Formatting and unit tests are PR gates (pr.yml), not main gates: branch + # protection requires an up-to-date, green branch, so main re-tests a tree + # that has already passed. + push_i18n_source: + if: ${{ github.ref == 'refs/heads/main' && inputs.deploy }} + uses: ./.github/workflows/push_i18n_source.yml + secrets: + TRANSIFEX_TOKEN: ${{ secrets.TRANSIFEX_TOKEN }} ############################################################################## lint: uses: ./.github/workflows/lint.yml - needs: - - format_i18n - - format_prettier - - test_yarn: - uses: ./.github/workflows/test_yarn.yml - needs: - - format_i18n - - format_prettier ############################################################################## # uses maven to build via yarn into a war file + # No `needs: lint`. lint is a required check on the pull request (pr.yml + # produces `lint / pre-commit`), so main is re-running something the merge + # already gated on. Running it in parallel takes ~40s off the critical path; + # rel_tag still waits for it, so nothing is tagged or released on a lint failure. bld_mvn: uses: ./.github/workflows/bld_mvn.yml secrets: inherit # pass all secrets for uploading assets - needs: - - lint - - format_i18n - - format_prettier permissions: checks: write contents: read @@ -109,15 +113,12 @@ jobs: version_tag: ${{ inputs.version_tag }} bump: ${{ inputs.bump }} ref: ${{ inputs.ref }} + deploy: ${{ inputs.deploy }} bld_docker: if: ${{ inputs.run_docker }} uses: ./.github/workflows/bld_docker.yml secrets: inherit # pass all secrets for uploading assets - needs: - - lint - - format_i18n - - format_prettier permissions: checks: write contents: read @@ -130,10 +131,15 @@ jobs: ############################################################################## + # Gated on lint as well as the WAR: the build can run in parallel, but nothing + # gets tagged or released while lint is red. lint (~30s) always finishes long + # before bld_mvn (~4min), so this adds no latency. rel_tag: uses: ./.github/workflows/rel_tag.yml if: ${{ inputs.rel_tag }} - needs: bld_mvn + needs: + - bld_mvn + - lint with: version_tag: ${{ inputs.version_tag }} bump: ${{ inputs.bump }} diff --git a/.github/workflows/bld_yarn.yml b/.github/workflows/bld_yarn.yml index 96003ef51b..fe28d0cc2a 100644 --- a/.github/workflows/bld_yarn.yml +++ b/.github/workflows/bld_yarn.yml @@ -1,9 +1,8 @@ name: bld_yarn +# The job only builds; it calls no GitHub API. permissions: - checks: write contents: read - issues: read on: workflow_call: @@ -20,9 +19,45 @@ jobs: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + - id: deps + uses: ./.github/actions/yarn-install + + # Angular's persistent build cache (webpack's module and minifier caches + # and babel's, ~70 MB compressed), restored from the newest entry bld_mvn + # saved on main. Restore only. Angular gives webpack timestamp snapshots + # for app modules, so a fresh checkout recompiles every app module + # whichever entry is restored, and what carries over (node_modules, babel + # and minifier results) is the same in main's entry as in one this pull + # request could save, apart from the few files it changes itself. Saving + # would add a ~70 MB upload to this job, the slowest on the pull request. + # test_yarn keeps a separate `ng-test-` lineage: the two jobs run at once, + # and one shared key made them race and restore each other's cache. + # + # When it refreshes: the key hashes yarn.lock, angular.json and .nvmrc, so + # a dependency, Angular configuration or Node change starts a new lineage + # instead of reusing entries built by another toolchain, and every + # successful build on main saves a newer entry. A pull request that + # changes one of those files finds no entry yet and builds cold. Within a + # lineage webpack checks every cached module against its source before + # reusing it, so cold and warm builds emit the same content-hashed + # bundles. Translation inlining is not cached at all. + - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - node-version: 'v20.x' - cache: 'yarn' - - run: yarn + path: .angular/cache + key: ng-build-${{ steps.deps.outputs.os }}-${{ runner.arch }}-${{ hashFiles('yarn.lock', 'angular.json', '.nvmrc') }}-${{ github.sha }} + restore-keys: | + ng-build-${{ steps.deps.outputs.os }}-${{ runner.arch }}-${{ hashFiles('yarn.lock', 'angular.json', '.nvmrc') }}- + - run: yarn build + + # Fails when the real build changes the shape of the deployed tree (a + # locale that stopped being emitted, a bundle that lost its locale + # suffix, a file that moved out of share-assets). Lazy-chunk count + # changes are reported but do not fail; see the helper for why. + - run: yarn build:manifest:check + + # Resolves every asset URL in the real 21-locale tree through the + # production nginx rules, and every lazy chunk in each locale's webpack + # runtime map to a file with that locale's suffix. Catches what the + # fixture cannot. + - run: yarn build:verify diff --git a/.github/workflows/ci_only.yml b/.github/workflows/ci_only.yml new file mode 100644 index 0000000000..2a5c40a6cd --- /dev/null +++ b/.github/workflows/ci_only.yml @@ -0,0 +1,110 @@ +name: ci_only + +# Dry run for pushes to main that only touch CI configuration. +# +# pushmain.yml has `paths-ignore: ['.github/**']`, so a merge that only changes +# workflows runs nothing: a broken bld_mvn.yml would first surface on the next +# feature merge, which would then fail to release. That ignore is worth keeping +# (a workflow tweak should not cut a release), so this runs the same pipeline +# with the side effects turned off: +# +# - pr.yml lint, test_yarn, bld_yarn, format (all check-only) +# - bld_test_rel_tag rel_tag: false -> no tag, no GitHub release, no Slack +# deploy: false -> no WAR upload, no Transifex push +# +# A push that mixes workflow and source changes is already handled by pushmain, +# so `detect` runs this only when EVERY changed path is under .github/. + +permissions: + contents: read + +on: + push: + branches: + - main + paths: + - '.github/**' + +concurrency: + group: ci-only-${{ github.ref }} + cancel-in-progress: true + +jobs: + detect: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + ci_only: ${{ steps.check.outputs.ci_only }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - name: Is every changed path under .github/? + id: check + env: + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} + run: | + # A branch's first push, or a force push past a pruned commit, reports + # an unusable `before`. Fall back to the push's own commit list. + if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then + changed=$(git show --name-only --format= "$AFTER") + else + changed=$(git diff --name-only "$BEFORE" "$AFTER") + fi + + echo "Changed paths:" + echo "$changed" | while IFS= read -r p; do echo " $p"; done + + if [ -z "$changed" ]; then + echo "No changed paths; nothing to dry run." + echo "ci_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if echo "$changed" | grep -qv '^\.github/'; then + echo "Push also changes non-CI files; pushmain covers it." + echo "ci_only=false" >> "$GITHUB_OUTPUT" + else + echo "CI-only push: running the pipeline as a dry run." + echo "ci_only=true" >> "$GITHUB_OUTPUT" + fi + + # Same jobs a pull request runs. + pr_checks: + needs: detect + if: needs.detect.outputs.ci_only == 'true' + uses: ./.github/workflows/pr.yml + # No secrets, and no write: nothing in pr.yml uses either. The format job + # checks the pushed commit and reports drift, as it does on every PR. + permissions: + contents: read + pull-requests: read + + # The release pipeline with every side effect disabled: proves lint and the + # maven WAR packaging still work. + release_dry_run: + needs: detect + if: needs.detect.outputs.ci_only == 'true' + uses: ./.github/workflows/bld_test_rel_tag.yml + # No secrets. With rel_tag, deploy and run_docker all false below, every job + # in that chain that reads one is gated off: push_i18n_source and bld_mvn's + # upload step both need `deploy`, bld_docker needs `run_docker`, rel_tag + # needs `rel_tag`. What actually runs is lint and the maven packaging, and + # the only secret they touch is the automatic GITHUB_TOKEN. + permissions: + checks: write + contents: read + issues: read + pull-requests: write + with: + ref: main + rel_tag: false + deploy: false diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml deleted file mode 100644 index 445de6088f..0000000000 --- a/.github/workflows/commit.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: commit - -# run lints even before someone submits a pr - -on: - push: - branches: - - '**' # run on any branch - - '!main' - - '!development' - -# cancel running job if another commit comes in -concurrency: - group: commit-${{ github.ref }}-1 - cancel-in-progress: true - -jobs: - lint: - uses: ./.github/workflows/lint.yml diff --git a/.github/workflows/deploy-ui-docs-pr.yml b/.github/workflows/deploy-ui-docs-pr.yml index 5d389fae98..853c51e908 100644 --- a/.github/workflows/deploy-ui-docs-pr.yml +++ b/.github/workflows/deploy-ui-docs-pr.yml @@ -16,6 +16,11 @@ on: jobs: build-and-deploy-preview: + # Skipped for pull requests from a fork. GitHub caps those runs at a + # read-only token, so the gh-pages push and the PR comment below would both + # 403 after burning a couple of minutes, and show an external contributor a + # red X they cannot act on. Not a required check, so skipping is safe. + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: @@ -54,14 +59,8 @@ jobs: echo "ticket_id=$TICKET_ID" >> "$GITHUB_OUTPUT" echo "Extracted ticket ID: $TICKET_ID" - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: '20' - cache: 'yarn' - - name: Install dependencies - run: yarn install --frozen-lockfile + uses: ./.github/actions/yarn-install - name: Build orcid-ui-docs with dynamic base-href run: | diff --git a/.github/workflows/deploy-ui-docs.yml b/.github/workflows/deploy-ui-docs.yml index d7cde3300e..d63eb0eead 100644 --- a/.github/workflows/deploy-ui-docs.yml +++ b/.github/workflows/deploy-ui-docs.yml @@ -32,14 +32,8 @@ jobs: with: fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: '20' - cache: 'yarn' - - name: Install dependencies - run: yarn install --frozen-lockfile + uses: ./.github/actions/yarn-install - name: Build orcid-ui-docs run: yarn build:ui-docs --base-href /orcid-angular/ diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000000..46ae1767d5 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,43 @@ +name: format + +# Formatting gate for pull requests. It only checks: an unformatted pull request +# fails this job, and nothing is pushed back to its branch. The remedy is the +# same for every author, whether the branch lives in this repository or in a +# fork: run `yarn format` and push. +# +# It must be a required status check, so unformatted code cannot reach main. + +permissions: + contents: read + +on: + workflow_call: + workflow_dispatch: + +jobs: + format: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + # The pull request's own head commit, not the default merge ref. + # Otherwise a PR is judged on its branch merged into main, and drift on + # main would fail an innocent PR with a message telling the author to + # format files they never touched. Falls back to the pushed commit when + # called from a push (the ci_only dry run). + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - uses: ./.github/actions/yarn-install + + - name: Check formatting + run: | + if ! yarn format:check; then + echo "::error::Some files are not formatted with Prettier (listed above). Run 'yarn format' and push the result." + exit 1 + fi diff --git a/.github/workflows/format_i18n.yml b/.github/workflows/format_i18n.yml deleted file mode 100644 index 1a84255349..0000000000 --- a/.github/workflows/format_i18n.yml +++ /dev/null @@ -1,43 +0,0 @@ -on: - workflow_call: - workflow_dispatch: - -name: format_i18n - -jobs: - format_i18n: - runs-on: ubuntu-latest - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - # token with write permissions to protected branches - # standard github token does not allow this - token: ${{ secrets.RELEASE_TOKEN }} - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 'v20.x' - cache: 'yarn' - - run: yarn - - run: yarn build:i18n - - - name: add and commit any files that have changed - id: add_and_commit - uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 - with: - message: '🤖 GITHUB ACTIONS i18n generator' - env: - GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} - - - name: cancel run if commit is done so the second run is used - run: | - if ${{ steps.add_and_commit.outputs.committed }};then - gh run cancel ${{ github.run_id }} - fi - env: - GH_TOKEN: ${{ github.token }} - diff --git a/.github/workflows/format_prettier.yml b/.github/workflows/format_prettier.yml deleted file mode 100644 index 14958285ad..0000000000 --- a/.github/workflows/format_prettier.yml +++ /dev/null @@ -1,45 +0,0 @@ -on: - workflow_call: - workflow_dispatch: - -name: format_prettier - -jobs: - format_prettier: - runs-on: ubuntu-latest - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 - with: - egress-policy: audit - - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - # token with write permissions to protected branches - # standard github token does not allow this - token: ${{ secrets.RELEASE_TOKEN }} - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: 'v20.x' - cache: 'yarn' - - run: yarn - - - run: yarn format - - - name: add and commit any files that have changed - id: add_and_commit - uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 - with: - message: '🤖 GITHUB ACTIONS format_prettier' - env: - GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} - - - name: cancel run if commit is done so the second run is used - run: | - if ${{ steps.add_and_commit.outputs.committed }};then - gh run cancel ${{ github.run_id }} - fi - env: - GH_TOKEN: ${{ github.token }} - diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2eec9f2408..6c9ebbb5e8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,11 +2,10 @@ name: lint # we don't pay for pre-commit-ci yet so need this for private repos +# Only runs pre-commit; it calls no GitHub API. Kept at read so this workflow can +# be called from pr.yml, where a fork pull request caps the run at read-only. permissions: - checks: write contents: read - issues: read - pull-requests: write on: workflow_call: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7c862e1355..262e9675e7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,7 +1,16 @@ name: pr -# read-only repo token -# no access to secrets +# Nothing in this chain asks for more than a fork pull request is granted, and +# no job in it is given a secret. +# +# On a `pull_request` whose head is a fork, GitHub caps the run at read-only. +# Requesting `contents: write` on a reusable-workflow call (`uses:`) exceeds that +# ceiling, and for `uses:` jobs GitHub validates the request when the run is +# created rather than capping it, which can fail the whole workflow with +# startup_failure. That would leave every required check stuck on "Expected" +# with no red X to explain it. +permissions: + contents: read on: pull_request: @@ -17,10 +26,22 @@ concurrency: cancel-in-progress: true jobs: + # Produces the required `lint / pre-commit` context. It lives here rather than + # in a push-triggered workflow so it also reports on pull requests from a fork, + # whose pushes fire in the fork and never reach this repository's check runs. + lint: + uses: ./.github/workflows/lint.yml + test_yarn: uses: ./.github/workflows/test_yarn.yml - # NOTE: this is just to test + # Prettier gate, check-only: an unformatted PR fails here and its author runs + # `yarn format`. Must be a required status check; see + # .github/workflows/format.yml. + format: + uses: ./.github/workflows/format.yml + + # Full production build: the only place the postbuild layout is exercised. bld_yarn: uses: ./.github/workflows/bld_yarn.yml # NOTE: codeql is only available on public repos or enterprise github accounts diff --git a/.github/workflows/pull-translations.yml b/.github/workflows/pull-translations.yml index 5647c087c9..40224b0050 100644 --- a/.github/workflows/pull-translations.yml +++ b/.github/workflows/pull-translations.yml @@ -40,10 +40,17 @@ jobs: fi - name: Install Transifex CLI + env: + # Pinned: the upstream install.sh always fetches the newest release, so + # an unrelated tx release could change behaviour on any run. This job + # holds a token with push rights. + TX_CLI_VERSION: '1.6.17' run: | - curl -sSfL https://raw.githubusercontent.com/transifex/cli/master/install.sh | bash mkdir -p "$HOME/bin" - install -m 0755 ./tx "$HOME/bin/tx" + curl -sSfL -o /tmp/tx.tar.gz \ + "https://github.com/transifex/cli/releases/download/v${TX_CLI_VERSION}/tx-linux-amd64.tar.gz" + tar -xzf /tmp/tx.tar.gz -C /tmp tx + install -m 0755 /tmp/tx "$HOME/bin/tx" echo "$HOME/bin" >> "$GITHUB_PATH" export PATH="$HOME/bin:$PATH" tx --version diff --git a/.github/workflows/push_i18n_source.yml b/.github/workflows/push_i18n_source.yml new file mode 100644 index 0000000000..222f32291d --- /dev/null +++ b/.github/workflows/push_i18n_source.yml @@ -0,0 +1,81 @@ +name: push_i18n_source + +# Extract the i18n source (src/locale/messages.xlf) from the code and push it to +# Transifex, so new/changed strings reach translators. +# +# messages.xlf is generated, not committed (see .gitignore). Translations come +# back through pull-translations.yml, which opens a PR from the `transifex` +# branch. +# +# Runs on main only, from bld_test_rel_tag.yml. + +permissions: + contents: read + +on: + workflow_call: + secrets: + TRANSIFEX_TOKEN: + required: false + description: Transifex API token, used by `tx push -s`. + + workflow_dispatch: + +jobs: + push_i18n_source: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + TX_TOKEN: ${{ secrets.TRANSIFEX_TOKEN }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: ./.github/actions/yarn-install + + # scripts/environment.runtime.js is generated and gitignored, and the build + # target that extract-i18n compiles lists it as a global script. Generating + # it first, as `yarn build`'s prebuild does, keeps extraction independent of + # whether the builder tolerates a missing script file. Two seconds. + - run: yarn build-runtime-env + + # ng extract-i18n + scripts/normalize-xlf.prebuild.ts. + # Writes src/locale/messages.xlf and the xx/lr/rl test languages. + - run: yarn build:i18n + + - name: Install Transifex CLI + env: + # Pinned: the upstream install.sh always fetches the newest release, so + # an unrelated tx release could change behaviour on any run. This job + # holds a token with push rights. + TX_CLI_VERSION: '1.6.17' + run: | + mkdir -p "$HOME/bin" + curl -sSfL -o /tmp/tx.tar.gz \ + "https://github.com/transifex/cli/releases/download/v${TX_CLI_VERSION}/tx-linux-amd64.tar.gz" + tar -xzf /tmp/tx.tar.gz -C /tmp tx + install -m 0755 /tmp/tx "$HOME/bin/tx" + echo "$HOME/bin" >> "$GITHUB_PATH" + export PATH="$HOME/bin:$PATH" + tx --version + + - name: Configure Transifex credentials + run: | + if [ -z "${TX_TOKEN:-}" ]; then + echo "TRANSIFEX_TOKEN secret is not set" >&2 + exit 1 + fi + mkdir -p "$HOME" + printf "%s\n" \ + "[https://app.transifex.com]" \ + "hostname = https://app.transifex.com" \ + "api_hostname = https://api.transifex.com" \ + "token = ${TX_TOKEN}" > "$HOME/.transifexrc" + chmod 600 "$HOME/.transifexrc" + echo "Created ~/.transifexrc" + + - name: Push source file to Transifex + run: TX_CONFIG=.tx/config tx push -s -r orcid-angular.messages-xlf diff --git a/.github/workflows/sast.yml b/.github/workflows/sast.yml index ca37c6d85a..513841ba57 100644 --- a/.github/workflows/sast.yml +++ b/.github/workflows/sast.yml @@ -45,7 +45,13 @@ jobs: SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} container: image: semgrep/semgrep - if: (github.actor != 'dependabot[bot]') + # Also skipped for fork pull requests: SEMGREP_APP_TOKEN is unavailable there, + # so the scan runs unconfigured, and `semgrep ci > /dev/null 2>&1` below hides + # any resulting failure. Not a required check. + if: >- + github.actor != 'dependabot[bot]' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 diff --git a/.github/workflows/test_yarn.yml b/.github/workflows/test_yarn.yml index 1e1a16f222..f7c70ac752 100644 --- a/.github/workflows/test_yarn.yml +++ b/.github/workflows/test_yarn.yml @@ -6,7 +6,17 @@ on: jobs: test_yarn: - runs-on: ubuntu-22.04 + # Same image as every other job. The yarn-install cache key includes the OS + # release, and nothing on main runs on 22.04 any more, so a 22.04 job could + # never restore a node_modules cache saved on main and installed cold on + # every pull request's first run. + # + # The 22.04 pin (2024-10) dated from puppeteer's downloaded Chrome, which + # Ubuntu 24.04's AppArmor user-namespace restriction blocks from using its + # sandbox. karma now launches the image's own google-chrome, installed at + # /opt/google/chrome/chrome, which Ubuntu ships an AppArmor profile for. + runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 @@ -14,9 +24,20 @@ jobs: egress-policy: audit - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + - uses: ./.github/actions/yarn-install + + # Angular's persistent cache, keyed apart from bld_yarn's (see there): a + # shared key made the two jobs race to save it and overwrite each other. + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - node-version: 'v20.x' - cache: 'yarn' - - run: yarn + path: .angular/cache + key: ${{ runner.os }}-ng-test-${{ hashFiles('yarn.lock') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-ng-test-${{ hashFiles('yarn.lock') }}- + + # Characterization tests for scripts/postbuild.ts. Node-only and a couple + # of seconds, so they run before the browser suite. + - run: yarn test:scripts + - run: yarn typecheck:scripts + - run: yarn test-headless:ci diff --git a/.gitignore b/.gitignore index 8b7bbc1e0e..68f9544f59 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,13 @@ cypress/plugins/token.json # Packages ignore package-lock to depend only on yarn package-lock.json scripts/environment.runtime.js -tx \ No newline at end of file +tx +# Generated by `yarn build:i18n` (ng extract-i18n + scripts/normalize-xlf.prebuild.ts). +# messages.xlf is the Transifex source: CI extracts it and runs `tx push -s` on +# every push to main (.github/workflows/push_i18n_source.yml). The xx/lr/rl test +# languages are derived from it. All four are rebuilt by the `prebuild` hook on +# every `yarn build`, so they are never committed. +src/locale/messages.xlf +src/locale/messages.xx.xlf +src/locale/messages.lr.xlf +src/locale/messages.rl.xlf diff --git a/.husky/pre-commit b/.husky/pre-commit index 16778b2d0c..2d2babb25b 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -4,5 +4,7 @@ node scripts/validate-branch-name.husky.js || exit 1 node scripts/check-focused-tests.husky.js || exit 1 +node scripts/prettier-staged.husky.js || exit 1 + # Fast pre-commit sanity check: TypeScript type-check only npx tsc --noEmit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fbebca6177..e60f0fb8cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,12 +27,6 @@ repos: exclude: '.ytt.yaml$' types: [yaml] - - repo: https://github.com/pre-commit/mirrors-prettier - rev: 'v2.7.1' - hooks: - - id: prettier - types_or: [css, javascript] - - repo: local hooks: - id: actionlint diff --git a/.prettierignore b/.prettierignore index 5d0ae63fb4..91dda20b82 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,4 +5,24 @@ dist .vscode/* .angular/* .github/* -src/assets/css/tailwind.css \ No newline at end of file +src/assets/css/tailwind.css + +# Generated at build time (gitignored). `prettier --write .` used to rewrite +# this on every run, which the old format_prettier job then committed. +scripts/environment.runtime.js + +# Byte-stable test fixtures: reformatting them would invalidate the golden +# layout manifests in scripts/__fixtures__/golden. +scripts/__fixtures__ + +# Generated translation artifacts (gitignored, see .gitignore) +src/locale/messages.xlf +src/locale/messages.xx.xlf +src/locale/messages.lr.xlf +src/locale/messages.rl.xlf + +# Local scratch / tooling directories that are not part of the app +**/.venv +coverage +temp +out-tsc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..7f1925282c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,86 @@ +# Contributing + +External contributions are welcome. This page describes what continuous +integration will do to your pull request, so nothing about it is a surprise. + +## Opening a pull request + +Branch naming, for people with write access to this repository, is +`/`, for example `lmendoa/PD-1234`. A pre-commit hook +enforces it. Contributors working from a fork can name their branch anything. + +Four checks must pass before a pull request can merge: + +| Check | What it does | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lint / pre-commit` | Runs [pre-commit](https://pre-commit.com/): YAML validity, no oversized files, shebangs on executables, and **actionlint** over `.github/workflows/`. This is the only thing that lints workflow files. | +| `format / format` | Checks that every file Prettier covers is formatted. It never changes your branch; see below. | +| `test_yarn / test_yarn` | Unit tests for the app and the five sibling projects under `projects/`, plus the postbuild characterization tests. | +| `bld_yarn / bld_yarn` | A full production build of all 21 locales, then checks that the emitted layout is still routable by the deployed nginx and Tomcat rules. | + +## Formatting + +Run Prettier before you push: + +```bash +yarn format +``` + +CI only checks. The `format` job runs `yarn format:check` on your pull +request's head commit and fails if anything is unformatted; it never pushes a +fix to your branch. This is the same for everyone, whether your branch is in +this repository or in a fork. + +Prettier does not look at `.github/**`; workflow files are covered by +actionlint inside `lint / pre-commit`. + +## Running things locally + +```bash +nvm use # Node version comes from .nvmrc +yarn --frozen-lockfile + +yarn build:i18n # generates messages.xlf and the xx/lr/rl test locales +yarn start # dev server against the QA backend + +yarn test-headless:ci # unit tests, uses your system Chrome +yarn test:scripts # postbuild characterization tests +yarn format:check # what the format job checks +``` + +Three notes that save time: + +- `src/locale/messages.xlf` and the `xx`, `lr` and `rl` locale files are + generated and deliberately not committed. Run `yarn build:i18n` once after a + fresh clone. Real translations come from Transifex through the + `pull-translations` workflow. +- A branch that still commits those four files gets a modify/delete conflict + on them when it merges `main`. Resolve it by deleting them: + `git rm src/locale/messages.xlf src/locale/messages.{xx,lr,rl}.xlf`. +- The dev server only recognises port **4200**. On any other port the app falls + back to its production environment and calls hosts that do not exist locally. + +## Changing the build output + +`scripts/postbuild.ts` shapes the directory tree that production actually +serves, and four systems outside this repository depend on that shape: the WAR, +a Tomcat rewrite valve, two nginx layers, and Cloudflare's URL-keyed cache. The +rules are written down, with their sources, in +`scripts/__tests__/helpers/routing-contract.ts`. + +If you change anything under `scripts/`, run: + +```bash +yarn test:scripts # characterization tests +yarn build && yarn build:manifest:check # the committed layout snapshot +yarn build:verify # resolves the real build through the nginx rules +``` + +A layout change is not forbidden, but it has to be deliberate. `build:manifest:check` +fails on any new or missing path so the diff shows up in review; re-record it +with `yarn build:manifest` once you are sure. + +## Reporting a security issue + +Please do not open a public issue. Follow the process at +. diff --git a/Dockerfile.build.yarn b/Dockerfile.build.yarn index 31e008da3f..e1dcaf45e5 100644 --- a/Dockerfile.build.yarn +++ b/Dockerfile.build.yarn @@ -12,6 +12,7 @@ COPY src/ src/ COPY scripts/ scripts/ +# the app imports projects/orcid-tokens/tokens.css and the orcid-ui libraries COPY projects/ projects/ # RUN apk add gettext diff --git a/README.md b/README.md index 263f04383d..a6d0d0f6ec 100644 --- a/README.md +++ b/README.md @@ -49,27 +49,28 @@ At the moment the only way to run the application on development time with a tra yarn build:i18n ``` +This writes `src/locale/messages.xlf` (the source strings) and the three test +languages `messages.xx.xlf`, `messages.lr.xlf` and `messages.rl.xlf`. None of +those four files are committed: they are regenerated by the `prebuild` hook on +every `yarn build`, and CI pushes `messages.xlf` to Transifex on every push to +`main`. Translations for real locales come back through the +`pull-translations` workflow, so run this step once after a fresh clone. + 2- Run the application on the language you want using one of the following options ``` -yarn start:en ## Runs the application in using the english properties -yarn start:fr ## Runs the application in using the french properties -yarn start:ar ## Runs the application in using the arabic properties -yarn start:es ## Runs the application in using the spanish properties -yarn start:ca ## ... and so on -yarn start:cs -yarn start:it -yarn start:ja -yarn start:ko -yarn start:pt -yarn start:ru -yarn start:uk -yarn start:zh_CN -yarn start:zh_TW -yarn start:lr ## These last fourth configurations are used only for testing -yarn start:rl -yarn start:xx -yarn start:source +yarn start:en ## Runs the application using the english translations +yarn start:fr ## Runs the application using the french translations +yarn start:ar ## Runs the application using the arabic translations +yarn start:xx ## Pseudo-locale, used only for testing i18n coverage +``` + +Only those four have a script. Every other locale declared in `angular.json` +(`ca cs es it ja ko lr pt rl ru src uk zh-CN zh-TW`) has a serve configuration +you can use directly, for example: + +``` +ng serve --configuration=es ``` ## Set up your source code editor (optional) diff --git a/angular.json b/angular.json index f97f0ae87b..9180687dc4 100644 --- a/angular.json +++ b/angular.json @@ -417,7 +417,7 @@ } }, "test": { - "builder": "@angular-devkit/build-angular:karma", + "builder": "@angular/build:karma", "options": { "polyfills": ["src/polyfills.ts", "zone.js/testing"], "tsConfig": "src/tsconfig.spec.json", @@ -429,8 +429,8 @@ "src/styles.scss" ], "scripts": [ - "/scripts/environment.runtime.js", - "/scripts/onetrust.runtime.js", + "scripts/environment.runtime.js", + "scripts/onetrust.runtime.js", "src/assets/print-view/fetch-orcid.js" ], "assets": ["src/favicon.ico", "src/assets", "src/manifest.json"], @@ -439,24 +439,6 @@ } } }, - "ng-orcid-e2e": { - "root": "e2e/", - "projectType": "application", - "architect": { - "e2e": { - "builder": "@angular-devkit/build-angular:protractor", - "options": { - "protractorConfig": "e2e/protractor.conf.js", - "devServerTarget": "ng-orcid:serve" - }, - "configurations": { - "production": { - "devServerTarget": "ng-orcid:serve:production" - } - } - } - } - }, "orcid-ui": { "projectType": "library", "root": "projects/orcid-ui", @@ -709,7 +691,12 @@ }, "cli": { "analytics": "5a5330f1-74b5-403d-bd97-0318d391a33a", - "packageManager": "yarn" + "packageManager": "yarn", + "cache": { + "enabled": true, + "environment": "all", + "path": ".angular/cache" + } }, "schematics": { "@schematics/angular:component": { diff --git a/package.json b/package.json index b1b1a0c15d..c9a69ef2cc 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,15 @@ "test": "npm run build-runtime-env && ng test", "test-headless": "npm run build-runtime-env && ng test --watch=false --browsers=ChromeHeadless", "test-headless:ci": "node scripts/check-focused-tests.husky.js && npm run build-runtime-env && ng test --watch=false --browsers=ChromeHeadless", + "test:scripts": "node --import tsx --test scripts/__tests__/postbuild.test.ts", + "test:scripts:update": "UPDATE_GOLDEN=1 node --import tsx --test scripts/__tests__/postbuild.test.ts", + "build:manifest": "tsx scripts/__tests__/helpers/layout-manifest.ts dist --write scripts/__fixtures__/golden/real-build.manifest.json", + "build:manifest:check": "tsx scripts/__tests__/helpers/layout-manifest.ts dist --check scripts/__fixtures__/golden/real-build.manifest.json", + "build:verify": "tsx scripts/__tests__/helpers/verify-dist.ts dist", + "typecheck:scripts": "tsc -p scripts/tsconfig.json --noEmit", "build-runtime-env": "ts-node -P scripts/tsconfig.json scripts/environment.prebuild.ts", "lint": "echo 'temporally disable Angular linter to until eslint update'", - "e2e": "ng e2e", - "prebuild": "rimraf dist && yarn build:i18n && yarn build:browserslist && npm run build-runtime-env", + "prebuild": "rimraf dist && npm run build-runtime-env && yarn build:i18n && yarn build:browserslist", "build": "ng build --configuration production --localize", "postbuild": "ts-node -P scripts/tsconfig.json scripts/postbuild.ts", "build:i18n:clone": "echo '⚠️ DEPRECATED: Cloning .properties files. This is part of the deprecated .properties workflow.' && ts-node -P scripts/tsconfig.json scripts/properties-clone.postbuild.ts", @@ -28,6 +33,7 @@ "build:local": "yarn run prebuild && ng build --localize", "build:local-tomcat": "npm run build-runtime-env && ng build --configuration=en", "format": "npm run lint && prettier --write .", + "format:check": "prettier --check .", "build:report": "webpack-bundle-analyzer dist/en/stats.json", "build:browserslist": "tsx scripts/browserlist.prebuild.ts", "build:fetchCombineWordpressCss": "ts-node -P scripts/tsconfig.json scripts/fetch-combine-wordpress-css.prebuild", @@ -59,6 +65,7 @@ "bowser": "^2.11.0", "browserslist": "^4.28.0", "browserslist-useragent-regexp": "^4.1.3", + "buffer": "^5.7.1", "core-js": "^3.6.5", "del": "^6.0.0", "gulp-clean": "^0.4.0", @@ -85,10 +92,8 @@ "@angular/compiler-cli": "20.3.27", "@angular/language-service": "20.3.27", "@types/jasmine": "~3.6.0", - "@types/jasminewd2": "~2.0.3", "@types/lodash": "^4.14.168", - "@types/node": "^12.11.1", - "@types/puppeteer": "^2.0.0", + "@types/node": "^22", "@types/xml2js": "^0.4.4", "caniuse-lite": "^1.0.30001733", "git-repo-info": "^2.1.1", @@ -96,15 +101,12 @@ "gulp": "^4.0.2", "husky": "^9.1.7", "jasmine-core": "~3.8.0", - "jasmine-spec-reporter": "~5.0.0", "karma": "^6.3.16", "karma-chrome-launcher": "~3.2", "karma-coverage": "^2.2.1", - "karma-coverage-istanbul-reporter": "^2.1.0", "karma-jasmine": "~4.0.0", "ng-packagr": "^20.1.0", "prettier": "^2.0.5", - "puppeteer": "^23.11.1", "rimraf": "^3.0.0", "ts-node": "^8.5.2", "tslint": "~6.1.0", diff --git a/scripts/__fixtures__/README.md b/scripts/__fixtures__/README.md new file mode 100644 index 0000000000..f8c2c09187 --- /dev/null +++ b/scripts/__fixtures__/README.md @@ -0,0 +1,55 @@ +# Test fixtures for the postbuild harness + +These directories are inputs for `scripts/__tests__/postbuild.test.ts` +(`yarn test:scripts`). They are byte-stable on purpose and are listed in +`.prettierignore`: reformatting them would invalidate the golden layout +manifests in `golden/`. + +Note that no README lives inside `dist-webpack/` or `dist-esbuild/`. Those +directories are copied verbatim into the workspace `dist/`, so anything in them +shows up in the layout manifest as if the build had emitted it. + +--- + +# `dist-webpack` fixture + +A byte-sized miniature of the `dist/` tree that +`ng build --configuration production --localize` produces, laid out exactly as +`scripts/postbuild.ts` expects: `./dist//index.html` plus the locale's +hashed JS/CSS/font/image siblings, an `assets/` subtree, and `print-view/`. +Tests copy it into a temp `dist/`, run the real postbuild over it, and assert +the result still satisfies the deployed routing contract. `fetch-orcid.js` here +is a placeholder — postbuild's localize step overwrites it per locale. + +## Identical filenames, different contents + +Every locale ships the same hashed filenames (`main.3333333333333333.js`, …) +because webpack hashes the pre-localization chunk, so all locale builds collide +on name. Each JS file therefore carries a distinct `LOCALE:` marker: once +step 6 flattens every locale into one `share-assets/`, a test can read +`main.3333333333333333-fr.js` and prove it still holds `LOCALE:fr` and not some +other locale's bytes. That collision is the whole reason postbuild appends +`-`: Cloudflare caches by URL for 2h with no cookie in the cache key. +CSS, fonts and images are byte-identical across locales — not localized, so +their flattened copies overwrite each other harmlessly and keep no suffix. + +## Why `src` and `zh-CN` + +`zh-CN` exercises step 5, which renames it to `zh_CN` so the Tomcat rewrite +valve's `^[a-z]{2}(_[A-Za-z]{2})?$` locale pattern matches and deep links fall +back to `index.html`. `src` is the pseudo-locale that pattern cannot match +(three letters): built and shipped, but unroutable. That is pre-existing, so +the harness allowlists it rather than failing. + +# `golden` + +`postbuild-webpack.manifest.json` is the fixture's expected layout after +postbuild; `real-build.manifest.json` is a normalized snapshot of an actual +21-locale build, checked by `yarn build:manifest:check`. Hashes are collapsed so +the files stay stable across builds while the shape of the tree stays +reviewable. Re-record with `yarn test:scripts:update` and `yarn build:manifest` +respectively, and read the diff: every line is a change production will see. + +`yarn build:verify` complements both on the real build: it resolves every asset +URL through the production nginx rules and every lazy chunk in each locale's +webpack runtime map to a file carrying that locale's suffix. diff --git a/scripts/__fixtures__/dist-webpack/en/123.abcdefabcdefabcd.js b/scripts/__fixtures__/dist-webpack/en/123.abcdefabcdefabcd.js new file mode 100644 index 0000000000..5dbb665823 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/123.abcdefabcdefabcd.js @@ -0,0 +1 @@ +export const lazyRoute=1;/*LOCALE:en*/ diff --git a/scripts/__fixtures__/dist-webpack/en/3rdpartylicenses.txt b/scripts/__fixtures__/dist-webpack/en/3rdpartylicenses.txt new file mode 100644 index 0000000000..b60f88b99e --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/3rdpartylicenses.txt @@ -0,0 +1,2 @@ +MIT +Apache-2.0 diff --git a/scripts/__fixtures__/dist-webpack/en/44.aaaaaaaaaaaaaaaa.js b/scripts/__fixtures__/dist-webpack/en/44.aaaaaaaaaaaaaaaa.js new file mode 100644 index 0000000000..8abafdb5f5 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/44.aaaaaaaaaaaaaaaa.js @@ -0,0 +1 @@ +export const tinyChunk=1;/*LOCALE:en*/ diff --git a/scripts/__fixtures__/dist-webpack/en/NotoSans-Regular.6666666666666666.woff2 b/scripts/__fixtures__/dist-webpack/en/NotoSans-Regular.6666666666666666.woff2 new file mode 100644 index 0000000000..459c9f7ba6 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/NotoSans-Regular.6666666666666666.woff2 @@ -0,0 +1 @@ +wOF2-placeholder diff --git a/scripts/__fixtures__/dist-webpack/en/assets/en-only.txt b/scripts/__fixtures__/dist-webpack/en/assets/en-only.txt new file mode 100644 index 0000000000..2a0fe1b748 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/assets/en-only.txt @@ -0,0 +1 @@ +copied from dist/en/assets only diff --git a/scripts/__fixtures__/dist-webpack/en/assets/icons/favicon.ico b/scripts/__fixtures__/dist-webpack/en/assets/icons/favicon.ico new file mode 100644 index 0000000000..bef1948ff9 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/assets/icons/favicon.ico @@ -0,0 +1 @@ +ico-placeholder diff --git a/scripts/__fixtures__/dist-webpack/en/assets/img/hero.jpg b/scripts/__fixtures__/dist-webpack/en/assets/img/hero.jpg new file mode 100644 index 0000000000..c2423f55aa --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/assets/img/hero.jpg @@ -0,0 +1 @@ +jpeg-placeholder diff --git a/scripts/__fixtures__/dist-webpack/en/assets/vectors/orcid.logo.svg b/scripts/__fixtures__/dist-webpack/en/assets/vectors/orcid.logo.svg new file mode 100644 index 0000000000..d2e8351c9a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/assets/vectors/orcid.logo.svg @@ -0,0 +1 @@ + diff --git a/scripts/__fixtures__/dist-webpack/en/common.4444444444444444.js b/scripts/__fixtures__/dist-webpack/en/common.4444444444444444.js new file mode 100644 index 0000000000..615a6801cd --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/common.4444444444444444.js @@ -0,0 +1 @@ +export const shared=1;/*LOCALE:en*/ diff --git a/scripts/__fixtures__/dist-webpack/en/flags.7777777777777777.webp b/scripts/__fixtures__/dist-webpack/en/flags.7777777777777777.webp new file mode 100644 index 0000000000..46efe1bda1 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/flags.7777777777777777.webp @@ -0,0 +1 @@ +RIFF-webp-placeholder diff --git a/scripts/__fixtures__/dist-webpack/en/index.html b/scripts/__fixtures__/dist-webpack/en/index.html new file mode 100644 index 0000000000..ee9cf0460c --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/index.html @@ -0,0 +1,15 @@ + + + + + ORCID + + + + + + + + + + diff --git a/scripts/__fixtures__/dist-webpack/en/main.3333333333333333.js b/scripts/__fixtures__/dist-webpack/en/main.3333333333333333.js new file mode 100644 index 0000000000..aa9414c680 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/main.3333333333333333.js @@ -0,0 +1 @@ +a.e(123).then(()=>a(123));/*LOCALE:en*/ diff --git a/scripts/__fixtures__/dist-webpack/en/orcid.logo.8888888888888888.svg b/scripts/__fixtures__/dist-webpack/en/orcid.logo.8888888888888888.svg new file mode 100644 index 0000000000..d2e8351c9a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/orcid.logo.8888888888888888.svg @@ -0,0 +1 @@ + diff --git a/scripts/__fixtures__/dist-webpack/en/polyfills.1111111111111111.js b/scripts/__fixtures__/dist-webpack/en/polyfills.1111111111111111.js new file mode 100644 index 0000000000..2fadfeaa2e --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/polyfills.1111111111111111.js @@ -0,0 +1 @@ +globalThis.__polyfilled=1;/*LOCALE:en*/ diff --git a/scripts/__fixtures__/dist-webpack/en/print-view/cv-style.css b/scripts/__fixtures__/dist-webpack/en/print-view/cv-style.css new file mode 100644 index 0000000000..2d863fae7a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/print-view/cv-style.css @@ -0,0 +1 @@ +.cv-root{max-width:60em;margin:0 auto} diff --git a/scripts/__fixtures__/dist-webpack/en/print-view/fetch-orcid.js b/scripts/__fixtures__/dist-webpack/en/print-view/fetch-orcid.js new file mode 100644 index 0000000000..fe672a27be --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/print-view/fetch-orcid.js @@ -0,0 +1 @@ +// placeholder: overwritten per-locale by print-view-localize.postbuild.ts diff --git a/scripts/__fixtures__/dist-webpack/en/print-view/index.html b/scripts/__fixtures__/dist-webpack/en/print-view/index.html new file mode 100644 index 0000000000..db973642b0 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/print-view/index.html @@ -0,0 +1,39 @@ + + + + + ORCID Print View + + + + + + + +
+ +
+ +
+ +
+

Loading ORCID record...

+
+ + + + + diff --git a/scripts/__fixtures__/dist-webpack/en/runtime.0123456789abcdef.js b/scripts/__fixtures__/dist-webpack/en/runtime.0123456789abcdef.js new file mode 100644 index 0000000000..0e89dee4b0 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/runtime.0123456789abcdef.js @@ -0,0 +1 @@ +a.u=e=>(76===e?"common":e)+"."+{44:"aaaaaaaaaaaaaaaa",76:"4444444444444444",123:"abcdefabcdefabcd"}[e]+".js";a.p="";/*LOCALE:en*/ diff --git a/scripts/__fixtures__/dist-webpack/en/scripts.2222222222222222.js b/scripts/__fixtures__/dist-webpack/en/scripts.2222222222222222.js new file mode 100644 index 0000000000..f281b56ac2 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/scripts.2222222222222222.js @@ -0,0 +1 @@ +window.__vendorScript=1;/*LOCALE:en*/ diff --git a/scripts/__fixtures__/dist-webpack/en/styles.5555555555555555.css b/scripts/__fixtures__/dist-webpack/en/styles.5555555555555555.css new file mode 100644 index 0000000000..c20b9be88e --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/en/styles.5555555555555555.css @@ -0,0 +1,3 @@ +@font-face{font-family:Noto Sans;src:url(NotoSans-Regular.6666666666666666.woff2) format("woff2")} +:root{--iti-path-flags-1x:url(flags.7777777777777777.webp)} +body{margin:0;font-family:Noto Sans,sans-serif} diff --git a/scripts/__fixtures__/dist-webpack/fr/123.abcdefabcdefabcd.js b/scripts/__fixtures__/dist-webpack/fr/123.abcdefabcdefabcd.js new file mode 100644 index 0000000000..d37dc9fabb --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/123.abcdefabcdefabcd.js @@ -0,0 +1 @@ +export const lazyRoute=1;/*LOCALE:fr*/ diff --git a/scripts/__fixtures__/dist-webpack/fr/3rdpartylicenses.txt b/scripts/__fixtures__/dist-webpack/fr/3rdpartylicenses.txt new file mode 100644 index 0000000000..b60f88b99e --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/3rdpartylicenses.txt @@ -0,0 +1,2 @@ +MIT +Apache-2.0 diff --git a/scripts/__fixtures__/dist-webpack/fr/44.aaaaaaaaaaaaaaaa.js b/scripts/__fixtures__/dist-webpack/fr/44.aaaaaaaaaaaaaaaa.js new file mode 100644 index 0000000000..de293f6865 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/44.aaaaaaaaaaaaaaaa.js @@ -0,0 +1 @@ +export const tinyChunk=1;/*LOCALE:fr*/ diff --git a/scripts/__fixtures__/dist-webpack/fr/NotoSans-Regular.6666666666666666.woff2 b/scripts/__fixtures__/dist-webpack/fr/NotoSans-Regular.6666666666666666.woff2 new file mode 100644 index 0000000000..459c9f7ba6 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/NotoSans-Regular.6666666666666666.woff2 @@ -0,0 +1 @@ +wOF2-placeholder diff --git a/scripts/__fixtures__/dist-webpack/fr/assets/icons/favicon.ico b/scripts/__fixtures__/dist-webpack/fr/assets/icons/favicon.ico new file mode 100644 index 0000000000..bef1948ff9 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/assets/icons/favicon.ico @@ -0,0 +1 @@ +ico-placeholder diff --git a/scripts/__fixtures__/dist-webpack/fr/assets/img/hero.jpg b/scripts/__fixtures__/dist-webpack/fr/assets/img/hero.jpg new file mode 100644 index 0000000000..c2423f55aa --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/assets/img/hero.jpg @@ -0,0 +1 @@ +jpeg-placeholder diff --git a/scripts/__fixtures__/dist-webpack/fr/assets/vectors/orcid.logo.svg b/scripts/__fixtures__/dist-webpack/fr/assets/vectors/orcid.logo.svg new file mode 100644 index 0000000000..d2e8351c9a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/assets/vectors/orcid.logo.svg @@ -0,0 +1 @@ + diff --git a/scripts/__fixtures__/dist-webpack/fr/common.4444444444444444.js b/scripts/__fixtures__/dist-webpack/fr/common.4444444444444444.js new file mode 100644 index 0000000000..0614d691df --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/common.4444444444444444.js @@ -0,0 +1 @@ +export const shared=1;/*LOCALE:fr*/ diff --git a/scripts/__fixtures__/dist-webpack/fr/flags.7777777777777777.webp b/scripts/__fixtures__/dist-webpack/fr/flags.7777777777777777.webp new file mode 100644 index 0000000000..46efe1bda1 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/flags.7777777777777777.webp @@ -0,0 +1 @@ +RIFF-webp-placeholder diff --git a/scripts/__fixtures__/dist-webpack/fr/index.html b/scripts/__fixtures__/dist-webpack/fr/index.html new file mode 100644 index 0000000000..9afb27c99d --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/index.html @@ -0,0 +1,15 @@ + + + + + ORCID + + + + + + + + + + diff --git a/scripts/__fixtures__/dist-webpack/fr/main.3333333333333333.js b/scripts/__fixtures__/dist-webpack/fr/main.3333333333333333.js new file mode 100644 index 0000000000..df7eaa0e23 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/main.3333333333333333.js @@ -0,0 +1 @@ +a.e(123).then(()=>a(123));/*LOCALE:fr*/ diff --git a/scripts/__fixtures__/dist-webpack/fr/orcid.logo.8888888888888888.svg b/scripts/__fixtures__/dist-webpack/fr/orcid.logo.8888888888888888.svg new file mode 100644 index 0000000000..d2e8351c9a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/orcid.logo.8888888888888888.svg @@ -0,0 +1 @@ + diff --git a/scripts/__fixtures__/dist-webpack/fr/polyfills.1111111111111111.js b/scripts/__fixtures__/dist-webpack/fr/polyfills.1111111111111111.js new file mode 100644 index 0000000000..0dcacac763 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/polyfills.1111111111111111.js @@ -0,0 +1 @@ +globalThis.__polyfilled=1;/*LOCALE:fr*/ diff --git a/scripts/__fixtures__/dist-webpack/fr/print-view/cv-style.css b/scripts/__fixtures__/dist-webpack/fr/print-view/cv-style.css new file mode 100644 index 0000000000..2d863fae7a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/print-view/cv-style.css @@ -0,0 +1 @@ +.cv-root{max-width:60em;margin:0 auto} diff --git a/scripts/__fixtures__/dist-webpack/fr/print-view/fetch-orcid.js b/scripts/__fixtures__/dist-webpack/fr/print-view/fetch-orcid.js new file mode 100644 index 0000000000..fe672a27be --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/print-view/fetch-orcid.js @@ -0,0 +1 @@ +// placeholder: overwritten per-locale by print-view-localize.postbuild.ts diff --git a/scripts/__fixtures__/dist-webpack/fr/print-view/index.html b/scripts/__fixtures__/dist-webpack/fr/print-view/index.html new file mode 100644 index 0000000000..db973642b0 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/print-view/index.html @@ -0,0 +1,39 @@ + + + + + ORCID Print View + + + + + + + +
+ +
+ +
+ +
+

Loading ORCID record...

+
+ + + + + diff --git a/scripts/__fixtures__/dist-webpack/fr/runtime.0123456789abcdef.js b/scripts/__fixtures__/dist-webpack/fr/runtime.0123456789abcdef.js new file mode 100644 index 0000000000..f06ba62489 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/runtime.0123456789abcdef.js @@ -0,0 +1 @@ +a.u=e=>(76===e?"common":e)+"."+{44:"aaaaaaaaaaaaaaaa",76:"4444444444444444",123:"abcdefabcdefabcd"}[e]+".js";a.p="";/*LOCALE:fr*/ diff --git a/scripts/__fixtures__/dist-webpack/fr/scripts.2222222222222222.js b/scripts/__fixtures__/dist-webpack/fr/scripts.2222222222222222.js new file mode 100644 index 0000000000..c6bdbb0f23 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/scripts.2222222222222222.js @@ -0,0 +1 @@ +window.__vendorScript=1;/*LOCALE:fr*/ diff --git a/scripts/__fixtures__/dist-webpack/fr/styles.5555555555555555.css b/scripts/__fixtures__/dist-webpack/fr/styles.5555555555555555.css new file mode 100644 index 0000000000..c20b9be88e --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/fr/styles.5555555555555555.css @@ -0,0 +1,3 @@ +@font-face{font-family:Noto Sans;src:url(NotoSans-Regular.6666666666666666.woff2) format("woff2")} +:root{--iti-path-flags-1x:url(flags.7777777777777777.webp)} +body{margin:0;font-family:Noto Sans,sans-serif} diff --git a/scripts/__fixtures__/dist-webpack/src/123.abcdefabcdefabcd.js b/scripts/__fixtures__/dist-webpack/src/123.abcdefabcdefabcd.js new file mode 100644 index 0000000000..8ecd387587 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/123.abcdefabcdefabcd.js @@ -0,0 +1 @@ +export const lazyRoute=1;/*LOCALE:src*/ diff --git a/scripts/__fixtures__/dist-webpack/src/3rdpartylicenses.txt b/scripts/__fixtures__/dist-webpack/src/3rdpartylicenses.txt new file mode 100644 index 0000000000..b60f88b99e --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/3rdpartylicenses.txt @@ -0,0 +1,2 @@ +MIT +Apache-2.0 diff --git a/scripts/__fixtures__/dist-webpack/src/44.aaaaaaaaaaaaaaaa.js b/scripts/__fixtures__/dist-webpack/src/44.aaaaaaaaaaaaaaaa.js new file mode 100644 index 0000000000..f642a2e534 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/44.aaaaaaaaaaaaaaaa.js @@ -0,0 +1 @@ +export const tinyChunk=1;/*LOCALE:src*/ diff --git a/scripts/__fixtures__/dist-webpack/src/NotoSans-Regular.6666666666666666.woff2 b/scripts/__fixtures__/dist-webpack/src/NotoSans-Regular.6666666666666666.woff2 new file mode 100644 index 0000000000..459c9f7ba6 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/NotoSans-Regular.6666666666666666.woff2 @@ -0,0 +1 @@ +wOF2-placeholder diff --git a/scripts/__fixtures__/dist-webpack/src/assets/icons/favicon.ico b/scripts/__fixtures__/dist-webpack/src/assets/icons/favicon.ico new file mode 100644 index 0000000000..bef1948ff9 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/assets/icons/favicon.ico @@ -0,0 +1 @@ +ico-placeholder diff --git a/scripts/__fixtures__/dist-webpack/src/assets/img/hero.jpg b/scripts/__fixtures__/dist-webpack/src/assets/img/hero.jpg new file mode 100644 index 0000000000..c2423f55aa --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/assets/img/hero.jpg @@ -0,0 +1 @@ +jpeg-placeholder diff --git a/scripts/__fixtures__/dist-webpack/src/assets/vectors/orcid.logo.svg b/scripts/__fixtures__/dist-webpack/src/assets/vectors/orcid.logo.svg new file mode 100644 index 0000000000..d2e8351c9a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/assets/vectors/orcid.logo.svg @@ -0,0 +1 @@ + diff --git a/scripts/__fixtures__/dist-webpack/src/common.4444444444444444.js b/scripts/__fixtures__/dist-webpack/src/common.4444444444444444.js new file mode 100644 index 0000000000..6bd5ea08d5 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/common.4444444444444444.js @@ -0,0 +1 @@ +export const shared=1;/*LOCALE:src*/ diff --git a/scripts/__fixtures__/dist-webpack/src/flags.7777777777777777.webp b/scripts/__fixtures__/dist-webpack/src/flags.7777777777777777.webp new file mode 100644 index 0000000000..46efe1bda1 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/flags.7777777777777777.webp @@ -0,0 +1 @@ +RIFF-webp-placeholder diff --git a/scripts/__fixtures__/dist-webpack/src/index.html b/scripts/__fixtures__/dist-webpack/src/index.html new file mode 100644 index 0000000000..cc334137a7 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/index.html @@ -0,0 +1,15 @@ + + + + + ORCID + + + + + + + + + + diff --git a/scripts/__fixtures__/dist-webpack/src/main.3333333333333333.js b/scripts/__fixtures__/dist-webpack/src/main.3333333333333333.js new file mode 100644 index 0000000000..fc11331596 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/main.3333333333333333.js @@ -0,0 +1 @@ +a.e(123).then(()=>a(123));/*LOCALE:src*/ diff --git a/scripts/__fixtures__/dist-webpack/src/orcid.logo.8888888888888888.svg b/scripts/__fixtures__/dist-webpack/src/orcid.logo.8888888888888888.svg new file mode 100644 index 0000000000..d2e8351c9a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/orcid.logo.8888888888888888.svg @@ -0,0 +1 @@ + diff --git a/scripts/__fixtures__/dist-webpack/src/polyfills.1111111111111111.js b/scripts/__fixtures__/dist-webpack/src/polyfills.1111111111111111.js new file mode 100644 index 0000000000..c59ee05c1f --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/polyfills.1111111111111111.js @@ -0,0 +1 @@ +globalThis.__polyfilled=1;/*LOCALE:src*/ diff --git a/scripts/__fixtures__/dist-webpack/src/print-view/cv-style.css b/scripts/__fixtures__/dist-webpack/src/print-view/cv-style.css new file mode 100644 index 0000000000..2d863fae7a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/print-view/cv-style.css @@ -0,0 +1 @@ +.cv-root{max-width:60em;margin:0 auto} diff --git a/scripts/__fixtures__/dist-webpack/src/print-view/fetch-orcid.js b/scripts/__fixtures__/dist-webpack/src/print-view/fetch-orcid.js new file mode 100644 index 0000000000..fe672a27be --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/print-view/fetch-orcid.js @@ -0,0 +1 @@ +// placeholder: overwritten per-locale by print-view-localize.postbuild.ts diff --git a/scripts/__fixtures__/dist-webpack/src/print-view/index.html b/scripts/__fixtures__/dist-webpack/src/print-view/index.html new file mode 100644 index 0000000000..db973642b0 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/print-view/index.html @@ -0,0 +1,39 @@ + + + + + ORCID Print View + + + + + + + +
+ +
+ +
+ +
+

Loading ORCID record...

+
+ + + + + diff --git a/scripts/__fixtures__/dist-webpack/src/runtime.0123456789abcdef.js b/scripts/__fixtures__/dist-webpack/src/runtime.0123456789abcdef.js new file mode 100644 index 0000000000..3aa579b248 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/runtime.0123456789abcdef.js @@ -0,0 +1 @@ +a.u=e=>(76===e?"common":e)+"."+{44:"aaaaaaaaaaaaaaaa",76:"4444444444444444",123:"abcdefabcdefabcd"}[e]+".js";a.p="";/*LOCALE:src*/ diff --git a/scripts/__fixtures__/dist-webpack/src/scripts.2222222222222222.js b/scripts/__fixtures__/dist-webpack/src/scripts.2222222222222222.js new file mode 100644 index 0000000000..4348fbd8e7 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/scripts.2222222222222222.js @@ -0,0 +1 @@ +window.__vendorScript=1;/*LOCALE:src*/ diff --git a/scripts/__fixtures__/dist-webpack/src/styles.5555555555555555.css b/scripts/__fixtures__/dist-webpack/src/styles.5555555555555555.css new file mode 100644 index 0000000000..c20b9be88e --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/src/styles.5555555555555555.css @@ -0,0 +1,3 @@ +@font-face{font-family:Noto Sans;src:url(NotoSans-Regular.6666666666666666.woff2) format("woff2")} +:root{--iti-path-flags-1x:url(flags.7777777777777777.webp)} +body{margin:0;font-family:Noto Sans,sans-serif} diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/123.abcdefabcdefabcd.js b/scripts/__fixtures__/dist-webpack/zh-CN/123.abcdefabcdefabcd.js new file mode 100644 index 0000000000..33319d2c56 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/123.abcdefabcdefabcd.js @@ -0,0 +1 @@ +export const lazyRoute=1;/*LOCALE:zh-CN*/ diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/3rdpartylicenses.txt b/scripts/__fixtures__/dist-webpack/zh-CN/3rdpartylicenses.txt new file mode 100644 index 0000000000..b60f88b99e --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/3rdpartylicenses.txt @@ -0,0 +1,2 @@ +MIT +Apache-2.0 diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/44.aaaaaaaaaaaaaaaa.js b/scripts/__fixtures__/dist-webpack/zh-CN/44.aaaaaaaaaaaaaaaa.js new file mode 100644 index 0000000000..71e93cf548 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/44.aaaaaaaaaaaaaaaa.js @@ -0,0 +1 @@ +export const tinyChunk=1;/*LOCALE:zh-CN*/ diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/NotoSans-Regular.6666666666666666.woff2 b/scripts/__fixtures__/dist-webpack/zh-CN/NotoSans-Regular.6666666666666666.woff2 new file mode 100644 index 0000000000..459c9f7ba6 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/NotoSans-Regular.6666666666666666.woff2 @@ -0,0 +1 @@ +wOF2-placeholder diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/assets/icons/favicon.ico b/scripts/__fixtures__/dist-webpack/zh-CN/assets/icons/favicon.ico new file mode 100644 index 0000000000..bef1948ff9 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/assets/icons/favicon.ico @@ -0,0 +1 @@ +ico-placeholder diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/assets/img/hero.jpg b/scripts/__fixtures__/dist-webpack/zh-CN/assets/img/hero.jpg new file mode 100644 index 0000000000..c2423f55aa --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/assets/img/hero.jpg @@ -0,0 +1 @@ +jpeg-placeholder diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/assets/vectors/orcid.logo.svg b/scripts/__fixtures__/dist-webpack/zh-CN/assets/vectors/orcid.logo.svg new file mode 100644 index 0000000000..d2e8351c9a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/assets/vectors/orcid.logo.svg @@ -0,0 +1 @@ + diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/common.4444444444444444.js b/scripts/__fixtures__/dist-webpack/zh-CN/common.4444444444444444.js new file mode 100644 index 0000000000..7371d82f31 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/common.4444444444444444.js @@ -0,0 +1 @@ +export const shared=1;/*LOCALE:zh-CN*/ diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/flags.7777777777777777.webp b/scripts/__fixtures__/dist-webpack/zh-CN/flags.7777777777777777.webp new file mode 100644 index 0000000000..46efe1bda1 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/flags.7777777777777777.webp @@ -0,0 +1 @@ +RIFF-webp-placeholder diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/index.html b/scripts/__fixtures__/dist-webpack/zh-CN/index.html new file mode 100644 index 0000000000..544f9800ae --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/index.html @@ -0,0 +1,15 @@ + + + + + ORCID + + + + + + + + + + diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/main.3333333333333333.js b/scripts/__fixtures__/dist-webpack/zh-CN/main.3333333333333333.js new file mode 100644 index 0000000000..376c88b4f6 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/main.3333333333333333.js @@ -0,0 +1 @@ +a.e(123).then(()=>a(123));/*LOCALE:zh-CN*/ diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/orcid.logo.8888888888888888.svg b/scripts/__fixtures__/dist-webpack/zh-CN/orcid.logo.8888888888888888.svg new file mode 100644 index 0000000000..d2e8351c9a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/orcid.logo.8888888888888888.svg @@ -0,0 +1 @@ + diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/polyfills.1111111111111111.js b/scripts/__fixtures__/dist-webpack/zh-CN/polyfills.1111111111111111.js new file mode 100644 index 0000000000..3cc8b54da3 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/polyfills.1111111111111111.js @@ -0,0 +1 @@ +globalThis.__polyfilled=1;/*LOCALE:zh-CN*/ diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/print-view/cv-style.css b/scripts/__fixtures__/dist-webpack/zh-CN/print-view/cv-style.css new file mode 100644 index 0000000000..2d863fae7a --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/print-view/cv-style.css @@ -0,0 +1 @@ +.cv-root{max-width:60em;margin:0 auto} diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/print-view/fetch-orcid.js b/scripts/__fixtures__/dist-webpack/zh-CN/print-view/fetch-orcid.js new file mode 100644 index 0000000000..fe672a27be --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/print-view/fetch-orcid.js @@ -0,0 +1 @@ +// placeholder: overwritten per-locale by print-view-localize.postbuild.ts diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/print-view/index.html b/scripts/__fixtures__/dist-webpack/zh-CN/print-view/index.html new file mode 100644 index 0000000000..db973642b0 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/print-view/index.html @@ -0,0 +1,39 @@ + + + + + ORCID Print View + + + + + + + +
+ +
+ +
+ +
+

Loading ORCID record...

+
+ + + + + diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/runtime.0123456789abcdef.js b/scripts/__fixtures__/dist-webpack/zh-CN/runtime.0123456789abcdef.js new file mode 100644 index 0000000000..285ca9c276 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/runtime.0123456789abcdef.js @@ -0,0 +1 @@ +a.u=e=>(76===e?"common":e)+"."+{44:"aaaaaaaaaaaaaaaa",76:"4444444444444444",123:"abcdefabcdefabcd"}[e]+".js";a.p="";/*LOCALE:zh-CN*/ diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/scripts.2222222222222222.js b/scripts/__fixtures__/dist-webpack/zh-CN/scripts.2222222222222222.js new file mode 100644 index 0000000000..4b73bc9d71 --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/scripts.2222222222222222.js @@ -0,0 +1 @@ +window.__vendorScript=1;/*LOCALE:zh-CN*/ diff --git a/scripts/__fixtures__/dist-webpack/zh-CN/styles.5555555555555555.css b/scripts/__fixtures__/dist-webpack/zh-CN/styles.5555555555555555.css new file mode 100644 index 0000000000..c20b9be88e --- /dev/null +++ b/scripts/__fixtures__/dist-webpack/zh-CN/styles.5555555555555555.css @@ -0,0 +1,3 @@ +@font-face{font-family:Noto Sans;src:url(NotoSans-Regular.6666666666666666.woff2) format("woff2")} +:root{--iti-path-flags-1x:url(flags.7777777777777777.webp)} +body{margin:0;font-family:Noto Sans,sans-serif} diff --git a/scripts/__fixtures__/golden/postbuild-webpack.manifest.json b/scripts/__fixtures__/golden/postbuild-webpack.manifest.json new file mode 100644 index 0000000000..7f10e2f238 --- /dev/null +++ b/scripts/__fixtures__/golden/postbuild-webpack.manifest.json @@ -0,0 +1,55 @@ +{ + "en/index.html": 1, + "en/print-view/cv-style.css": 1, + "en/print-view/fetch-orcid.js": 1, + "en/print-view/index.html": 1, + "fr/index.html": 1, + "fr/print-view/cv-style.css": 1, + "fr/print-view/fetch-orcid.js": 1, + "fr/print-view/index.html": 1, + "share-assets/3rdpartylicenses.txt": 1, + "share-assets/NotoSans-Regular-.woff2": 1, + "share-assets/assets/en-only.txt": 1, + "share-assets/assets/icons/favicon.ico": 1, + "share-assets/assets/img/hero.jpg": 1, + "share-assets/assets/vectors/orcid.logo.svg": 1, + "share-assets/chunk--en.js": 2, + "share-assets/chunk--fr.js": 2, + "share-assets/chunk--src.js": 2, + "share-assets/chunk--zh-CN.js": 2, + "share-assets/common--en.js": 1, + "share-assets/common--fr.js": 1, + "share-assets/common--src.js": 1, + "share-assets/common--zh-CN.js": 1, + "share-assets/flags-.webp": 1, + "share-assets/main--en.js": 1, + "share-assets/main--fr.js": 1, + "share-assets/main--src.js": 1, + "share-assets/main--zh-CN.js": 1, + "share-assets/new-relic.runtime--en.js": 1, + "share-assets/new-relic.runtime--fr.js": 1, + "share-assets/new-relic.runtime--src.js": 1, + "share-assets/new-relic.runtime--zh-CN.js": 1, + "share-assets/orcid.logo-.svg": 1, + "share-assets/polyfills--en.js": 1, + "share-assets/polyfills--fr.js": 1, + "share-assets/polyfills--src.js": 1, + "share-assets/polyfills--zh-CN.js": 1, + "share-assets/runtime--en.js": 1, + "share-assets/runtime--fr.js": 1, + "share-assets/runtime--src.js": 1, + "share-assets/runtime--zh-CN.js": 1, + "share-assets/scripts--en.js": 1, + "share-assets/scripts--fr.js": 1, + "share-assets/scripts--src.js": 1, + "share-assets/scripts--zh-CN.js": 1, + "share-assets/styles-.css": 1, + "src/index.html": 1, + "src/print-view/cv-style.css": 1, + "src/print-view/fetch-orcid.js": 1, + "src/print-view/index.html": 1, + "zh_CN/index.html": 1, + "zh_CN/print-view/cv-style.css": 1, + "zh_CN/print-view/fetch-orcid.js": 1, + "zh_CN/print-view/index.html": 1 +} diff --git a/scripts/__fixtures__/golden/real-build.manifest.json b/scripts/__fixtures__/golden/real-build.manifest.json new file mode 100644 index 0000000000..2026245a09 --- /dev/null +++ b/scripts/__fixtures__/golden/real-build.manifest.json @@ -0,0 +1,324 @@ +{ + "ar/index.html": 1, + "ar/print-view/cv-style.css": 1, + "ar/print-view/fetch-orcid.js": 1, + "ar/print-view/index.html": 1, + "ca/index.html": 1, + "ca/print-view/cv-style.css": 1, + "ca/print-view/fetch-orcid.js": 1, + "ca/print-view/index.html": 1, + "cs/index.html": 1, + "cs/print-view/cv-style.css": 1, + "cs/print-view/fetch-orcid.js": 1, + "cs/print-view/index.html": 1, + "de/index.html": 1, + "de/print-view/cv-style.css": 1, + "de/print-view/fetch-orcid.js": 1, + "de/print-view/index.html": 1, + "en/index.html": 1, + "en/print-view/cv-style.css": 1, + "en/print-view/fetch-orcid.js": 1, + "en/print-view/index.html": 1, + "es/index.html": 1, + "es/print-view/cv-style.css": 1, + "es/print-view/fetch-orcid.js": 1, + "es/print-view/index.html": 1, + "fr/index.html": 1, + "fr/print-view/cv-style.css": 1, + "fr/print-view/fetch-orcid.js": 1, + "fr/print-view/index.html": 1, + "it/index.html": 1, + "it/print-view/cv-style.css": 1, + "it/print-view/fetch-orcid.js": 1, + "it/print-view/index.html": 1, + "ja/index.html": 1, + "ja/print-view/cv-style.css": 1, + "ja/print-view/fetch-orcid.js": 1, + "ja/print-view/index.html": 1, + "ko/index.html": 1, + "ko/print-view/cv-style.css": 1, + "ko/print-view/fetch-orcid.js": 1, + "ko/print-view/index.html": 1, + "lr/index.html": 1, + "lr/print-view/cv-style.css": 1, + "lr/print-view/fetch-orcid.js": 1, + "lr/print-view/index.html": 1, + "pl/index.html": 1, + "pl/print-view/cv-style.css": 1, + "pl/print-view/fetch-orcid.js": 1, + "pl/print-view/index.html": 1, + "pt/index.html": 1, + "pt/print-view/cv-style.css": 1, + "pt/print-view/fetch-orcid.js": 1, + "pt/print-view/index.html": 1, + "rl/index.html": 1, + "rl/print-view/cv-style.css": 1, + "rl/print-view/fetch-orcid.js": 1, + "rl/print-view/index.html": 1, + "ru/index.html": 1, + "ru/print-view/cv-style.css": 1, + "ru/print-view/fetch-orcid.js": 1, + "ru/print-view/index.html": 1, + "share-assets/3rdpartylicenses.txt": 1, + "share-assets/NotoSans-Black-.woff": 1, + "share-assets/NotoSans-Black-.woff2": 1, + "share-assets/NotoSans-Bold-.woff": 1, + "share-assets/NotoSans-Bold-.woff2": 1, + "share-assets/NotoSans-ExtraBold-.woff": 1, + "share-assets/NotoSans-ExtraBold-.woff2": 1, + "share-assets/NotoSans-ExtraLight-.woff": 1, + "share-assets/NotoSans-ExtraLight-.woff2": 1, + "share-assets/NotoSans-Light-.woff": 1, + "share-assets/NotoSans-Light-.woff2": 1, + "share-assets/NotoSans-Medium-.woff": 1, + "share-assets/NotoSans-Medium-.woff2": 1, + "share-assets/NotoSans-Regular-.woff": 1, + "share-assets/NotoSans-Regular-.woff2": 1, + "share-assets/NotoSans-SemiBold-.woff": 1, + "share-assets/NotoSans-SemiBold-.woff2": 1, + "share-assets/NotoSans-Thin-.woff": 1, + "share-assets/NotoSans-Thin-.woff2": 1, + "share-assets/assets/css/tailwind.css": 1, + "share-assets/assets/icons/favicon.ico": 1, + "share-assets/assets/img/hero-header-image-1024.jpg": 1, + "share-assets/assets/img/hero-header-image-1140.jpg": 1, + "share-assets/assets/img/hero-header-image-2560.jpg": 1, + "share-assets/assets/img/hero-header-image-3000.jpg": 1, + "share-assets/assets/img/hero-header-image-425w.jpg": 1, + "share-assets/assets/img/hero-header-image-768w.jpg": 1, + "share-assets/assets/img/hero-header-image.png": 1, + "share-assets/assets/img/orcid-og-image.png": 1, + "share-assets/assets/img/organizations.jpg": 1, + "share-assets/assets/img/organizations.png": 1, + "share-assets/assets/scripts/latexParse.js": 1, + "share-assets/assets/scss/index.js": 1, + "share-assets/assets/vectors/CSPs/csp-placeholder.svg": 1, + "share-assets/assets/vectors/arrow-right.svg": 1, + "share-assets/assets/vectors/bullet-point.svg": 1, + "share-assets/assets/vectors/cc-zero.svg": 1, + "share-assets/assets/vectors/check-window.svg": 1, + "share-assets/assets/vectors/draggable.svg": 1, + "share-assets/assets/vectors/email-unverfied-icon.svg": 1, + "share-assets/assets/vectors/email-verified-icon.svg": 1, + "share-assets/assets/vectors/facebook.svg": 1, + "share-assets/assets/vectors/favicon.svg": 1, + "share-assets/assets/vectors/featured-item-star-icon.svg": 1, + "share-assets/assets/vectors/google.svg": 1, + "share-assets/assets/vectors/helphero/HH-btn-arrow.svg": 1, + "share-assets/assets/vectors/helphero/HH-record-ready.svg": 1, + "share-assets/assets/vectors/helphero/HH-verify-email.svg": 1, + "share-assets/assets/vectors/institutional-access-no-background.svg": 1, + "share-assets/assets/vectors/institutional-access.svg": 1, + "share-assets/assets/vectors/institutional-generic-logo.svg": 1, + "share-assets/assets/vectors/mat-baseline-check_circle.svg": 1, + "share-assets/assets/vectors/mat-baseline-check_circle_outline.svg": 1, + "share-assets/assets/vectors/no-results.svg": 1, + "share-assets/assets/vectors/notification-button-active.svg": 1, + "share-assets/assets/vectors/notification-button.svg": 1, + "share-assets/assets/vectors/orcid.logo-deactivated.icon.svg": 1, + "share-assets/assets/vectors/orcid.logo-deprecated.icon.svg": 1, + "share-assets/assets/vectors/orcid.logo-locked.icon.svg": 1, + "share-assets/assets/vectors/orcid.logo.background.svg": 1, + "share-assets/assets/vectors/orcid.logo.black.icon.svg": 1, + "share-assets/assets/vectors/orcid.logo.icon.svg": 1, + "share-assets/assets/vectors/orcid.logo.svg": 1, + "share-assets/assets/vectors/organization-no-logo.svg": 1, + "share-assets/assets/vectors/personal-email-icon.svg": 1, + "share-assets/assets/vectors/professional-email-icon.svg": 1, + "share-assets/assets/vectors/profile-icon.svg": 1, + "share-assets/assets/vectors/profile-not-verified.svg": 1, + "share-assets/assets/vectors/registration-affiliation-icon.svg": 1, + "share-assets/assets/vectors/social/Bluesky.svg": 1, + "share-assets/assets/vectors/social/Facebook.svg": 1, + "share-assets/assets/vectors/social/Github.svg": 1, + "share-assets/assets/vectors/social/Linkedin.svg": 1, + "share-assets/assets/vectors/social/Mastodon.svg": 1, + "share-assets/assets/vectors/social/RSS.svg": 1, + "share-assets/assets/vectors/social/Vimeo.svg": 1, + "share-assets/assets/vectors/social/Youtube.svg": 1, + "share-assets/assets/vectors/thumbs-up.svg": 1, + "share-assets/assets/vectors/translucent-arrow.svg": 1, + "share-assets/assets/vectors/undefined-email-icon.svg": 1, + "share-assets/assets/vectors/verified-outline-icon.svg": 1, + "share-assets/chunk--ar.js": 40, + "share-assets/chunk--ca.js": 40, + "share-assets/chunk--cs.js": 40, + "share-assets/chunk--de.js": 40, + "share-assets/chunk--en.js": 40, + "share-assets/chunk--es.js": 40, + "share-assets/chunk--fr.js": 40, + "share-assets/chunk--it.js": 40, + "share-assets/chunk--ja.js": 40, + "share-assets/chunk--ko.js": 40, + "share-assets/chunk--lr.js": 40, + "share-assets/chunk--pl.js": 40, + "share-assets/chunk--pt.js": 40, + "share-assets/chunk--rl.js": 40, + "share-assets/chunk--ru.js": 40, + "share-assets/chunk--src.js": 40, + "share-assets/chunk--tr.js": 40, + "share-assets/chunk--uk.js": 40, + "share-assets/chunk--xx.js": 40, + "share-assets/chunk--zh-CN.js": 40, + "share-assets/chunk--zh-TW.js": 40, + "share-assets/common--ar.js": 1, + "share-assets/common--ca.js": 1, + "share-assets/common--cs.js": 1, + "share-assets/common--de.js": 1, + "share-assets/common--en.js": 1, + "share-assets/common--es.js": 1, + "share-assets/common--fr.js": 1, + "share-assets/common--it.js": 1, + "share-assets/common--ja.js": 1, + "share-assets/common--ko.js": 1, + "share-assets/common--lr.js": 1, + "share-assets/common--pl.js": 1, + "share-assets/common--pt.js": 1, + "share-assets/common--rl.js": 1, + "share-assets/common--ru.js": 1, + "share-assets/common--src.js": 1, + "share-assets/common--tr.js": 1, + "share-assets/common--uk.js": 1, + "share-assets/common--xx.js": 1, + "share-assets/common--zh-CN.js": 1, + "share-assets/common--zh-TW.js": 1, + "share-assets/flags-.webp": 1, + "share-assets/flags@2x-.webp": 1, + "share-assets/hero-header-image-1024-.jpg": 1, + "share-assets/hero-header-image-1140-.jpg": 1, + "share-assets/hero-header-image-2560-.jpg": 1, + "share-assets/hero-header-image-3000-.jpg": 1, + "share-assets/hero-header-image-425w-.jpg": 1, + "share-assets/hero-header-image-768w-.jpg": 1, + "share-assets/main--ar.js": 1, + "share-assets/main--ca.js": 1, + "share-assets/main--cs.js": 1, + "share-assets/main--de.js": 1, + "share-assets/main--en.js": 1, + "share-assets/main--es.js": 1, + "share-assets/main--fr.js": 1, + "share-assets/main--it.js": 1, + "share-assets/main--ja.js": 1, + "share-assets/main--ko.js": 1, + "share-assets/main--lr.js": 1, + "share-assets/main--pl.js": 1, + "share-assets/main--pt.js": 1, + "share-assets/main--rl.js": 1, + "share-assets/main--ru.js": 1, + "share-assets/main--src.js": 1, + "share-assets/main--tr.js": 1, + "share-assets/main--uk.js": 1, + "share-assets/main--xx.js": 1, + "share-assets/main--zh-CN.js": 1, + "share-assets/main--zh-TW.js": 1, + "share-assets/new-relic.runtime--ar.js": 1, + "share-assets/new-relic.runtime--ca.js": 1, + "share-assets/new-relic.runtime--cs.js": 1, + "share-assets/new-relic.runtime--de.js": 1, + "share-assets/new-relic.runtime--en.js": 1, + "share-assets/new-relic.runtime--es.js": 1, + "share-assets/new-relic.runtime--fr.js": 1, + "share-assets/new-relic.runtime--it.js": 1, + "share-assets/new-relic.runtime--ja.js": 1, + "share-assets/new-relic.runtime--ko.js": 1, + "share-assets/new-relic.runtime--lr.js": 1, + "share-assets/new-relic.runtime--pl.js": 1, + "share-assets/new-relic.runtime--pt.js": 1, + "share-assets/new-relic.runtime--rl.js": 1, + "share-assets/new-relic.runtime--ru.js": 1, + "share-assets/new-relic.runtime--src.js": 1, + "share-assets/new-relic.runtime--tr.js": 1, + "share-assets/new-relic.runtime--uk.js": 1, + "share-assets/new-relic.runtime--xx.js": 1, + "share-assets/new-relic.runtime--zh-CN.js": 1, + "share-assets/new-relic.runtime--zh-TW.js": 1, + "share-assets/orcid.logo.background-.svg": 1, + "share-assets/polyfills--ar.js": 1, + "share-assets/polyfills--ca.js": 1, + "share-assets/polyfills--cs.js": 1, + "share-assets/polyfills--de.js": 1, + "share-assets/polyfills--en.js": 1, + "share-assets/polyfills--es.js": 1, + "share-assets/polyfills--fr.js": 1, + "share-assets/polyfills--it.js": 1, + "share-assets/polyfills--ja.js": 1, + "share-assets/polyfills--ko.js": 1, + "share-assets/polyfills--lr.js": 1, + "share-assets/polyfills--pl.js": 1, + "share-assets/polyfills--pt.js": 1, + "share-assets/polyfills--rl.js": 1, + "share-assets/polyfills--ru.js": 1, + "share-assets/polyfills--src.js": 1, + "share-assets/polyfills--tr.js": 1, + "share-assets/polyfills--uk.js": 1, + "share-assets/polyfills--xx.js": 1, + "share-assets/polyfills--zh-CN.js": 1, + "share-assets/polyfills--zh-TW.js": 1, + "share-assets/runtime--ar.js": 1, + "share-assets/runtime--ca.js": 1, + "share-assets/runtime--cs.js": 1, + "share-assets/runtime--de.js": 1, + "share-assets/runtime--en.js": 1, + "share-assets/runtime--es.js": 1, + "share-assets/runtime--fr.js": 1, + "share-assets/runtime--it.js": 1, + "share-assets/runtime--ja.js": 1, + "share-assets/runtime--ko.js": 1, + "share-assets/runtime--lr.js": 1, + "share-assets/runtime--pl.js": 1, + "share-assets/runtime--pt.js": 1, + "share-assets/runtime--rl.js": 1, + "share-assets/runtime--ru.js": 1, + "share-assets/runtime--src.js": 1, + "share-assets/runtime--tr.js": 1, + "share-assets/runtime--uk.js": 1, + "share-assets/runtime--xx.js": 1, + "share-assets/runtime--zh-CN.js": 1, + "share-assets/runtime--zh-TW.js": 1, + "share-assets/scripts--ar.js": 1, + "share-assets/scripts--ca.js": 1, + "share-assets/scripts--cs.js": 1, + "share-assets/scripts--de.js": 1, + "share-assets/scripts--en.js": 1, + "share-assets/scripts--es.js": 1, + "share-assets/scripts--fr.js": 1, + "share-assets/scripts--it.js": 1, + "share-assets/scripts--ja.js": 1, + "share-assets/scripts--ko.js": 1, + "share-assets/scripts--lr.js": 1, + "share-assets/scripts--pl.js": 1, + "share-assets/scripts--pt.js": 1, + "share-assets/scripts--rl.js": 1, + "share-assets/scripts--ru.js": 1, + "share-assets/scripts--src.js": 1, + "share-assets/scripts--tr.js": 1, + "share-assets/scripts--uk.js": 1, + "share-assets/scripts--xx.js": 1, + "share-assets/scripts--zh-CN.js": 1, + "share-assets/scripts--zh-TW.js": 1, + "share-assets/styles-.css": 1, + "src/index.html": 1, + "src/print-view/cv-style.css": 1, + "src/print-view/fetch-orcid.js": 1, + "src/print-view/index.html": 1, + "tr/index.html": 1, + "tr/print-view/cv-style.css": 1, + "tr/print-view/fetch-orcid.js": 1, + "tr/print-view/index.html": 1, + "uk/index.html": 1, + "uk/print-view/cv-style.css": 1, + "uk/print-view/fetch-orcid.js": 1, + "uk/print-view/index.html": 1, + "xx/index.html": 1, + "xx/print-view/cv-style.css": 1, + "xx/print-view/fetch-orcid.js": 1, + "xx/print-view/index.html": 1, + "zh_CN/index.html": 1, + "zh_CN/print-view/cv-style.css": 1, + "zh_CN/print-view/fetch-orcid.js": 1, + "zh_CN/print-view/index.html": 1, + "zh_TW/index.html": 1, + "zh_TW/print-view/cv-style.css": 1, + "zh_TW/print-view/fetch-orcid.js": 1, + "zh_TW/print-view/index.html": 1 +} diff --git a/scripts/__fixtures__/print-view/README.md b/scripts/__fixtures__/print-view/README.md new file mode 100644 index 0000000000..158e0ba9a6 --- /dev/null +++ b/scripts/__fixtures__/print-view/README.md @@ -0,0 +1,20 @@ +# print-view localizer fixtures + +Inputs for `scripts/print-view-localize.postbuild.ts`, which Babel-inlines +`$localize` once per `dist//` folder. Tests point it here instead of at +the real 1400-line source and the 21 x ~20k-line `src/locale/messages.*.xlf`. + +- `fetch-orcid.js` — stands in for `src/assets/print-view/fetch-orcid.js`: + `printView.biography`, `printView.worksCount` (one `${count}:count:` + placeholder), `printView.lastModified`. +- `messages.fr.xlf` — locale `fr`, whose folder name is its own XLF suffix. +- `messages.zh_CN.xlf` — folder `zh-CN` via `XLF_LOCALE_MAP`, like + `pl`->`pl_PL`, `tr`->`tr_TR`, `zh-TW`->`zh_TW`. +- `messages.source.xlf` — folder `src`, mapped to suffix `source`. + +No `messages.en.xlf` on purpose: `en` is the source locale, so +`getTranslations` returns null and the English source is inlined with +`missingTranslation: 'ignore'`; a stray en file would move the test onto the +other code path. `worksCount` uses a real `` element, never +literal `${expr}:name:` text — `scripts/normalize-xlf.prebuild.ts` throws on +that syntax, and the translate plugin needs a named placeholder. diff --git a/scripts/__fixtures__/print-view/fetch-orcid.js b/scripts/__fixtures__/print-view/fetch-orcid.js new file mode 100644 index 0000000000..86f71978e2 --- /dev/null +++ b/scripts/__fixtures__/print-view/fetch-orcid.js @@ -0,0 +1,42 @@ +// Stand-in for src/assets/print-view/fetch-orcid.js: same shape, three messages. +// The real file is ~1400 lines; the localizer only cares about the $localize +// calls, so a trimmed copy keeps the expected output small enough to assert on. + +// Fallback for non-localized builds (development / unit tests). +// In production, all $localize calls are statically inlined by the Angular build. +if (typeof $localize === 'undefined') { + self.$localize = function (messageParts) { + var substitutions = Array.prototype.slice.call(arguments, 1) + // Strip Angular localize metadata: `:description@@id:` (first part) or `:PLACEHOLDER:` (rest) + var parts = Array.prototype.map.call(messageParts, function (part) { + return typeof part === 'string' ? part.replace(/^:[^:]*:/, '') : part + }) + return parts.reduce(function (result, part, i) { + return ( + result + + (substitutions[i - 1] != null ? substitutions[i - 1] : '') + + part + ) + }) + } +} + +// All user-visible strings. Values are replaced per-locale by the Angular localize pipeline at build time. +const STRINGS = { + biography: $localize`:@@printView.biography:Biography`, + lastModified: $localize`:@@printView.lastModified:Last modified`, +} + +// `${expr}:name:` is the placeholder form the real file uses (see +// printView.peerReviewSummary); the XLF must carry it as an . +function worksHeading(count) { + return $localize`:@@printView.worksCount:${count}:count: works` +} + +function render(record) { + const heading = document.createElement('h2') + heading.textContent = STRINGS.biography + heading.setAttribute('data-works', worksHeading(record.works.length)) + document.body.appendChild(heading) + return STRINGS.lastModified + ': ' + record.lastModified +} diff --git a/scripts/__fixtures__/print-view/messages.fr.xlf b/scripts/__fixtures__/print-view/messages.fr.xlf new file mode 100644 index 0000000000..bafc069875 --- /dev/null +++ b/scripts/__fixtures__/print-view/messages.fr.xlf @@ -0,0 +1,18 @@ + + + + + Biography + Biographie + + + works + travaux + + + Last modified + Dernière modification + + + + diff --git a/scripts/__fixtures__/print-view/messages.source.xlf b/scripts/__fixtures__/print-view/messages.source.xlf new file mode 100644 index 0000000000..13b41306a0 --- /dev/null +++ b/scripts/__fixtures__/print-view/messages.source.xlf @@ -0,0 +1,18 @@ + + + + + Biography + Biography + + + works + works + + + Last modified + Last modified + + + + diff --git a/scripts/__fixtures__/print-view/messages.zh_CN.xlf b/scripts/__fixtures__/print-view/messages.zh_CN.xlf new file mode 100644 index 0000000000..1cc7c47cc5 --- /dev/null +++ b/scripts/__fixtures__/print-view/messages.zh_CN.xlf @@ -0,0 +1,18 @@ + + + + + Biography + 简历 + + + works + 项作品 + + + Last modified + 上次修改时间 + + + + diff --git a/scripts/__tests__/helpers/layout-manifest.ts b/scripts/__tests__/helpers/layout-manifest.ts new file mode 100644 index 0000000000..7d8896157f --- /dev/null +++ b/scripts/__tests__/helpers/layout-manifest.ts @@ -0,0 +1,227 @@ +// A normalized snapshot of a built `dist/` tree, committed as a golden file so +// that any change to the postbuild layout shows up as a reviewable diff. +// +// Content hashes change on every build, which makes a raw file list useless as +// a golden. Collapsing hashes to a fixed marker preserves the SHAPE of the tree +// -- which locale directories exist, how many chunks each holds, what ends up +// flat in share-assets -- and the shape is precisely what the nginx and Tomcat +// routing rules in ./routing-contract.ts depend on. +// +// Usable as a library (buildManifest) or as a CLI: +// tsx scripts/__tests__/helpers/layout-manifest.ts ./dist --write +// tsx scripts/__tests__/helpers/layout-manifest.ts ./dist --check + +import * as fs from 'fs' +import * as path from 'path' + +export type LayoutManifest = Record + +// `.` or `-` then exactly 16 lowercase hex: a webpack content hash, as in +// main.9f2c1a0b7d4e6538-en.js. The lookahead is what enforces "exactly" -- a +// longer hex run is left alone rather than half-replaced. +const WEBPACK_HASH_RE = /[.-][0-9a-f]{16}(?![0-9a-f])/g + +// `-` then exactly 8 uppercase alphanumerics: an esbuild content hash, as in +// chunk-QW4K7ZP2.js. The repo still builds with the webpack browser builder, so +// this matches nothing today; normalizing it now means the golden survives the +// esbuild migration instead of turning into one enormous diff on that day. +const ESBUILD_HASH_RE = /-[0-9A-Z]{8}(?![0-9A-Z])/g + +// What both hash rules collapse to. Deliberately a shape no real filename can +// contain, so LEADING_CHUNK_ID_RE below cannot key off anything but a hash this +// module already replaced. +const HASH_MARKER = '-' + +// A leading numeric webpack chunk id, but only when the hash marker follows it +// immediately: 105--en.js -> chunk--en.js. Webpack numbers lazy chunks by +// build order, so the ids churn whenever an unrelated lazy route is added and +// carry nothing the golden should be pinned to. The lookahead is what keeps +// ordinary names that merely start with a digit intact: 3rdpartylicenses.txt +// must not become chunkrdpartylicenses.txt. +const LEADING_CHUNK_ID_RE = /^\d+(?=-)/ + +/** + * Normalizes the volatile parts of a file's BASENAME. + * + * Traced by hand against the rules above; these are the outputs they produce: + * + * main.9f2c1a0b7d4e6538-en.js -> main--en.js + * 105.aeb3547eda2d9bbd-en.js -> chunk--en.js + * styles.6cbe2b2f2a2ee7a5.css -> styles-.css + * chunk-QW4K7ZP2.js -> chunk-.js + * 3rdpartylicenses.txt -> 3rdpartylicenses.txt + * fetch-orcid.js -> fetch-orcid.js + * new-relic.runtime.0123456789abcdef-fr.js + * -> new-relic.runtime--fr.js + * + * Order matters: both hash rules must run before LEADING_CHUNK_ID_RE, because + * that rule keys off the marker they leave behind. + */ +function normalizeBasename(basename: string): string { + return basename + .replace(WEBPACK_HASH_RE, HASH_MARKER) + .replace(ESBUILD_HASH_RE, HASH_MARKER) + .replace(LEADING_CHUNK_ID_RE, 'chunk') +} + +/** + * Directory segments are deliberately left untouched. A locale directory's name + * IS the routing contract (LOCALE_DIR_RE in ./routing-contract.ts), so + * normalizing anything inside one would hide the exact regression this golden + * exists to catch -- zh_CN quietly reverting to zh-CN, for instance. + */ +function normalizeRelativePath(relativePath: string): string { + const lastSlash = relativePath.lastIndexOf('/') + if (lastSlash === -1) { + return normalizeBasename(relativePath) + } + const dir = relativePath.slice(0, lastSlash + 1) + return dir + normalizeBasename(relativePath.slice(lastSlash + 1)) +} + +// Paths are joined with '/' as we descend rather than reconstructed with +// path.relative, so the keys are forward-slashed on Windows too and the golden +// stays comparable across machines. +function walk(dir: string, prefix: string, counts: Map): void { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + const relativePath = prefix + entry.name + if (entry.isDirectory()) { + walk(path.join(dir, entry.name), relativePath + '/', counts) + continue + } + const key = normalizeRelativePath(relativePath) + counts.set(key, (counts.get(key) || 0) + 1) + } +} + +export function buildManifest(distRoot: string): LayoutManifest { + const counts = new Map() + walk(distRoot, '', counts) + + // Insert in sorted order: JSON.stringify emits string keys in insertion + // order, so this is what makes the golden byte-stable rather than dependent + // on readdir order. Plain .sort() compares UTF-16 code units and is therefore + // machine-independent, which localeCompare would not be. (No key is an + // integer-like string, which JS would hoist and reorder.) + const manifest: LayoutManifest = {} + const keys = Array.from(counts.keys()).sort() + for (const key of keys) { + manifest[key] = counts.get(key) + } + return manifest +} + +const USAGE = + 'usage: tsx scripts/__tests__/helpers/layout-manifest.ts --write|--check ' + +function formatSummary(manifest: LayoutManifest): string { + const keys = Object.keys(manifest) + let total = 0 + for (const key of keys) { + total += manifest[key] + } + return `${keys.length} entries, ${total} files` +} + +function main(argv: string[]): void { + const distRoot = argv[0] + const mode = argv[1] + const manifestPath = argv[2] + const validMode = mode === '--write' || mode === '--check' + + if (!distRoot || !validMode || !manifestPath) { + console.error(USAGE) + process.exit(1) + } + + if (!fs.existsSync(distRoot) || !fs.statSync(distRoot).isDirectory()) { + console.error(`error: dist root is not a directory: ${distRoot}`) + console.error('Run `yarn build` first, or pass the correct path.') + process.exit(1) + } + + const manifest = buildManifest(distRoot) + + if (mode === '--write') { + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n') + console.log(`wrote ${manifestPath}: ${formatSummary(manifest)}`) + return + } + + if (!fs.existsSync(manifestPath)) { + console.error(`error: golden manifest not found: ${manifestPath}`) + console.error('Create it by re-running with --write.') + process.exit(1) + } + + const expected = JSON.parse( + fs.readFileSync(manifestPath, 'utf8') + ) as LayoutManifest + + // A new or missing KEY is a change to the shape of the deployed tree: a file + // kind that moved directory, a locale that stopped being emitted, a bundle + // that lost its locale suffix. Those break routing, so they fail. + // + // A changed COUNT under an existing key is almost always just lazy chunks: + // adding a lazily loaded route bumps chunk--*.js in all 21 locales at + // once. Gating on that would force a re-record on ordinary feature work + // while proving nothing about routing, so counts are reported and not + // enforced. Pass --strict when you do want them exact, which is the useful + // mode when checking that a bundler migration changed nothing. + const strict = argv.includes('--strict') + + const structural: string[] = [] + const counts: string[] = [] + const allKeys = Array.from( + new Set(Object.keys(expected).concat(Object.keys(manifest))) + ).sort() + + for (const key of allKeys) { + const before = expected[key] + const after = manifest[key] + if (before === undefined) { + structural.push(`+ ${key} (${after})`) + } else if (after === undefined) { + structural.push(`- ${key} (${before})`) + } else if (before !== after) { + counts.push(`~ ${key} (${before} -> ${after})`) + } + } + + if (counts.length > 0) { + console.log(`file counts changed vs ${manifestPath} (not a failure):`) + for (const line of counts) { + console.log(` ${line}`) + } + } + + if (structural.length > 0 || (strict && counts.length > 0)) { + console.error(`layout drift vs ${manifestPath}:`) + for (const line of structural) { + console.error(` ${line}`) + } + if (strict) { + for (const line of counts) { + console.error(` ${line}`) + } + } + console.error('') + console.error( + 'Each line is a change production will see. Check ./routing-contract.ts' + ) + console.error('before accepting it; if intended, re-run with --write.') + process.exit(1) + } + + console.log(`OK: ${formatSummary(manifest)} match ${manifestPath}`) +} + +// scripts/tsconfig.json sets "module": "commonjs" and tsx transpiles this file +// to CJS, so `module` and `require.main` are real at runtime -- the same guard +// scripts/environment.prebuild.ts uses. It also keeps `import { buildManifest }` +// from a test harmless: require.main is then the test runner's entry rather +// than this file, so the CLI never fires. +if (require.main === module) { + main(process.argv.slice(2)) +} diff --git a/scripts/__tests__/helpers/routing-contract.ts b/scripts/__tests__/helpers/routing-contract.ts new file mode 100644 index 0000000000..036343e1f8 --- /dev/null +++ b/scripts/__tests__/helpers/routing-contract.ts @@ -0,0 +1,128 @@ +// The deployed routing contract for `dist/`, copied from the deployment configs +// cited on each constant. Tests import these instead of re-deriving them, so a +// postbuild layout change that breaks routing fails in review rather than 404ing +// after deploy. +// +// Sibling repos referenced below: +// inventory = orcid-ansible_inventory-private +// source = ORCID-Source + +/** + * inventory prod/.files/slotr_java/reg/etc/slotr/instance/reg-ui/1/conf/ + * Catalina/localhost/rewrite.config lines 1-2: + * + * RewriteCond %{REQUEST_PATH} !-f + * RewriteRule ^/orcid-web-frontend/([a-z]{2}(_[A-Za-z]{2})?)/.* + * /orcid-web-frontend/$1/index.html + * + * The valve is what makes client-side deep links work: any request under a + * locale directory that is not an existing file falls back to that locale's + * index.html. A directory whose name does not match this pattern never gets the + * fallback, so every Angular route under it 404s. That is the whole reason + * postbuild renames zh-CN -> zh_CN: a dash is not in the character class. + */ +export const LOCALE_DIR_RE = /^[a-z]{2}(_[A-Za-z]{2})?$/ + +/** + * `src` is Angular's i18n source locale; `--localize` emits dist/src/ alongside + * the real locales. Three letters, so LOCALE_DIR_RE rejects it and the rewrite + * valve above never serves it -- it ships inside the WAR and is unreachable. + * Pre-existing and out of scope for the postbuild tests; this allowlist exists + * only so the locale-directory assertions can stay strict about everything else. + * + * Shrink this list, never grow it. A new entry means a new locale shipped that + * nobody can load. + */ +export const UNROUTABLE_LOCALE_DIRS: readonly string[] = ['src'] + +/** + * inventory prod/.files/nginx/reg/sites-enabled/ui.conf.j2 line 133 -- the + * `location ~` whose block sets $app_path "/orcid-web-frontend/share-assets". + * + * index.html carries and bare filenames, so the browser asks + * for /main.-fr.js and /assets/... at the site ROOT, never under the + * locale directory it came from. Only root paths matching this regex are sent + * to share-assets; everything else falls through to the app routes and returns + * HTML. Three consequences worth asserting: + * + * - share-assets must stay flat apart from assets/, because the extension + * branch `\/([^\/]*)(\.js|...)$` spans a single path segment only; + * - a flat file must end in one of those extensions or start with + * styles/runtime/polyfills/main, or it is simply unreachable; + * - `.css` is NOT in the extension list. styles..css reaches + * share-assets only via the `\/styles.*$` branch, so a stylesheet put in + * share-assets under any other name is unreachable. + * + * Do NOT assert this regex against print-view files. ui.conf.j2 line 125 + * declares `location ^~ /print-view/`, and an `^~` prefix match takes priority + * over every regex location in nginx, so /print-view/* is served from + * /orcid-web-frontend//print-view/ instead. That exemption is what + * lets cv-style.css keep its name and what lets postbuild write a separately + * localized fetch-orcid.js per locale rather than one shared copy. + */ +export const PROD_ASSET_RE = + /^(\/assets\/.*$|\/([^\/]*)(\.js|\.jpg|\.jpeg|\.png|\.svg|\.woff|\.woff2|\.webp)$|\/styles.*$|\/runtime.*$|\/polyfills.*$|\/main.*$|\/manifest\.json$|\/assets.*$)/ + +/** + * source orcid-web-proxy/nginx/conf.d/default.conf line 83, which is also the + * copy the inventory's int/ and stage/ ui.conf.j2 still carry (neither file + * mentions webp). It is PROD_ASSET_RE minus `|\.webp`. + * + * Kept as a separate constant on purpose: a .webp reaching share-assets + * resolves in prod and qa but 404s on the dev proxy, on int and on stage. + * Assert new asset types against this narrower regex so the build cannot ship + * something that only works in two of the five environments. + */ +export const DEV_ASSET_RE = + /^(\/assets\/.*$|\/([^\/]*)(\.js|\.jpg|\.jpeg|\.png|\.svg|\.woff|\.woff2)$|\/styles.*$|\/runtime.*$|\/polyfills.*$|\/main.*$|\/manifest\.json$|\/assets.*$)/ + +/** + * inventory prod/.files/nginx/reg/sites-enabled/ui.conf.j2 lines 12-33, + * `map $cookie_locale_v3 $langCode`. These are the map's VALUES (the folder + * names), not its keys: the cookie reads `zh-CN`, the folder it selects is + * `zh_CN`. nginx serves /orcid-web-frontend/<$langCode>/index.html, so if the + * build stops emitting one of these directories, every visitor already holding + * that cookie gets nothing -- and they cannot clear it from inside the app. + * + * This is a strict SUBSET of what the build emits: `src` is unroutable, and + * `uk` is built and routable but has no cookie entry, so it is reachable only + * by direct URL. Assert containment (every folder named here exists in dist), + * never equality. + * + * Listed alphabetically for review; the nginx map is in its own order. + */ +export const COOKIE_MAP_FOLDERS: readonly string[] = [ + 'ar', + 'ca', + 'cs', + 'de', + 'en', + 'es', + 'fr', + 'it', + 'ja', + 'ko', + 'lr', + 'pl', + 'pt', + 'rl', + 'ru', + 'tr', + 'xx', + 'zh_CN', + 'zh_TW', +] + +/** + * `url` is the root-relative URL the BROWSER requests, not a path inside dist: + * because of , dist/share-assets/main.-en.js is fetched as + * '/main.-en.js'. Neither regex carries the /g flag, so .test() keeps no + * lastIndex state and both helpers are safe to call in a loop. + */ +export function matchesProdAssetRoute(url: string): boolean { + return PROD_ASSET_RE.test(url) +} + +export function matchesDevAssetRoute(url: string): boolean { + return DEV_ASSET_RE.test(url) +} diff --git a/scripts/__tests__/helpers/verify-dist.ts b/scripts/__tests__/helpers/verify-dist.ts new file mode 100644 index 0000000000..b55bc777cf --- /dev/null +++ b/scripts/__tests__/helpers/verify-dist.ts @@ -0,0 +1,356 @@ +/** + * Validates a REAL `dist/` against the deployed routing contract. + * + * postbuild.test.ts proves the postbuild transforms a small synthetic tree + * correctly. This proves the tree that actually ships is servable: every URL + * the browser will request is routable by the production nginx rules and + * resolves to a file, and every lazy chunk a locale's webpack runtime can load + * exists under that same locale's suffix. + * + * The two are complementary. The fixture has 4 locales and a handful of files; + * this sees all 21 locales and ~2000 bundles, including anything a feature + * branch newly imports. + * + * Usage: tsx scripts/__tests__/helpers/verify-dist.ts [distRoot] + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as posix from 'path/posix' + +import { + COOKIE_MAP_FOLDERS, + LOCALE_DIR_RE, + UNROUTABLE_LOCALE_DIRS, + matchesDevAssetRoute, + matchesProdAssetRoute, +} from './routing-contract' + +/** + * Top-level directories under dist/ that are not locale output: share-assets is + * created by postbuild, and test-out is where `@angular/build:karma` compiles + * the specs (it leaves the directory behind, empty, after a run). + */ +const NON_LOCALE_DIRS = ['share-assets', 'test-out'] + +interface Problem { + where: string + what: string +} + +const problems: Problem[] = [] +const notes: string[] = [] +const fail = (where: string, what: string) => problems.push({ where, what }) + +/** Values in an href/src/url() that are not a request for an asset. */ +function isExternal(url: string): boolean { + return ( + url.startsWith('http://') || + url.startsWith('https://') || + url.startsWith('//') || + url.startsWith('data:') || + url.startsWith('#') || + url.startsWith('mailto:') || + url.startsWith('tel:') || + // `` is not something the browser fetches + url.trim() === '/' || + url.trim() === '' + ) +} + +function referencedUrls(text: string): string[] { + const found = new Set() + for (const m of text.matchAll(/(?:src|href)="([^"]+)"/g)) found.add(m[1]) + for (const m of text.matchAll(/url\(([^)]+)\)/g)) + found.add(m[1].trim().replace(/^['"]|['"]$/g, '')) + return [...found].filter((u) => !isExternal(u)) +} + +/** + * Resolves a browser URL to a file on disk the way the deployed nginx does: + * every asset URL that matches the asset route is served from share-assets. + * Returns null when nginx would not route the URL there at all. + */ +function resolveThroughNginx(shareAssets: string, url: string): string | null { + if (!matchesProdAssetRoute(url)) return null + return path.join(shareAssets, url.replace(/^\//, '').split('?')[0]) +} + +function listFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return [] + const out: string[] = [] + const walk = (cur: string, prefix: string) => { + for (const entry of fs.readdirSync(cur, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) walk(path.join(cur, entry.name), rel) + else out.push(rel) + } + } + walk(dir, '') + return out.sort() +} + +function main(distRoot: string): void { + if (!fs.existsSync(distRoot) || !fs.statSync(distRoot).isDirectory()) { + console.error(`error: not a directory: ${distRoot}`) + console.error('Run `yarn build` first, or pass the correct path.') + process.exit(1) + } + + const shareAssets = path.join(distRoot, 'share-assets') + if (!fs.existsSync(shareAssets)) { + console.error(`error: ${distRoot}/share-assets is missing.`) + console.error('postbuild did not run, or it failed partway.') + process.exit(1) + } + + const localeDirs = fs + .readdirSync(distRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .filter((name) => !NON_LOCALE_DIRS.includes(name)) + .sort() + + // ---- locale directories are reachable through the Tomcat rewrite valve ---- + for (const dir of localeDirs) { + if (!LOCALE_DIR_RE.test(dir) && !UNROUTABLE_LOCALE_DIRS.includes(dir)) { + fail( + `dist/${dir}`, + 'directory name does not match the Tomcat rewrite valve pattern ' + + '[a-z]{2}(_[A-Za-z]{2})?, so every deep link under it 404s' + ) + } + } + + // ---- every locale nginx can select actually exists ---- + for (const folder of COOKIE_MAP_FOLDERS) { + if (!localeDirs.includes(folder)) { + fail( + `dist/${folder}`, + 'missing, but the nginx cookie map routes locale_v3 to it; visitors ' + + 'holding that cookie would get nothing' + ) + } + } + + // ---- nothing stray at the root of the served tree ---- + const strayRootFiles = fs + .readdirSync(distRoot, { withFileTypes: true }) + .filter((e) => e.isFile()) + .map((e) => e.name) + for (const file of strayRootFiles) { + fail( + `dist/${file}`, + 'file at the root of the deployed tree; the WAR copies dist/ verbatim, ' + + 'so this is served at the site root' + ) + } + + // ---- every referenced URL is routable and exists ---- + const shareAssetFiles = new Set(listFiles(shareAssets)) + let urlsChecked = 0 + const devUnroutable = new Set() + + const checkUrls = (source: string, text: string) => { + for (const raw of referencedUrls(text)) { + // Resolved against the site root: index.html carries . + const url = posix.normalize(posix.join('/', raw)) + urlsChecked++ + + const resolved = resolveThroughNginx(shareAssets, url) + if (resolved === null) { + fail( + source, + `references "${raw}", which the production asset route does not ` + + `match, so nginx sends it to the Java app instead of share-assets` + ) + continue + } + const relative = path + .relative(shareAssets, resolved) + .split(path.sep) + .join('/') + if (!shareAssetFiles.has(relative)) { + fail( + source, + `references "${raw}" -> share-assets/${relative}, which does not exist` + ) + } + if (!matchesDevAssetRoute(url)) devUnroutable.add(raw) + } + } + + for (const dir of localeDirs) { + const index = path.join(distRoot, dir, 'index.html') + if (!fs.existsSync(index)) { + fail(`dist/${dir}`, 'has no index.html') + continue + } + checkUrls(`dist/${dir}/index.html`, fs.readFileSync(index, 'utf8')) + } + for (const file of [...shareAssetFiles].filter((f) => f.endsWith('.css'))) { + checkUrls( + `share-assets/${file}`, + fs.readFileSync(path.join(shareAssets, file), 'utf8') + ) + } + + // ---- every bundle carries a locale suffix ---- + // The suffix is the directory Angular emitted, so dist/zh_CN holds -zh-CN. + const suffixFor = (dir: string) => dir.replace('_', '-') + const suffixes = [...new Set(localeDirs.map(suffixFor))].sort( + (a, b) => b.length - a.length + ) + const suffixOf = (file: string) => + suffixes.find((s) => file.endsWith(`-${s}.js`)) + + const bundles = [...shareAssetFiles].filter( + (f) => f.endsWith('.js') && !f.includes('/') + ) + let bundlesChecked = 0 + + for (const file of bundles) { + if (!suffixOf(file)) { + fail( + `share-assets/${file}`, + 'has no locale suffix; share-assets is one flat namespace and ' + + 'Cloudflare caches on the URL alone, so locales would collide' + ) + continue + } + bundlesChecked++ + } + + // ---- index.html loads only its own locale's bundles ---- + for (const dir of localeDirs) { + const index = path.join(distRoot, dir, 'index.html') + if (!fs.existsSync(index)) continue + for (const raw of referencedUrls(fs.readFileSync(index, 'utf8'))) { + const file = raw.replace(/^\.?\//, '').split('?')[0] + if (!file.endsWith('.js')) continue + if (!file.endsWith(`-${suffixFor(dir)}.js`)) { + fail( + `dist/${dir}/index.html`, + `references "${raw}", which is not suffixed for ${suffixFor(dir)}; ` + + 'an unsuffixed or foreign bundle is shared across locales by ' + + "Cloudflare's URL-keyed cache" + ) + } + } + } + + // ---- every lazy chunk the runtime can load exists for the same locale ---- + // webpack builds a lazy chunk's URL in the runtime chunk: + // .u=e=>(76===e?"common":e)+"."+{42:"-en",76:"-en"}[e]+".js" + // postbuild suffixes each hash in that map with the locale, so chunk 42 + // resolves to 42.-en.js and the named chunk 76 to common.-en.js. + // Nothing else in the tree references those files, so an entry that points + // at a missing or foreign file only shows up as a 404 when a user opens the + // route. + let chunksChecked = 0 + for (const dir of localeDirs) { + const suffix = suffixFor(dir) + const runtimes = bundles.filter( + (f) => /^runtime\.[0-9a-f]{16}-/.test(f) && suffixOf(f) === suffix + ) + if (runtimes.length !== 1) { + fail( + `share-assets/runtime.*-${suffix}.js`, + `expected exactly one runtime chunk for ${suffix}, found ${runtimes.length}` + ) + continue + } + const runtime = runtimes[0] + const source = fs.readFileSync(path.join(shareAssets, runtime), 'utf8') + + const start = source.indexOf('.u=e=>') + const end = start < 0 ? -1 : source.indexOf('+".js"', start) + const urlFn = start < 0 || end < 0 ? '' : source.slice(start, end) + const map = urlFn.match(/\{([^{}]*)\}\[e\]$/) + if (!map) { + fail( + `share-assets/${runtime}`, + 'has no chunk URL map in the expected shape (.u=e=>...{id:"hash"}[e]+".js"); ' + + 'the webpack runtime changed and this check needs updating' + ) + continue + } + + const aliases = new Map() + for (const m of urlFn.matchAll(/(\d+)===e\?"([^"]+)"/g)) { + aliases.set(m[1], m[2]) + } + + const entries = [...map[1].matchAll(/(\d+):"([^"]*)"/g)] + if (entries.length === 0) { + fail(`share-assets/${runtime}`, 'chunk URL map is empty') + continue + } + for (const [, id, value] of entries) { + chunksChecked++ + const chunk = `${aliases.get(id) ?? id}.${value}.js` + if (!value.endsWith(`-${suffix}`)) { + fail( + `share-assets/${runtime}`, + `maps chunk ${id} to "${chunk}", which is not suffixed for ${suffix}` + ) + } else if (!shareAssetFiles.has(chunk)) { + fail( + `share-assets/${runtime}`, + `maps chunk ${id} to "${chunk}", which does not exist` + ) + } + } + } + + // ---- the monit health check ---- + const frIndex = path.join(distRoot, 'fr', 'index.html') + if (fs.existsSync(frIndex)) { + if (!fs.readFileSync(frIndex, 'utf8').includes('--fr')) { + fail( + 'dist/fr/index.html', + "does not contain '--fr'; the monit check greps /reset-password for it " + + 'and would flap the service' + ) + } + } + + // ---- informational ---- + if (devUnroutable.size > 0) { + notes.push( + `${devUnroutable.size} referenced file(s) are routable in prod/qa/sandbox ` + + `but not on the dev proxy, int or stage (their asset regex is narrower): ` + + `${[...devUnroutable].slice(0, 4).join(', ')}${ + devUnroutable.size > 4 ? ', ...' : '' + }` + ) + } + + console.log( + `verify-dist: ${localeDirs.length} locales, ${urlsChecked} asset URLs, ` + + `${bundlesChecked} bundles, ${chunksChecked} runtime chunk entries` + ) + for (const note of notes) console.log(` note: ${note}`) + + if (problems.length > 0) { + console.error('') + console.error(`${problems.length} problem(s) in the deployed tree:`) + for (const p of problems.slice(0, 40)) { + console.error(` ${p.where}: ${p.what}`) + } + if (problems.length > 40) { + console.error(` ... and ${problems.length - 40} more`) + } + console.error('') + console.error( + 'See scripts/__tests__/helpers/routing-contract.ts for the rules.' + ) + process.exit(1) + } + + console.log('verify-dist: OK') +} + +if (require.main === module) { + main(process.argv[2] || 'dist') +} diff --git a/scripts/__tests__/helpers/workspace.ts b/scripts/__tests__/helpers/workspace.ts new file mode 100644 index 0000000000..f070aa8043 --- /dev/null +++ b/scripts/__tests__/helpers/workspace.ts @@ -0,0 +1,212 @@ +/** + * Disposable copy of a built `dist/` tree, plus a runner for the REAL + * `scripts/postbuild.ts`. + * + * The harness characterizes production code as-is: nothing here reimplements + * or stubs a postbuild step, so an assertion that passes proves the shipped + * script produces the layout the deployed nginx/Tomcat/Cloudflare rules + * expect. + */ + +import { spawnSync } from 'child_process' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +/** /scripts/__tests__/helpers/workspace.ts -> */ +export const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') + +const FIXTURES_DIR = path.join(REPO_ROOT, 'scripts', '__fixtures__') +const PRINT_VIEW_FIXTURES = path.join(FIXTURES_DIR, 'print-view') + +const TS_NODE_BIN = path.join( + REPO_ROOT, + 'node_modules', + 'ts-node', + 'dist', + 'bin.js' +) +const SCRIPTS_TSCONFIG = path.join(REPO_ROOT, 'scripts', 'tsconfig.json') +const POSTBUILD_SCRIPT = path.join(REPO_ROOT, 'scripts', 'postbuild.ts') + +/** Guards cleanupWorkspace against deleting anything we did not create. */ +const WORKSPACE_PREFIX = 'orcid-postbuild-' + +/** + * Copies `scripts/__fixtures__//` into a throwaway `/dist/` + * and stages the two `src/` inputs postbuild reads from cwd. + * + * The fixture lands at `/dist//...` and nowhere else: utils.ts + * derives the locale as `file.split('/')[2]` of each matched + * `index.html` path, so an extra or missing directory level silently yields the + * wrong locale suffix rather than an error. + * + * The workspace is deliberately NOT a git repo. git-repo-info walks up from + * cwd and returns nulls (rather than throwing) when it finds no `.git`, so + * build-info emits a stable ``. Only the other half of that + * comment matters in production: monit fetches /reset-password with + * `Cookie: locale_v3=fr` and requires the body to contain the literal `--fr`. + */ +export function createWorkspace(fixtureName: string): string { + const fixtureDir = path.join(FIXTURES_DIR, fixtureName) + if (!fs.existsSync(fixtureDir)) { + throw new Error( + `createWorkspace: fixture "${fixtureName}" not found at ${fixtureDir}` + ) + } + if (!fs.existsSync(PRINT_VIEW_FIXTURES)) { + throw new Error( + `createWorkspace: print-view fixtures not found at ${PRINT_VIEW_FIXTURES}` + ) + } + + // realpath because macOS os.tmpdir() is a symlink (/var -> /private/var): + // the spawned process reports the resolved cwd, so resolving here keeps the + // path a test asserts on identical to the one postbuild wrote to. + const ws = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), WORKSPACE_PREFIX)) + ) + + fs.cpSync(fixtureDir, path.join(ws, 'dist'), { recursive: true }) + + // print-view-localize.postbuild.ts reads both of these cwd-relative: + // './src/assets/print-view/fetch-orcid.js' and './src/locale/messages.*.xlf'. + // (new-relic.runtime.js is resolved from __dirname instead, so the real one + // in the repo is used and must not be copied here.) + const printViewSource = path.join(PRINT_VIEW_FIXTURES, 'fetch-orcid.js') + if (!fs.existsSync(printViewSource)) { + throw new Error( + `createWorkspace: print-view source not found at ${printViewSource}` + ) + } + const printViewDest = path.join( + ws, + 'src', + 'assets', + 'print-view', + 'fetch-orcid.js' + ) + fs.mkdirSync(path.dirname(printViewDest), { recursive: true }) + fs.cpSync(printViewSource, printViewDest, { recursive: true }) + + const localeDir = path.join(ws, 'src', 'locale') + fs.mkdirSync(localeDir, { recursive: true }) + fs.readdirSync(PRINT_VIEW_FIXTURES) + .filter((name) => name.startsWith('messages.') && name.endsWith('.xlf')) + .forEach((name) => { + fs.cpSync( + path.join(PRINT_VIEW_FIXTURES, name), + path.join(localeDir, name), + { recursive: true } + ) + }) + + return ws +} + +/** + * Runs the real postbuild against a workspace and returns stdout + stderr. + * + * The three repo paths must be absolute: cwd is the workspace, and every glob + * inside postbuild ('./dist/...') plus the print-view inputs ('./src/...') + * resolve against that cwd. A repo-relative script path would make ts-node + * look for postbuild.ts inside the workspace. + */ +export function runPostbuild(ws: string): string { + const required: [string, string][] = [ + ['ts-node binary', TS_NODE_BIN], + ['scripts tsconfig', SCRIPTS_TSCONFIG], + ['postbuild script', POSTBUILD_SCRIPT], + ] + for (const [label, target] of required) { + if (!fs.existsSync(target)) { + throw new Error( + `runPostbuild: ${label} not found at ${target} (run \`yarn install\`?)` + ) + } + } + + const result = spawnSync( + process.execPath, + [TS_NODE_BIN, '-P', SCRIPTS_TSCONFIG, POSTBUILD_SCRIPT], + { + cwd: ws, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + } + ) + + const output = (result.stdout || '') + (result.stderr || '') + + if (result.error) { + throw new Error( + `runPostbuild: could not spawn ts-node: ${result.error.message}\n${output}` + ) + } + + // Fold the whole transcript into the message: a CI failure here has no other + // artifact to inspect. + if (result.status !== 0) { + const signal = result.signal ? ` (signal ${result.signal})` : '' + throw new Error( + [ + `runPostbuild: postbuild exited with status ${result.status}${signal}`, + `cwd: ${ws}`, + output, + ].join('\n') + ) + } + + return output +} + +export function cleanupWorkspace(ws: string): void { + if (!path.basename(ws).startsWith(WORKSPACE_PREFIX)) { + throw new Error( + `cleanupWorkspace: refusing to delete ${ws}, which was not created by createWorkspace` + ) + } + fs.rmSync(ws, { recursive: true, force: true }) +} + +/** Recursive file list, relative to `dir`, forward slashes, sorted. */ +export function listFiles(dir: string): string[] { + if (!fs.existsSync(dir)) { + return [] + } + const found: string[] = [] + const walk = (current: string, prefix: string): void => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + // Always '/': the nginx asset route regex and the golden manifests are + // written in URL terms, not in whatever path.sep this host uses. + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) { + walk(path.join(current, entry.name), relative) + } else { + found.push(relative) + } + } + } + walk(dir, '') + return found.sort() +} + +export function readText(file: string): string { + return fs.readFileSync(file, 'utf8') +} + +export function exists(p: string): boolean { + return fs.existsSync(p) +} + +/** Immediate subdirectory names, sorted. */ +export function listDirs(dir: string): string[] { + if (!fs.existsSync(dir)) { + return [] + } + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() +} diff --git a/scripts/__tests__/postbuild.test.ts b/scripts/__tests__/postbuild.test.ts new file mode 100644 index 0000000000..26a61ede98 --- /dev/null +++ b/scripts/__tests__/postbuild.test.ts @@ -0,0 +1,657 @@ +/** + * Characterization tests for `scripts/postbuild.ts`. + * + * postbuild is the only thing that turns raw `ng build --localize` output into + * the layout production actually serves. Nothing else tests it, and the shape + * it produces is depended on by four systems outside this repo: + * + * - the WAR (pom.xml copies dist/ verbatim to context /orcid-web-frontend) + * - a Tomcat rewrite valve that only recognises 2-letter locale directories + * - two nginx layers that route flat, single-segment asset URLs to + * share-assets and locale-scoped page URLs to dist//index.html + * - Cloudflare, which caches every asset by URL alone with no cookie in the + * key, so each locale's copy of a file must have a distinct URL + * + * The assertions below are split in two. GROUP A is the deployed contract: it + * must hold no matter which bundler produced the input, so it is the thing that + * makes a bundler migration safe. GROUP B characterises webpack specifics + * (a runtime.js chunk manifest, numeric chunk ids) and is expected to be + * replaced when the app moves to the esbuild application builder. + * + * Each fixture is copied into a throwaway workspace and the REAL postbuild is + * spawned against it, so this exercises production code with no refactor. + * + * Run: yarn test:scripts Re-record goldens: yarn test:scripts:update + */ + +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import * as fs from 'fs' +import * as path from 'path' + +import { + REPO_ROOT, + createWorkspace, + runPostbuild, + cleanupWorkspace, + listFiles, + listDirs, + readText, + exists, +} from './helpers/workspace' +import { + LOCALE_DIR_RE, + UNROUTABLE_LOCALE_DIRS, + matchesProdAssetRoute, + matchesDevAssetRoute, +} from './helpers/routing-contract' +import { buildManifest } from './helpers/layout-manifest' + +const GOLDEN_DIR = path.join(REPO_ROOT, 'scripts/__fixtures__/golden') +const UPDATE_GOLDEN = process.env.UPDATE_GOLDEN === '1' + +/** + * Locales present in every fixture. + * + * `dir` is the directory name after postbuild; `suffix` is what the bundles for + * that locale are tagged with. They differ for Chinese, and that asymmetry is + * real and load-bearing: steps 1-2 stamp filenames using the directory name + * Angular emitted (`zh-CN`), and only step 5 renames the directory itself to + * `zh_CN` for the Tomcat rewrite valve. So production really does serve + * `/orcid-web-frontend/zh_CN/index.html` referencing `/main.-zh-CN.js`. + * + * `src` is 3 letters and therefore unroutable (see UNROUTABLE_LOCALE_DIRS). + */ +const FIXTURE_LOCALES = [ + { dir: 'en', suffix: 'en' }, + { dir: 'fr', suffix: 'fr' }, + { dir: 'zh_CN', suffix: 'zh-CN' }, + { dir: 'src', suffix: 'src' }, +] + +const LOCALE_DIRS = FIXTURE_LOCALES.map((l) => l.dir) +const BUNDLE_SUFFIXES = FIXTURE_LOCALES.map((l) => l.suffix) + +/** Splits `main.-zh-CN.js` into its base and its locale suffix. Matching + * against the known suffixes rather than a regex avoids mis-reading a hyphen + * inside the locale code as the separator. */ +function splitLocaleSuffix( + file: string +): { base: string; suffix: string } | null { + for (const suffix of BUNDLE_SUFFIXES) { + if (file.endsWith(`-${suffix}.js`)) { + return { base: file.slice(0, -`-${suffix}.js`.length), suffix } + } + } + return null +} + +/** Fixtures to run the contract group against. `dist-esbuild` is added when the + * app build migrates to the application builder; until then only the webpack + * shape exists and the esbuild entry is skipped rather than failing. */ +const FIXTURES = [ + { + name: 'dist-webpack', + golden: 'postbuild-webpack.manifest.json', + webpack: true, + }, + { + name: 'dist-esbuild', + golden: 'postbuild-esbuild.manifest.json', + webpack: false, + }, +] + +/** Reference values a page can legitimately carry that are not asset requests. */ +function isNotAnAssetRef(ref: string): boolean { + return ( + ref.startsWith('http://') || + ref.startsWith('https://') || + ref.startsWith('//') || + ref.startsWith('data:') || + ref.startsWith('#') || + ref.startsWith('mailto:') || + // `` + ref === '/' || + // assets are copied verbatim into share-assets/assets and reached via + // the /assets/** route, which does allow multiple segments + ref.startsWith('./assets/') || + ref.startsWith('/assets/') || + ref.startsWith('assets/') + ) +} + +/** Every URL an index.html or a stylesheet asks the browser to fetch. */ +function assetRefs(html: string): string[] { + const refs = new Set() + for (const m of html.matchAll(/(?:src|href)="([^"]+)"/g)) refs.add(m[1]) + for (const m of html.matchAll(/url\(([^)]+)\)/g)) + refs.add(m[1].trim().replace(/^['"]|['"]$/g, '')) + return [...refs].filter((r) => !isNotAnAssetRef(r)) +} + +function compareGolden(name: string, actual: Record): void { + const file = path.join(GOLDEN_DIR, name) + + // Auto-recording a missing golden is a convenience for the first local run. + // In CI it would turn the strongest assertion in this file into a no-op if + // the golden were ever deleted, so there it is a failure. + if (!UPDATE_GOLDEN && !fs.existsSync(file) && process.env.CI) { + assert.fail( + `Golden manifest ${name} is missing. It is committed on purpose: without ` + + `it this test cannot detect a layout change. Run ` + + `\`yarn test:scripts:update\` locally and commit the result.` + ) + } + + if (UPDATE_GOLDEN || !fs.existsSync(file)) { + fs.mkdirSync(GOLDEN_DIR, { recursive: true }) + fs.writeFileSync(file, JSON.stringify(actual, null, 2) + '\n') + if (!UPDATE_GOLDEN) { + console.log( + ` (recorded new golden ${name}; commit it and review the diff)` + ) + } + return + } + + const expected = JSON.parse(fs.readFileSync(file, 'utf8')) + const keys = [ + ...new Set([...Object.keys(expected), ...Object.keys(actual)]), + ].sort() + const diffs = keys + .filter((k) => expected[k] !== actual[k]) + .map((k) => + expected[k] === undefined + ? `+ ${k} (${actual[k]})` + : actual[k] === undefined + ? `- ${k} (${expected[k]})` + : `~ ${k} (${expected[k]} -> ${actual[k]})` + ) + + assert.deepEqual( + diffs, + [], + `Deployed layout changed vs ${name}.\n${diffs.join('\n')}\n\n` + + `Every line here is a change production will see. If it is intended, ` + + `re-record with: yarn test:scripts:update` + ) +} + +for (const fixture of FIXTURES) { + const fixtureDir = path.join(REPO_ROOT, 'scripts/__fixtures__', fixture.name) + const present = fs.existsSync(fixtureDir) + + describe( + `postbuild against ${fixture.name}`, + { skip: present ? false : `fixture ${fixture.name} not present yet` }, + () => { + let ws: string + let dist: string + + before(() => { + ws = createWorkspace(fixture.name) + dist = path.join(ws, 'dist') + runPostbuild(ws) + }) + + after(() => { + if (ws) cleanupWorkspace(ws) + }) + + // ---------------------------------------------------------------- GROUP A + // The deployed contract. Must hold for any bundler. + + it('emits an index.html for every locale, with zh-CN renamed to zh_CN', () => { + for (const { dir } of FIXTURE_LOCALES) { + assert.ok( + exists(path.join(dist, dir, 'index.html')), + `dist/${dir}/index.html is missing` + ) + } + // The Tomcat rewrite valve matches [a-z]{2}(_[A-Za-z]{2})?, so a hyphen + // would make every deep link in Chinese 404. + assert.ok( + !exists(path.join(dist, 'zh-CN')), + 'dist/zh-CN must be renamed to zh_CN' + ) + }) + + it('names locale directories so the Tomcat rewrite valve can match them', () => { + const offenders = listDirs(dist) + .filter((d) => d !== 'share-assets') + .filter((d) => !LOCALE_DIR_RE.test(d)) + .filter((d) => !UNROUTABLE_LOCALE_DIRS.includes(d)) + + assert.deepEqual( + offenders, + [], + 'These locale directories cannot be reached through the SPA fallback ' + + '(RewriteRule ^/orcid-web-frontend/([a-z]{2}(_[A-Za-z]{2})?)/.*). ' + + 'Add to UNROUTABLE_LOCALE_DIRS only if that is deliberate.' + ) + }) + + it('loads the application from every index.html', () => { + // The assertions below all check that what IS referenced resolves. This + // one checks that something is referenced at all: an index.html reduced + // to a comment would otherwise pass the whole file. + for (const { dir } of FIXTURE_LOCALES) { + const html = readText(path.join(dist, dir, 'index.html')) + const refs = assetRefs(html) + + for (const [label, pattern] of [ + ['entry point', /^main[-.]/], + ['polyfills', /^polyfills[-.]/], + ['stylesheet', /^styles[-.].*\.css$/], + ] as [string, RegExp][]) { + assert.ok( + refs.some((ref) => pattern.test(ref)), + `dist/${dir}/index.html references no ${label} (refs: ${ + refs.join(', ') || 'none' + })` + ) + } + + const scriptTags = html.match(/]*\ssrc=/g) || [] + assert.ok( + scriptTags.length >= 3, + `dist/${dir}/index.html has ${scriptTags.length} script tag(s) with a src; ` + + `a real build emits at least main, polyfills and new-relic` + ) + } + }) + + it('references only files that exist, flat in share-assets', () => { + const shareAssets = new Set(listFiles(path.join(dist, 'share-assets'))) + + for (const { dir } of FIXTURE_LOCALES) { + const html = readText(path.join(dist, dir, 'index.html')) + for (const ref of assetRefs(html)) { + assert.ok( + !ref.includes('/'), + `dist/${dir}/index.html asks for "${ref}". With the ` + + `browser requests a root path, and only single-segment asset URLs are ` + + `routed to share-assets; anything deeper falls through to the Java app.` + ) + assert.ok( + shareAssets.has(ref), + `dist/${dir}/index.html references "${ref}", which is not in share-assets` + ) + } + } + }) + + it('references only URLs the production nginx asset route matches', () => { + for (const { dir } of FIXTURE_LOCALES) { + const html = readText(path.join(dist, dir, 'index.html')) + for (const ref of assetRefs(html)) { + assert.ok( + matchesProdAssetRoute('/' + ref), + `"/${ref}" is not matched by the production asset route, so nginx would ` + + `send it to /orcid-web (the Java app) instead of share-assets.` + ) + } + } + }) + + it('reports which referenced URLs the dev/int/stage proxies cannot route', () => { + // Not a failure: prod/qa/sbox allow .webp, the dev proxy and the int and + // stage inventories do not. Surfaced so the gap stays visible and so a + // NEW unroutable extension is noticed. + const unroutable = new Set() + for (const { dir } of FIXTURE_LOCALES) { + for (const ref of assetRefs( + readText(path.join(dist, dir, 'index.html')) + )) { + if (!matchesDevAssetRoute('/' + ref)) unroutable.add(ref) + } + } + const exts = [ + ...new Set([...unroutable].map((f) => path.extname(f))), + ].sort() + assert.deepEqual( + exts.filter((e) => e !== '.webp'), + [], + `New file extensions that prod routes but dev/int/stage do not: ${exts.join( + ', ' + )}. ` + + `Either add them to the asset regex in those nginx templates or stop shipping them.` + ) + if (unroutable.size > 0) { + console.log( + ` note: ${unroutable.size} .webp reference(s) are unroutable on dev/int/stage ` + + `(pre-existing: their asset regex lacks .webp)` + ) + } + }) + + it('leaves nothing but index.html and print-view inside a locale directory', () => { + for (const { dir } of FIXTURE_LOCALES) { + const stray = listFiles(path.join(dist, dir)).filter( + (f) => f !== 'index.html' && !f.startsWith('print-view/') + ) + assert.deepEqual( + stray, + [], + `dist/${dir} still holds ${stray.join( + ', ' + )}. Anything left behind is ` + + `served from a locale path that nginx only routes to index.html.` + ) + } + }) + + it('gives every locale its own copy of each JS bundle, with distinct content', () => { + // Cloudflare caches by URL with no cookie in the key. If two locales + // shared a filename, whichever was written last would be served to + // everyone, in the wrong language. + const jsFiles = listFiles(path.join(dist, 'share-assets')).filter( + (f) => f.endsWith('.js') && !f.includes('/') + ) + assert.ok(jsFiles.length > 0, 'share-assets holds no JS at all') + + const byBase = new Map() + for (const f of jsFiles) { + const split = splitLocaleSuffix(f) + assert.ok( + split, + `share-assets/${f} carries no known locale suffix ` + + `(expected one of ${BUNDLE_SUFFIXES.join( + ', ' + )}). An unsuffixed bundle ` + + `would be shared across locales by Cloudflare's URL-keyed cache.` + ) + byBase.set(split!.base, [...(byBase.get(split!.base) || []), f]) + } + + for (const [base, files] of byBase) { + assert.equal( + files.length, + FIXTURE_LOCALES.length, + `${base} has ${files.length} locale copies, expected ${FIXTURE_LOCALES.length}: ${files}` + ) + const contents = files.map((f) => + readText(path.join(dist, 'share-assets', f)) + ) + + // Content distinctness is only meaningful for bundles that carry + // translated code. `new-relic.runtime.-.js` is a verbatim + // copy of scripts/new-relic.runtime.js and is byte-identical in every + // locale in the real build too; it still needs its own URL per locale + // because share-assets is a single flat namespace. The fixture stamps + // every localized bundle with a LOCALE: marker, so keying off that + // keeps the check honest without hardcoding an exemption list. + const localized = contents.filter((c) => c.includes('LOCALE:')) + if (localized.length > 0) { + assert.equal( + localized.length, + contents.length, + `${base} is localized in some locales but not others` + ) + assert.equal( + new Set(contents).size, + contents.length, + `Two locales produced byte-identical ${base}. Cloudflare keys its ` + + `cache on the URL alone, so whichever was written last would be ` + + `served to everyone, in the wrong language.` + ) + } + } + }) + + it('keeps the stylesheet shared and unsuffixed', () => { + // CSS carries no translated text, so one copy serves every locale. + const css = listFiles(path.join(dist, 'share-assets')).filter( + (f) => f.endsWith('.css') && !f.includes('/') + ) + assert.equal( + css.length, + 1, + `expected exactly one shared stylesheet, got ${css}` + ) + for (const suffix of BUNDLE_SUFFIXES) { + assert.ok( + !css[0].endsWith(`-${suffix}.css`), + `${css[0]} is locale-suffixed; it should be shared` + ) + } + }) + + it('copies share-assets/assets from en and removes the per-locale copies', () => { + const shared = listFiles(path.join(dist, 'share-assets/assets')) + assert.ok(shared.length > 0, 'share-assets/assets is empty') + + // en is the source of truth for assets; the marker file proves the copy + // came from en rather than being merged across locales. + assert.ok( + shared.includes('en-only.txt'), + 'share-assets/assets was not copied from dist/en (en-only.txt is missing)' + ) + for (const dir of LOCALE_DIRS) { + assert.ok( + !exists(path.join(dist, dir, 'assets')), + `dist/${dir}/assets survived; only share-assets/assets should remain` + ) + } + }) + + it('injects the New Relic script and marks every script for OneTrust', () => { + const newRelicSource = readText( + path.join(REPO_ROOT, 'scripts/new-relic.runtime.js') + ) + + for (const { dir } of FIXTURE_LOCALES) { + const html = readText(path.join(dist, dir, 'index.html')) + assert.ok( + !html.includes('NEW_RELIC_PLACEHOLDER'), + `dist/${dir}/index.html still has the placeholder comment` + ) + + const injected = html.match(/src="(new-relic\.runtime\.[^"]+)"/) + assert.ok( + injected, + `dist/${dir}/index.html has no New Relic script tag` + ) + const file = path.join(dist, 'share-assets', injected![1]) + assert.ok( + exists(file), + `${injected![1]} was referenced but not emitted` + ) + assert.equal( + readText(file), + newRelicSource, + 'the emitted New Relic runtime does not match scripts/new-relic.runtime.js' + ) + + // OneTrust must not auto-block our own scripts. + const scriptTags = html.match(/]*>/g) || [] + const unmarked = scriptTags.filter( + (t) => !t.includes('data-ot-ignore') + ) + assert.deepEqual( + unmarked, + [], + `dist/${dir}/index.html has script tags without data-ot-ignore: ${unmarked}` + ) + } + }) + + it('stamps the build-info comment monit health-checks on', () => { + for (const { dir, suffix } of FIXTURE_LOCALES) { + const html = readText(path.join(dist, dir, 'index.html')) + // The comment is stamped in step 1, before the zh-CN -> zh_CN rename, + // so it carries the locale Angular emitted, not the final directory + // name. It also spans two lines, hence the `s` flag rather than `[^>]*`. + assert.match( + html, + new RegExp(``), + `dist/${dir}/index.html has no build-info comment for "${suffix}"` + ) + } + + // monit (inventory .../monit/conf.d/reg-ui-1.j2) fetches /reset-password + // with `Cookie: locale_v3=fr` and requires the body to contain the literal + // `--fr`. That substring is the opening of the comment above: `