Skip to content
10 changes: 10 additions & 0 deletions packages/software-factory/realm/test-results.gts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ export class TestRun extends CardDef {
<strong>#{{@model.sequenceNumber}}</strong>
<span class='status status-{{@model.status}}'>{{@model.status}}</span>
</div>
{{#if @model.issue}}
<div class='issue-name'>{{@model.issue.summary}}</div>
{{/if}}
<div class='counts'>
{{@model.passedCount}}/{{this.total}}
passed
Expand Down Expand Up @@ -281,6 +284,13 @@ export class TestRun extends CardDef {
color: var(--boxel-blue, #2563eb);
background: #eff6ff;
}
.issue-name {
font-size: 0.8rem;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.counts {
font-size: 0.85rem;
color: var(--muted-foreground);
Expand Down
42 changes: 19 additions & 23 deletions packages/software-factory/scripts/smoke-tests/issue-loop-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ async function scenarioSingleIssue(): Promise<void> {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
validator: new MockValidator([makePassingValidation()]),
createValidator: () => new MockValidator([makePassingValidation()]),
targetRealmUrl: 'https://example.test/target/',
});

Expand Down Expand Up @@ -328,10 +328,7 @@ async function scenarioDependencyCascade(): Promise<void> {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
validator: new MockValidator([
makePassingValidation(),
makePassingValidation(),
]),
createValidator: () => new MockValidator([makePassingValidation()]),
targetRealmUrl: 'https://example.test/target/',
});

Expand Down Expand Up @@ -402,11 +399,7 @@ async function scenarioPriorityOrdering(): Promise<void> {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
validator: new MockValidator([
makePassingValidation(),
makePassingValidation(),
makePassingValidation(),
]),
createValidator: () => new MockValidator([makePassingValidation()]),
targetRealmUrl: 'https://example.test/target/',
});

Expand Down Expand Up @@ -461,7 +454,7 @@ async function scenarioMaxIterations(): Promise<void> {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
validator: new MockValidator(validations),
createValidator: () => new MockValidator(validations),
targetRealmUrl: 'https://example.test/target/',
maxIterationsPerIssue: 3,
});
Expand Down Expand Up @@ -507,7 +500,7 @@ async function scenarioBlockedIssue(): Promise<void> {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
validator: new MockValidator([makePassingValidation()]),
createValidator: () => new MockValidator([makePassingValidation()]),
targetRealmUrl: 'https://example.test/target/',
});

Expand Down Expand Up @@ -535,7 +528,7 @@ async function scenarioEmptyProject(): Promise<void> {
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
validator: new NoOpValidator(),
createValidator: () => new NoOpValidator(),
targetRealmUrl: 'https://example.test/target/',
});

Expand Down Expand Up @@ -572,21 +565,23 @@ async function scenarioValidationPipeline(): Promise<void> {
store,
);

// Use a real ValidationPipeline with all NoOp steps (no server needed)
let pipeline = new ValidationPipeline([
new NoOpStepRunner('parse'),
new NoOpStepRunner('lint'),
new NoOpStepRunner('evaluate'),
new NoOpStepRunner('instantiate'),
new NoOpStepRunner('test'),
]);
// Use a real ValidationPipeline with all NoOp steps (no server needed).
// The factory creates a fresh pipeline per issue, as in production.
let createPipeline = () =>
new ValidationPipeline([
new NoOpStepRunner('parse'),
new NoOpStepRunner('lint'),
new NoOpStepRunner('evaluate'),
new NoOpStepRunner('instantiate'),
new NoOpStepRunner('test'),
]);

let result = await runIssueLoop({
agent,
contextBuilder: new StubContextBuilder(),
tools: TOOLS,
issueStore: store,
validator: pipeline,
createValidator: createPipeline,
targetRealmUrl: 'https://example.test/target/',
});

Expand All @@ -604,8 +599,9 @@ async function scenarioValidationPipeline(): Promise<void> {

// Verify formatForContext works
let lastValidation = result.issueResults[0]?.lastValidation;
let pipelineForFormat = createPipeline();
let formatted = lastValidation
? pipeline.formatForContext(lastValidation)
? pipelineForFormat.formatForContext(lastValidation)
: '';
check(
'formatForContext reports all passed',
Expand Down
13 changes: 11 additions & 2 deletions packages/software-factory/src/issue-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ export interface IssueLoopConfig {
contextBuilder: IssueContextBuilderLike;
tools: FactoryTool[];
issueStore: IssueStore;
validator: Validator;
/**
* Factory that creates a fresh Validator for each issue.
* Receives the issue ID so the validator can scope artifacts (e.g. TestRun
* slugs) to the specific issue being validated.
*/
createValidator: (issueId: string) => Validator;
targetRealmUrl: string;
briefUrl?: string;
/** Maximum inner-loop iterations per issue. Default: 5. */
Expand Down Expand Up @@ -199,7 +204,7 @@ export async function runIssueLoop(
contextBuilder,
tools,
issueStore,
validator,
createValidator,
targetRealmUrl,
briefUrl,
maxIterationsPerIssue = DEFAULT_MAX_ITERATIONS_PER_ISSUE,
Expand Down Expand Up @@ -254,6 +259,10 @@ export async function runIssueLoop(
`Outer cycle ${outerCycles}: picked issue ${issueSummaryLabel(issue)} (status=${issue.status}, priority=${issue.priority})`,
);

// Create a fresh validator scoped to this issue so that artifacts
// (e.g. TestRun cards) are named per-issue rather than shared.
let validator = createValidator(issue.id);

// -----------------------------------------------------------------------
// Inner loop: iterate on a single issue with validation
// -----------------------------------------------------------------------
Expand Down
44 changes: 35 additions & 9 deletions packages/software-factory/src/test-run-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export async function resolveTestRun(
}

let sequenceNumber = await getNextSequenceNumber(
options.slug,
realmOptions,
options.lastSequenceNumber,
);
Expand Down Expand Up @@ -142,7 +143,13 @@ async function findResumableTestRun(
};
}

/**
* Get the next sequence number for a given slug by searching existing
* TestRun cards in the realm and filtering by card ID. Each slug (issue)
* gets its own independent sequence starting from 1.
*/
async function getNextSequenceNumber(
slug: string,
options: TestRunRealmOptions,
minSequenceNumber = 0,
): Promise<number> {
Expand All @@ -153,18 +160,34 @@ async function getNextSequenceNumber(
on: { module: options.testResultsModuleUrl, name: 'TestRun' },
},
sort: [{ by: 'sequenceNumber', direction: 'desc' }],
page: { size: 1 },
},
{ authorization: options.authorization, fetch: options.fetch },
);

let latest = result?.ok
? (result.data?.[0] as
| { attributes?: { sequenceNumber?: number } }
| undefined)
: undefined;
let fromIndex = latest?.attributes?.sequenceNumber ?? 0;
return Math.max(fromIndex, minSequenceNumber) + 1;
if (!result?.ok || !result.data) {
return minSequenceNumber + 1;
}

let targetRealmUrl = ensureTrailingSlash(options.targetRealmUrl);
let prefix = `Test Runs/${slug}-`;
let maxSeq = 0;

for (let card of result.data) {
let cardId = (card as { id?: string }).id ?? '';
let relativePath = cardId.startsWith(targetRealmUrl)
? cardId.slice(targetRealmUrl.length)
: cardId;
if (relativePath.startsWith(prefix)) {
let attrs = (card as { attributes?: { sequenceNumber?: number } })
.attributes;
let seq = attrs?.sequenceNumber ?? 0;
if (seq > maxSeq) {
maxSeq = seq;
}
}
}

return Math.max(maxSeq, minSequenceNumber) + 1;
}

// ---------------------------------------------------------------------------
Expand All @@ -190,6 +213,8 @@ function buildQunitTestPageHtml(opts: {
targetRealmUrl: string;
/** Browser-accessible URL of the realm server (compat proxy) */
realmProxyUrl: string;
/** Optional slug identifying the issue under test — shown in the page title. */
slug?: string;
}): string {
let host = opts.assetServerUrl.replace(/\/$/, '');
// Ember config URLs must use the browser-accessible realm proxy,
Expand Down Expand Up @@ -268,7 +293,7 @@ function buildQunitTestPageHtml(opts: {
<head>
<meta charset="utf-8">
${metaTags.join('\n ')}
<title>Software Factory Card Tests</title>
<title>Software Factory Card Tests${opts.slug ? ` — ${opts.slug}` : ''}</title>
${linkTags.join('\n ')}
</head>
<body>
Expand Down Expand Up @@ -466,6 +491,7 @@ export async function executeTestRunFromRealm(
hostDistDir,
targetRealmUrl: options.targetRealmUrl,
realmProxyUrl: options.hostAppUrl,
slug: options.slug,
});
setHtml(html);

Expand Down
7 changes: 7 additions & 0 deletions packages/software-factory/src/validators/test-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ export class TestValidationStep implements ValidationStepRunner {
? deriveIssueSlug(this.config.issueId)
: 'validation';

// Build the issue card URL for the TestRun → Issue linksTo relationship.
// issueId is a realm-relative path like "Issues/sticky-note-define-core".
let issueURL = this.config.issueId
? new URL(this.config.issueId, targetRealmUrl).href
: undefined;

handle = await this.executeTestRunFn({
targetRealmUrl,
testResultsModuleUrl: this.config.testResultsModuleUrl,
Expand All @@ -149,6 +155,7 @@ export class TestValidationStep implements ValidationStepRunner {
hostAppUrl: this.config.hostAppUrl,
forceNew: true,
lastSequenceNumber: this.lastSequenceNumber,
issueURL,
});

if (handle.sequenceNumber != null) {
Expand Down
2 changes: 1 addition & 1 deletion packages/software-factory/tests/factory-test-realm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ module('factory-test-realm > resolveTestRun', function () {
let urlStr = String(url);
let method = init?.method ?? 'GET';

// Search endpoint
// Search endpoint (used by findResumableTestRun and getNextSequenceNumber)
if (urlStr.includes('_search') && method === 'QUERY') {
return new Response(
JSON.stringify({
Expand Down
Loading
Loading