Run your tests in GitLab CI

The GitLab variant, including the docker:dind difference that silently gives you an emulator with no Apex if you translate the GitHub recipe literally.

The GitLab variant of the GitHub Actions recipe. Same idea, one significant difference that will bite you if you translate the GitHub version literally.

What is verified about this page, and what is not

The GitHub recipe was executed as a real workflow run before it was published. This one was not run on a GitLab runner — we do not have one. What was run, locally, is the mechanically identical job: a node:22-alpine container talking to a docker:dind service over DOCKER_HOST, starting the emulator, waiting on the census, and executing the same test file, which passed 4/4.

So the shell logic, the dind interaction, the corpus-transfer approach and the service hostname are all verified. GitLab’s own parsing of .gitlab-ci.yml and its service startup semantics are not. If something here is wrong, that is where it will be, and we would like to hear about it.

The pipeline

integration-tests:
  image: node:22
  services:
    - docker:27-dind
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""        # plain TCP to the dind service
    SF_BASE_URL: http://docker:8080
  before_script:
    - apt-get update && apt-get install -y docker.io
  script:
    # THE CORPUS IS COPIED IN, NOT MOUNTED. See below — this is the one that bites.
    - docker create --name fidelic -p 8080:8080 fidelic/emulator --apex-src /mdapi
    - docker cp ./mdapi fidelic:/mdapi
    - docker start fidelic

    # Wait for LOADED, not merely up.
    - |
      for i in $(seq 1 60); do
        docker logs fidelic 2>&1 | grep -q 'listening on' && break
        sleep 1
      done
      CENSUS="$(docker logs fidelic 2>&1 | grep -o '[0-9]* triggers loaded' | head -1)"
      if [ -z "$CENSUS" ]; then
        echo "the emulator started but loaded NO Apex"; docker logs fidelic; exit 1
      fi
      echo "org ready: $CENSUS"

    - npm test                    # or pytest, or go test
  after_script:
    - docker rm -f fidelic || true

The difference that matters: dind has its own filesystem

In GitHub Actions the Docker daemon is the runner’s own, so -v "$PWD/mdapi:/mdapi:ro" mounts your checked-out metadata straight in. In GitLab with docker:dind, the daemon is a separate container that cannot see your job’s working directory at all. The same -v flag will appear to succeed and mount an empty or non-existent path, and the emulator will boot with no Apex.

That failure is quiet in the worst way: the container starts, the API answers, and your tests fail one by one with messages about your code.

Two ways around it, in order of preference:

  1. docker create → docker cp → docker start, as above. The copy crosses into the dind daemon explicitly. This is what was validated.
  2. Bake your metadata into an image and use it as a service: FROM fidelic/emulator + COPY mdapi /mdapi, push to your GitLab registry, then list it under services: with command: ["--apex-src", "/mdapi"]. More moving parts, but it removes dind entirely and starts faster.

Because the emulator runs inside the dind service rather than on the job container, your tests reach it at http://docker:8080, not localhost. That is what SF_BASE_URL is set to above.

The same readiness trap as everywhere else

/services/data/v62.0 returns 404 — it looks like an API version root and is not one.

And a 200 from any generic endpoint proves the server answers, not that your metadata loaded: Account describes fine on a completely empty org, because it is built in. Gate on the emulator’s own census —

org ready: 8 triggers loaded

— so a corpus that failed to transfer fails the pipeline immediately, at the step that is actually wrong, instead of producing a green setup and a page of misleading test failures.

Notes