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
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { AppError } from '@agent-device/kernel/errors';
import { discoverReplayTestEntries } from '../session-test-discovery.ts';
import {
buildReplayTestInvocationId,
buildReplayTestSessionName,
discoverReplayTestEntries,
} from '../session-test-discovery.ts';
import { buildReplayTestArtifactSlug, trimEdgeDashes } from '../session-test-artifacts.ts';
import type { ReplayTestManifest, ReplayTestSource } from '../session-test-types.ts';

// Scheduler-owned discovery policy (#1478 P3b): which sources a --platform filter runs, which
Expand Down Expand Up @@ -75,3 +80,27 @@ test('a suite that matched nothing after filtering is rejected', () => {
(error: unknown) => error instanceof AppError && /No replay tests matched/.test(error.message),
);
});

test('edge-dash trimming stays linear on an interior dash run (CodeQL js/polynomial-redos)', () => {
// The slug pipeline collapses character runs before trimming, so only a
// direct call can carry a long interior run — which is exactly the shape
// the retired /^-+|-+$/g form re-scans quadratically (each interior dash
// restarts a -+$ attempt that fails at the trailing x). 100k dashes take
// seconds there and must stay well under a second here, with the input
// returned byte-identical since nothing sits at the edges.
const interiorRun = `x${'-'.repeat(100_000)}x`;
const startedAt = performance.now();
const trimmed = trimEdgeDashes(interiorRun);
assert.ok(performance.now() - startedAt < 1000);
assert.equal(trimmed, interiorRun);
});

test('all-dash inputs land on the documented fallbacks instead of empty identifiers', () => {
assert.equal(buildReplayTestArtifactSlug('/tmp/####', '/tmp'), 'test');
assert.equal(buildReplayTestInvocationId('----'), 'suite');
// A session-name slug that trims to nothing is omitted entirely — no dangling separator.
assert.equal(
buildReplayTestSessionName('s', 'suite1', '/tmp/----.ad', 0),
's:test:suite1:1:attempt-1',
);
});
24 changes: 19 additions & 5 deletions packages/replay-test/src/internal/session-test-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ export function buildReplayTestArtifactSlug(filePath: string, cwd?: string): str
? path.basename(filePath)
: relativePath;
return (
value
.toLowerCase()
.replace(/[\\/]+/g, '__')
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'test'
trimEdgeDashes(
value
.toLowerCase()
.replace(/[\\/]+/g, '__')
.replace(/[^a-z0-9._-]+/g, '-'),
) || 'test'
);
}

Expand Down Expand Up @@ -135,3 +136,16 @@ function isExistingFile(filePath: string): boolean {
return false;
}
}

/**
* Linear-time edge trim. The regex form (`/^-+|-+$/g`) backtracks
* polynomially on long dash runs (CodeQL js/polynomial-redos #27/#28), and
* these slugs are built from caller-supplied file paths.
*/
export function trimEdgeDashes(value: string): string {
let start = 0;
let end = value.length;
while (start < end && value[start] === '-') start += 1;
while (end > start && value[end - 1] === '-') end -= 1;
return value.slice(start, end);
}
11 changes: 3 additions & 8 deletions packages/replay-test/src/internal/session-test-discovery.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from 'node:path';
import { trimEdgeDashes } from './session-test-artifacts.ts';
import { AppError } from '@agent-device/kernel/errors';
import { isApplePlatform, type PlatformSelector } from '@agent-device/kernel/device';
import type {
Expand Down Expand Up @@ -89,20 +90,14 @@ export function buildReplayTestSessionName(
attemptIndex = 0,
): string {
const baseName = path.basename(filePath, path.extname(filePath));
const slug = baseName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const slug = trimEdgeDashes(baseName.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
const testNumber = caseIndex + 1;
return `${sessionName}:test:${suiteInvocationId}:${testNumber}${slug ? `-${slug}` : ''}:attempt-${attemptIndex + 1}`;
}

export function buildReplayTestInvocationId(requestId?: string): string {
const raw = requestId?.trim() || `${process.pid}-${Date.now().toString(36)}`;
const normalized = raw
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const normalized = trimEdgeDashes(raw.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
return normalized || 'suite';
}

Expand Down
2 changes: 1 addition & 1 deletion src/replay/target-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { AppError } from '@agent-device/kernel/errors';
const TARGET_ANNOTATION_TAG = 'agent-device:target-v1';
// Captures the rest of the line verbatim: a line claiming the tag with a
// garbage payload is a malformed v1 annotation, never an ordinary comment.
const TARGET_ANNOTATION_LINE_RE = /^#\s*agent-device:target-v(\d+)(?:\s+(.*))?$/;
const TARGET_ANNOTATION_LINE_RE = /^#\s*agent-device:target-v(\d+)(?:\s+(\S.*))?$/;

export const TARGET_ANNOTATION_MAX_FIELD_BYTES = 256;
export const TARGET_ANNOTATION_MAX_PAYLOAD_BYTES = 4096;
Expand Down
Loading