e6d34ee01b
* add OpenRouter proxy for managed model routing and OSS program proxy layer that mints ephemeral OpenRouter keys for users without BYOK API keys. two paths: pro plan users get their selected model proxied via OpenRouter; OSS program repos (hard-coded allowlist in config.ts) get free Claude Opus. BYOK env vars (PULLFROG_MODEL/OPENCODE_MODEL) always take precedence. frontend: OSS repos see a static "Opus (Free)" badge with the model dropdown disabled and no API key requirement. all models now carry openRouterResolve metadata for proxy target resolution. Made-with: Cursor * implement OSS program: proxy infrastructure for free model credits server-side OSS allowlist determines eligible repos. action mints ephemeral OpenRouter keys via OIDC-authenticated /api/proxy-token endpoint (idempotent on runId, $10 per-key safety limit). keys are disabled on workflow completion when no running refs remain. HWM-based usage sync tracks cumulative spend per account. schema: ProxyKey model, Account.usageUsd/activeKeyId, WorkflowRun.proxyKeyId action: OIDC credential stashing, resolveProxyModel uses server oss flag frontend: isOss flows from server page to components (no client allowlist) Made-with: Cursor * address PR review: repo cross-check, key retirement lifecycle, dead code removal - proxy-token: verify runId belongs to OIDC-authenticated repo via repo relation - add retireKey() shared primitive: disable in OpenRouter first, then mark disabled in DB - rotateKey: retire old active key after swap to prevent orphans - webhook: replace inline cleanupProxyKey with retireKey calls - syncAccountUsage: skip disabled keys - remove vestigial AccountPlan/plan field from action types - add disabled field to ProxyKey schema + migration Made-with: Cursor * replace deprecated opencode/mimo-v2-flash-free with mimo-v2-pro-free Made-with: Cursor * fix migration ordering: rename disabled migration to sort after table creation Made-with: Cursor * squash proxy key migrations into single migration Made-with: Cursor * add preview repo to OSS allowlist for testing Made-with: Cursor * populate OSS allowlist from oss-program-invitees.json Made-with: Cursor * format oss-program-invitees.json Made-with: Cursor * add installed public repos to OSS allowlist split ossRepos into three provenance-tracked lists: - internalRepos (pullfrog, colinhacks, RobinTail) - installedPublicRepos (external public non-fork repos with active installs) - invitees (from oss-program-invitees.json) also adds scripts/list-oss-candidates.ts to regenerate the installed list Made-with: Cursor * fix: resolve tokens before clearing OIDC env vars resolveTokens → acquireNewToken → isOIDCAvailable() checks ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN env vars. The new OIDC stashing code was deleting them in restricted shell mode before resolveTokens ran, causing it to fall through to the GitHub App path which requires GITHUB_APP_ID/GITHUB_PRIVATE_KEY. Made-with: Cursor * derive proxy-token auth from OIDC claims, add ensureWorkflowRun upsert - proxy-token no longer requires body.runId; uses claims.run_id + claims.repository - shared ensureWorkflowRun upsert called from both webhook and proxy-token - workflow_run_requested handler now eagerly creates WorkflowRun records - eliminates race condition between webhook and action proxy-token call Made-with: Cursor * hardcode PULLFROG_ACCOUNT_ID, document preview debugging lessons GitHub node IDs are constant — no reason for this to be an env var. Removes the trailing-newline bug that caused P2025 errors. Adds wiki docs on workflow testing, Vercel env gotchas, and Neon preview branch discovery. Made-with: Cursor * fix: parse OpenRouter create-key response correctly the API returns `key` at the top level, not inside `data` Made-with: Cursor * onboarding cards, unlock OSS model selection, simplify console - add OnboardingCard component with two states: workflow install and model+test (dispatches "Tell me a joke" for test run) - replace PromptBox overlay gates with dedicated onboarding cards; PromptBox is now just the form, always enabled - use hasWorkflowRuns DB check to decide onboarding vs promptbox - unlock ModelSelector for OSS repos (was locked to Opus badge); resolve proxyModel from repo's selected model alias in run-context - rename "API key" row to "Model costs" with pure client-side states: OSS covered, auto-resolve, free model, BYOK with env var names - add "(Recommended)" badge to model aliases with recommended: true - remove OSS_MODEL_DISPLAY_NAME constant and secrets-fetching logic Made-with: Cursor * update stale xai model snapshot Made-with: Cursor * rename Permissions to Security, hide git push toggle, add shell isolation toggle with disabled state for public repos, remove opentoad agent name from logs Made-with: Cursor * chevron hover states, sidebar hooks/security entries Made-with: Cursor * address PR review feedback: rename recommended→preferred, fix dispatch orphan, update wiki docs - rename `recommended` to `preferred` in model alias registry to distinguish from the UI "Recommended" badge (which is hardcoded for opus + codex only) - cancel precreated WorkflowRun when workflow lookup fails in dispatch-workflow - replace run_sql/vercel env pull in wiki docs with neonctl + Prisma pattern - extend scripts/neon-branch.ts to output DATABASE_URL via neonctl - add Recommended badge to GPT Codex alongside Claude Opus in ModelSelector Made-with: Cursor
526 lines
16 KiB
TypeScript
526 lines
16 KiB
TypeScript
import { existsSync, readdirSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { config } from "dotenv";
|
|
import { runInDocker } from "../utils/docker.ts";
|
|
import { ensureGitHubToken } from "../utils/github.ts";
|
|
import { isInsideDocker } from "../utils/globals.ts";
|
|
import { killTrackedChildren, setSignalHandler } from "../utils/subprocess.ts";
|
|
import {
|
|
type AgentResult,
|
|
agents,
|
|
getPrefix,
|
|
printResults,
|
|
printSingleValidation,
|
|
runAgentStreaming,
|
|
type TestRunnerOptions,
|
|
type TestTag,
|
|
type ValidationResult,
|
|
validateResult,
|
|
} from "./utils.ts";
|
|
|
|
/**
|
|
* unified test runner for all agent tests.
|
|
*
|
|
* usage: node test/run.ts [filters...]
|
|
*
|
|
* filters can be test names, tags, or agent names:
|
|
* node test/run.ts # run all tests (excludes adhoc-tagged tests)
|
|
* node test/run.ts smoke # run tests named "smoke" or tagged "smoke"
|
|
* node test/run.ts opentoad # run all tests for opentoad only
|
|
* node test/run.ts security # run all tests tagged "security"
|
|
* node test/run.ts agnostic # run all agnostic-tagged tests (with opentoad)
|
|
* node test/run.ts adhoc # run all adhoc-tagged tests
|
|
* node test/run.ts smoke opentoad # run smoke tests for opentoad only
|
|
*
|
|
* special tags:
|
|
* - "agnostic": runs with opentoad only, excluded when filtering by agent
|
|
* - "adhoc": excluded from default runs, must be explicitly requested
|
|
*
|
|
* by default, runs in a Docker container for isolation.
|
|
*/
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
export const actionDir = join(__dirname, "..");
|
|
|
|
// load .env files
|
|
config({ path: join(actionDir, ".env") });
|
|
config({ path: join(actionDir, "..", ".env") });
|
|
|
|
const nodeModulesVolume = "pullfrog-action-test-node-modules";
|
|
const mcpPortBase = 49000;
|
|
let nextMcpPort = mcpPortBase;
|
|
|
|
function allocateMcpPort(): number {
|
|
const port = nextMcpPort;
|
|
nextMcpPort += 1;
|
|
return port;
|
|
}
|
|
|
|
function buildNodeCmd(args: string[]): string {
|
|
const passArgs = args.map((arg) => `'${arg.replace(/'/g, "'\\''")}'`).join(" ");
|
|
return `node test/run.ts ${passArgs}`;
|
|
}
|
|
|
|
// run the test runner inside docker
|
|
function runTestsInDocker(args: string[]): never {
|
|
const result = runInDocker({
|
|
actionDir,
|
|
args,
|
|
nodeCmd: buildNodeCmd(args),
|
|
volumeName: nodeModulesVolume,
|
|
envFilterMode: "allowlist",
|
|
onStart: () => console.log("» running tests in docker container...\n"),
|
|
});
|
|
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
|
|
type TestInfo = {
|
|
name: string;
|
|
config: TestRunnerOptions;
|
|
};
|
|
|
|
type CancelState = {
|
|
canceled: boolean;
|
|
signal: NodeJS.Signals | null;
|
|
};
|
|
|
|
type TestModule = {
|
|
test?: TestRunnerOptions;
|
|
tests?: Record<string, TestRunnerOptions>;
|
|
};
|
|
|
|
// load all tests from all directories
|
|
async function loadAllTests(): Promise<TestInfo[]> {
|
|
const testInfos: TestInfo[] = [];
|
|
const dirs = ["crossagent", "agnostic", "adhoc"];
|
|
|
|
for (const dir of dirs) {
|
|
const dirPath = join(__dirname, dir);
|
|
if (!existsSync(dirPath)) continue;
|
|
|
|
const files = readdirSync(dirPath).filter((f) => f.endsWith(".ts"));
|
|
for (const file of files) {
|
|
const filePath = join(dirPath, file);
|
|
const module = (await import(filePath)) as TestModule;
|
|
|
|
if (module.test) {
|
|
testInfos.push({ name: module.test.name, config: module.test });
|
|
} else if (module.tests) {
|
|
const entries = Object.entries(module.tests);
|
|
for (const entry of entries) {
|
|
testInfos.push({ name: entry[0], config: entry[1] });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return testInfos;
|
|
}
|
|
|
|
// check if test has a specific tag
|
|
function hasTag(test: TestInfo, tag: TestTag): boolean {
|
|
return test.config.tags?.includes(tag) ?? false;
|
|
}
|
|
|
|
type ParsedArgs = {
|
|
filters: string[]; // test names or tags
|
|
agentFilters: string[];
|
|
};
|
|
|
|
function parseArgs(args: string[], allTests: TestInfo[]): ParsedArgs {
|
|
const testNames = new Set(allTests.map((t) => t.name));
|
|
const allTags = new Set(allTests.flatMap((t) => t.config.tags ?? []));
|
|
|
|
const filters: string[] = [];
|
|
const agentFilters: string[] = [];
|
|
|
|
for (const arg of args) {
|
|
if (agents.includes(arg as (typeof agents)[number])) {
|
|
agentFilters.push(arg);
|
|
} else if (testNames.has(arg) || allTags.has(arg as TestTag)) {
|
|
filters.push(arg);
|
|
} else {
|
|
console.error(`unknown argument: ${arg}`);
|
|
console.error(`available tests: ${[...testNames].join(", ")}`);
|
|
console.error(`available tags: ${[...allTags].join(", ")}`);
|
|
console.error(`available agents: ${agents.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
return { filters, agentFilters };
|
|
}
|
|
|
|
// filter tests based on filters (names or tags)
|
|
function filterTests(allTests: TestInfo[], filters: string[]): TestInfo[] {
|
|
if (filters.length === 0) {
|
|
// default: exclude adhoc tests
|
|
return allTests.filter((t) => !hasTag(t, "adhoc"));
|
|
}
|
|
|
|
// match tests by name or tag
|
|
return allTests.filter((t) => {
|
|
for (const filter of filters) {
|
|
if (t.name === filter || hasTag(t, filter as TestTag)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
type RunContext = {
|
|
testInfo: TestInfo;
|
|
agent: string;
|
|
cancelState: CancelState;
|
|
results: Map<string, ValidationResult>;
|
|
};
|
|
|
|
function getRunKey(test: string, agent: string): string {
|
|
return `${test}::${agent}`;
|
|
}
|
|
|
|
type CanceledValidationContext = {
|
|
testInfo: TestInfo;
|
|
agent: string;
|
|
signal: NodeJS.Signals;
|
|
};
|
|
|
|
function buildCanceledValidation(ctx: CanceledValidationContext): ValidationResult {
|
|
return {
|
|
test: ctx.testInfo.name,
|
|
agent: ctx.agent,
|
|
passed: false,
|
|
canceled: true,
|
|
checks: [{ name: "canceled", passed: false }],
|
|
output: `canceled by ${ctx.signal}`,
|
|
};
|
|
}
|
|
|
|
const MAX_RETRIES = 2;
|
|
const RATE_LIMIT_BACKOFF_MS = 60_000; // 1 minute for rate limits
|
|
const FLAKY_RETRY_BACKOFF_MS = 5_000; // 5 seconds for transient failures
|
|
|
|
type RetryDecision = { retry: false } | { retry: true; reason: string; backoffMs: number };
|
|
|
|
/**
|
|
* determine if a failed test run should be retried.
|
|
*
|
|
* retryable (transient infrastructure failures):
|
|
* - rate limit errors from API providers
|
|
* - agent crashed/errored but no security-relevant checks failed
|
|
* (e.g., agent didn't call set_output due to MCP connection drop)
|
|
* - set_output not called — all output-dependent checks cascade fail
|
|
*
|
|
* NOT retryable (genuine test failures):
|
|
* - security checks failed (sandbox breach, token leak, etc.)
|
|
* - agent successfully ran and called set_output but produced wrong results
|
|
*/
|
|
// detect rate limit / quota errors across all providers
|
|
const RATE_LIMIT_PATTERNS = [
|
|
"Rate limit reached", // anthropic
|
|
"Resource has been exhausted", // google/gemini
|
|
"quota exceeded", // google/gemini
|
|
"429", // generic HTTP 429
|
|
"Too Many Requests", // generic
|
|
];
|
|
|
|
function isRateLimited(output: string): boolean {
|
|
const lower = output.toLowerCase();
|
|
return RATE_LIMIT_PATTERNS.some((p) => lower.includes(p.toLowerCase()));
|
|
}
|
|
|
|
function shouldRetry(result: AgentResult, validation: ValidationResult): RetryDecision {
|
|
// rate limit / quota exhaustion: agent never got to run properly
|
|
if (!result.success && isRateLimited(result.output)) {
|
|
return { retry: true, reason: "rate limited", backoffMs: RATE_LIMIT_BACKOFF_MS };
|
|
}
|
|
|
|
// already passed — no retry needed
|
|
if (validation.passed) {
|
|
return { retry: false };
|
|
}
|
|
|
|
// if the test has a set_output check and it failed, other check failures are
|
|
// cascade failures — validators gate their checks on `setOutputCalled && ...`
|
|
// so they always fail when there's no structured output.
|
|
// security-relevant checks (like no_leak_filtered, native_blocked) are designed
|
|
// to PASS when set_output wasn't called (defensive coding). so cascade failures
|
|
// are never genuine security findings — they're transient instruction-following
|
|
// issues (MCP connection drop, agent confusion, etc.).
|
|
const setOutputCheck = validation.checks.find((c) => c.name === "set_output");
|
|
if (setOutputCheck && !setOutputCheck.passed) {
|
|
// if the output contains rate limit indicators, use the longer backoff
|
|
// (the agent process may have succeeded but hit quota limits mid-run)
|
|
const rateLimited = isRateLimited(result.output);
|
|
return {
|
|
retry: true,
|
|
reason: rateLimited ? "rate limited (set_output cascade)" : "set_output not called (cascade)",
|
|
backoffMs: rateLimited ? RATE_LIMIT_BACKOFF_MS : FLAKY_RETRY_BACKOFF_MS,
|
|
};
|
|
}
|
|
|
|
// set_output was called (or test has no set_output check) — if any other check
|
|
// failed, that's a genuine test failure with real data, not a cascade. don't retry.
|
|
const otherCheckFailed = validation.checks.some((c) => !c.passed && c.name !== "set_output");
|
|
if (otherCheckFailed) {
|
|
return { retry: false };
|
|
}
|
|
|
|
// agent process failed (non-zero exit) but no structured output to validate
|
|
if (!result.success) {
|
|
return { retry: true, reason: "agent process failed", backoffMs: FLAKY_RETRY_BACKOFF_MS };
|
|
}
|
|
|
|
return { retry: false };
|
|
}
|
|
|
|
async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
|
const testConfig = ctx.testInfo.config;
|
|
const env: Record<string, string> = {};
|
|
if (testConfig.env) {
|
|
const entries = Object.entries(testConfig.env);
|
|
for (const entry of entries) {
|
|
env[entry[0]] = entry[1];
|
|
}
|
|
}
|
|
if (testConfig.agentEnv) {
|
|
const agentEnv = testConfig.agentEnv.get(ctx.agent);
|
|
if (agentEnv) {
|
|
const entries = Object.entries(agentEnv);
|
|
for (const entry of entries) {
|
|
env[entry[0]] = entry[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!Object.hasOwn(env, "PULLFROG_MCP_PORT")) {
|
|
env.PULLFROG_MCP_PORT = String(allocateMcpPort());
|
|
}
|
|
|
|
// pass repo setup commands to play.ts for pre-agent execution
|
|
if (testConfig.repoSetup) {
|
|
env.PULLFROG_TEST_REPO_SETUP = testConfig.repoSetup;
|
|
}
|
|
|
|
// use anthropic sonnet to avoid google quota issues and gemini doom-looping
|
|
if (ctx.agent === "opentoad") {
|
|
env.PULLFROG_MODEL ??= "anthropic/claude-sonnet-4-5";
|
|
}
|
|
|
|
// build file-based env vars for MCP servers that don't inherit parent env
|
|
let fileEnv: Record<string, string> | undefined;
|
|
if (testConfig.fileAgentEnv) {
|
|
const agentFileEnv = testConfig.fileAgentEnv.get(ctx.agent);
|
|
if (agentFileEnv) {
|
|
fileEnv = {};
|
|
const entries = Object.entries(agentFileEnv);
|
|
for (const entry of entries) {
|
|
fileEnv[entry[0]] = entry[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
const prefix = getPrefix({ test: ctx.testInfo.name, agent: ctx.agent });
|
|
|
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
if (ctx.cancelState.canceled) break;
|
|
|
|
// allocate a fresh port on retries (previous server is gone)
|
|
if (attempt > 0) {
|
|
env.PULLFROG_MCP_PORT = String(allocateMcpPort());
|
|
}
|
|
|
|
const result = await runAgentStreaming({
|
|
test: ctx.testInfo.name,
|
|
agent: ctx.agent,
|
|
fixture: testConfig.fixture,
|
|
env,
|
|
fileEnv,
|
|
isCanceled: () => ctx.cancelState.canceled,
|
|
});
|
|
|
|
const validation = validateResult(result, testConfig.validator, {
|
|
test: ctx.testInfo.name,
|
|
expectFailure: testConfig.expectFailure,
|
|
});
|
|
|
|
// check if we should retry
|
|
if (attempt < MAX_RETRIES) {
|
|
const decision = shouldRetry(result, validation);
|
|
if (decision.retry) {
|
|
console.log(
|
|
`\n${prefix} ${decision.reason} — retrying in ${decision.backoffMs / 1000}s (retry ${attempt + 1}/${MAX_RETRIES})...\n`
|
|
);
|
|
await new Promise((r) => setTimeout(r, decision.backoffMs));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
ctx.results.set(getRunKey(ctx.testInfo.name, ctx.agent), validation);
|
|
return validation;
|
|
}
|
|
|
|
// should not reach here, but handle canceled state
|
|
return buildCanceledValidation({
|
|
testInfo: ctx.testInfo,
|
|
agent: ctx.agent,
|
|
signal: ctx.cancelState.signal ?? "SIGTERM",
|
|
});
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const args = process.argv.slice(2);
|
|
|
|
// run in Docker unless already inside
|
|
if (!isInsideDocker) {
|
|
// acquire token for docker if needed
|
|
await ensureGitHubToken();
|
|
runTestsInDocker(args);
|
|
}
|
|
|
|
// load all tests
|
|
const allTests = await loadAllTests();
|
|
const parsed = parseArgs(args, allTests);
|
|
|
|
// filter tests
|
|
const filteredTests = filterTests(allTests, parsed.filters);
|
|
|
|
if (filteredTests.length === 0) {
|
|
console.error("no tests to run");
|
|
process.exit(1);
|
|
}
|
|
|
|
// determine which agents to run
|
|
const agentsToRun = parsed.agentFilters.length > 0 ? parsed.agentFilters : [...agents];
|
|
|
|
// build list of test runs
|
|
type TestRun = { testInfo: TestInfo; agent: string };
|
|
const runs: TestRun[] = [];
|
|
|
|
for (const testInfo of filteredTests) {
|
|
const isAgnostic = hasTag(testInfo, "agnostic");
|
|
|
|
if (isAgnostic) {
|
|
// agnostic tests: skip if only filtering by agent, otherwise run with opentoad
|
|
if (parsed.filters.length === 0 && parsed.agentFilters.length > 0) {
|
|
continue;
|
|
}
|
|
runs.push({ testInfo, agent: "opentoad" });
|
|
} else {
|
|
// determine which agents to run for this test
|
|
const testAgents = testInfo.config.agents ?? agents;
|
|
const effectiveAgents = agentsToRun.filter((a) => testAgents.includes(a));
|
|
for (const agent of effectiveAgents) {
|
|
runs.push({ testInfo, agent });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (runs.length === 0) {
|
|
console.error("no test runs after filtering");
|
|
process.exit(1);
|
|
}
|
|
|
|
// describe what we're running
|
|
const runTestNames = [...new Set(runs.map((r) => r.testInfo.name))];
|
|
const runAgentNames = [...new Set(runs.map((r) => r.agent))];
|
|
console.log(`running ${runTestNames.join(", ")} for: ${runAgentNames.join(", ")}\n`);
|
|
|
|
const cancelState: CancelState = { canceled: false, signal: null };
|
|
const results = new Map<string, ValidationResult>();
|
|
let resultsPrinted = false;
|
|
|
|
function printAndExit(validations: ValidationResult[]): void {
|
|
if (resultsPrinted) return;
|
|
resultsPrinted = true;
|
|
console.log();
|
|
for (const v of validations) {
|
|
printSingleValidation(v);
|
|
}
|
|
printResults(validations);
|
|
const allPassed = validations.every((v) => v.passed);
|
|
process.exit(allPassed ? 0 : 1);
|
|
}
|
|
|
|
function handleCancel(signal: NodeJS.Signals): void {
|
|
if (cancelState.canceled) return;
|
|
cancelState.canceled = true;
|
|
cancelState.signal = signal;
|
|
killTrackedChildren();
|
|
|
|
const validations: ValidationResult[] = [];
|
|
for (const run of runs) {
|
|
const key = getRunKey(run.testInfo.name, run.agent);
|
|
const existing = results.get(key);
|
|
if (existing) {
|
|
validations.push(existing);
|
|
} else {
|
|
validations.push(
|
|
buildCanceledValidation({
|
|
testInfo: run.testInfo,
|
|
agent: run.agent,
|
|
signal,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
printAndExit(validations);
|
|
}
|
|
|
|
setSignalHandler(handleCancel);
|
|
|
|
// run tests with limited concurrency to avoid overwhelming agent APIs
|
|
const maxConcurrency = 5;
|
|
const validations = await runWithConcurrencyLimit(runs, maxConcurrency, (run) =>
|
|
runTestForAgent({
|
|
testInfo: run.testInfo,
|
|
agent: run.agent,
|
|
cancelState,
|
|
results,
|
|
})
|
|
);
|
|
|
|
if (!cancelState.canceled) {
|
|
printAndExit(validations);
|
|
}
|
|
}
|
|
|
|
// simple concurrency limiter
|
|
async function runWithConcurrencyLimit<T, R>(
|
|
items: T[],
|
|
limit: number,
|
|
fn: (item: T) => Promise<R>
|
|
): Promise<R[]> {
|
|
const results: R[] = [];
|
|
const executing: Promise<void>[] = [];
|
|
|
|
for (const item of items) {
|
|
const p = fn(item).then(
|
|
(result) => {
|
|
results.push(result);
|
|
},
|
|
(err: unknown) => {
|
|
console.error("runWithConcurrencyLimit: fn rejected unexpectedly", err);
|
|
throw err;
|
|
}
|
|
);
|
|
|
|
const e = p.then(() => {
|
|
executing.splice(executing.indexOf(e), 1);
|
|
});
|
|
executing.push(e);
|
|
|
|
if (executing.length >= limit) {
|
|
await Promise.race(executing);
|
|
}
|
|
}
|
|
|
|
await Promise.all(executing);
|
|
return results;
|
|
}
|
|
|
|
main();
|