From 477cd5c3f640c0afce5e61d9d595d6104d575aaf Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Wed, 26 Aug 2026 09:19:17 +0000 Subject: [PATCH 01/13] Add image-loader to the list of running services --- e2e-tests/README.md | 2 +- e2e-tests/global-setup.ts | 12 +++++++++--- e2e-tests/images/Dockerfile | 18 ++++++++++-------- e2e-tests/images/README.md | 4 ++-- e2e-tests/images/entrypoint.common.sh | 3 ++- e2e-tests/testcontainers/constants.ts | 1 + 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/e2e-tests/README.md b/e2e-tests/README.md index beda695272..09df0ac120 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -55,7 +55,7 @@ Playwright's `globalSetup` ([`global-setup.ts`](global-setup.ts)) uses 3. the **CloudFormation core stack** (`dev/cloudformation/grid-dev-core.yml`) and seeded buckets, provisioned into LocalStack; 4. per-service config, generated by `dev/script/generate-config/service-config.js`; -5. the Grid Docker image (either `grid-e2e-ci` or `grid-e2e-dev`) — a single container running all eight Play services. +5. the Grid Docker image (either `grid-e2e-ci` or `grid-e2e-dev`) — a single container running all nine Play services. Elasticsearch is then seeded with image fixtures, and the resolved Kahuna base URL is exposed to the tests. `globalTeardown` stops everything and cleans up. diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 6431789a9b..7248f9f68a 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -6,7 +6,7 @@ * 2. Elasticsearch + LocalStack (infrastructure), * 3. the CloudFormation core stack + seeded buckets (provisioning), * 4. generated per-service config (reusing dev/script/generate-config), - * 5. the pre-built `grid-e2e-ci` image running all eight services. + * 5. the pre-built `grid-e2e-ci` image running all nine services. * * The Kahuna base URL is exposed to tests via `GRID_BASE_URL`, and the started * containers are stashed for `global-teardown.ts`. @@ -95,6 +95,7 @@ function buildCaddyfile(coreStackProps: Record): string { const appServices: Record = { [`media.${DOMAIN}`]: SERVICE_PORTS.kahuna, [`api.media.${DOMAIN}`]: SERVICE_PORTS['media-api'], + [`loader.media.${DOMAIN}`]: SERVICE_PORTS['image-loader'], [`cropper.media.${DOMAIN}`]: SERVICE_PORTS.cropper, [`thrall.media.${DOMAIN}`]: SERVICE_PORTS.thrall, [`media-metadata.${DOMAIN}`]: SERVICE_PORTS['metadata-editor'], @@ -177,7 +178,7 @@ async function globalSetup(): Promise { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'grid-config-')); generateServiceConfig(configDir, coreStackProps); - // All eight Grid services run inside this single container and talk to each + // All nine Grid services run inside this single container and talk to each // other over its localhost. Each is published on the fixed host port its // dev-nginx mapping expects (dev/nginx-mappings.yml), so the developer's // dev-nginx routes the https://*.media. domains straight into this @@ -200,7 +201,12 @@ async function globalSetup(): Promise { AWS_DEFAULT_REGION: REGION, AWS_CBOR_DISABLE: 'true', }) - .withWaitStrategy(Wait.forHttp('/management/healthcheck', MEDIA_API_PORT).forStatusCode(200)) + .withWaitStrategy( + Wait.forAll([ + Wait.forHttp('/management/healthcheck', MEDIA_API_PORT).forStatusCode(200), + Wait.forHttp('/management/healthcheck', SERVICE_PORTS['image-loader']).forStatusCode(200), + ]), + ) .withStartupTimeout(startupTimeoutMs); if (process.env.GRID_DEBUG) { diff --git a/e2e-tests/images/Dockerfile b/e2e-tests/images/Dockerfile index 265365f3c4..430c2a911e 100644 --- a/e2e-tests/images/Dockerfile +++ b/e2e-tests/images/Dockerfile @@ -1,8 +1,8 @@ # syntax=docker/dockerfile:1 -# Single-container image that builds and runs eight Grid Play services -# (auth, collections, cropper, kahuna, leases, media-api, metadata-editor, -# thrall) together with Kahuna's frontend. +# Single-container image that builds and runs nine Grid Play services +# (auth, collections, cropper, image-loader, kahuna, leases, media-api, +# metadata-editor, thrall) together with Kahuna's frontend. # # This one Dockerfile produces two images, selected with `--target`: # @@ -75,6 +75,7 @@ RUN sbt \ "auth/stage" \ "collections/stage" \ "cropper/stage" \ + "image-loader/stage" \ "kahuna/stage" \ "leases/stage" \ "media-api/stage" \ @@ -97,6 +98,7 @@ RUN set -eux; \ COPY --from=backend /build/auth/target/universal/stage /usr/share/auth COPY --from=backend /build/collections/target/universal/stage /usr/share/collections COPY --from=backend /build/cropper/target/universal/stage /usr/share/cropper +COPY --from=backend /build/image-loader/target/universal/stage /usr/share/image-loader COPY --from=backend /build/kahuna/target/universal/stage /usr/share/kahuna COPY --from=backend /build/leases/target/universal/stage /usr/share/leases COPY --from=backend /build/media-api/target/universal/stage /usr/share/media-api @@ -104,7 +106,7 @@ COPY --from=backend /build/metadata-editor/target/universal/stage /usr/share/met COPY --from=backend /build/thrall/target/universal/stage /usr/share/thrall RUN set -eux; \ - for service in auth collections cropper kahuna leases media-api metadata-editor thrall; do \ + for service in auth collections cropper image-loader kahuna leases media-api metadata-editor thrall; do \ mkdir -p "/var/log/$service"; \ done @@ -115,8 +117,8 @@ RUN chmod +x /usr/local/bin/entrypoint.ci.sh # The Java SDK CBOR protocol is disabled for Localstack/Kinesis compatibility. ENV AWS_CBOR_DISABLE=true -# media-api, thrall, kahuna, cropper, metadata-editor, collections, auth, leases -EXPOSE 9001 9002 9005 9006 9007 9010 9011 9012 +# media-api, thrall, image-loader, kahuna, cropper, metadata-editor, collections, auth, leases +EXPOSE 9001 9002 9003 9005 9006 9007 9010 9011 9012 ENTRYPOINT ["/usr/local/bin/entrypoint.ci.sh"] @@ -173,9 +175,9 @@ RUN chmod +x /usr/local/bin/entrypoint.dev.sh # The Java SDK CBOR protocol is disabled for Localstack/Kinesis compatibility. ENV AWS_CBOR_DISABLE=true -# media-api, thrall, kahuna, cropper, metadata-editor, collections, auth, leases +# media-api, thrall, image-loader, kahuna, cropper, metadata-editor, collections, auth, leases # plus the JDWP debug port used when GRID_DEBUG is set. -EXPOSE 9001 9002 9005 9006 9007 9010 9011 9012 5005 +EXPOSE 9001 9002 9003 9005 9006 9007 9010 9011 9012 5005 ENTRYPOINT ["/usr/local/bin/entrypoint.dev.sh"] diff --git a/e2e-tests/images/README.md b/e2e-tests/images/README.md index 5ca9ecc976..d3cec50f94 100644 --- a/e2e-tests/images/README.md +++ b/e2e-tests/images/README.md @@ -1,6 +1,6 @@ c# Grid E2E test images -A single `Dockerfile` produces two single-container images that run eight +A single `Dockerfile` produces two single-container images that run nine Grid Play services plus Kahuna's frontend. You can select between CI and local-dev images using the `--target` parameter: - **CI image** (`--target ci`, tagged `grid-e2e-ci`): stages pre-compiled artefacts and runs them in a production-style JRE. Used by the e2e-tests testcontainers harness. - **Local-dev image** (`--target dev`, tagged `grid-e2e-dev`): runs the services under `sbt /run` (Play dev mode) with the repo bind-mounted, so source changes recompile live. See [Development image (live reload)](#development-image-live-reload) below. @@ -24,7 +24,7 @@ from `/etc/grid/stage`; defaults to `DEV`). Mount your environment's config with ```bash docker run --rm \ -v "$PWD/my-config:/etc/grid:ro" \ - -p 9001:9001 -p 9002:9002 -p 9005:9005 -p 9006:9006 \ + -p 9001:9001 -p 9002:9002 -p 9003:9003 -p 9005:9005 -p 9006:9006 \ -p 9007:9007 -p 9010:9010 -p 9011:9011 -p 9012:9012 \ grid-e2e-[ci|dev] ``` diff --git a/e2e-tests/images/entrypoint.common.sh b/e2e-tests/images/entrypoint.common.sh index 52e2342b16..f4c9666ceb 100644 --- a/e2e-tests/images/entrypoint.common.sh +++ b/e2e-tests/images/entrypoint.common.sh @@ -9,6 +9,7 @@ declare -A PORTS=( [media-api]=9001 [thrall]=9002 + [image-loader]=9003 [kahuna]=9005 [cropper]=9006 [metadata-editor]=9007 @@ -17,7 +18,7 @@ declare -A PORTS=( [leases]=9012 ) -export SERVICES="auth collections cropper kahuna leases media-api metadata-editor thrall" +export SERVICES="auth collections cropper image-loader kahuna leases media-api metadata-editor thrall" # Install a TERM/INT trap that kills the given child pids, waits for them, and # exits cleanly. Pass the pids to tear down as arguments, e.g. diff --git a/e2e-tests/testcontainers/constants.ts b/e2e-tests/testcontainers/constants.ts index 6d8cdb7ecc..6e3386cad9 100644 --- a/e2e-tests/testcontainers/constants.ts +++ b/e2e-tests/testcontainers/constants.ts @@ -33,6 +33,7 @@ export const GRID_ALIAS = 'grid-e2e-ci'; export const SERVICE_PORTS: Record = { 'media-api': 9001, thrall: 9002, + 'image-loader': 9003, kahuna: 9005, cropper: 9006, 'metadata-editor': 9007, From 9208cccf71364a89ffc501ed59006188c7a8744b Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Wed, 26 Aug 2026 10:25:54 +0000 Subject: [PATCH 02/13] Add imgops service to testcontainers setup --- e2e-tests/global-setup.ts | 26 +++++++++++++++++++++++++- e2e-tests/testcontainers/constants.ts | 10 ++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 7248f9f68a..1b3aaeb5a9 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -3,7 +3,7 @@ * * Boots the full local Grid stack with Testcontainers: * 1. a shared network, - * 2. Elasticsearch + LocalStack (infrastructure), + * 2. Elasticsearch + LocalStack + imgops (infrastructure), * 3. the CloudFormation core stack + seeded buckets (provisioning), * 4. generated per-service config (reusing dev/script/generate-config), * 5. the pre-built `grid-e2e-ci` image running all nine services. @@ -23,6 +23,10 @@ import { ELASTICSEARCH_IMAGE, GRID_ALIAS, GRID_IMAGE, + IMGOPS_ALIAS, + IMGOPS_CONTEXT, + IMGOPS_NGINX_CONF, + IMGOPS_PORT, KAHUNA_PORT, LOCALSTACK_ALIAS, LOCALSTACK_IMAGE, @@ -127,6 +131,11 @@ function buildCaddyfile(coreStackProps: Record): string { `localstack.media.${DOMAIN} {\n\ttls internal\n\treverse_proxy ${LOCALSTACK_ALIAS}:${LOCALSTACK_PORT}\n}`, ); + // On-the-fly image resizing (optimised / full-screen views) -> the imgops container. + blocks.push( + `media-imgops.${DOMAIN} {\n\ttls internal\n\treverse_proxy ${IMGOPS_ALIAS}:80\n}`, + ); + return `${blocks.join('\n\n')}\n`; } @@ -172,6 +181,21 @@ async function globalSetup(): Promise { .start(); started.push(localstack); + // imgops: standalone nginx image resizer, built from dev/imgops. Its nginx.conf proxies to + // the `localstack` alias on 4566, so it shares this network. Published on the fixed host + // port dev-nginx maps `media-imgops` to; in CI the Caddy proxy routes to it instead. The + // Dockerfile doesn't bake in nginx.conf (docker-compose bind-mounts it), so copy it in. + const imgopsImage = await GenericContainer.fromDockerfile(IMGOPS_CONTEXT).build(); + const imgops = await imgopsImage + .withNetwork(network) + .withNetworkAliases(IMGOPS_ALIAS) + .withCopyFilesToContainer([{ source: IMGOPS_NGINX_CONF, target: '/etc/nginx/nginx.conf' }]) + .withExposedPorts({ container: 80, host: IMGOPS_PORT }) + .withWaitStrategy(Wait.forHttp('/_', 80).forStatusCode(200)) + .withStartupTimeout(120_000) + .start(); + started.push(imgops); + // Provisioning + config generation const coreStackProps = await provisionCoreStack(localstack.getConnectionUri()); diff --git a/e2e-tests/testcontainers/constants.ts b/e2e-tests/testcontainers/constants.ts index 6e3386cad9..0b468f3bec 100644 --- a/e2e-tests/testcontainers/constants.ts +++ b/e2e-tests/testcontainers/constants.ts @@ -22,6 +22,16 @@ export const LOCALSTACK_IMAGE = 'localstack/localstack:4.5.0'; /** Reverse proxy used in CI to stand in for the developer's dev-nginx (see global-setup). */ export const PROXY_IMAGE = 'caddy:2.8-alpine'; +/** + * imgops: standalone nginx on-the-fly image resizer, built from dev/imgops at setup time. + * Its nginx.conf proxies to the `localstack` alias on 4566, so it shares the stack network. + * Published on IMGOPS_PORT, the fixed host port dev-nginx maps `media-imgops` to. + */ +export const IMGOPS_ALIAS = 'imgops'; +export const IMGOPS_PORT = 9008; +export const IMGOPS_CONTEXT = path.join(REPO_ROOT, 'dev', 'imgops'); +export const IMGOPS_NGINX_CONF = path.join(IMGOPS_CONTEXT, 'nginx.conf'); + /** Network aliases the app container uses to reach the infrastructure containers. */ export const ELASTICSEARCH_ALIAS = 'elasticsearch'; export const LOCALSTACK_ALIAS = 'localstack'; From 57ad17cdf9518cfd58c3967f4026bf543f674b7d Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Wed, 2 Sep 2026 09:44:45 +0100 Subject: [PATCH 03/13] Add localstack.media, and provide a unique configuration var for the presign service --- .../scala/com/gu/mediaservice/lib/aws/S3.scala | 6 ++++-- .../gu/mediaservice/lib/config/CommonConfig.scala | 15 +++++++++++++++ e2e-tests/global-setup.ts | 3 +++ e2e-tests/package.json | 3 ++- e2e-tests/testcontainers/config.ts | 10 +++++++++- 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala index 2b3cd2a09f..73b63dd8db 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala @@ -226,8 +226,10 @@ object S3Ops { .credentialsProvider(config.awsCredentials) .region(config.awsRegion) - config.awsLocalEndpointUri match { - case Some(endpoint) if config.isDev => builder.endpointOverride(endpoint) + config.awsLocalPresigningEndpointUri match { + case Some(endpoint) if config.isDev => + println(s"Using localPresigningEndpointUri $endpoint") + builder.endpointOverride(endpoint) .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()).build() case _ => builder.build() diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala index 60ebefabcf..c155be5f10 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala @@ -30,6 +30,21 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientBui val awsLocalEndpoint: Option[String] = if(isDev) stringOpt("aws.local.endpoint").filter(_.nonEmpty) else None override val awsLocalEndpointUri: Option[URI] = awsLocalEndpoint.map(new URI(_)) + // Endpoint baked into presigned URLs handed to the browser. Distinct from `aws.local.endpoint` + // (used by the S3 client for container-internal calls) because the browser cannot resolve the + // container-network host. Falls back to `aws.local.endpoint` when unset. + println("PRESIGN ENDPOINTS") + println(stringOpt("aws.local.presigningEndpoint")) + println(awsLocalEndpoint) + val awsLocalPresigningEndpointUri: Option[URI] = + if (isDev) stringOpt("aws.local.presigningEndpoint") + .filter(_.nonEmpty) + .orElse(awsLocalEndpoint) + .map(new URI(_)) + else None + + println(awsLocalPresigningEndpointUri) + val useLocalAuth: Boolean = isDev && boolean("auth.useLocal") val localLogShipping: Boolean = sys.env.getOrElse("LOCAL_LOG_SHIPPING", "false").toBoolean diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 1b3aaeb5a9..4e759ba5d6 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -112,8 +112,11 @@ function buildCaddyfile(coreStackProps: Record): string { const imageBuckets: Record = { [`images.media.${DOMAIN}`]: coreStackProps.ImageBucket, [`public.media.${DOMAIN}`]: coreStackProps.ImageOriginBucket, + [`localstack.media.${DOMAIN}`]: coreStackProps.IngestQueueBucket }; + console.log({coreStackProps}) + const blocks: string[] = []; for (const [siteHost, port] of Object.entries(appServices)) { diff --git a/e2e-tests/package.json b/e2e-tests/package.json index af46044d32..33f1ab316c 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -7,7 +7,8 @@ "test": "bddgen && playwright test", "test:headed": "bddgen && playwright test --headed", "test:report": "playwright show-report", - "test:ui": "bddgen && playwright test --ui-port 6080" + "test:ui": "bddgen && playwright test --ui-port 6080", + "test:log": "bddgen && DEBUG=testcontainers* playwright test --ui-port 6080" }, "keywords": [], "author": "", diff --git a/e2e-tests/testcontainers/config.ts b/e2e-tests/testcontainers/config.ts index 3b6108faa3..24ddf25579 100644 --- a/e2e-tests/testcontainers/config.ts +++ b/e2e-tests/testcontainers/config.ts @@ -34,13 +34,21 @@ function rewriteEndpoints(conf: string): string { const guardianLocalstackUrl = `https://${LOCALSTACK_ALIAS}.media.${DOMAIN}`; const legacyLocalstackUrl = 'http://localhost:4576'; const localLocalstackUrl = `http://localhost:${LOCALSTACK_PORT}`; - return conf + + const rewrittenConf = conf .split(guardianLocalstackUrl) .join(localstackUrl) .split(legacyLocalstackUrl) .join(localstackUrl) .split(localLocalstackUrl) .join(localstackUrl); + + // The S3 client reaches LocalStack over the container network (localstack:4566), but + // presigned URLs are handed to the browser, which can only reach LocalStack via the + // `localstack.media.` vanity domain (dev-nginx locally, the Caddy proxy in CI). + // Sign against that host so the URLs resolve outside the container network. + console.log(`${rewrittenConf}\naws.local.presigningEndpoint="${guardianLocalstackUrl}"\n`); + return `${rewrittenConf}\naws.local.presigningEndpoint="${guardianLocalstackUrl}"\n`; } /** From 8a8c2e0f0be8a5b1ff3e8838bb249bb285d48b50 Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Wed, 2 Sep 2026 11:26:22 +0000 Subject: [PATCH 04/13] Add localstack to devcontainer forwarded ports; mount project into container when not in CI --- .devcontainer/devenv.yaml | 1 + .devcontainer/shared/devcontainer.json | 1 + .devcontainer/user/devcontainer.json | 1 + e2e-tests/global-setup.ts | 4 ++++ 4 files changed, 7 insertions(+) diff --git a/.devcontainer/devenv.yaml b/.devcontainer/devenv.yaml index 661a33352a..f11896105a 100644 --- a/.devcontainer/devenv.yaml +++ b/.devcontainer/devenv.yaml @@ -27,6 +27,7 @@ forwardPorts: - 9010 - 9011 - 9012 + - 4566 - 6080 # Optional: Mount directories from host to container diff --git a/.devcontainer/shared/devcontainer.json b/.devcontainer/shared/devcontainer.json index da4c20e25b..8796a02a51 100644 --- a/.devcontainer/shared/devcontainer.json +++ b/.devcontainer/shared/devcontainer.json @@ -29,6 +29,7 @@ 9010, 9011, 9012, + 4566, 6080 ], "image" : "mcr.microsoft.com/devcontainers/base:ubuntu26.04", diff --git a/.devcontainer/user/devcontainer.json b/.devcontainer/user/devcontainer.json index 59b78adfb8..3abef9f164 100644 --- a/.devcontainer/user/devcontainer.json +++ b/.devcontainer/user/devcontainer.json @@ -29,6 +29,7 @@ 9010, 9011, 9012, + 4566, 6080 ], "image" : "mcr.microsoft.com/devcontainers/base:ubuntu26.04", diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 4e759ba5d6..d5779fdec9 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -34,6 +34,7 @@ import { MEDIA_API_PORT, PROXY_IMAGE, REGION, + REPO_ROOT, SERVICE_PORTS, URLS_FILE, } from './testcontainers/constants'; @@ -220,6 +221,9 @@ async function globalSetup(): Promise { // DEV stage reads ~/.grid; /etc/grid is honoured for non-DEV stages. Mount both. { source: configDir, target: '/root/.grid', mode: 'ro' }, { source: configDir, target: '/etc/grid', mode: 'ro' }, + // Outside CI the grid-e2e-dev image runs services under sbt; mount the repo + // over /build so host edits recompile live. + ...(process.env.CI ? [] : [{ source: REPO_ROOT, target: '/build', mode: 'rw' as const }]), ]) .withEnvironment({ AWS_ACCESS_KEY_ID: 'test', From 4c2d4cd750bca635267bd936990f9eb06c7c739f Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Wed, 2 Sep 2026 15:08:00 +0000 Subject: [PATCH 05/13] Add stable tag for imgops container to stop it rebuilding every startup --- e2e-tests/global-setup.ts | 5 ++++- e2e-tests/testcontainers/constants.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index d5779fdec9..0952720a08 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -25,6 +25,7 @@ import { GRID_IMAGE, IMGOPS_ALIAS, IMGOPS_CONTEXT, + IMGOPS_IMAGE, IMGOPS_NGINX_CONF, IMGOPS_PORT, KAHUNA_PORT, @@ -189,7 +190,9 @@ async function globalSetup(): Promise { // the `localstack` alias on 4566, so it shares this network. Published on the fixed host // port dev-nginx maps `media-imgops` to; in CI the Caddy proxy routes to it instead. The // Dockerfile doesn't bake in nginx.conf (docker-compose bind-mounts it), so copy it in. - const imgopsImage = await GenericContainer.fromDockerfile(IMGOPS_CONTEXT).build(); + const imgopsImage = await GenericContainer.fromDockerfile(IMGOPS_CONTEXT).build(IMGOPS_IMAGE, { + deleteOnExit: false, + }); const imgops = await imgopsImage .withNetwork(network) .withNetworkAliases(IMGOPS_ALIAS) diff --git a/e2e-tests/testcontainers/constants.ts b/e2e-tests/testcontainers/constants.ts index 0b468f3bec..04c858250b 100644 --- a/e2e-tests/testcontainers/constants.ts +++ b/e2e-tests/testcontainers/constants.ts @@ -31,6 +31,8 @@ export const IMGOPS_ALIAS = 'imgops'; export const IMGOPS_PORT = 9008; export const IMGOPS_CONTEXT = path.join(REPO_ROOT, 'dev', 'imgops'); export const IMGOPS_NGINX_CONF = path.join(IMGOPS_CONTEXT, 'nginx.conf'); +/** Stable tag for the imgops image so it persists across runs and Docker reuses cached layers. */ +export const IMGOPS_IMAGE = 'grid-e2e-imgops'; /** Network aliases the app container uses to reach the infrastructure containers. */ export const ELASTICSEARCH_ALIAS = 'elasticsearch'; From e7f3cb909c2fc07d4d729777fbb7d904bd4f80ca Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Wed, 2 Sep 2026 15:08:52 +0000 Subject: [PATCH 06/13] Wait for all Grid services to come up before reporting the container as healthy --- e2e-tests/global-setup.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 0952720a08..aa8dea3545 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -236,10 +236,9 @@ async function globalSetup(): Promise { AWS_CBOR_DISABLE: 'true', }) .withWaitStrategy( - Wait.forAll([ - Wait.forHttp('/management/healthcheck', MEDIA_API_PORT).forStatusCode(200), - Wait.forHttp('/management/healthcheck', SERVICE_PORTS['image-loader']).forStatusCode(200), - ]), + Wait.forAll(Object.values(SERVICE_PORTS).map(port => + Wait.forHttp('/management/healthcheck', port).forStatusCode(200), + )) ) .withStartupTimeout(startupTimeoutMs); From 98240e0deed2e410a0f391a5857f166223c3b3fa Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Wed, 2 Sep 2026 15:19:22 +0000 Subject: [PATCH 07/13] Tidy up after a manual review --- .../src/main/scala/com/gu/mediaservice/lib/aws/S3.scala | 6 ++---- e2e-tests/README.md | 2 +- e2e-tests/global-setup.ts | 2 +- e2e-tests/images/Dockerfile | 8 ++++---- e2e-tests/images/README.md | 4 ++-- e2e-tests/testcontainers/config.ts | 2 +- 6 files changed, 11 insertions(+), 13 deletions(-) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala index 73b63dd8db..2b3cd2a09f 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala @@ -226,10 +226,8 @@ object S3Ops { .credentialsProvider(config.awsCredentials) .region(config.awsRegion) - config.awsLocalPresigningEndpointUri match { - case Some(endpoint) if config.isDev => - println(s"Using localPresigningEndpointUri $endpoint") - builder.endpointOverride(endpoint) + config.awsLocalEndpointUri match { + case Some(endpoint) if config.isDev => builder.endpointOverride(endpoint) .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()).build() case _ => builder.build() diff --git a/e2e-tests/README.md b/e2e-tests/README.md index 09df0ac120..89dcb68994 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -55,7 +55,7 @@ Playwright's `globalSetup` ([`global-setup.ts`](global-setup.ts)) uses 3. the **CloudFormation core stack** (`dev/cloudformation/grid-dev-core.yml`) and seeded buckets, provisioned into LocalStack; 4. per-service config, generated by `dev/script/generate-config/service-config.js`; -5. the Grid Docker image (either `grid-e2e-ci` or `grid-e2e-dev`) — a single container running all nine Play services. +5. the Grid Docker image (either `grid-e2e-ci` or `grid-e2e-dev`) — a single container running Grid's Play services. Elasticsearch is then seeded with image fixtures, and the resolved Kahuna base URL is exposed to the tests. `globalTeardown` stops everything and cleans up. diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index aa8dea3545..4527b514df 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -209,7 +209,7 @@ async function globalSetup(): Promise { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'grid-config-')); generateServiceConfig(configDir, coreStackProps); - // All nine Grid services run inside this single container and talk to each + // All Grid services under test run inside this single container and talk to each // other over its localhost. Each is published on the fixed host port its // dev-nginx mapping expects (dev/nginx-mappings.yml), so the developer's // dev-nginx routes the https://*.media. domains straight into this diff --git a/e2e-tests/images/Dockerfile b/e2e-tests/images/Dockerfile index 430c2a911e..579c271a5b 100644 --- a/e2e-tests/images/Dockerfile +++ b/e2e-tests/images/Dockerfile @@ -1,14 +1,14 @@ # syntax=docker/dockerfile:1 -# Single-container image that builds and runs nine Grid Play services +# Single-container image that builds and runs all Grid Play services under test # (auth, collections, cropper, image-loader, kahuna, leases, media-api, # metadata-editor, thrall) together with Kahuna's frontend. # # This one Dockerfile produces two images, selected with `--target`: # -# * ci (grid-e2e-ci) — CI/production style. Stages pre-compiled artefacts -# and runs them under a slim JRE. -# docker build --target ci -f e2e-tests/images/Dockerfile -t grid-e2e-ci . +# * ci (grid-e2e-ci) — CI/production style. Stages pre-compiled artefacts and +# runs them under a slim JRE. docker build --target ci +# -f e2e-tests/images/Dockerfile -t grid-e2e-ci . # # * dev (grid-e2e-dev) — local development. Runs the services under # `sbt /run` (Play dev mode) with the repo diff --git a/e2e-tests/images/README.md b/e2e-tests/images/README.md index d3cec50f94..c23ff06cd7 100644 --- a/e2e-tests/images/README.md +++ b/e2e-tests/images/README.md @@ -1,7 +1,7 @@ c# Grid E2E test images -A single `Dockerfile` produces two single-container images that run nine -Grid Play services plus Kahuna's frontend. You can select between CI and local-dev images using the `--target` parameter: +A single `Dockerfile` produces two single-container images that run all +Grid Play services under test, plus Kahuna's frontend. You can select between CI and local-dev images using the `--target` parameter: - **CI image** (`--target ci`, tagged `grid-e2e-ci`): stages pre-compiled artefacts and runs them in a production-style JRE. Used by the e2e-tests testcontainers harness. - **Local-dev image** (`--target dev`, tagged `grid-e2e-dev`): runs the services under `sbt /run` (Play dev mode) with the repo bind-mounted, so source changes recompile live. See [Development image (live reload)](#development-image-live-reload) below. diff --git a/e2e-tests/testcontainers/config.ts b/e2e-tests/testcontainers/config.ts index 24ddf25579..8021abf552 100644 --- a/e2e-tests/testcontainers/config.ts +++ b/e2e-tests/testcontainers/config.ts @@ -47,7 +47,6 @@ function rewriteEndpoints(conf: string): string { // presigned URLs are handed to the browser, which can only reach LocalStack via the // `localstack.media.` vanity domain (dev-nginx locally, the Caddy proxy in CI). // Sign against that host so the URLs resolve outside the container network. - console.log(`${rewrittenConf}\naws.local.presigningEndpoint="${guardianLocalstackUrl}"\n`); return `${rewrittenConf}\naws.local.presigningEndpoint="${guardianLocalstackUrl}"\n`; } @@ -97,6 +96,7 @@ export function generateServiceConfig(configDir: string, coreStackProps: StackPr if (!conf) { throw new Error(`service-config.js did not produce config for '${service}'`); } + fs.writeFileSync(path.join(configDir, `${service}.conf`), rewriteEndpoints(conf)); } } From 3612f487dde771f62d58117c2458e4a4a88ac08a Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Wed, 2 Sep 2026 15:46:33 +0000 Subject: [PATCH 08/13] Correct presigning config --- .../src/main/scala/com/gu/mediaservice/lib/aws/S3.scala | 2 +- .../scala/com/gu/mediaservice/lib/config/CommonConfig.scala | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala index 2b3cd2a09f..ebfd333496 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/aws/S3.scala @@ -226,7 +226,7 @@ object S3Ops { .credentialsProvider(config.awsCredentials) .region(config.awsRegion) - config.awsLocalEndpointUri match { + config.awsLocalPresigningEndpointUri match { case Some(endpoint) if config.isDev => builder.endpointOverride(endpoint) .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()).build() case _ => builder.build() diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala index c155be5f10..669e49490c 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/config/CommonConfig.scala @@ -33,9 +33,6 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientBui // Endpoint baked into presigned URLs handed to the browser. Distinct from `aws.local.endpoint` // (used by the S3 client for container-internal calls) because the browser cannot resolve the // container-network host. Falls back to `aws.local.endpoint` when unset. - println("PRESIGN ENDPOINTS") - println(stringOpt("aws.local.presigningEndpoint")) - println(awsLocalEndpoint) val awsLocalPresigningEndpointUri: Option[URI] = if (isDev) stringOpt("aws.local.presigningEndpoint") .filter(_.nonEmpty) @@ -43,8 +40,6 @@ abstract class CommonConfig(resources: GridConfigResources) extends AwsClientBui .map(new URI(_)) else None - println(awsLocalPresigningEndpointUri) - val useLocalAuth: Boolean = isDev && boolean("auth.useLocal") val localLogShipping: Boolean = sys.env.getOrElse("LOCAL_LOG_SHIPPING", "false").toBoolean From 1c61f4ead7db069d323c9eba231e2017cc841a8d Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Wed, 2 Sep 2026 16:10:20 +0000 Subject: [PATCH 09/13] Remove unnecessary caddy entry, the localstack entry already exists --- e2e-tests/global-setup.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 4527b514df..bca9bd1816 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -6,7 +6,7 @@ * 2. Elasticsearch + LocalStack + imgops (infrastructure), * 3. the CloudFormation core stack + seeded buckets (provisioning), * 4. generated per-service config (reusing dev/script/generate-config), - * 5. the pre-built `grid-e2e-ci` image running all nine services. + * 5. the pre-built `grid-e2e-ci` image running the Grid services under test. * * The Kahuna base URL is exposed to tests via `GRID_BASE_URL`, and the started * containers are stashed for `global-teardown.ts`. @@ -113,12 +113,9 @@ function buildCaddyfile(coreStackProps: Record): string { // S3 vanity domains that omit the bucket -> localstack, with the bucket prepended. const imageBuckets: Record = { [`images.media.${DOMAIN}`]: coreStackProps.ImageBucket, - [`public.media.${DOMAIN}`]: coreStackProps.ImageOriginBucket, - [`localstack.media.${DOMAIN}`]: coreStackProps.IngestQueueBucket + [`public.media.${DOMAIN}`]: coreStackProps.ImageOriginBucket }; - console.log({coreStackProps}) - const blocks: string[] = []; for (const [siteHost, port] of Object.entries(appServices)) { @@ -187,9 +184,7 @@ async function globalSetup(): Promise { started.push(localstack); // imgops: standalone nginx image resizer, built from dev/imgops. Its nginx.conf proxies to - // the `localstack` alias on 4566, so it shares this network. Published on the fixed host - // port dev-nginx maps `media-imgops` to; in CI the Caddy proxy routes to it instead. The - // Dockerfile doesn't bake in nginx.conf (docker-compose bind-mounts it), so copy it in. + // the `localstack` alias on 4566, so it shares this network. const imgopsImage = await GenericContainer.fromDockerfile(IMGOPS_CONTEXT).build(IMGOPS_IMAGE, { deleteOnExit: false, }); From c54af614da92f1e71b2e652610f1e3150f05a843 Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Thu, 3 Sep 2026 13:51:24 +0100 Subject: [PATCH 10/13] Add loader-projection service to CI Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- e2e-tests/global-setup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index bca9bd1816..70acafad49 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -102,6 +102,7 @@ function buildCaddyfile(coreStackProps: Record): string { [`media.${DOMAIN}`]: SERVICE_PORTS.kahuna, [`api.media.${DOMAIN}`]: SERVICE_PORTS['media-api'], [`loader.media.${DOMAIN}`]: SERVICE_PORTS['image-loader'], + [`loader-projection.media.${DOMAIN}`]: SERVICE_PORTS['image-loader'], [`cropper.media.${DOMAIN}`]: SERVICE_PORTS.cropper, [`thrall.media.${DOMAIN}`]: SERVICE_PORTS.thrall, [`media-metadata.${DOMAIN}`]: SERVICE_PORTS['metadata-editor'], From 6292737ec965af448c91a611b9de97ef39b8285f Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Thu, 3 Sep 2026 12:50:39 +0000 Subject: [PATCH 11/13] Add 9008 for image-serving vanity urls --- .devcontainer/devenv.yaml | 1 + .devcontainer/shared/devcontainer.json | 1 + .devcontainer/user/devcontainer.json | 1 + 3 files changed, 3 insertions(+) diff --git a/.devcontainer/devenv.yaml b/.devcontainer/devenv.yaml index f11896105a..44306cce15 100644 --- a/.devcontainer/devenv.yaml +++ b/.devcontainer/devenv.yaml @@ -23,6 +23,7 @@ forwardPorts: - 9005 - 9006 - 9007 + - 9008 - 9009 - 9010 - 9011 diff --git a/.devcontainer/shared/devcontainer.json b/.devcontainer/shared/devcontainer.json index 8796a02a51..1bc566af49 100644 --- a/.devcontainer/shared/devcontainer.json +++ b/.devcontainer/shared/devcontainer.json @@ -25,6 +25,7 @@ 9005, 9006, 9007, + 9008, 9009, 9010, 9011, diff --git a/.devcontainer/user/devcontainer.json b/.devcontainer/user/devcontainer.json index 3abef9f164..3c366bf3cd 100644 --- a/.devcontainer/user/devcontainer.json +++ b/.devcontainer/user/devcontainer.json @@ -25,6 +25,7 @@ 9005, 9006, 9007, + 9008, 9009, 9010, 9011, From 988cd58fd5e2d781391400b2adb8ee74ab6a7e31 Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Fri, 28 Aug 2026 13:06:48 +0000 Subject: [PATCH 12/13] Add first pass at upload features --- e2e-tests/features/upload.feature | 322 ++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 e2e-tests/features/upload.feature diff --git a/e2e-tests/features/upload.feature b/e2e-tests/features/upload.feature new file mode 100644 index 0000000000..641d49c585 --- /dev/null +++ b/e2e-tests/features/upload.feature @@ -0,0 +1,322 @@ +Feature: Uploading images to the Grid + This lets an authorised user get images into the Grid by selecting files, + dragging and dropping files or URLs, importing Witness contributions, and + then completing the required metadata while tracking each upload's progress. + + Background: + Given the application stack is running + And I am signed in through pan-domain auth + And I have opened the image upload page + + # --------------------------------------------------------------------------- + # Upload page shell (view.html + controller.js) + # --------------------------------------------------------------------------- + + Scenario: An authorised user sees the upload tools + Given I am permitted to upload images + When the upload page loads + Then I should see the file upload prompt + And I should see my past 50 uploads + And the drag-and-drop uploader should be active + # Evidence: kahuna/public/js/upload/view.html + # Evidence: kahuna/public/js/upload/controller.js + + Scenario: An unauthorised user is told they cannot upload + Given I am not permitted to upload images + When the upload page loads + Then I should see a message that I am not authorised to upload images + And I should see a link to email support + And I should not see the file upload prompt + # Evidence: kahuna/public/js/upload/view.html + # Evidence: kahuna/public/js/upload/controller.js + + Scenario: Returning to search from the upload page + When I choose "Back to search" from the top bar + Then I should be taken to the image search page + # Evidence: kahuna/public/js/upload/view.html + + Scenario: The current uploads section only appears while an upload is running + Given I have an upload in progress + When the upload page loads + Then I should see my current uploads section + # Evidence: kahuna/public/js/upload/view.html + # Evidence: kahuna/public/js/upload/controller.js + + Scenario: Viewing all of my uploads + When I choose "View all your uploads" + Then I should be taken to a search filtered to images I uploaded + # Evidence: kahuna/public/js/upload/view.html + + Scenario: Warning before leaving the page with uploads in progress + Given I have an upload in progress + When I try to navigate away from the upload page + Then I should be warned that uploads are in progress and asked to confirm + # Evidence: kahuna/public/js/upload/controller.js + + # --------------------------------------------------------------------------- + # File upload prompt (prompt/prompt.html + prompt.js) + # --------------------------------------------------------------------------- + + Scenario: The prompt explains how to upload + When the upload page loads + Then I should see a message telling me to drag and drop or click to upload to the system + # Evidence: kahuna/public/js/upload/prompt/prompt.html + # Evidence: kahuna/public/js/upload/prompt/prompt.js + + Scenario: The prompt suggests an example label when no labels are applied + Given I have not applied any preset labels + When the upload page loads + Then I should see a suggested example label to apply to all uploads + # Evidence: kahuna/public/js/upload/prompt/prompt.html + # Evidence: kahuna/public/js/upload/prompt/prompt.js + + Scenario: Preset labels are applied to all uploads + When I add a preset label in the prompt + Then that label should be applied to all my uploads + # Evidence: kahuna/public/js/upload/prompt/prompt.html + # Evidence: kahuna/public/js/upload/prompt/prompt.js + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + + # --------------------------------------------------------------------------- + # Select-files uploader (file-uploader.html + file-uploader.js) + # --------------------------------------------------------------------------- + + Scenario: Opening the file picker from the Upload button + When I click the "Upload" button + Then the system file picker should open + # Evidence: kahuna/public/js/upload/file-uploader.html + # Evidence: kahuna/public/js/upload/file-uploader.js + + Scenario: Selecting files queues them for upload + When I select one or more image files to upload + Then those files should be queued for upload + And I should be taken to the upload progress view + # Evidence: kahuna/public/js/upload/file-uploader.html + # Evidence: kahuna/public/js/upload/file-uploader.js + # Evidence: kahuna/public/js/upload/manager.js + + Scenario: Files above the size limit are skipped with a warning + Given an upload size limit is configured + When I select a file that is larger than the size limit + Then I should be warned that the oversized file will be skipped + And only the files within the limit should be queued for upload + # Evidence: kahuna/public/js/upload/manager.js + + # --------------------------------------------------------------------------- + # Drag-and-drop uploader (dnd-uploader.html + dnd-uploader.js) + # --------------------------------------------------------------------------- + + Scenario: Dragging valid content over the page shows the dropzone + When I drag files over the upload page + Then the dropzone overlay should appear with an explanation + # Evidence: kahuna/public/js/upload/dnd-uploader.html + # Evidence: kahuna/public/js/upload/dnd-uploader.js + + Scenario: The dropzone hides when I stop dragging + Given the dropzone overlay is showing + When I drag away from the upload page + Then the dropzone overlay should disappear + # Evidence: kahuna/public/js/upload/dnd-uploader.html + # Evidence: kahuna/public/js/upload/dnd-uploader.js + + Scenario: Dropping files uploads them + When I drop one or more image files onto the page + Then those files should be queued for upload + And I should be taken to the upload progress view + # Evidence: kahuna/public/js/upload/dnd-uploader.js + # Evidence: kahuna/public/js/upload/manager.js + + Scenario: Dropping a URL loads the image from that URL + When I drop an image URL onto the page + Then the image at that URL should be loaded for upload + And I should be taken to the upload progress view + # Evidence: kahuna/public/js/upload/dnd-uploader.js + # Evidence: kahuna/public/js/upload/manager.js + + Scenario: Dropping a Witness contribution imports it + When I drop a Witness contribution URL onto the page + Then the importing overlay should be shown + And the Witness image should be imported with its metadata and usage rights + And I should be taken to the imported image's page + # Evidence: kahuna/public/js/upload/dnd-uploader.html + # Evidence: kahuna/public/js/upload/dnd-uploader.js + + Scenario: A failed Witness import is reported + Given I drop a Witness contribution URL onto the page + When the Witness import fails + Then I should see an alert that importing the Witness contribution failed + # Evidence: kahuna/public/js/upload/dnd-uploader.js + + Scenario: Dropping invalid content is rejected + When I drop something that is not a valid file or URL + Then I should see an alert that I must drop valid files or URLs + # Evidence: kahuna/public/js/upload/dnd-uploader.js + + Scenario: Dragging a Grid image back onto the page is ignored + When I drag an image that is already in the Grid over the page + Then the dropzone overlay should not appear + # Evidence: kahuna/public/js/upload/dnd-uploader.js + + # --------------------------------------------------------------------------- + # Current uploads list (jobs/upload-jobs.html + upload-jobs.js) + # --------------------------------------------------------------------------- + + Scenario: Current uploads show how many remain + Given I have several uploads in progress + When I view my current uploads + Then I should see a count of how many uploads remain + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + + Scenario: An uploading job shows a preview with its name and size + Given a file is uploading + When I view my current uploads + Then I should see a preview thumbnail with the file name and size + And I should see the job's status + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + # Evidence: kahuna/public/js/upload/manager.js + + Scenario: A failed upload shows the error and can be removed + Given an upload has failed + When I view my current uploads + Then the job should be marked as an upload error with the error message + And I should be able to remove the failed job after confirming + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + + Scenario: An unsupported file type gives a helpful error + When I upload a file that is not a JPG, PNG or TIFF + Then the job should show an error explaining only JPG, PNG and TIFF are supported + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + + Scenario: A completed upload becomes an editable image + Given an upload has completed + When I view my current uploads + Then the job should switch to the image metadata editor + And I should be able to delete the image if I am permitted + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + + Scenario: A missing description defaults to the file name + Given an uploaded image has no description + When the upload completes + Then the description should default to the file name without its extension + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + + Scenario: Deleting an image removes it from current uploads + Given an uploaded image is shown in my current uploads + When the image is deleted + Then it should be removed from my current uploads + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + + Scenario: A failed deletion is reported + Given an uploaded image is shown in my current uploads + When deleting the image fails + Then I should see an alert explaining the deletion failed + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + + # --------------------------------------------------------------------------- + # Required metadata editor (jobs/required-metadata-editor.html + .js) + # --------------------------------------------------------------------------- + + Scenario: Editing required metadata for an uploaded image + Given an uploaded image is shown in the metadata editor + When I fill in the description, byline and credit + Then the metadata should be saved automatically + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + + Scenario: Description and credit are required + Given an uploaded image is shown in the metadata editor + When I leave the description or credit empty + Then those fields should be marked as required + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + + Scenario: The description placeholder gives guidance + Given an uploaded image with no description + When I view the description field + Then I should see placeholder guidance about who, what, where, when and why + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + + Scenario: Choosing an image type when image types are configured + Given image types are configured + When I view the metadata editor + Then I should be able to choose an image type from a dropdown + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + + Scenario: The credit field suggests existing values + Given an uploaded image is shown in the metadata editor + When I type into the credit field + Then I should see suggestions matching existing credits + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + + Scenario: Copyright only shows when it was already present + Given an uploaded image that already has a copyright value + When I view the metadata editor + Then I should see the copyright field + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + + Scenario: Existing usage instructions are shown with room for more + Given an uploaded image that already has usage instructions + When I view the metadata editor + Then I should see the existing usage instructions + And I should be able to add further special instructions + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + + Scenario: Applying a field value to all uploads in a batch + Given I am uploading more than one image + And I am permitted to edit + When I apply a field value to all uploads + Then that value should be applied to the same field on every current upload + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + + Scenario: Metadata editing is disabled without edit permission + Given I am not permitted to edit the image + When I view the metadata editor + Then the metadata fields should be disabled + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + + Scenario: Applying a metadata template makes fields read-only + Given an uploaded image is shown in the metadata editor + When a metadata template is selected + Then the affected fields should be populated and made read-only + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + + # --------------------------------------------------------------------------- + # Recent uploads (recent/recent-uploads.html + recent-uploads.js) + # --------------------------------------------------------------------------- + + Scenario: Past uploads are loading + When my past uploads have not yet loaded + Then I should see a loading message + # Evidence: kahuna/public/js/upload/recent/recent-uploads.html + # Evidence: kahuna/public/js/upload/recent/recent-uploads.js + + Scenario: I have not uploaded anything yet + Given I have never uploaded an image + When my past uploads load + Then I should see a message that I haven't uploaded anything yet + # Evidence: kahuna/public/js/upload/recent/recent-uploads.html + # Evidence: kahuna/public/js/upload/recent/recent-uploads.js + + Scenario: My past uploads are listed + Given I have uploaded images before + When my past uploads load + Then I should see each of my past uploaded images + And I should be able to delete an image I am permitted to delete + # Evidence: kahuna/public/js/upload/recent/recent-uploads.html + # Evidence: kahuna/public/js/upload/recent/recent-uploads.js + + Scenario: A failed deletion of a past upload is reported + Given a past uploaded image is listed + When deleting the image fails + Then I should see an alert explaining the deletion failed + # Evidence: kahuna/public/js/upload/recent/recent-uploads.js From 6fa14a6ed176cbc5fd65b121631612c2b07373ef Mon Sep 17 00:00:00 2001 From: Jonathon Herbert Date: Fri, 28 Aug 2026 14:44:14 +0000 Subject: [PATCH 13/13] Add .pending as we are missing the tests; add a few todos for amends and missing features --- ...{upload.feature => upload.feature.pending} | 149 +++++++++--------- 1 file changed, 75 insertions(+), 74 deletions(-) rename e2e-tests/features/{upload.feature => upload.feature.pending} (74%) diff --git a/e2e-tests/features/upload.feature b/e2e-tests/features/upload.feature.pending similarity index 74% rename from e2e-tests/features/upload.feature rename to e2e-tests/features/upload.feature.pending index 641d49c585..9af1ef9401 100644 --- a/e2e-tests/features/upload.feature +++ b/e2e-tests/features/upload.feature.pending @@ -18,8 +18,8 @@ Feature: Uploading images to the Grid Then I should see the file upload prompt And I should see my past 50 uploads And the drag-and-drop uploader should be active - # Evidence: kahuna/public/js/upload/view.html - # Evidence: kahuna/public/js/upload/controller.js + # Evidence: kahuna/public/js/upload/view.html lines 11-12, 19-27, 31 + # Evidence: kahuna/public/js/upload/controller.js lines 40-45 Scenario: An unauthorised user is told they cannot upload Given I am not permitted to upload images @@ -27,31 +27,31 @@ Feature: Uploading images to the Grid Then I should see a message that I am not authorised to upload images And I should see a link to email support And I should not see the file upload prompt - # Evidence: kahuna/public/js/upload/view.html - # Evidence: kahuna/public/js/upload/controller.js + # Evidence: kahuna/public/js/upload/view.html lines 11, 29 + # Evidence: kahuna/public/js/upload/controller.js lines 36-41 Scenario: Returning to search from the upload page When I choose "Back to search" from the top bar Then I should be taken to the image search page - # Evidence: kahuna/public/js/upload/view.html + # Evidence: kahuna/public/js/upload/view.html lines 3-6 Scenario: The current uploads section only appears while an upload is running Given I have an upload in progress When the upload page loads Then I should see my current uploads section - # Evidence: kahuna/public/js/upload/view.html - # Evidence: kahuna/public/js/upload/controller.js + # Evidence: kahuna/public/js/upload/view.html lines 14-17 + # Evidence: kahuna/public/js/upload/controller.js lines 45 Scenario: Viewing all of my uploads When I choose "View all your uploads" Then I should be taken to a search filtered to images I uploaded - # Evidence: kahuna/public/js/upload/view.html + # Evidence: kahuna/public/js/upload/view.html lines 22-23 Scenario: Warning before leaving the page with uploads in progress Given I have an upload in progress When I try to navigate away from the upload page Then I should be warned that uploads are in progress and asked to confirm - # Evidence: kahuna/public/js/upload/controller.js + # Evidence: kahuna/public/js/upload/controller.js lines 18-34, 47-54 # --------------------------------------------------------------------------- # File upload prompt (prompt/prompt.html + prompt.js) @@ -60,22 +60,21 @@ Feature: Uploading images to the Grid Scenario: The prompt explains how to upload When the upload page loads Then I should see a message telling me to drag and drop or click to upload to the system - # Evidence: kahuna/public/js/upload/prompt/prompt.html - # Evidence: kahuna/public/js/upload/prompt/prompt.js + # Evidence: kahuna/public/js/upload/prompt/prompt.html lines 1-3 + # Evidence: kahuna/public/js/upload/prompt/prompt.js lines 20 Scenario: The prompt suggests an example label when no labels are applied Given I have not applied any preset labels When the upload page loads Then I should see a suggested example label to apply to all uploads - # Evidence: kahuna/public/js/upload/prompt/prompt.html - # Evidence: kahuna/public/js/upload/prompt/prompt.js + # Evidence: kahuna/public/js/upload/prompt/prompt.html lines 5-11 + # Evidence: kahuna/public/js/upload/prompt/prompt.js lines 21 Scenario: Preset labels are applied to all uploads When I add a preset label in the prompt Then that label should be applied to all my uploads - # Evidence: kahuna/public/js/upload/prompt/prompt.html - # Evidence: kahuna/public/js/upload/prompt/prompt.js - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + # Evidence: kahuna/public/js/upload/prompt/prompt.html lines 5-11 + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js lines 43, 113-114 # --------------------------------------------------------------------------- # Select-files uploader (file-uploader.html + file-uploader.js) @@ -84,23 +83,23 @@ Feature: Uploading images to the Grid Scenario: Opening the file picker from the Upload button When I click the "Upload" button Then the system file picker should open - # Evidence: kahuna/public/js/upload/file-uploader.html - # Evidence: kahuna/public/js/upload/file-uploader.js + # Evidence: kahuna/public/js/upload/file-uploader.html lines 9-15 + # Evidence: kahuna/public/js/upload/file-uploader.js lines 41-42 Scenario: Selecting files queues them for upload When I select one or more image files to upload Then those files should be queued for upload And I should be taken to the upload progress view - # Evidence: kahuna/public/js/upload/file-uploader.html - # Evidence: kahuna/public/js/upload/file-uploader.js - # Evidence: kahuna/public/js/upload/manager.js + # Evidence: kahuna/public/js/upload/file-uploader.html lines 4-7 + # Evidence: kahuna/public/js/upload/file-uploader.js lines 20-26 + # Evidence: kahuna/public/js/upload/manager.js lines 68-83 Scenario: Files above the size limit are skipped with a warning Given an upload size limit is configured When I select a file that is larger than the size limit Then I should be warned that the oversized file will be skipped And only the files within the limit should be queued for upload - # Evidence: kahuna/public/js/upload/manager.js + # Evidence: kahuna/public/js/upload/manager.js lines 36-47 # --------------------------------------------------------------------------- # Drag-and-drop uploader (dnd-uploader.html + dnd-uploader.js) @@ -109,53 +108,53 @@ Feature: Uploading images to the Grid Scenario: Dragging valid content over the page shows the dropzone When I drag files over the upload page Then the dropzone overlay should appear with an explanation - # Evidence: kahuna/public/js/upload/dnd-uploader.html - # Evidence: kahuna/public/js/upload/dnd-uploader.js + # Evidence: kahuna/public/js/upload/dnd-uploader.html lines 1-6 + # Evidence: kahuna/public/js/upload/dnd-uploader.js lines 113, 116, 158-171 Scenario: The dropzone hides when I stop dragging Given the dropzone overlay is showing When I drag away from the upload page Then the dropzone overlay should disappear - # Evidence: kahuna/public/js/upload/dnd-uploader.html - # Evidence: kahuna/public/js/upload/dnd-uploader.js + # Evidence: kahuna/public/js/upload/dnd-uploader.html lines 1 + # Evidence: kahuna/public/js/upload/dnd-uploader.js lines 117, 173-180 Scenario: Dropping files uploads them When I drop one or more image files onto the page Then those files should be queued for upload And I should be taken to the upload progress view - # Evidence: kahuna/public/js/upload/dnd-uploader.js - # Evidence: kahuna/public/js/upload/manager.js + # Evidence: kahuna/public/js/upload/dnd-uploader.js lines 33-37, 182-205 + # Evidence: kahuna/public/js/upload/manager.js lines 68-83 Scenario: Dropping a URL loads the image from that URL When I drop an image URL onto the page Then the image at that URL should be loaded for upload And I should be taken to the upload progress view - # Evidence: kahuna/public/js/upload/dnd-uploader.js - # Evidence: kahuna/public/js/upload/manager.js + # Evidence: kahuna/public/js/upload/dnd-uploader.js lines 64-66, 219-220 + # Evidence: kahuna/public/js/upload/manager.js lines 86-96 Scenario: Dropping a Witness contribution imports it When I drop a Witness contribution URL onto the page Then the importing overlay should be shown And the Witness image should be imported with its metadata and usage rights And I should be taken to the imported image's page - # Evidence: kahuna/public/js/upload/dnd-uploader.html - # Evidence: kahuna/public/js/upload/dnd-uploader.js + # Evidence: kahuna/public/js/upload/dnd-uploader.html lines 8-12 + # Evidence: kahuna/public/js/upload/dnd-uploader.js lines 40-62, 69-78, 206-217 Scenario: A failed Witness import is reported Given I drop a Witness contribution URL onto the page When the Witness import fails Then I should see an alert that importing the Witness contribution failed - # Evidence: kahuna/public/js/upload/dnd-uploader.js + # Evidence: kahuna/public/js/upload/dnd-uploader.js lines 79-85 Scenario: Dropping invalid content is rejected When I drop something that is not a valid file or URL Then I should see an alert that I must drop valid files or URLs - # Evidence: kahuna/public/js/upload/dnd-uploader.js + # Evidence: kahuna/public/js/upload/dnd-uploader.js lines 222-231 Scenario: Dragging a Grid image back onto the page is ignored When I drag an image that is already in the Grid over the page Then the dropzone overlay should not appear - # Evidence: kahuna/public/js/upload/dnd-uploader.js + # Evidence: kahuna/public/js/upload/dnd-uploader.js lines 135-150, 158-160 # --------------------------------------------------------------------------- # Current uploads list (jobs/upload-jobs.html + upload-jobs.js) @@ -165,56 +164,55 @@ Feature: Uploading images to the Grid Given I have several uploads in progress When I view my current uploads Then I should see a count of how many uploads remain - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html lines 2 + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js lines 45, 79, 88 Scenario: An uploading job shows a preview with its name and size Given a file is uploading When I view my current uploads Then I should see a preview thumbnail with the file name and size And I should see the job's status - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js - # Evidence: kahuna/public/js/upload/manager.js + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html lines 9-31 + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js lines 61-72 + # Evidence: kahuna/public/js/upload/manager.js lines 16-21 Scenario: A failed upload shows the error and can be removed Given an upload has failed When I view my current uploads Then the job should be marked as an upload error with the error message And I should be able to remove the failed job after confirming - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html lines 24-38 + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js lines 77-78, 179-185 Scenario: An unsupported file type gives a helpful error When I upload a file that is not a JPG, PNG or TIFF Then the job should show an error explaining only JPG, PNG and TIFF are supported - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js lines 155-163 Scenario: A completed upload becomes an editable image Given an upload has completed When I view my current uploads Then the job should switch to the image metadata editor - And I should be able to delete the image if I am permitted - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.html lines 40-48 + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js lines 84-92 Scenario: A missing description defaults to the file name Given an uploaded image has no description When the upload completes Then the description should default to the file name without its extension - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js lines 105-110 Scenario: Deleting an image removes it from current uploads Given an uploaded image is shown in my current uploads When the image is deleted Then it should be removed from my current uploads - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js lines 187-195 Scenario: A failed deletion is reported Given an uploaded image is shown in my current uploads When deleting the image fails Then I should see an alert explaining the deletion failed - # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js + # Evidence: kahuna/public/js/upload/jobs/upload-jobs.js lines 197-203 # --------------------------------------------------------------------------- # Required metadata editor (jobs/required-metadata-editor.html + .js) @@ -224,71 +222,74 @@ Feature: Uploading images to the Grid Given an uploaded image is shown in the metadata editor When I fill in the description, byline and credit Then the metadata should be saved automatically - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html lines 1, 30-56, 61-72, 85-104 + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js lines 33, 40-65 Scenario: Description and credit are required Given an uploaded image is shown in the metadata editor When I leave the description or credit empty Then those fields should be marked as required - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html lines 36, 96 + @todo necessary? static placeholder Scenario: The description placeholder gives guidance Given an uploaded image with no description When I view the description field Then I should see placeholder guidance about who, what, where, when and why - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html lines 34-40 + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js lines 157-161 + @todo how to verify writes Scenario: Choosing an image type when image types are configured Given image types are configured When I view the metadata editor Then I should be able to choose an image type from a dropdown - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html lines 5-27 + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js lines 38 Scenario: The credit field suggests existing values Given an uploaded image is shown in the metadata editor When I type into the credit field Then I should see suggestions matching existing credits - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html lines 87-104 + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js lines 67-71 Scenario: Copyright only shows when it was already present Given an uploaded image that already has a copyright value When I view the metadata editor Then I should see the copyright field - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html lines 114-134 + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js lines 36 Scenario: Existing usage instructions are shown with room for more Given an uploaded image that already has usage instructions When I view the metadata editor Then I should see the existing usage instructions And I should be able to add further special instructions - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html lines 140-167 Scenario: Applying a field value to all uploads in a batch Given I am uploading more than one image And I am permitted to edit When I apply a field value to all uploads Then that value should be applied to the same field on every current upload - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html lines 20-27, 49-53, 76-80, 107-111, 129-133, 156-160, 179-183 + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js lines 108-119 Scenario: Metadata editing is disabled without edit permission Given I am not permitted to edit the image When I view the metadata editor Then the metadata fields should be disabled - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html lines 14, 44, 71, 101, 124, 152, 173 + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js lines 27-29 + @todo how do we apply metadata templates? Scenario: Applying a metadata template makes fields read-only Given an uploaded image is shown in the metadata editor When a metadata template is selected Then the affected fields should be populated and made read-only - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html - # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.html lines 15, 45, 72, 102, 125, 153, 175 + # Evidence: kahuna/public/js/upload/jobs/required-metadata-editor.js lines 84-98 # --------------------------------------------------------------------------- # Recent uploads (recent/recent-uploads.html + recent-uploads.js) @@ -297,26 +298,26 @@ Feature: Uploading images to the Grid Scenario: Past uploads are loading When my past uploads have not yet loaded Then I should see a loading message - # Evidence: kahuna/public/js/upload/recent/recent-uploads.html - # Evidence: kahuna/public/js/upload/recent/recent-uploads.js + # Evidence: kahuna/public/js/upload/recent/recent-uploads.html lines 1-2 + # Evidence: kahuna/public/js/upload/recent/recent-uploads.js lines 34 Scenario: I have not uploaded anything yet Given I have never uploaded an image When my past uploads load Then I should see a message that I haven't uploaded anything yet - # Evidence: kahuna/public/js/upload/recent/recent-uploads.html - # Evidence: kahuna/public/js/upload/recent/recent-uploads.js + # Evidence: kahuna/public/js/upload/recent/recent-uploads.html lines 4-5 + # Evidence: kahuna/public/js/upload/recent/recent-uploads.js lines 25-34 Scenario: My past uploads are listed Given I have uploaded images before When my past uploads load Then I should see each of my past uploaded images And I should be able to delete an image I am permitted to delete - # Evidence: kahuna/public/js/upload/recent/recent-uploads.html - # Evidence: kahuna/public/js/upload/recent/recent-uploads.js + # Evidence: kahuna/public/js/upload/recent/recent-uploads.html lines 7-13 + # Evidence: kahuna/public/js/upload/recent/recent-uploads.js lines 25-51 Scenario: A failed deletion of a past upload is reported Given a past uploaded image is listed When deleting the image fails Then I should see an alert explaining the deletion failed - # Evidence: kahuna/public/js/upload/recent/recent-uploads.js + # Evidence: kahuna/public/js/upload/recent/recent-uploads.js lines 63-69