mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 12:15:29 +02:00
fix: preserve activity errors, asset bytes and runner summaries
Treat non-2xx activity responses as feed errors, preserve non-Markdown Copilot agent assets, and apply the reviewed reference escape parity correction with regenerated bindings. Allow the test runner to drain piped output on its existing success or failure exit status. Validate installed workflows and bounded real Node20/24 pipe regressions.
This commit is contained in:
@@ -16,7 +16,7 @@ REFERENCE_DEFINITION_RE = re.compile(
|
||||
"""
|
||||
)
|
||||
REFERENCE_USAGE_RE = re.compile(
|
||||
r"(?<![\\])\[(?P<text>[^\]\n]+)\](?:\[(?P<label>[^\]\n]*)\])?"
|
||||
r"(?<![\\])(?:\\\\)*\[(?P<text>[^\]\n]+)\](?:\[(?P<label>[^\]\n]*)\])?"
|
||||
)
|
||||
EXTERNAL_URI_RE = re.compile(
|
||||
r"^(?:[A-Za-z][A-Za-z0-9+.-]*://|(?:data|doi|geo|irc|magnet|mailto|news|sms|tel|urn):)",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@ REFERENCE_DEFINITION_RE = re.compile(
|
||||
"""
|
||||
)
|
||||
REFERENCE_USAGE_RE = re.compile(
|
||||
r"(?<![\\])\[(?P<text>[^\]\n]+)\](?:\[(?P<label>[^\]\n]*)\])?"
|
||||
r"(?<![\\])(?:\\\\)*\[(?P<text>[^\]\n]+)\](?:\[(?P<label>[^\]\n]*)\])?"
|
||||
)
|
||||
EXTERNAL_URI_RE = re.compile(
|
||||
r"^(?:[A-Za-z][A-Za-z0-9+.-]*://|(?:data|doi|geo|irc|magnet|mailto|news|sms|tel|urn):)",
|
||||
|
||||
@@ -856,7 +856,10 @@ handleRoute();
|
||||
var panel = document.getElementById('panel-activity');
|
||||
if (!panel) return;
|
||||
fetch('/api/activity')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('Activity request failed');
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
var entries = (data && data.entries) || [];
|
||||
var skillCount = entries.filter(function (e) { return e.tool_name === 'Skill'; }).length;
|
||||
|
||||
@@ -104,9 +104,10 @@ function buildCopyFileOperation({
|
||||
strategy,
|
||||
contentTransform,
|
||||
}) {
|
||||
// Copilot's workflow reference transform is textual. Skill directories may
|
||||
// Copilot's agent and workflow transforms are textual. Their directories may
|
||||
// also contain binary assets and executable helpers, which must keep bytes.
|
||||
const applicableTransform = contentTransform === 'copilot-workflow-paths'
|
||||
const applicableTransform = (contentTransform === 'copilot-workflow-paths'
|
||||
|| contentTransform === 'copilot-agent-frontmatter')
|
||||
&& !/\.md$/i.test(sourceRelativePath) ? undefined : contentTransform;
|
||||
return {
|
||||
kind: 'copy-file',
|
||||
|
||||
@@ -16,7 +16,7 @@ REFERENCE_DEFINITION_RE = re.compile(
|
||||
"""
|
||||
)
|
||||
REFERENCE_USAGE_RE = re.compile(
|
||||
r"(?<![\\])\[(?P<text>[^\]\n]+)\](?:\[(?P<label>[^\]\n]*)\])?"
|
||||
r"(?<![\\])(?:\\\\)*\[(?P<text>[^\]\n]+)\](?:\[(?P<label>[^\]\n]*)\])?"
|
||||
)
|
||||
EXTERNAL_URI_RE = re.compile(
|
||||
r"^(?:[A-Za-z][A-Za-z0-9+.-]*://|(?:data|doi|geo|irc|magnet|mailto|news|sms|tel|urn):)",
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'run-all.js'), 'utf8');
|
||||
|
||||
@@ -15,6 +17,7 @@ function run(result, filename = 'sample.test.js', actions = true) {
|
||||
const fakeProcess = {
|
||||
env: actions ? { GITHUB_ACTIONS: 'true' } : {},
|
||||
exit(code) { status = code; throw exit; },
|
||||
set exitCode(code) { status = code; },
|
||||
};
|
||||
const fakeFs = {
|
||||
readdirSync: () => [{
|
||||
@@ -140,6 +143,40 @@ const tests = [
|
||||
assert.deepStrictEqual(result.annotations, []);
|
||||
assert.ok(result.logs.includes('Error: local failure'));
|
||||
}],
|
||||
...[0, 1].map(fixtureFailures => [`piped runner flushes the full tail and summary with exit ${fixtureFailures}`, () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-runner-pipe-'));
|
||||
try {
|
||||
const testsDir = path.join(root, 'tests');
|
||||
fs.mkdirSync(testsDir);
|
||||
const runner = path.join(testsDir, 'run-all.js');
|
||||
fs.writeFileSync(runner, source);
|
||||
// Keep fixture stdout below the runner's default 1 MiB spawnSync ceiling,
|
||||
// but above a pipe buffer so the runner must drain pending async writes.
|
||||
const payloadBytes = 256 * 1024;
|
||||
fs.writeFileSync(path.join(testsDir, 'large.test.js'), [
|
||||
`console.log('x'.repeat(${payloadBytes}));`,
|
||||
"console.log('FIXTURE_FULL_TAIL');",
|
||||
`console.log('Passed: 1, Failed: ${fixtureFailures}');`,
|
||||
`process.exitCode = ${fixtureFailures};`,
|
||||
].join('\n'));
|
||||
const env = { ...process.env, PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}` };
|
||||
delete env.ECC_TEST_SKIP;
|
||||
delete env.ECC_TEST_ALLOW_FAILURES;
|
||||
const result = spawnSync(process.execPath, [runner], {
|
||||
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], env,
|
||||
maxBuffer: 512 * 1024, timeout: 15000,
|
||||
});
|
||||
assert.ifError(result.error);
|
||||
assert.strictEqual(result.status, fixtureFailures, result.stderr);
|
||||
assert.ok(result.stdout.includes('x'.repeat(payloadBytes)), 'full bounded fixture payload must reach the pipe');
|
||||
assert.match(result.stdout, /FIXTURE_FULL_TAIL/);
|
||||
assert.match(result.stdout, /Final Results/);
|
||||
assert.ok(result.stdout.trimEnd().endsWith(`Passed: 1, Failed: ${fixtureFailures}`));
|
||||
assert.match(result.stdout, new RegExp(`Files: ${fixtureFailures ? '0 passed, 1 failed' : '1 passed, 0 failed'}`));
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}]),
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -289,5 +289,40 @@ test('Copilot rewrites Markdown commands while preserving binary skill assets an
|
||||
}
|
||||
});
|
||||
|
||||
test('Copilot installs non-Markdown agent assets byte-for-byte while adapting Markdown agents', () => {
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { createManifestInstallPlan, applyInstallPlan } = require('../../scripts/lib/install-executor');
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-copilot-agent-assets-'));
|
||||
try {
|
||||
const sourceRoot = path.join(temp, 'source');
|
||||
fs.mkdirSync(path.join(sourceRoot, 'manifests'), { recursive: true });
|
||||
fs.writeFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), JSON.stringify({ version: 1, modules: [{
|
||||
id: 'fixture', kind: 'agents', paths: ['agents'], targets: ['copilot'], dependencies: [],
|
||||
}] }));
|
||||
fs.writeFileSync(path.join(sourceRoot, 'manifests', 'install-profiles.json'), JSON.stringify({ version: 1, profiles: {} }));
|
||||
const agentRoot = path.join(sourceRoot, 'agents');
|
||||
fs.mkdirSync(path.join(agentRoot, 'assets'), { recursive: true });
|
||||
const binary = Buffer.from([0, 255, 254, 128, 42]);
|
||||
const helper = 'Run node scripts/setup-package-manager.js --detect\n';
|
||||
fs.writeFileSync(path.join(agentRoot, 'assets', 'icon.png'), binary);
|
||||
fs.writeFileSync(path.join(agentRoot, 'assets', 'helper.txt'), helper);
|
||||
fs.writeFileSync(path.join(agentRoot, 'architect.MD'), CLAUDE_AGENT);
|
||||
const plan = createManifestInstallPlan({ sourceRoot, target: 'copilot', moduleIds: ['fixture'], projectRoot: path.join(temp, 'project'), homeDir: path.join(temp, 'home'), env: {} });
|
||||
applyInstallPlan(plan);
|
||||
const installed = path.join(plan.targetRoot, 'agents');
|
||||
assert.deepStrictEqual(fs.readFileSync(path.join(installed, 'assets', 'icon.png')), binary);
|
||||
assert.strictEqual(fs.readFileSync(path.join(installed, 'assets', 'helper.txt'), 'utf8'), helper);
|
||||
assert.strictEqual(fs.readFileSync(path.join(installed, 'architect.MD'), 'utf8'), adaptCopilotAgent(CLAUDE_AGENT, 'agents/architect.MD'));
|
||||
for (const operation of plan.operations.filter(operation => operation.sourceRelativePath.includes('/assets/'))) {
|
||||
assert.ok(!operation.contentTransform, operation.sourceRelativePath);
|
||||
}
|
||||
assert.strictEqual(fs.readFileSync(path.join(agentRoot, 'architect.MD'), 'utf8'), CLAUDE_AGENT);
|
||||
assert.deepStrictEqual(fs.readFileSync(path.join(agentRoot, 'assets', 'icon.png')), binary);
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
|
||||
+1
-1
@@ -209,4 +209,4 @@ if (failedFiles.length > 0) {
|
||||
console.log('╚' + '═'.repeat(BOX_W) + '╝');
|
||||
console.log(`\nPassed: ${totalPassed}, Failed: ${totalFailed}`);
|
||||
|
||||
process.exit(totalFailed > 0 ? 1 : 0);
|
||||
process.exitCode = totalFailed > 0 ? 1 : 0;
|
||||
|
||||
@@ -58,6 +58,68 @@ test('renderHTML emits executable scripts without syntax errors', () => {
|
||||
for (const script of scripts) new vm.Script(script[1]);
|
||||
});
|
||||
|
||||
asyncTest('served activity UI shows HTTP failures as errors and preserves healthy empty and populated feeds', async () => {
|
||||
const dir = createTempDir('ecc-activity-response-');
|
||||
const savedDataHome = process.env.ECC_AGENT_DATA_HOME;
|
||||
const originalClose = fs.closeSync;
|
||||
try {
|
||||
process.env.ECC_AGENT_DATA_HOME = dir;
|
||||
const log = path.join(dir, 'metrics', 'tool-usage.jsonl');
|
||||
writeFile(dir, 'metrics/tool-usage.jsonl', '');
|
||||
const reported = [];
|
||||
await withDashboardServer(async port => {
|
||||
const page = await requestDashboard(port);
|
||||
assert.strictEqual(page.statusCode, 200);
|
||||
const script = [...page.body.matchAll(/<script>([\s\S]*?)<\/script>/g)]
|
||||
.find(match => match[1].includes('function renderActivity()'))[1];
|
||||
async function renderFeed() {
|
||||
let status;
|
||||
let jsonReads = 0;
|
||||
const html = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Activity UI did not render')), REQUEST_TIMEOUT_MS);
|
||||
const panel = { set innerHTML(value) { clearTimeout(timeout); resolve(value); } };
|
||||
const context = vm.createContext({
|
||||
document: { getElementById: () => panel, createElement: () => ({}), head: { appendChild() {} } },
|
||||
window: { showTab() {} }, setInterval() {}, clearInterval() {},
|
||||
fetch: async url => {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${url}`);
|
||||
status = response.status;
|
||||
return { ok: response.ok, json() { jsonReads++; return response.json(); } };
|
||||
},
|
||||
});
|
||||
new vm.Script(script).runInContext(context);
|
||||
context.window.showTab('activity');
|
||||
});
|
||||
return { html, status, jsonReads };
|
||||
}
|
||||
// A descriptor-close failure escapes loadActivity's read catch and drives
|
||||
// the real server's generic HTTP 500 response without leaking its detail.
|
||||
fs.closeSync = fd => { originalClose(fd); throw new Error('private close detail'); };
|
||||
const failed = await renderFeed();
|
||||
fs.closeSync = originalClose;
|
||||
assert.strictEqual(failed.status, 500);
|
||||
assert.match(failed.html, /Could not load activity feed/);
|
||||
assert.doesNotMatch(failed.html, /No recorded tool calls|private close detail/);
|
||||
assert.strictEqual(failed.jsonReads, 0);
|
||||
assert.strictEqual(reported.length, 1);
|
||||
const empty = await renderFeed();
|
||||
assert.strictEqual(empty.status, 200);
|
||||
assert.match(empty.html, /No recorded tool calls yet/);
|
||||
assert.strictEqual(empty.jsonReads, 1);
|
||||
fs.writeFileSync(log, JSON.stringify({ session_id: 'response-fixture', tool_name: 'Read', input_summary: 'visible activity' }) + '\n');
|
||||
const populated = await renderFeed();
|
||||
assert.strictEqual(populated.status, 200);
|
||||
assert.match(populated.html, /visible activity/);
|
||||
assert.doesNotMatch(populated.html, /Could not load activity feed|No recorded tool calls/);
|
||||
}, { reportError: (message, error) => reported.push({ message, error }) });
|
||||
} finally {
|
||||
fs.closeSync = originalClose;
|
||||
if (savedDataHome === undefined) delete process.env.ECC_AGENT_DATA_HOME;
|
||||
else process.env.ECC_AGENT_DATA_HOME = savedDataHome;
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('activity loading bounds large logs and ignores malformed rows', () => {
|
||||
const { loadActivity } = require(SCRIPT);
|
||||
withTempDir('ecc-activity-', dir => {
|
||||
|
||||
@@ -882,3 +882,24 @@ def test_artifact_scope_resolves_escaped_bracket_and_apostrophe_filenames(
|
||||
(project / "index.md").write_text(link + "\n", encoding="utf-8")
|
||||
result = run_audit(project, "artifacts")
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backslashes", range(5))
|
||||
@pytest.mark.parametrize("usage", ("[diagram]", "[diagram][]"))
|
||||
@pytest.mark.parametrize("scope", ("artifacts", "full"))
|
||||
def test_audit_checks_reference_usage_after_even_backslash_runs(
|
||||
project: Path, backslashes: int, usage: str, scope: str
|
||||
) -> None:
|
||||
(project / "index.md").write_text(
|
||||
"\\" * backslashes + usage + "\n\n[diagram]: missing.png\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
before = {path: path.read_bytes() for path in project.rglob("*") if path.is_file()}
|
||||
result = run_audit(project, scope)
|
||||
expected = 1 if backslashes % 2 == 0 else 0
|
||||
assert result.returncode == expected, result.stdout + result.stderr
|
||||
diagnostic = "Broken Markdown link: 'index.md' -> 'missing.png'"
|
||||
assert (diagnostic in result.stdout) == (expected == 1), result.stdout
|
||||
assert "Traceback" not in result.stderr
|
||||
after = {path: path.read_bytes() for path in project.rglob("*") if path.is_file()}
|
||||
assert after == before, "the actual audit CLI must not modify project files"
|
||||
|
||||
Reference in New Issue
Block a user