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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/shaka-perf/README-twin-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,30 @@ yarn shaka-perf servers sync-changes experiment
yarn shaka-perf servers sync-changes control
```

Manual sync, running-menu auto-sync, and `copy-changes-to-ssh` always leave
`audit-results/`, `compare-results/`, and `compare-bisect-results/` on the host
by default. These defaults ship with the `shaka-perf` CLI, so they also apply
when the CLI is installed in another project. Override either list in
`abtests.config.ts`:

```ts
twinServers: {
// ...
copyIgnore: {
folders: [
'audit-results',
'compare-results',
'compare-bisect-results',
'tmp/traces',
],
files: ['debug.log'],
},
},
```

Paths use gitignore pattern syntax and are relative to the Git repository root.
Supplying `folders` or `files` replaces that corresponding default list.

### CI / SSH Integration

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function fakeConfig(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
ports: { control: 3020, experiment: 3030 },
setupCommands: [{ command: 'bin/setup', description: 'Set up experiment' }],
rebuildCommands: [],
copyIgnore: { folders: [], files: [] },
projectSlug: 'bisect-session',
...overrides,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ function createMockConfig(tmpDir: string): ResolvedConfig {
ports: { control: 3020, experiment: 3030 },
setupCommands: [],
rebuildCommands: [],
copyIgnore: {
folders: ['audit-results', 'compare-results', 'compare-bisect-results'],
files: [],
},
projectSlug: 'test-slug',
};
}
Expand Down Expand Up @@ -626,6 +630,7 @@ describe('get-config command', () => {
'images',
'volumes',
'setupCommands',
'copyIgnore',
'projectSlug',
];
expectedKeys.forEach((key) => {
Expand Down
33 changes: 33 additions & 0 deletions packages/shaka-perf/src/twin-servers/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,39 @@ describe('resolveConfig', () => {
expect(resolved.rebuildCommands).toEqual([]);
});

it('defaults copy-ignore folders to generated report directories', () => {
const resolved = resolveConfig(makeConfig(), tmpDir);
expect(resolved.copyIgnore).toEqual({
folders: ['audit-results', 'compare-results', 'compare-bisect-results'],
files: [],
});
});

it('overrides copy-ignore files and folders from abtests config', () => {
const resolved = resolveConfig(makeConfig({
copyIgnore: {
folders: ['tmp/screenshots'],
files: ['debug.log'],
},
}), tmpDir);

expect(resolved.copyIgnore).toEqual({
folders: ['tmp/screenshots'],
files: ['debug.log'],
});
});

it('retains the default folder list when only files are overridden', () => {
const resolved = resolveConfig(makeConfig({
copyIgnore: { files: ['debug.log'] },
}), tmpDir);

expect(resolved.copyIgnore).toEqual({
folders: ['audit-results', 'compare-results', 'compare-bisect-results'],
files: ['debug.log'],
});
});

it('passes through configured rebuildCommands', () => {
const resolved = resolveConfig(makeConfig({
rebuildCommands: [{ description: 'Build assets', command: 'yarn build' }],
Expand Down
32 changes: 32 additions & 0 deletions packages/shaka-perf/src/twin-servers/__tests__/copy-ignore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 ShakaCode LLC.
*
* This file is part of ShakaPerf. Use is governed by The ShakaPerf
* License in LICENSE.md.
*/

import * as path from 'path';
import {
createCopyIgnoreMatcher,
isCopyIgnored,
repositoryRelativeCopyPath,
} from '../helpers/copy-ignore';

describe('copy-ignore paths', () => {
it('matches repository-relative overrides for events from a nested build context', () => {
const repositoryRoot = path.join(path.sep, 'repo');
const buildRoot = path.join(repositoryRoot, 'packages', 'web');
const eventPath = repositoryRelativeCopyPath(
repositoryRoot,
buildRoot,
path.join('tmp', 'traces', 'trace.json'),
);
const matcher = createCopyIgnoreMatcher({
folders: ['packages/web/tmp/traces'],
files: [],
});

expect(eventPath).toBe(path.join('packages', 'web', 'tmp', 'traces', 'trace.json'));
expect(isCopyIgnored(matcher, eventPath)).toBe(true);
});
});
2 changes: 2 additions & 0 deletions packages/shaka-perf/src/twin-servers/__tests__/docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ describe('docker compose helpers', () => {
ports: { control: 3020, experiment: 3030 },
setupCommands: [],
rebuildCommands: [],
copyIgnore: { folders: [], files: [] },
projectSlug: 'printivity',
};

Expand Down Expand Up @@ -163,6 +164,7 @@ describe('docker compose helpers', () => {
ports: { control: 3020, experiment: 3030 },
setupCommands: [],
rebuildCommands: [],
copyIgnore: { folders: [], files: [] },
projectSlug: 'printivity',
};

Expand Down
57 changes: 51 additions & 6 deletions packages/shaka-perf/src/twin-servers/__tests__/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
* License in LICENSE.md.
*/

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getChangedFiles, getGitRootDirectory } from '../helpers/git';
import { defaultCopyIgnoreConfig } from '../copy-ignore-defaults';
import * as shell from '../helpers/shell';

jest.mock('../helpers/shell');
Expand All @@ -22,7 +26,7 @@ describe('getChangedFiles', () => {
.mockReturnValueOnce('file3.ts') // git diff --cached
.mockReturnValueOnce('file4.ts'); // untracked

const files = getChangedFiles('/repo');
const files = getChangedFiles('/repo', defaultCopyIgnoreConfig());

expect(files).toEqual(['file1.ts', 'file2.ts', 'file3.ts', 'file4.ts']);
});
Expand All @@ -33,15 +37,15 @@ describe('getChangedFiles', () => {
.mockReturnValueOnce('shared.ts\nonly-staged.ts')
.mockReturnValueOnce('shared.ts\nonly-untracked.ts');

const files = getChangedFiles('/repo');
const files = getChangedFiles('/repo', defaultCopyIgnoreConfig());

expect(files).toEqual(['shared.ts', 'only-diff.ts', 'only-staged.ts', 'only-untracked.ts']);
});

it('returns empty array when no changes', () => {
mockExecSync.mockReturnValue('');

const files = getChangedFiles('/repo');
const files = getChangedFiles('/repo', defaultCopyIgnoreConfig());

expect(files).toEqual([]);
});
Expand All @@ -52,7 +56,7 @@ describe('getChangedFiles', () => {
.mockReturnValueOnce('')
.mockReturnValueOnce('');

const files = getChangedFiles('/repo');
const files = getChangedFiles('/repo', defaultCopyIgnoreConfig());

expect(files).toEqual(['modified.ts']);
});
Expand All @@ -63,15 +67,15 @@ describe('getChangedFiles', () => {
.mockReturnValueOnce('')
.mockReturnValueOnce('new-file.ts');

const files = getChangedFiles('/repo');
const files = getChangedFiles('/repo', defaultCopyIgnoreConfig());

expect(files).toEqual(['new-file.ts']);
});

it('passes cwd to execSync_', () => {
mockExecSync.mockReturnValue('');

getChangedFiles('/my/repo');
getChangedFiles('/my/repo', defaultCopyIgnoreConfig());

expect(mockExecSync).toHaveBeenCalledWith(
'git diff --name-only',
Expand All @@ -86,6 +90,47 @@ describe('getChangedFiles', () => {
{ cwd: '/my/repo', silent: true }
);
});

it('filters packaged default host-only result directories', () => {
const repositoryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'shaka-copy-ignore-'));
mockExecSync
.mockReturnValueOnce([
'src/app.ts',
'audit-results/report.json',
'compare-results/report.json',
'packages/app/compare-bisect-results/session.json',
].join('\n'))
.mockReturnValueOnce('')
.mockReturnValueOnce('');

try {
expect(getChangedFiles(repositoryRoot, defaultCopyIgnoreConfig())).toEqual(['src/app.ts']);
} finally {
fs.rmSync(repositoryRoot, { recursive: true, force: true });
}
});

it('uses configured files and folders instead of the corresponding defaults', () => {
const repositoryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'shaka-copy-ignore-'));
mockExecSync
.mockReturnValueOnce([
'src/app.ts',
'compare-results/report.json',
'local-artifacts/trace.json',
'debug.log',
].join('\n'))
.mockReturnValueOnce('')
.mockReturnValueOnce('');

try {
expect(getChangedFiles(repositoryRoot, {
folders: ['local-artifacts'],
files: ['debug.log'],
})).toEqual(['src/app.ts', 'compare-results/report.json']);
} finally {
fs.rmSync(repositoryRoot, { recursive: true, force: true });
}
});
});

describe('getGitRootDirectory', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function createConfig(tmpDir: string): ResolvedConfig {
ports: { control: 3021, experiment: 3031 },
setupCommands: [],
rebuildCommands: [],
copyIgnore: { folders: [], files: [] },
projectSlug: 'test-project',
};
}
Expand Down
16 changes: 16 additions & 0 deletions packages/shaka-perf/src/twin-servers/__tests__/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ describe('TwinServersConfigSchema', () => {
expect(result.success).toBe(true);
});

it('rejects unknown copy-ignore keys instead of silently dropping them', () => {
const result = TwinServersConfigSchema.safeParse({
...validConfig,
copyIgnore: { folder: ['tmp/traces'] },
});

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toContainEqual(expect.objectContaining({
code: 'unrecognized_keys',
path: ['copyIgnore'],
keys: ['folder'],
}));
}
});

it('rejects empty experimentDir', () => {
const result = TwinServersConfigSchema.safeParse({
...validConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export async function copyChangesToSsh(
}

// Get git changed files from the source repo
const changedFiles = getChangedFiles(sourceDir);
const changedFiles = getChangedFiles(sourceDir, config.copyIgnore);

if (changedFiles.length === 0) {
printInfo('No git changes to copy');
Expand Down
14 changes: 14 additions & 0 deletions packages/shaka-perf/src/twin-servers/commands/servers-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ import {
type ServerLogStatus,
} from '../helpers/server-log';
import { dockerBuildDirForSide, dockerfileAbsForSide } from '../helpers/project-paths';
import {
createCopyIgnoreMatcher,
isCopyIgnored,
repositoryRelativeCopyPath,
} from '../helpers/copy-ignore';
import { getGitRootDirectory } from '../helpers/git';
import { BisectSessionController, type BisectExperimentReloadResult } from './bisect-session';
import {
experimentRebuildMenuDefinition,
Expand Down Expand Up @@ -922,6 +928,7 @@ export async function runServersMenu(
// ---------- Auto-sync ----------

const experimentBuildDir = dockerBuildDirForSide(config, 'experiment');
const experimentGitRoot = getGitRootDirectory(experimentBuildDir) || experimentBuildDir;
const liveIgnore = loadDockerignore(experimentBuildDir, dockerfileAbsForSide(config, 'experiment'));
const pendingSync = new Set<string>();
let syncTimer: NodeJS.Timeout | null = null;
Expand All @@ -935,6 +942,7 @@ export async function runServersMenu(
// image in the first place.
const manifest = readBuildManifest(config.volumes.experiment);
const ig = manifest ? ignoreFromManifest(manifest) : liveIgnore;
const copyIgnore = createCopyIgnoreMatcher(config.copyIgnore);
const manifestSet = manifest ? new Set(manifest.files) : null;

const batch = Array.from(pendingSync);
Expand All @@ -943,6 +951,12 @@ export async function runServersMenu(
let deleted = 0;
const errors: Error[] = [];
for (const rel of batch) {
const copyIgnorePath = repositoryRelativeCopyPath(
experimentGitRoot,
experimentBuildDir,
rel,
);
if (isCopyIgnored(copyIgnore, copyIgnorePath)) continue;
const src = path.join(experimentBuildDir, rel);
const dst = path.join(config.volumes.experiment, rel);
let srcStat: fs.Stats | null = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export async function syncChanges(
}

// Get git changed files from the source repo
const changedFiles = getChangedFiles(sourceDir);
const changedFiles = getChangedFiles(sourceDir, config.copyIgnore);

if (changedFiles.length === 0) {
printInfo('No git changes to sync');
Expand Down
7 changes: 7 additions & 0 deletions packages/shaka-perf/src/twin-servers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import {
type TwinServersConfig,
type TwinServersConfigInput,
} from './types';
import {
defaultCopyIgnoreConfig,
} from './copy-ignore-defaults';

// At runtime __dirname is dist/twin-servers/, so go up two levels to package root
const DEFAULT_COMPOSE_FILE = path.resolve(__dirname, '..', '..', 'templates', 'docker-compose.yml');
Expand Down Expand Up @@ -147,6 +150,10 @@ export function resolveConfig(config: unknown, cwd: string = process.cwd()): Res
ports: validConfig.ports,
setupCommands: validConfig.setupCommands ?? [],
rebuildCommands: validConfig.rebuildCommands ?? [],
copyIgnore: {
...defaultCopyIgnoreConfig(),
...validConfig.copyIgnore,
},
Comment on lines +153 to +156

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor UX footgun: because folders/files are replaced wholesale rather than merged/appended, a project that only wants to add one extra ignored path (e.g. tmp/traces) and writes copyIgnore: { folders: ['tmp/traces'] } silently loses the audit-results/compare-results/compare-bisect-results defaults — exactly the multi-GB result dirs this PR is meant to keep off the wire. The README example does show repeating the full default list, so this is mitigated by docs, but it's an easy trap since nothing warns when a supplied list omits the defaults. Consider a merge-by-default (with an explicit opt-out) or at least a runtime warning when overriding.

projectSlug: slug,
};
}
Loading