Skip to main content

Project ID Rules and Validation

Status: Stable

Valid Project IDs​

Project IDs must follow these rules:

✅ Format: [a-z0-9]+​

  • Only lowercase letters and numbers
  • No uppercase letters
  • No special characters (hyphens, underscores, etc.)
  • At least one character

The restriction is not cosmetic. An environment variable addresses a project by its id, uppercased, inside a name built with double underscores — DESIGN_TESTS__PROJECTS__BFH__URL. A hyphen makes that name invalid as a shell identifier, so DESIGN_TESTS__PROJECTS__KUS-CONTRACTS__URL=... is rejected by the shell before the library ever sees it. Other ways of setting a variable accept the name — env, a Docker -e flag, a Jenkins withEnv block, a Kubernetes env entry — and the override then works, so what a hyphen costs is the one form people actually type.

Underscores are excluded for a different mechanism. The name splits on __ only, so a single underscore creates no segment; each segment is then camel-cased, and DESIGN_TESTS__PROJECTS__MY_PROJECT__URL addresses a project called myProject. An id carrying an underscore is unreachable under its own name.

A consumer migrating an existing suite renames its project ids to satisfy this. Renaming moves baselines: the default path is <baselineDir>/<projectKey>/<title>/<name>-<viewport>.png, so every baseline of a project that sets no snapshotsDirFn moves with the id. A project that sets one gets exactly what that function returns, and renames for free only where the returned path does not name the key.

✅ Examples of Valid IDs​

webapp
api
mobile1
project2025
bfh
hkb
alumni

❌ Examples of Invalid IDs​

Web-App          # Contains uppercase and hyphen
api_v2 # Contains underscore
Mobile! # Contains uppercase and special char
project-2025 # Contains hyphen
all # Reserved keyword

Reserved Keywords​

"all" is Reserved​

The project ID "all" is reserved for the --all CLI flag and cannot be used as a project ID.

Why? To avoid ambiguity:

# With --all flag: run ALL projects
design-tests run --all

# With --project flag: run specific project
design-tests run --project webapp

# If "all" were allowed as a project ID, this would be confusing:
design-tests run --project all # Run project named "all" or all projects?

Error message:

Project ID cannot be "all" - this is a reserved keyword for the --all flag.
Please rename your project to something else (e.g., "allprojects", "main", etc.)

Validation Timing​

Project IDs are validated when the configuration is loaded:

// .designTests.js
module.exports = {
projects: {
// ✓ Valid - lowercase alphanumeric
webapp: { ... },
api: { ... },

// ✗ Invalid - will throw error on load
"Web-App": { ... }, // uppercase + hyphen
"api_v2": { ... }, // underscore
"all": { ... }, // reserved keyword
}
};

When validation happens:

  1. Config file is loaded
  2. Zod schema validates structure
  3. Normalization sets missing keys
  4. Project ID validation runs ← Checks format and reserved words
  5. Returns validated config

Error Messages​

Invalid Format​

❌ Error: Invalid project ID: "Web-App"
Project IDs must contain only lowercase letters and numbers [a-z0-9].
Examples:
✓ Valid: "webapp", "api", "mobile1", "project2025"
✗ Invalid: "Web-App", "api_v2", "Mobile!", "project-2025"

To fix: Rename the project to use only lowercase letters and numbers.

Reserved Keyword​

❌ Error: Project ID cannot be "all" - this is a reserved keyword for the --all flag.
Please rename your project to something else (e.g., "allprojects", "main", etc.)

Type Safety (Branded Type)​

Project IDs use a branded type ProjectId for compile-time safety:

import { type ProjectId, toProjectId } from '@qs/design-tests/config';

// Validate and create branded type
const projectId: ProjectId = toProjectId('webapp'); // ✓ OK
const invalidId: ProjectId = toProjectId('Web-App'); // ✗ Throws error

// String is not assignable to ProjectId
const unsafeId: ProjectId = 'webapp'; // ✗ Type error

This ensures project IDs are validated before use.

Migration Guide​

If you have existing projects with invalid IDs:

Before​

module.exports = {
projects: {
"Web-App": { url: "..." },
"api_v2": { url: "..." },
"mobile-app": { url: "..." },
}
};

After​

module.exports = {
projects: {
webapp: { url: "..." }, // lowercase, no hyphen
apiv2: { url: "..." }, // no underscore
mobileapp: { url: "..." }, // no hyphen
}
};

Note: This will change baseline directory names. You may need to rename directories:

mv design-tests/baseline-images/Web-App design-tests/baseline-images/webapp
mv design-tests/baseline-images/api_v2 design-tests/baseline-images/apiv2
mv design-tests/baseline-images/mobile-app design-tests/baseline-images/mobileapp

Or just delete and recreate baselines:

rm -rf design-tests/baseline-images/
npm run test # Auto-creates new baselines with correct names

CLI Flag Behavior​

--project Flag​

Processes only the specified project (as if other projects don't exist):

# Only tests the "webapp" project
design-tests run --project webapp

# Baseline creation works per-project
rm -rf design-tests/baseline-images/webapp/
design-tests run --project webapp # Creates baseline for webapp only

--all Flag​

Processes all projects in the configuration:

# Tests all projects defined in config
design-tests run --all

# Baseline creation works per-project
# - Projects with baselines: compare mode
# - Projects without baselines: create mode
design-tests run --all

Mixed Mode Example​

// Config has 3 projects
{
projects: {
webapp: { ... },
api: { ... },
mobile: { ... }
}
}

Baseline state:

design-tests/baseline-images/
├── webapp/ # Has baseline (10 images)
└── api/ # Has baseline (5 images)
# mobile/ missing - no baseline yet

Running --all:

design-tests run --all

# Output:
🔍 Project "webapp": Comparing against baseline
🔍 Project "api": Comparing against baseline
📝 Project "mobile": Creating baseline (no existing baseline found)

📊 Test Results: 0 visual differences, 8 new baselines detected

Result:

  • webapp: compared against existing baseline
  • api: compared against existing baseline
  • mobile: new baseline created
  • Report shows mixed results: 0 diffs, 8 new items

See Also​