Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 84 additions & 24 deletions .github/actions/app-stop-local-env/action.yaml
Original file line number Diff line number Diff line change
@@ -1,38 +1,98 @@
name: "App Stop Local Environment"
description: "Stops localtest and app containers"
inputs:
collect-diagnostics:
description: "Whether to collect local environment diagnostics before stopping it"
required: false
default: "false"
runs:
using: "composite"
steps:
- name: Log localtest diagnostics
if: failure()
- name: Collect local environment diagnostics
if: ${{ inputs.collect-diagnostics == 'true' }}
shell: bash
run: |
if command -v studioctl > /dev/null 2>&1; then
studioctl server status || true
studioctl app ps || true

apps_json="$(mktemp)"
if studioctl app ps --json > "$apps_json"; then
node -e '
const fs = require("fs");
const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
for (const app of data.apps ?? []) {
if (app.id) console.log(app.id);
}
' "$apps_json" | while IFS= read -r id; do
echo "::group::studioctl app logs --id $id"
studioctl app logs --id "$id" --tail 200 --follow=false || true
echo "::endgroup::"
done || true
diagnostics_dir="${GITHUB_WORKSPACE}/src/App/frontend"
diagnostics_log="${diagnostics_dir}/local-env-diagnostics.log"
localtest_log="${diagnostics_dir}/localtest-container.log"
frontend_pid_file="${diagnostics_dir}/frontend-server.pid"

mkdir -p "$diagnostics_dir"
set +e
{
echo "Collected at: $(date --iso-8601=seconds)"
echo "Runner: $(uname -a)"

run_diagnostic() {
echo
echo "### $*"
timeout 30 "$@"
echo "Exit code: $?"
}

run_diagnostic curl -v --max-time 10 --output /dev/null \
--write-out $'HTTP %{http_code}; connect %{time_connect}s; first byte %{time_starttransfer}s; total %{time_total}s\n' \
http://localhost:8080/
run_diagnostic curl -v --max-time 10 --output /dev/null \
--write-out $'HTTP %{http_code}; connect %{time_connect}s; first byte %{time_starttransfer}s; total %{time_total}s\n' \
http://app-frontend.local.altinn.cloud:8000/
run_diagnostic curl -v --max-time 10 --output /dev/null \
--write-out $'HTTP %{http_code}; connect %{time_connect}s; first byte %{time_starttransfer}s; total %{time_total}s\n' \
http://local.altinn.cloud:8000/ttd/frontend-test

if [[ -f "$frontend_pid_file" ]]; then
frontend_pid="$(<"$frontend_pid_file")"
run_diagnostic ps -o pid,ppid,nlwp,rss,stat,lstart,cmd -p "$frontend_pid"
run_diagnostic ls -l "/proc/${frontend_pid}/fd"
run_diagnostic cat "/proc/${frontend_pid}/limits"
else
echo
echo "Frontend PID file not found: $frontend_pid_file"
fi
rm -f "$apps_json"

echo "::group::studioctl-server logs"
studioctl server logs --tail 200 --follow=false || true
echo "::endgroup::"
fi
run_diagnostic ss -antp
run_diagnostic docker ps --no-trunc
run_diagnostic docker inspect --format '{{json .State}}' localtest
run_diagnostic docker stats --no-stream
run_diagnostic df -h
run_diagnostic free -h
run_diagnostic ps -eLf
run_diagnostic sh -c 'printf "Runner thread count: "; ps -eLf --no-headers | wc -l'
run_diagnostic sh -c 'printf "Open system file handles: "; cat /proc/sys/fs/file-nr'
run_diagnostic sh -c 'printf "PID limit: "; cat /proc/sys/kernel/pid_max'

if command -v studioctl > /dev/null 2>&1; then
run_diagnostic studioctl doctor
run_diagnostic studioctl server status
run_diagnostic studioctl app ps

apps_json="$(mktemp)"
if timeout 30 studioctl app ps --json > "$apps_json"; then
node -e '
const fs = require("fs");
const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
for (const app of data.apps ?? []) {
if (app.id) console.log(app.id);
}
' "$apps_json" | while IFS= read -r id; do
echo
echo "### studioctl app logs --id $id"
timeout 30 studioctl app logs --id "$id" --tail 200 --follow=false
done
fi
rm -f "$apps_json"

echo
echo "### studioctl server logs"
timeout 30 studioctl server logs --tail 200 --follow=false
fi
} 2>&1 | tee "$diagnostics_log"

timeout 30 docker logs --timestamps localtest > "$localtest_log" 2>&1 || true
exit 0

- name: Stop localtest
if: always()
shell: bash
run: |
if command -v studioctl > /dev/null 2>&1; then
Expand Down
39 changes: 26 additions & 13 deletions .github/workflows/app-frontend-cypress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ jobs:
run: npx cypress install

- name: Cypress run
id: cypress
shell: bash
env:
CYPRESS_PROJECT_ID: o7mikf
Expand All @@ -277,12 +278,13 @@ jobs:
PERCY_BRANCH: ${{ github.head_ref || github.ref_name }}
PERCY_TARGET_BRANCH: ${{ github.base_ref || 'main' }}
IS_PR_DRAFT: ${{ github.event.pull_request.draft }}
E2E_SHARD_TOTAL: 6
E2E_SHARD_NUMBER: ${{ matrix.containers }}
run: |
cypress_args=(
run
--record
--parallel
--group altinn-app-frontend
--group "altinn-app-frontend-shard-${{ matrix.containers }}"
--tag altinn-app-frontend
--browser "${CHROME_PATH:-chrome}"
--ci-build-id "${{ github.run_id }}-${{ github.run_attempt }}"
Expand All @@ -296,19 +298,24 @@ jobs:
npx cypress "${cypress_args[@]}"
fi

- name: Stop localtest
if: always()
uses: ./.github/actions/app-stop-local-env
with:
collect-diagnostics: ${{ steps.cypress.outcome == 'failure' }}

- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: failure()
if: ${{ always() && steps.cypress.outcome == 'failure' }}
with:
name: app-frontend-logs
name: app-frontend-logs-${{ matrix.containers }}
path: |
src/App/frontend/frontend-server.log
src/App/frontend/local-env-diagnostics.log
src/App/frontend/localtest-container.log
src/App/frontend/test/logs/*
src/App/frontend/test/screenshots/**
${{ env.STUDIOCTL_HOME }}/logs/**

- name: Stop localtest
if: always()
uses: ./.github/actions/app-stop-local-env

cypress-run-external:
name: Cypress Run External
if: |
Expand Down Expand Up @@ -358,19 +365,25 @@ jobs:
run: npx cypress install

- name: Cypress run
id: cypress
shell: bash
run: npx cypress run --browser "${CHROME_PATH:-chrome}" --spec test/e2e/integration --env environment=localtest

- name: Stop localtest
if: always()
uses: ./.github/actions/app-stop-local-env
with:
collect-diagnostics: ${{ steps.cypress.outcome == 'failure' }}

- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: failure()
if: ${{ always() && steps.cypress.outcome == 'failure' }}
with:
name: app-frontend-external-logs
path: |
src/App/frontend/frontend-server.log
src/App/frontend/local-env-diagnostics.log
src/App/frontend/localtest-container.log
src/App/frontend/test/logs/*
src/App/frontend/test/screenshots/*
src/App/frontend/test/videos/*
${{ env.STUDIOCTL_HOME }}/logs/**

- name: Stop localtest
if: always()
uses: ./.github/actions/app-stop-local-env
51 changes: 31 additions & 20 deletions src/App/frontend/cypress.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const path = require('node:path');
const fs = require('node:fs/promises');
const { existsSync } = require('node:fs');
const env = require('dotenv').config();
const configureCypressShard = require('./scripts/cypress-shard');

const CYPRESS_WINDOW_WIDTH = env.parsed?.CYPRESS_WINDOW_WIDTH || 1920;
const CYPRESS_WINDOW_HEIGHT = env.parsed?.CYPRESS_WINDOW_HEIGHT || 1080;
Expand Down Expand Up @@ -80,26 +81,36 @@ module.exports = defineConfig({

const validEnvironments = ['localtest', 'tt02'];
if (validEnvironments.includes(config.env.environment)) {
return getConfigurationByFile(config.env.environment).then((fileConfig) => ({
...fileConfig,
env: {
...config.env,
...fileConfig.env,
},
expose: {
...config.expose,
...fileConfig.expose,
// Specs that assert on backend-local date/time values need the backend's timezone.
// Only in localtest does the app backend run on the same machine as Cypress, so only
// then is the machine timezone valid - read it here in the Node process, since the
// browser's timezone may be emulated via CDP and cannot be trusted. Against remote
// environments (tt02) this is deliberately left unset; specs fall back to UTC, which
// is what those backends run in.
...(config.env.environment === 'localtest'
? { machineTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone }
: {}),
},
}));
return getConfigurationByFile(config.env.environment).then((fileConfig) => {
const configured = {
...config,
...fileConfig,
env: {
...config.env,
...fileConfig.env,
},
expose: {
...config.expose,
...fileConfig.expose,
// Specs that assert on backend-local date/time values need the backend's timezone.
// Only in localtest does the app backend run on the same machine as Cypress, so only
// then is the machine timezone valid - read it here in the Node process, since the
// browser's timezone may be emulated via CDP and cannot be trusted. Against remote
// environments (tt02) this is deliberately left unset; specs fall back to UTC, which
// is what those backends run in.
...(config.env.environment === 'localtest'
? { machineTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone }
: {}),
},
};

return configureCypressShard(configured, {
specRoot: path.resolve(__dirname, 'test/e2e/integration'),
timingsFile: path.resolve(__dirname, 'test/e2e/cypress-timings.json'),
total: process.env.E2E_SHARD_TOTAL,
number: process.env.E2E_SHARD_NUMBER,
});
});
}

throw new Error(`Unknown environment "${config.env.environment}"
Expand Down
85 changes: 85 additions & 0 deletions src/App/frontend/scripts/cypress-shard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/* eslint-disable @typescript-eslint/no-require-imports, no-console */
const fs = require('node:fs');
const path = require('node:path');

function configureCypressShard(config, { specRoot, timingsFile, total, number }) {
if (total === undefined && number === undefined) {
return config;
}

const shardTotal = parseInteger(total, 'E2E_SHARD_TOTAL');
const shardNumber = parseInteger(number, 'E2E_SHARD_NUMBER');
const shardIndex = shardNumber - 1;
if (shardTotal < 1) {
throw new Error('E2E_SHARD_TOTAL must be greater than zero.');
}
if (shardNumber < 1 || shardNumber > shardTotal) {
throw new Error(`E2E_SHARD_NUMBER must be between 1 and ${shardTotal}.`);
}

const specs = findSpecs(specRoot);
if (shardTotal > specs.length) {
throw new Error(`Cannot split ${specs.length} Cypress specs across ${shardTotal} shards.`);
}

const timingData = JSON.parse(fs.readFileSync(timingsFile, 'utf8')).durations;
const durations = new Map(timingData.map(({ spec, duration }) => [spec, duration]));
const averageDuration = timingData.reduce((sum, { duration }) => sum + duration, 0) / timingData.length;
const shards = Array.from({ length: shardTotal }, () => ({ duration: 0, specs: [] }));
let specsWithoutTimings = 0;

const weightedSpecs = specs
.map((spec) => {
const relativeSpec = toPosixPath(path.relative(config.projectRoot, spec));
const duration = durations.get(relativeSpec);
if (duration === undefined) {
specsWithoutTimings += 1;
}
return { spec, duration: duration ?? averageDuration };
})
.sort((left, right) => right.duration - left.duration || left.spec.localeCompare(right.spec));

for (const spec of weightedSpecs) {
const shard = shards.reduce((shortest, candidate) =>
candidate.duration < shortest.duration ? candidate : shortest,
);
shard.specs.push(spec.spec);
shard.duration += spec.duration;
}

const selectedShard = shards[shardIndex];
config.specPattern = selectedShard.specs.sort();
const missingTimingMessage =
specsWithoutTimings > 0
? ` (${specsWithoutTimings} ${specsWithoutTimings === 1 ? 'spec uses' : 'specs use'} the historical average)`
: '';
console.log(
`Cypress shard ${shardNumber}/${shardTotal}: ${selectedShard.specs.length} specs, ${Math.round(selectedShard.duration / 1000)} estimated seconds${missingTimingMessage}`,
);

return config;
}

function findSpecs(directory) {
return fs
.readdirSync(directory, { withFileTypes: true })
.flatMap((entry) => {
const entryPath = path.join(directory, entry.name);
return entry.isDirectory() ? findSpecs(entryPath) : entryPath.endsWith('.ts') ? [entryPath] : [];
})
.sort();
}

function parseInteger(value, name) {
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new Error(`${name} must be an integer.`);
}
return parsed;
}

function toPosixPath(value) {
return value.split(path.sep).join('/');
}

module.exports = configureCypressShard;
Loading
Loading