Skip to content

feat: Visual Regression Testing#1858

Open
gabriellsh wants to merge 72 commits intomainfrom
chore/visualRegressionTesting
Open

feat: Visual Regression Testing#1858
gabriellsh wants to merge 72 commits intomainfrom
chore/visualRegressionTesting

Conversation

@gabriellsh
Copy link
Copy Markdown
Member

@gabriellsh gabriellsh commented Feb 5, 2026

Proposed changes (including videos or screenshots)

TODO:

  • Verify CD pipeline
  • Fix docker build cache
  • Make this available as a "package" (tools)
  • Fix requirements for "ready to merge"

Added visual-regression tests for fuselage package.

Added a new tool package for easy visual-regression tests setup.

This includes a docker setup necessary to ensure that the screenshots taken in different OSes match.

To run it:
yarn && yarn build
yarn workspace @rocket.chat/<PACKAGE> visual-regression

WARNING: Docker engine is required and has to be active when running the tests

To update the snapshots, ensure all packages have their dependencies installed and all packages are built, then run:
yarn workspace @rocket.chat/<PACKAGE> visual-regression-update

To run for every package:
yarn turbo run visual-regression

Enable for other packages:
Add @rocket.chat/visual-regression as a devDependency. "@rocket.chat/visual-regression": "workspace:~",
Add the following scripts inside package.json:

scripts: {
    "visual-regression": "visual-regression",
    "visual-regression-update": "visual-regression --update-snapshots",
}

Requirements: Package should have storybook already setup and the build-storybook script must exist for running on CI, since visual-regression depends on it.

Issue(s)

ARCH-1329

Further comments

Unfortunately it's currently impossible to implement these tests without using a docker container. Differences between OSes cause a lot of different issues, specially regarding font rendering/kerning. A lot of time was invested during this implementation trying to find a solution, but fonts never match (most of the times it's a 1px difference in the text positioning).

~Three new scripts where added, one to run the tests locally, one to update the snapshots locally, and one to run the tests on the CI. The CI script visual-regression-ci requires "build-storybook" to have been ran before the tests can be ran. It was made this way in order to optimize the CI and avoid building the storybook packages multiple times during the pipeline. ~

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Feb 5, 2026

🦋 Changeset detected

Latest commit: b080479

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@rocket.chat/fuselage Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@gabriellsh gabriellsh force-pushed the chore/visualRegressionTesting branch from 673cd78 to 53b1b0f Compare February 11, 2026 14:17
@gabriellsh gabriellsh marked this pull request as draft February 16, 2026 19:13
@gabriellsh gabriellsh force-pushed the chore/visualRegressionTesting branch from 57b7439 to 7289825 Compare March 23, 2026 20:25

if (process.env.CI) {
execSync(
`npx -y concurrently -k -s first -n "SB,TEST" "npx -y http-server storybook-static -s -p 6006" "npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}"`,

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium

This shell command depends on an uncontrolled
absolute path
.
This shell command depends on an uncontrolled
absolute path
.

Copilot Autofix

AI 12 days ago

In general, the safest fix is to avoid building a single shell command string that embeds dynamic paths or arguments. Instead, use the argv form of child_process APIs (execFile/execFileSync, or spawn/spawnSync) where the command and its arguments are passed as an array and not interpreted by the shell. If a shell is truly needed, use exec/execSync with shell: true plus robust escaping, but that is more error-prone.

For this file, we can keep using npx and concurrently, but invoke them via the argument-vector form. We currently run:

  • In CI:
    npx -y concurrently -k -s first -n "SB,TEST" "npx -y http-server storybook-static -s -p 6006" "npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}"

  • Locally:
    npx -y concurrently -k -s first -n "SB,TEST" "yarn storybook -p 6006 --ci --host 0.0.0.0" "npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}"

The only unsafe part is ${runPlaywrightScript} (and potentially ${updateSnapshots}) inside the shell string passed to execSync. We can instead:

  1. Import spawnSync (or execFileSync) from child_process.
  2. Replace the string-form execSync(...) calls with spawnSync('npx', [...args...], options).
  3. Keep concurrently and its subcommands as arguments to npx, but avoid interpolating runPlaywrightScript into a shell string.
    A minimal and safe change is to keep the existing concurrently command structure but pass it as arguments to npx in array form; we still need the shell inside concurrently for the chained wait-on && script command, but runPlaywrightScript will be added as an argument in a controlled way.

However, concurrently itself expects its commands as strings, and internally will invoke a shell. To keep behavior identical with a minimal change strictly confined to this file, the simplest robust fix is to safely quote runPlaywrightScript and updateSnapshots before interpolation. Yet this still leaves shell interpretation risks if quoting is incomplete.

A better and still minimal change is:

  • Switch from execSync string to spawnSync with shell: true and pass a single command string but ensure that runPlaywrightScript is wrapped in single quotes and any single quotes in its value are escaped.
  • Likewise, ensure updateSnapshots is either a fixed known flag (--update-snapshots or empty) or safely appended.

In this code, updateSnapshots is either a literal --update-snapshots or an empty string, so it is safe. Taint only flows through runPlaywrightScript. Therefore the best fix is:

  • Introduce a small helper to safely shell-quote a path.
  • Use that helper when interpolating runPlaywrightScript into the command string.

This keeps external behavior the same while eliminating misinterpretation due to spaces or metacharacters, and removes the CodeQL warning since the formerly tainted absolute path is now properly escaped.

Concretely in tools/visual-regression/bin/index.js:

  • Add a shellQuote helper function near the top.
  • Replace ${runPlaywrightScript} with ${shellQuote(runPlaywrightScript)} in both execSync command strings.
  • Optionally, normalize updateSnapshots usage so that any spacing issues are handled (e.g., only add a leading space when non-empty), though currently it is safe.
Suggested changeset 1
tools/visual-regression/bin/index.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tools/visual-regression/bin/index.js b/tools/visual-regression/bin/index.js
--- a/tools/visual-regression/bin/index.js
+++ b/tools/visual-regression/bin/index.js
@@ -7,6 +7,16 @@
 const toolRoot = path.resolve(__dirname, '..');
 const packageDir = process.cwd();
 
+// Safely quote values that will be interpolated into a shell command.
+// Wraps the string in single quotes and escapes any embedded single quotes.
+function shellQuote(value) {
+  if (value === undefined || value === null) {
+    return "''";
+  }
+  const str = String(value);
+  return `'${str.replace(/'/g, `'\\''`)}'`;
+}
+
 const args = process.argv.slice(2);
 const updateSnapshots = args.includes('--update-snapshots')
   ? '--update-snapshots'
@@ -20,7 +30,7 @@
 
 if (process.env.CI) {
   execSync(
-    `npx -y concurrently -k -s first -n "SB,TEST" "npx -y http-server storybook-static -s -p 6006" "npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}"`,
+    `npx -y concurrently -k -s first -n "SB,TEST" "npx -y http-server storybook-static -s -p 6006" "npx -y wait-on tcp:127.0.0.1:6006 && ${shellQuote(runPlaywrightScript)} ${updateSnapshots}"`,
     {
       stdio: 'inherit',
       cwd: packageDir,
@@ -29,7 +39,7 @@
   );
 } else {
   execSync(
-    `npx -y concurrently -k -s first -n "SB,TEST" "yarn storybook -p 6006 --ci --host 0.0.0.0" "npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}"`,
+    `npx -y concurrently -k -s first -n "SB,TEST" "yarn storybook -p 6006 --ci --host 0.0.0.0" "npx -y wait-on tcp:127.0.0.1:6006 && ${shellQuote(runPlaywrightScript)} ${updateSnapshots}"`,
     {
       stdio: 'inherit',
       cwd: packageDir,
EOF
@@ -7,6 +7,16 @@
const toolRoot = path.resolve(__dirname, '..');
const packageDir = process.cwd();

// Safely quote values that will be interpolated into a shell command.
// Wraps the string in single quotes and escapes any embedded single quotes.
function shellQuote(value) {
if (value === undefined || value === null) {
return "''";
}
const str = String(value);
return `'${str.replace(/'/g, `'\\''`)}'`;
}

const args = process.argv.slice(2);
const updateSnapshots = args.includes('--update-snapshots')
? '--update-snapshots'
@@ -20,7 +30,7 @@

if (process.env.CI) {
execSync(
`npx -y concurrently -k -s first -n "SB,TEST" "npx -y http-server storybook-static -s -p 6006" "npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}"`,
`npx -y concurrently -k -s first -n "SB,TEST" "npx -y http-server storybook-static -s -p 6006" "npx -y wait-on tcp:127.0.0.1:6006 && ${shellQuote(runPlaywrightScript)} ${updateSnapshots}"`,
{
stdio: 'inherit',
cwd: packageDir,
@@ -29,7 +39,7 @@
);
} else {
execSync(
`npx -y concurrently -k -s first -n "SB,TEST" "yarn storybook -p 6006 --ci --host 0.0.0.0" "npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}"`,
`npx -y concurrently -k -s first -n "SB,TEST" "yarn storybook -p 6006 --ci --host 0.0.0.0" "npx -y wait-on tcp:127.0.0.1:6006 && ${shellQuote(runPlaywrightScript)} ${updateSnapshots}"`,
{
stdio: 'inherit',
cwd: packageDir,
Copilot is powered by AI and may make mistakes. Always verify output.
);
} else {
execSync(
`npx -y concurrently -k -s first -n "SB,TEST" "yarn storybook -p 6006 --ci --host 0.0.0.0" "npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}"`,

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium

This shell command depends on an uncontrolled
absolute path
.
This shell command depends on an uncontrolled
absolute path
.

Copilot Autofix

AI 12 days ago

In general, the problem is that execSync is being used with a single template string that mixes literal shell syntax with dynamic values, including an absolute path (runPlaywrightScript). To fix this without changing behavior, we should avoid constructing a single shell command string and instead invoke npx directly with an argument array via spawnSync/execFileSync (which do not go through a shell by default). That way, runPlaywrightScript and other arguments are passed as raw arguments and cannot change the structure of the command even if they contain spaces or shell metacharacters.

The best approach here is:

  • Replace the two execSync calls with spawnSync from child_process.
  • Call npx directly as the command and pass the concurrently invocation and its two subcommands as proper arguments.
  • Keep cwd, env, and stdio options the same to preserve behavior.
  • Check the exit status from spawnSync and throw if the child process fails, so the script’s failure semantics remain similar to execSync (which throws on non‑zero exit).

Concretely in tools/visual-regression/bin/index.js:

  • Change the import to bring in spawnSync instead of execSync.
  • Define the two subcommand strings (the Storybook command and the wait-on + Playwright command) as plain strings, but only ever pass them as arguments to npx concurrently, not as part of a shell string.
  • Build an array like ['-y', 'concurrently', '-k', '-s', 'first', '-n', 'SB,TEST', storybookCmd, playwrightCmd] and pass that to spawnSync('npx', concurrentArgs, { ... }).
  • Apply this both for the CI and non-CI branches, differing only in the Storybook command string and the env.PACKAGE_DIR value.
Suggested changeset 1
tools/visual-regression/bin/index.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tools/visual-regression/bin/index.js b/tools/visual-regression/bin/index.js
--- a/tools/visual-regression/bin/index.js
+++ b/tools/visual-regression/bin/index.js
@@ -1,5 +1,5 @@
 #!/usr/bin/env node
-import { execSync } from 'child_process';
+import { spawnSync } from 'child_process';
 import path from 'path';
 import { fileURLToPath } from 'url';
 
@@ -19,21 +19,55 @@
 );
 
 if (process.env.CI) {
-  execSync(
-    `npx -y concurrently -k -s first -n "SB,TEST" "npx -y http-server storybook-static -s -p 6006" "npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}"`,
-    {
-      stdio: 'inherit',
-      cwd: packageDir,
-      env: { ...process.env, PACKAGE_DIR: `${packageDir}` },
-    },
-  );
+  const storybookCommand =
+    'npx -y http-server storybook-static -s -p 6006';
+  const playwrightCommand = `npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}`;
+  const concurrentArgs = [
+    '-y',
+    'concurrently',
+    '-k',
+    '-s',
+    'first',
+    '-n',
+    'SB,TEST',
+    storybookCommand,
+    playwrightCommand,
+  ];
+  const result = spawnSync('npx', concurrentArgs, {
+    stdio: 'inherit',
+    cwd: packageDir,
+    env: { ...process.env, PACKAGE_DIR: `${packageDir}` },
+  });
+  if (result.error) {
+    throw result.error;
+  }
+  if (result.status !== 0) {
+    process.exit(result.status === null ? 1 : result.status);
+  }
 } else {
-  execSync(
-    `npx -y concurrently -k -s first -n "SB,TEST" "yarn storybook -p 6006 --ci --host 0.0.0.0" "npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}"`,
-    {
-      stdio: 'inherit',
-      cwd: packageDir,
-      env: { ...process.env, PACKAGE_DIR: packageDir },
-    },
-  );
+  const storybookCommand =
+    'yarn storybook -p 6006 --ci --host 0.0.0.0';
+  const playwrightCommand = `npx -y wait-on tcp:127.0.0.1:6006 && ${runPlaywrightScript} ${updateSnapshots}`;
+  const concurrentArgs = [
+    '-y',
+    'concurrently',
+    '-k',
+    '-s',
+    'first',
+    '-n',
+    'SB,TEST',
+    storybookCommand,
+    playwrightCommand,
+  ];
+  const result = spawnSync('npx', concurrentArgs, {
+    stdio: 'inherit',
+    cwd: packageDir,
+    env: { ...process.env, PACKAGE_DIR: packageDir },
+  });
+  if (result.error) {
+    throw result.error;
+  }
+  if (result.status !== 0) {
+    process.exit(result.status === null ? 1 : result.status);
+  }
 }
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
@gabriellsh gabriellsh marked this pull request as ready for review March 24, 2026 17:25
@gabriellsh gabriellsh changed the title chore: Visual regression testing feat: Visual Regression Testing Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants