mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(ci): stabilize merge queue checks (#6056)
This commit is contained in:
parent
f3694dde67
commit
7f34cdabe1
20 changed files with 274 additions and 122 deletions
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
|
|
@ -118,6 +118,7 @@ jobs:
|
|||
# post-merge re-run on `main` would be redundant.
|
||||
if: "${{ !cancelled() && github.event_name != 'push' }}"
|
||||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: 'read'
|
||||
checks: 'write'
|
||||
|
|
@ -209,6 +210,7 @@ jobs:
|
|||
|
||||
- name: 'Run actionlint'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||||
timeout-minutes: 5
|
||||
run: 'node scripts/lint.js --actionlint'
|
||||
|
||||
- name: 'Run shellcheck'
|
||||
|
|
@ -256,6 +258,13 @@ jobs:
|
|||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||||
env:
|
||||
NO_COLOR: true
|
||||
HOME: '${{ runner.temp }}/qwen-ci-home'
|
||||
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
|
||||
OPENAI_API_KEY: ''
|
||||
DASHSCOPE_API_KEY: ''
|
||||
QWEN_API_KEY: ''
|
||||
GEMINI_API_KEY: ''
|
||||
QWEN_DEFAULT_AUTH_TYPE: ''
|
||||
run: 'npm run test:ci'
|
||||
|
||||
- name: 'Run no-AK integration smoke tests'
|
||||
|
|
@ -338,6 +347,13 @@ jobs:
|
|||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||||
env:
|
||||
NO_COLOR: true
|
||||
HOME: '${{ runner.temp }}/qwen-ci-home'
|
||||
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
|
||||
OPENAI_API_KEY: ''
|
||||
DASHSCOPE_API_KEY: ''
|
||||
QWEN_API_KEY: ''
|
||||
GEMINI_API_KEY: ''
|
||||
QWEN_DEFAULT_AUTH_TYPE: ''
|
||||
run: 'npm run test:ci'
|
||||
|
||||
# Windows counterpart of test_macos (see that job's note). Runner is
|
||||
|
|
@ -384,6 +400,13 @@ jobs:
|
|||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||||
env:
|
||||
NO_COLOR: true
|
||||
HOME: '${{ runner.temp }}/qwen-ci-home'
|
||||
USERPROFILE: '${{ runner.temp }}/qwen-ci-home'
|
||||
OPENAI_API_KEY: ''
|
||||
DASHSCOPE_API_KEY: ''
|
||||
QWEN_API_KEY: ''
|
||||
GEMINI_API_KEY: ''
|
||||
QWEN_DEFAULT_AUTH_TYPE: ''
|
||||
run: 'npm run test:ci'
|
||||
|
||||
post_coverage_comment:
|
||||
|
|
|
|||
|
|
@ -5131,9 +5131,14 @@ describe('Session', () => {
|
|||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loopTickResolverDepsSpy).toHaveBeenCalled();
|
||||
});
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(loopTickResolverDepsSpy).toHaveBeenCalled();
|
||||
},
|
||||
{
|
||||
timeout: 3000,
|
||||
},
|
||||
);
|
||||
|
||||
const deps = loopTickResolverDepsSpy.mock.calls.at(-1)![0] as {
|
||||
homeDir: string;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ interface RegisterWorkspaceExtensionRoutesDeps {
|
|||
mutate: (opts?: { strict?: boolean }) => RequestHandler;
|
||||
safeBody: SafeBody;
|
||||
sendBridgeError: SendBridgeError;
|
||||
maxExtensionOperationHistory?: number;
|
||||
}
|
||||
|
||||
export function registerWorkspaceExtensionRoutes(
|
||||
|
|
@ -59,6 +60,7 @@ export function registerWorkspaceExtensionRoutes(
|
|||
safeBody,
|
||||
sendBridgeError,
|
||||
} = deps;
|
||||
const maxExtensionOperationHistory = deps.maxExtensionOperationHistory ?? 100;
|
||||
const buildWorkspaceCtx = createBuildWorkspaceCtx(boundWorkspace);
|
||||
|
||||
let extensionInstallQueue: Promise<unknown> = Promise.resolve();
|
||||
|
|
@ -273,7 +275,6 @@ export function registerWorkspaceExtensionRoutes(
|
|||
error?: string;
|
||||
};
|
||||
const extensionOperations = new Map<string, ExtensionOperationStatus>();
|
||||
const MAX_EXTENSION_OPERATION_HISTORY = 100;
|
||||
const isTerminalExtensionOperation = (
|
||||
operation: ExtensionOperationStatus,
|
||||
): boolean => operation.status !== 'queued' && operation.status !== 'running';
|
||||
|
|
@ -287,7 +288,7 @@ export function registerWorkspaceExtensionRoutes(
|
|||
operation: ExtensionOperationStatus,
|
||||
): void => {
|
||||
extensionOperations.set(operation.operationId, operation);
|
||||
while (extensionOperations.size > MAX_EXTENSION_OPERATION_HISTORY) {
|
||||
while (extensionOperations.size > maxExtensionOperationHistory) {
|
||||
let evicted = false;
|
||||
for (const [id, storedOperation] of extensionOperations) {
|
||||
if (!isTerminalExtensionOperation(storedOperation)) continue;
|
||||
|
|
|
|||
|
|
@ -345,6 +345,9 @@ describe('workspace permissions routes', () => {
|
|||
});
|
||||
|
||||
it('POST preserves already-stored malformed permission rules', async () => {
|
||||
await teardown(h);
|
||||
const live = vi.fn();
|
||||
h = await makeHarness({ setWorkspacePermissionRules: live });
|
||||
const acpResponse = {
|
||||
v: 1,
|
||||
user: {
|
||||
|
|
@ -358,7 +361,7 @@ describe('workspace permissions routes', () => {
|
|||
merged: { allow: ['Bash(git *', 'Bash(git status)'], ask: [], deny: [] },
|
||||
isTrusted: true,
|
||||
};
|
||||
h.setWorkspacePermissionRules.mockResolvedValueOnce(acpResponse);
|
||||
live.mockResolvedValueOnce(acpResponse);
|
||||
await writeJson(path.join(h.home, 'settings.json'), {
|
||||
permissions: {
|
||||
allow: ['Bash(git *'],
|
||||
|
|
@ -374,14 +377,11 @@ describe('workspace permissions routes', () => {
|
|||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(h.setWorkspacePermissionRules).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{
|
||||
scope: 'user',
|
||||
ruleType: 'allow',
|
||||
rules: ['Bash(git *', 'Bash(git status)'],
|
||||
},
|
||||
);
|
||||
expect(live).toHaveBeenCalledWith(expect.any(Object), {
|
||||
scope: 'user',
|
||||
ruleType: 'allow',
|
||||
rules: ['Bash(git *', 'Bash(git status)'],
|
||||
});
|
||||
});
|
||||
|
||||
it('POST still rejects newly malformed permission rules', async () => {
|
||||
|
|
|
|||
|
|
@ -3241,14 +3241,15 @@ describe('createServeApp', () => {
|
|||
try {
|
||||
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
|
||||
const bridge = fakeBridge({ knownClientIds: ['client-1'] });
|
||||
const maxExtensionOperationHistory = 3;
|
||||
const app = createServeApp(
|
||||
{ ...tokenOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge },
|
||||
{ bridge, maxExtensionOperationHistory },
|
||||
);
|
||||
const operationIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < 101; i += 1) {
|
||||
for (let i = 0; i <= maxExtensionOperationHistory; i += 1) {
|
||||
const res = await request(app)
|
||||
.post('/workspace/extensions/install')
|
||||
.set('Host', `127.0.0.1:${tokenOpts.port}`)
|
||||
|
|
@ -3296,7 +3297,7 @@ describe('createServeApp', () => {
|
|||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it('returns 404 for unknown extension operation ids', async () => {
|
||||
const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
|
||||
|
|
@ -8328,12 +8329,17 @@ describe('createServeApp', () => {
|
|||
expect(bridge.removeRuntimeMcpServerCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('400 invalid_server_name when name is a reserved JS property', async () => {
|
||||
for (const name of ['__proto__', 'constructor', 'prototype']) {
|
||||
it.each([
|
||||
['__proto__', '%5F%5Fproto%5F%5F'],
|
||||
['constructor', 'constructor'],
|
||||
['prototype', 'prototype'],
|
||||
] as const)(
|
||||
'400 invalid_server_name when name is a reserved JS property: %s',
|
||||
async (_name, pathSegment) => {
|
||||
const bridge = fakeBridge({ knownClientIds: ['client-1'] });
|
||||
const app = createServeApp(tokenOpts, undefined, { bridge });
|
||||
const res = await auth(
|
||||
request(app).delete(`/workspace/mcp/servers/${name}`),
|
||||
request(app).delete(`/workspace/mcp/servers/${pathSegment}`),
|
||||
)
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send();
|
||||
|
|
@ -8341,8 +8347,8 @@ describe('createServeApp', () => {
|
|||
expect(res.body.code).toBe('invalid_server_name');
|
||||
expect(res.body.error).toContain('reserved');
|
||||
expect(bridge.removeRuntimeMcpServerCalls).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('401 auth_required when no bearer token (strict gate)', async () => {
|
||||
const bridge = fakeBridge();
|
||||
|
|
@ -8800,7 +8806,8 @@ describe('createServeApp', () => {
|
|||
const res = await request(app)
|
||||
.post('/session/session-A/cancel')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('X-Qwen-Client-Id', 'client-1');
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send({});
|
||||
expect(res.status).toBe(204);
|
||||
expect(bridge.cancelCalls[0]?.context).toEqual({
|
||||
clientId: 'client-1',
|
||||
|
|
@ -8838,7 +8845,8 @@ describe('createServeApp', () => {
|
|||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const res = await request(app)
|
||||
.delete('/session/session-A')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`);
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send();
|
||||
expect(res.status).toBe(204);
|
||||
expect(bridge.closeCalls).toHaveLength(1);
|
||||
expect(bridge.closeCalls[0]?.sessionId).toBe('session-A');
|
||||
|
|
@ -8850,7 +8858,8 @@ describe('createServeApp', () => {
|
|||
const res = await request(app)
|
||||
.delete('/session/session-A')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('X-Qwen-Client-Id', 'client-1');
|
||||
.set('X-Qwen-Client-Id', 'client-1')
|
||||
.send();
|
||||
expect(res.status).toBe(204);
|
||||
expect(bridge.closeCalls[0]?.context).toEqual({
|
||||
clientId: 'client-1',
|
||||
|
|
@ -8866,7 +8875,8 @@ describe('createServeApp', () => {
|
|||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const res = await request(app)
|
||||
.delete('/session/missing')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`);
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send();
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.sessionId).toBe('missing');
|
||||
});
|
||||
|
|
@ -8881,7 +8891,8 @@ describe('createServeApp', () => {
|
|||
const res = await request(app)
|
||||
.delete('/session/session-A')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.set('X-Qwen-Client-Id', 'bad-client');
|
||||
.set('X-Qwen-Client-Id', 'bad-client')
|
||||
.send();
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('invalid_client_id');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ export interface ServeAppDeps {
|
|||
* and a stderr audit sink.
|
||||
*/
|
||||
deviceFlowRegistry?: DeviceFlowRegistry;
|
||||
maxExtensionOperationHistory?: number;
|
||||
/**
|
||||
* Extra device-flow providers for tests / future extensions.
|
||||
* Production builds register only `QwenOAuthDeviceFlowProvider`;
|
||||
|
|
@ -599,6 +600,9 @@ export function createServeApp(
|
|||
mutate,
|
||||
safeBody,
|
||||
sendBridgeError,
|
||||
...(deps.maxExtensionOperationHistory === undefined
|
||||
? {}
|
||||
: { maxExtensionOperationHistory: deps.maxExtensionOperationHistory }),
|
||||
});
|
||||
|
||||
// Workspace file routes (read-only + mutation).
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { Storage, QWEN_DIR } from '@qwen-code/qwen-code-core';
|
||||
import { QWEN_DIR, Storage } from '@qwen-code/qwen-code-core';
|
||||
import { createMutationGate } from './auth.js';
|
||||
import type { AcpSessionBridge } from './acp-session-bridge.js';
|
||||
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
|
||||
|
|
@ -114,7 +114,7 @@ function buildApp(opts: {
|
|||
const app = express();
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
const mutate = createMutationGate({
|
||||
tokenConfigured: !opts.strictNoToken,
|
||||
tokenConfigured: opts.strictNoToken !== true,
|
||||
requireAuth: false,
|
||||
});
|
||||
mountWorkspaceAgentsRoutes(app, {
|
||||
|
|
@ -155,7 +155,7 @@ describe('workspace agents routes', () => {
|
|||
let tmp: string;
|
||||
let workspace: string;
|
||||
let globalDir: string;
|
||||
let getGlobalQwenDirSpy: MockInstance<() => string>;
|
||||
let getGlobalQwenDirSpy: MockInstance<typeof Storage.getGlobalQwenDir>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-serve-agents-'));
|
||||
|
|
@ -173,6 +173,10 @@ describe('workspace agents routes', () => {
|
|||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function missingAgentName(prefix = 'missing-agent') {
|
||||
return `${prefix}-${path.basename(tmp)}`;
|
||||
}
|
||||
|
||||
it('lists built-in agents alongside on-disk project agents', async () => {
|
||||
const projectAgentsDir = path.join(workspace, QWEN_DIR, 'agents');
|
||||
await fs.mkdir(projectAgentsDir, { recursive: true });
|
||||
|
|
@ -241,18 +245,27 @@ describe('workspace agents routes', () => {
|
|||
it('returns the full detail (with systemPrompt) on GET /workspace/agents/:agentType', async () => {
|
||||
const bridge = buildBridgeStub();
|
||||
const app = buildApp({ bridge, boundWorkspace: workspace });
|
||||
const res = await request(app).get('/workspace/agents/general-purpose');
|
||||
const create = await request(app).post('/workspace/agents').send({
|
||||
name: 'detail-agent',
|
||||
description: 'detail agent description',
|
||||
systemPrompt: 'you are the detail agent',
|
||||
scope: 'workspace',
|
||||
});
|
||||
expect(create.status).toBe(201);
|
||||
|
||||
const res = await request(app).get('/workspace/agents/detail-agent');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('general-purpose');
|
||||
expect(typeof res.body.systemPrompt).toBe('string');
|
||||
expect(res.body.isBuiltin).toBe(true);
|
||||
expect(res.body.level).toBe('builtin');
|
||||
expect(res.body.name).toBe('detail-agent');
|
||||
expect(res.body.systemPrompt).toBe('you are the detail agent');
|
||||
expect(res.body.isBuiltin).toBe(false);
|
||||
expect(res.body.level).toBe('project');
|
||||
});
|
||||
|
||||
it('returns 404 agent_not_found for unknown agent', async () => {
|
||||
const bridge = buildBridgeStub();
|
||||
const app = buildApp({ bridge, boundWorkspace: workspace });
|
||||
const res = await request(app).get('/workspace/agents/no-such-agent');
|
||||
const name = missingAgentName();
|
||||
const res = await request(app).get(`/workspace/agents/${name}`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.code).toBe('agent_not_found');
|
||||
});
|
||||
|
|
@ -340,31 +353,22 @@ describe('workspace agents routes', () => {
|
|||
expect(second.body.code).toBe('agent_already_exists');
|
||||
});
|
||||
|
||||
it('rejects 422 invalid_config when create uses a builtin agent name', async () => {
|
||||
const bridge = buildBridgeStub();
|
||||
const app = buildApp({ bridge, boundWorkspace: workspace });
|
||||
const res = await request(app).post('/workspace/agents').send({
|
||||
name: 'general-purpose',
|
||||
description: 'a description longer than ten chars',
|
||||
systemPrompt: 'this is a system prompt',
|
||||
scope: 'workspace',
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.code).toBe('invalid_config');
|
||||
expect(res.body.error).toMatch(/built-in/i);
|
||||
|
||||
// BuiltinAgentRegistry.isBuiltinAgent is case-insensitive — both
|
||||
// `Explore` and `explore` must reject so a project-level shadow
|
||||
// can never land regardless of how the client cases the name.
|
||||
const res2 = await request(app).post('/workspace/agents').send({
|
||||
name: 'explore',
|
||||
description: 'a description longer than ten chars',
|
||||
systemPrompt: 'this is a system prompt',
|
||||
scope: 'workspace',
|
||||
});
|
||||
expect(res2.status).toBe(422);
|
||||
expect(res2.body.code).toBe('invalid_config');
|
||||
});
|
||||
it.each(['general-purpose', 'explore'])(
|
||||
'rejects 422 invalid_config when create uses builtin agent name %s',
|
||||
async (name) => {
|
||||
const bridge = buildBridgeStub();
|
||||
const app = buildApp({ bridge, boundWorkspace: workspace });
|
||||
const res = await request(app).post('/workspace/agents').send({
|
||||
name,
|
||||
description: 'a description longer than ten chars',
|
||||
systemPrompt: 'this is a system prompt',
|
||||
scope: 'workspace',
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.code).toBe('invalid_config');
|
||||
expect(res.body.error).toMatch(/built-in/i);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns 422 invalid_config for missing required fields', async () => {
|
||||
const bridge = buildBridgeStub();
|
||||
|
|
@ -416,8 +420,9 @@ describe('workspace agents routes', () => {
|
|||
it('returns 404 agent_not_found when updating an unknown agent', async () => {
|
||||
const bridge = buildBridgeStub();
|
||||
const app = buildApp({ bridge, boundWorkspace: workspace });
|
||||
const name = missingAgentName();
|
||||
const res = await request(app)
|
||||
.post('/workspace/agents/no-such-agent')
|
||||
.post(`/workspace/agents/${name}`)
|
||||
.send({ description: 'x' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.code).toBe('agent_not_found');
|
||||
|
|
@ -464,7 +469,8 @@ describe('workspace agents routes', () => {
|
|||
it('returns 404 when deleting a missing agent', async () => {
|
||||
const bridge = buildBridgeStub();
|
||||
const app = buildApp({ bridge, boundWorkspace: workspace });
|
||||
const res = await request(app).delete('/workspace/agents/no-such-agent');
|
||||
const name = missingAgentName();
|
||||
const res = await request(app).delete(`/workspace/agents/${name}`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.code).toBe('agent_not_found');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ import {
|
|||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AGENT_CONTEXT_FILENAME,
|
||||
DEFAULT_CONTEXT_FILENAME,
|
||||
Storage,
|
||||
setGeminiMdFilename,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { createMutationGate } from './auth.js';
|
||||
import {
|
||||
InvalidClientIdError,
|
||||
|
|
@ -115,16 +120,20 @@ function buildApp(opts: {
|
|||
bridge: AcpSessionBridge;
|
||||
boundWorkspace: string;
|
||||
strictNoToken?: boolean;
|
||||
collectStatus?: Parameters<
|
||||
typeof mountWorkspaceMemoryRoutes
|
||||
>[1]['collectStatus'];
|
||||
}) {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
const mutate = createMutationGate({
|
||||
tokenConfigured: !opts.strictNoToken,
|
||||
tokenConfigured: opts.strictNoToken !== true,
|
||||
requireAuth: false,
|
||||
});
|
||||
mountWorkspaceMemoryRoutes(app, {
|
||||
bridge: opts.bridge,
|
||||
boundWorkspace: opts.boundWorkspace,
|
||||
...(opts.collectStatus ? { collectStatus: opts.collectStatus } : {}),
|
||||
mutate,
|
||||
parseClientId: (req, res) => {
|
||||
const raw = req.get('x-qwen-client-id');
|
||||
|
|
@ -156,11 +165,15 @@ function buildApp(opts: {
|
|||
return app;
|
||||
}
|
||||
|
||||
function resetContextFilenames(): void {
|
||||
setGeminiMdFilename([DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]);
|
||||
}
|
||||
|
||||
describe('workspace memory routes', () => {
|
||||
let tmp: string;
|
||||
let workspace: string;
|
||||
let globalDir: string;
|
||||
let getGlobalQwenDirSpy: MockInstance<() => string>;
|
||||
let getGlobalQwenDirSpy: MockInstance<typeof Storage.getGlobalQwenDir>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-serve-memory-'));
|
||||
|
|
@ -170,9 +183,11 @@ describe('workspace memory routes', () => {
|
|||
getGlobalQwenDirSpy = vi
|
||||
.spyOn(Storage, 'getGlobalQwenDir')
|
||||
.mockReturnValue(globalDir);
|
||||
resetContextFilenames();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetContextFilenames();
|
||||
getGlobalQwenDirSpy.mockRestore();
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
|
@ -443,21 +458,16 @@ describe('workspace memory routes', () => {
|
|||
|
||||
it('returns 500 memory_discovery_failed when GET helper throws unexpectedly', async () => {
|
||||
const bridge = buildBridgeStub();
|
||||
const app = buildApp({ bridge, boundWorkspace: workspace });
|
||||
// Force the helper to throw by spying on `Storage.getGlobalQwenDir`
|
||||
// — every call site of the discovery walk uses it.
|
||||
const failGlobal = vi
|
||||
.spyOn(Storage, 'getGlobalQwenDir')
|
||||
.mockImplementation(() => {
|
||||
const app = buildApp({
|
||||
bridge,
|
||||
boundWorkspace: workspace,
|
||||
collectStatus: async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
try {
|
||||
const res = await request(app).get('/workspace/memory');
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.code).toBe('memory_discovery_failed');
|
||||
} finally {
|
||||
failGlobal.mockRestore();
|
||||
}
|
||||
},
|
||||
});
|
||||
const res = await request(app).get('/workspace/memory');
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.code).toBe('memory_discovery_failed');
|
||||
});
|
||||
|
||||
it('stamps originatorClientId on the memory_changed event for known clients', async () => {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import {
|
|||
export interface WorkspaceMemoryRouteDeps {
|
||||
bridge: AcpSessionBridge;
|
||||
boundWorkspace: string;
|
||||
collectStatus?: typeof collectWorkspaceMemoryStatus;
|
||||
/**
|
||||
* `mutate({ strict: true })`-style middleware factory from PR 15.
|
||||
* Passed in so `server.ts` stays the single composition root for
|
||||
|
|
@ -85,7 +86,8 @@ export function mountWorkspaceMemoryRoutes(
|
|||
): void {
|
||||
app.get('/workspace/memory', async (_req, res) => {
|
||||
try {
|
||||
const status = await collectWorkspaceMemoryStatus(deps.boundWorkspace);
|
||||
const collectStatus = deps.collectStatus ?? collectWorkspaceMemoryStatus;
|
||||
const status = await collectStatus(deps.boundWorkspace);
|
||||
res.status(200).json(status);
|
||||
} catch (err) {
|
||||
// Per-file stat failures are caught inside
|
||||
|
|
|
|||
|
|
@ -523,7 +523,7 @@ function ModelIdsStep({
|
|||
key="model-ids-input"
|
||||
value={flow.state.modelIds}
|
||||
onChange={flow.changeModelIds}
|
||||
onSubmit={flow.submitModelIds}
|
||||
onSubmit={() => flow.submitModelIds()}
|
||||
placeholder={defaultIds || 'model-id-1, model-id-2'}
|
||||
/>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -12,12 +12,19 @@ import { renderWithProviders } from '../../../test-utils/render.js';
|
|||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const writeText = async (
|
||||
stdin: { write: (input: string) => unknown },
|
||||
text: string,
|
||||
) => {
|
||||
stdin.write(text);
|
||||
await wait();
|
||||
};
|
||||
const clean = (value: string | undefined) => stripAnsi(value ?? '');
|
||||
const waitForFrame = async (
|
||||
predicate: () => void,
|
||||
options: { timeout?: number; interval?: number } = {},
|
||||
) => {
|
||||
const { timeout = 1000, interval = 10 } = options;
|
||||
const { timeout = 3000, interval = 10 } = options;
|
||||
const start = Date.now();
|
||||
let lastError: unknown;
|
||||
|
||||
|
|
@ -411,7 +418,7 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
|
|
@ -421,11 +428,16 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
|
||||
// Navigate to "Type something..." option (index 3, after 3 predefined options)
|
||||
stdin.write('4');
|
||||
await waitForFrame(() => {
|
||||
expect(clean(lastFrame())).toContain('❯ [ ] 4.');
|
||||
});
|
||||
await wait();
|
||||
|
||||
// Type custom text
|
||||
stdin.write('My custom answer');
|
||||
await wait();
|
||||
await writeText(stdin, 'My custom answer');
|
||||
await waitForFrame(() => {
|
||||
expect(clean(lastFrame())).toContain('My custom answer');
|
||||
});
|
||||
|
||||
// Press Enter — should auto-select and submit
|
||||
stdin.write('\r');
|
||||
|
|
@ -436,7 +448,7 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
{ answers: { 0: 'My custom answer' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
it('does not submit when Enter pressed on empty custom input', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
|
|
@ -444,7 +456,7 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
|
|
@ -454,6 +466,9 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
|
||||
// Navigate to "Type something..." option
|
||||
stdin.write('4');
|
||||
await waitForFrame(() => {
|
||||
expect(clean(lastFrame())).toContain('❯ [ ] 4.');
|
||||
});
|
||||
await wait();
|
||||
|
||||
// Press Enter without typing anything — should NOT submit
|
||||
|
|
@ -462,7 +477,7 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
it('submits predefined options together with custom input', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
|
|
@ -470,7 +485,7 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
|
|
@ -480,15 +495,22 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
|
||||
// Space to toggle first option (Red)
|
||||
stdin.write(' ');
|
||||
await wait();
|
||||
await waitForFrame(() => {
|
||||
expect(clean(lastFrame())).toContain('[✓] 1. Red');
|
||||
});
|
||||
|
||||
// Navigate to "Type something..." option
|
||||
stdin.write('4');
|
||||
await waitForFrame(() => {
|
||||
expect(clean(lastFrame())).toContain('❯ [ ] 4.');
|
||||
});
|
||||
await wait();
|
||||
|
||||
// Type custom text
|
||||
stdin.write('Purple');
|
||||
await wait();
|
||||
await writeText(stdin, 'Purple');
|
||||
await waitForFrame(() => {
|
||||
expect(clean(lastFrame())).toContain('Purple');
|
||||
});
|
||||
|
||||
// Press Enter — should submit both Red and Purple
|
||||
stdin.write('\r');
|
||||
|
|
@ -503,7 +525,7 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
{ answers: { 0: expect.stringContaining('Purple') } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('multiple questions', () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
type ToolAskUserQuestionConfirmationDetails,
|
||||
|
|
@ -39,6 +39,7 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
const [customInputValues, setCustomInputValues] = useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const customInputValuesRef = useRef<Record<number, string>>({});
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [multiSelectedOptions, setMultiSelectedOptions] = useState<
|
||||
Record<number, string[]>
|
||||
|
|
@ -67,7 +68,9 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
currentQuestion &&
|
||||
selectedIndex === currentQuestion.options.length;
|
||||
|
||||
const currentCustomInputValue = customInputValues[currentQuestionIndex] ?? '';
|
||||
const getCustomInputValue = (idx: number) =>
|
||||
customInputValuesRef.current[idx] ?? customInputValues[idx] ?? '';
|
||||
const currentCustomInputValue = getCustomInputValue(currentQuestionIndex);
|
||||
const isCustomInputAnswer =
|
||||
!isSubmitTab &&
|
||||
currentQuestion &&
|
||||
|
|
@ -82,7 +85,7 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
const q = confirmationDetails.questions[idx];
|
||||
if (q?.multiSelect) {
|
||||
const selections = [...(multiSelectedOptions[idx] ?? [])];
|
||||
const customValue = (customInputValues[idx] ?? '').trim();
|
||||
const customValue = getCustomInputValue(idx).trim();
|
||||
if (customInputChecked[idx] && customValue) {
|
||||
selections.push(customValue);
|
||||
}
|
||||
|
|
@ -119,20 +122,28 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleMultiSelectSubmit = () => {
|
||||
const getCurrentMultiSelectAnswer = (
|
||||
includeCustomInput = customInputChecked[currentQuestionIndex],
|
||||
inputValue = currentCustomInputValue,
|
||||
): string | undefined => {
|
||||
if (!currentQuestion) return;
|
||||
const selections = [...(multiSelectedOptions[currentQuestionIndex] ?? [])];
|
||||
const customValue = currentCustomInputValue.trim();
|
||||
if (customInputChecked[currentQuestionIndex] && customValue) {
|
||||
const customValue = inputValue.trim();
|
||||
if (includeCustomInput && customValue) {
|
||||
selections.push(customValue);
|
||||
}
|
||||
if (selections.length === 0) return;
|
||||
|
||||
selectAndAdvance(selections.join(', '));
|
||||
return selections.length > 0 ? selections.join(', ') : undefined;
|
||||
};
|
||||
|
||||
const handleCustomInputSubmit = () => {
|
||||
const trimmedValue = currentCustomInputValue.trim();
|
||||
const handleMultiSelectSubmit = () => {
|
||||
const answer = getCurrentMultiSelectAnswer();
|
||||
if (!answer) return;
|
||||
|
||||
selectAndAdvance(answer);
|
||||
};
|
||||
|
||||
const handleCustomInputSubmit = (inputValue = currentCustomInputValue) => {
|
||||
const trimmedValue = inputValue.trim();
|
||||
|
||||
if (isMultiSelect) {
|
||||
// Toggle custom input checked state, then submit/advance if non-empty
|
||||
|
|
@ -141,7 +152,7 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
[currentQuestionIndex]: trimmedValue.length > 0,
|
||||
}));
|
||||
if (trimmedValue) {
|
||||
handleMultiSelectSubmit();
|
||||
selectAndAdvance(getCurrentMultiSelectAnswer(true, inputValue)!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -472,7 +483,13 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
initialCursorOffset={currentCustomInputValue.length}
|
||||
onChange={(value: string) => {
|
||||
const oldValue =
|
||||
customInputValues[currentQuestionIndex] ?? '';
|
||||
customInputValuesRef.current[currentQuestionIndex] ??
|
||||
customInputValues[currentQuestionIndex] ??
|
||||
'';
|
||||
customInputValuesRef.current = {
|
||||
...customInputValuesRef.current,
|
||||
[currentQuestionIndex]: value,
|
||||
};
|
||||
if (isMultiSelect && value !== oldValue) {
|
||||
setCustomInputChecked((prevChecked) => ({
|
||||
...prevChecked,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { renderSoftwareCursor } from '../../utils/software-cursor.js';
|
|||
export interface TextInputProps {
|
||||
value: string;
|
||||
onChange: (text: string) => void;
|
||||
onSubmit?: () => void;
|
||||
onSubmit?: (text: string) => void;
|
||||
/** Called when Tab is pressed; if provided, prevents the default tab-insertion behaviour. */
|
||||
onTab?: (key: Key) => void;
|
||||
/** Called when ↑ is pressed; if provided, prevents cursor-up in the buffer. */
|
||||
|
|
@ -106,7 +106,7 @@ export function TextInput({
|
|||
|
||||
const handleSubmit = () => {
|
||||
if (!onSubmit) return;
|
||||
onSubmit();
|
||||
onSubmit(buffer.text);
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
|
|
|
|||
|
|
@ -190,6 +190,9 @@ export class TeamManager {
|
|||
/** Per-agent teammate identity for re-entering AsyncLocalStorage. */
|
||||
private readonly agentIdentities = new Map<string, TeammateIdentity>();
|
||||
|
||||
/** Async coordination work kicked off from synchronous event emitters. */
|
||||
private readonly pendingAsyncWork = new Set<Promise<unknown>>();
|
||||
|
||||
/** Optional subagent manager for loading specialized agent configs. */
|
||||
private readonly subagentManager: SubagentManager | null;
|
||||
|
||||
|
|
@ -1134,7 +1137,7 @@ export class TeamManager {
|
|||
* least observable to the leader driving the team.
|
||||
*/
|
||||
private fireAndForget(label: string, work: Promise<unknown>): void {
|
||||
void work.catch((err) => {
|
||||
const tracked = work.catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
debug.warn(`${label} failed: ${msg}`);
|
||||
// Guarded: the callback can be detached during teardown / manager
|
||||
|
|
@ -1152,6 +1155,10 @@ export class TeamManager {
|
|||
debug.warn(`${label}: leader message callback threw: ${cbMsg}`);
|
||||
}
|
||||
});
|
||||
this.pendingAsyncWork.add(tracked);
|
||||
void tracked.finally(() => {
|
||||
this.pendingAsyncWork.delete(tracked);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Cleanup ────────────────────────────────────────────
|
||||
|
|
@ -1167,6 +1174,10 @@ export class TeamManager {
|
|||
}
|
||||
this.eventBridgeCleanups.clear();
|
||||
|
||||
while (this.pendingAsyncWork.size > 0) {
|
||||
await Promise.allSettled([...this.pendingAsyncWork]);
|
||||
}
|
||||
|
||||
this.pendingMessages.clear();
|
||||
this.lastActivityAt.clear();
|
||||
this.agentIdentities.clear();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import * as path from 'node:path';
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { AgentStatus } from '../../runtime/agent-types.js';
|
||||
import { TeamCoordinationHarness } from './coordination-harness.js';
|
||||
import { createTask } from '../tasks.js';
|
||||
import type { FakeAgent } from './fake-agent.js';
|
||||
import { createTask, listTasks } from '../tasks.js';
|
||||
import { sendStructuredMessage, readInbox, getInboxPath } from '../mailbox.js';
|
||||
|
||||
// Mock Storage so all file I/O uses the harness's temp dir.
|
||||
|
|
@ -416,7 +417,7 @@ describe('TeamCoordinationHarness', () => {
|
|||
const h = await createHarness();
|
||||
|
||||
// Spawn 5 workers that stay running on message.
|
||||
const workers = [];
|
||||
const workers: FakeAgent[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const w = await h.spawnTeammate(`worker-${i}`, {
|
||||
onMessage: () => 'stay_running',
|
||||
|
|
@ -446,10 +447,20 @@ describe('TeamCoordinationHarness', () => {
|
|||
w.goIdle();
|
||||
}
|
||||
|
||||
// Wait for the dust to settle.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await vi.waitFor(() => {
|
||||
const claimers = workers.filter(
|
||||
(w) => w.getReceivedMessages().length > 1,
|
||||
);
|
||||
expect(claimers.length).toBe(1);
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
const claimedTasks = await listTasks(h.teamName, {
|
||||
status: 'in_progress',
|
||||
});
|
||||
expect(claimedTasks).toHaveLength(1);
|
||||
expect(claimedTasks[0]!.owner).toMatch(/^worker-\d$/);
|
||||
});
|
||||
|
||||
// Exactly one worker should have received the task.
|
||||
const claimers = workers.filter(
|
||||
(w) => w.getReceivedMessages().length > 1,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -548,7 +548,7 @@ describe('LoopTickResolver', () => {
|
|||
homeQwenDir: root,
|
||||
allowProjectFile: () => true,
|
||||
});
|
||||
expect(atRoot.homeLoopLabel()).toBe(`$QWEN_HOME${path.sep}loop.md`);
|
||||
expect(atRoot.homeLoopLabel()).toBe('$QWEN_HOME/loop.md');
|
||||
// The garbled, separator-less form must never appear.
|
||||
expect(atRoot.homeLoopLabel()).not.toContain('$QWEN_HOMEloop.md');
|
||||
} finally {
|
||||
|
|
@ -557,6 +557,34 @@ describe('LoopTickResolver', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('homeLoopLabel uses a forward slash in the $QWEN_HOME label even with Windows separators', async () => {
|
||||
const prevQwenHome = process.env['QWEN_HOME'];
|
||||
process.env['QWEN_HOME'] = 'C:\\qwen';
|
||||
vi.resetModules();
|
||||
vi.doMock('node:path', async (importActual) => {
|
||||
const actual = await importActual<typeof import('node:path')>();
|
||||
return { ...actual, sep: '\\' };
|
||||
});
|
||||
try {
|
||||
const { LoopTickResolver: WindowsPathResolver } = await import(
|
||||
'./loop-tick-resolver.js'
|
||||
);
|
||||
const windowsPathResolver = new WindowsPathResolver({
|
||||
projectRoot: 'C:\\project',
|
||||
homeDir: 'C:\\qwen',
|
||||
homeQwenDir: 'C:\\qwen',
|
||||
allowProjectFile: () => true,
|
||||
});
|
||||
|
||||
expect(windowsPathResolver.homeLoopLabel()).toBe('$QWEN_HOME/loop.md');
|
||||
} finally {
|
||||
vi.doUnmock('node:path');
|
||||
vi.resetModules();
|
||||
if (prevQwenHome === undefined) delete process.env['QWEN_HOME'];
|
||||
else process.env['QWEN_HOME'] = prevQwenHome;
|
||||
}
|
||||
});
|
||||
|
||||
it('re-expands after delete→recreate even when the recreated content is identical', async () => {
|
||||
await writeProject('- same tasks');
|
||||
expect((await resolver.resolve('dynamic')).full).toBe(true);
|
||||
|
|
|
|||
|
|
@ -219,13 +219,13 @@ export class LoopTickResolver {
|
|||
// global dir (homeQwenDir is its resolved value), report the literal env-var
|
||||
// name — never the absolute path. The home candidate is always
|
||||
// `<homeQwenDir>/loop.md`, so swap the whole resolved dir for `$QWEN_HOME` and
|
||||
// re-attach the separator + basename directly. Deriving the tail from the
|
||||
// re-attach the user-facing POSIX slash + basename directly. Deriving the tail from the
|
||||
// resolved path's length instead mishandles edge dirs: a trailing slash
|
||||
// (`$QWEN_HOME=/x/.qwen/`) over-counts the separator, and a filesystem-root
|
||||
// homeQwenDir (`$QWEN_HOME=/` → homeLoopPath `/loop.md`, dirname `/`) drops the
|
||||
// leading separator — both garbling the tail into `$QWEN_HOMEloop.md`.
|
||||
if (process.env['QWEN_HOME']) {
|
||||
return `$QWEN_HOME${path.sep}loop.md`;
|
||||
return '$QWEN_HOME/loop.md';
|
||||
}
|
||||
return 'the configured global loop.md';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -890,12 +890,12 @@ sha256_file() {
|
|||
local file_path="$1"
|
||||
|
||||
if command_exists sha256sum; then
|
||||
sha256sum "${file_path}" | awk '{print $1}'
|
||||
LC_ALL=C LANG=C sha256sum "${file_path}" | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command_exists shasum; then
|
||||
shasum -a 256 "${file_path}" | awk '{print $1}'
|
||||
LC_ALL=C LANG=C shasum -a 256 "${file_path}" | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -538,12 +538,12 @@ sha256_file() {
|
|||
local file_path="$1"
|
||||
|
||||
if command_exists sha256sum; then
|
||||
sha256sum "${file_path}" | awk '{print $1}'
|
||||
LC_ALL=C LANG=C sha256sum "${file_path}" | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command_exists shasum; then
|
||||
shasum -a 256 "${file_path}" | awk '{print $1}'
|
||||
LC_ALL=C LANG=C shasum -a 256 "${file_path}" | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ const LINTERS = {
|
|||
-ignore 'SC2002:' \
|
||||
-ignore 'SC2016:' \
|
||||
-ignore 'SC2129:' \
|
||||
-ignore 'unexpected key "deployment" for "environment" section' \
|
||||
-ignore 'label ".+" is unknown'
|
||||
`,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue