Files
1Panel/frontend/tests/app-compose-on-demand.test.mjs

350 lines
14 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { baseParse } from '@vue/compiler-dom';
import { parse } from '@vue/compiler-sfc';
import ts from 'typescript';
import { computed, proxyRefs, reactive, ref } from 'vue';
const view = (name) =>
parse(readFileSync(new URL(`../src/views/app-store/installed/${name}/index.vue`, import.meta.url), 'utf8'))
.descriptor;
const upgrade = view('upgrade');
const target = (version = '2.0', detailId = 2, dockerCompose = 'services:\n app:\n image: demo:2') => ({
version,
detailId,
dockerCompose,
});
function drawer(overrides = {}) {
const calls = { params: [], versions: [], errors: [], operations: [], confirmations: [] };
const bindings = {
ref,
reactive,
computed,
onBeforeUnmount: () => {},
defineEmits: () => () => {},
defineExpose: () => {},
useGlobalStore: () => ({ currentNode: ref('local') }),
Rules: { requiredSelect: {} },
i18n: { global: { t: (key) => key } },
bus: { on() {}, off() {}, emit() {} },
isOpenclawCurrentHTTPVersion: () => false,
isOpenclawHTTPSWindowVersion: () => false,
uuidv4: () => 'task-id',
MsgError: (message) => calls.errors.push(message),
MsgSuccess() {},
ElMessageBox: {
confirm: (...args) => {
calls.confirmations.push(args);
return Promise.resolve();
},
},
getAppStoreConfig: async () => ({ data: { upgradeBackup: 'Disable', upgradeDeleteImage: 'Disable' } }),
getAppInstallParams: async (...args) => {
calls.params.push(args);
return { data: { isEdit: true, dockerCompose: 'original custom instance compose' } };
},
getAppUpdateVersions: async (...args) => {
calls.versions.push(args);
return { data: [target()] };
},
installedOp: async (...args) => calls.operations.push(args),
ignoreUpgrade: async () => {},
...overrides,
};
const ast = ts.createSourceFile('upgrade.ts', upgrade.scriptSetup.content, ts.ScriptTarget.Latest, true);
const script = ast.statements
.filter((statement) => !ts.isImportDeclaration(statement))
.map((statement) => statement.getText(ast))
.join('\n');
const compiled = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ESNext } }).outputText;
const state = new Function(
...Object.keys(bindings),
`${compiled}\nreturn { acceptParams, getVersions, handleClose, openDiff, onOperate, operate, versions, loading, loadingVersions, composeReady, currentComposeReady, operateReq, oldContent, newContent, newCompose, useNewCompose, isEdit, node, appInstallID, feishuLegacy, feishuConfigured, open };`,
)(...Object.values(bindings));
state.node.value = 'child & node';
state.appInstallID.value = 17;
state.oldContent.value = 'saved instance compose';
return { ...state, calls };
}
function findNode(node, predicate) {
if (predicate(node)) return node;
for (const child of node.children || []) {
const found = findNode(child, predicate);
if (found) return found;
}
}
const template = baseParse(upgrade.template.content);
const button = findNode(template, (node) =>
node.props?.some((prop) => prop.name === 'on' && prop.exp?.content === 'onOperate'),
);
const disabled = button.props.find((prop) => prop.name === 'bind' && prop.arg.content === 'disabled').exp.content;
const isDisabled = (state) => new Function('state', `with (state) { return (${disabled}); }`)(proxyRefs(state));
test('upgrade lists versions independently and loads only the default target on the selected node', async () => {
const state = drawer();
assert.equal(isDisabled(state), true);
await state.getVersions('');
assert.deepEqual(state.calls.params, [[17, 'child & node']]);
assert.deepEqual(state.calls.versions, [
[{ appInstallID: 17, loadCompose: false }, 'child & node'],
[{ appInstallID: 17, loadCompose: true, updateVersion: '2.0' }, 'child & node'],
]);
assert.equal(state.isEdit.value, true, 'authoritative configuration overrides the stale list flag');
assert.equal(state.oldContent.value, 'original custom instance compose');
assert.equal(state.operateReq.detailId, 2);
assert.equal(state.newContent.value, target().dockerCompose);
assert.equal(state.composeReady.value, true);
assert.equal(isDisabled(state), false);
});
test('older agents without isEdit retain the existing custom Compose warning', async () => {
const state = drawer({ getAppInstallParams: async () => ({ data: { dockerCompose: 'old agent compose' } }) });
state.isEdit.value = true;
await state.getVersions('');
assert.equal(state.isEdit.value, true);
assert.equal(state.oldContent.value, 'old agent compose');
assert.equal(state.composeReady.value, true);
});
test('current-template failure preserves selectable versions while blocking preview and submission', async () => {
const state = drawer({
getAppInstallParams: async () => {
throw new Error('template unavailable');
},
});
await state.getVersions('');
assert.equal(state.calls.versions.length, 1);
assert.equal(state.calls.versions[0][0].loadCompose, false);
assert.deepEqual(
state.versions.value.map((item) => item.version),
['2.0'],
);
assert.equal(state.operateReq.version, '2.0');
assert.equal(state.oldContent.value, 'saved instance compose');
assert.equal(state.currentComposeReady.value, false);
assert.equal(state.loadingVersions.value, false);
assert.equal(isDisabled(state), true);
await state.onOperate();
await state.operate();
assert.equal(state.calls.confirmations.length, 0);
assert.equal(state.calls.operations.length, 0);
});
test('versions are available before current configuration finishes loading', async () => {
let reject;
const state = drawer({
getAppInstallParams: () => new Promise((resolve, fail) => (reject = fail)),
});
const request = state.getVersions('');
await Promise.resolve();
assert.deepEqual(
state.versions.value.map((item) => item.version),
['2.0'],
);
assert.equal(state.currentComposeReady.value, false);
reject(new Error('current template unavailable'));
await request;
assert.deepEqual(
state.versions.value.map((item) => item.version),
['2.0'],
);
assert.equal(isDisabled(state), true);
});
test('an unavailable default target leaves other versions selectable and able to upgrade', async () => {
const candidates = [target('3.0', 3), target()];
const requests = [];
const state = drawer({
getAppUpdateVersions: async (req) => {
requests.push(req);
if (req.loadCompose && req.updateVersion === '3.0') throw new Error('404 target removed');
return { data: candidates };
},
});
await state.getVersions('');
assert.deepEqual(
state.versions.value.map((item) => item.version),
['3.0', '2.0'],
);
assert.equal(state.operateReq.version, '3.0');
assert.equal(state.operateReq.detailId, 0);
assert.equal(isDisabled(state), true);
await state.onOperate();
assert.equal(state.calls.confirmations.length, 0);
state.operateReq.version = '2.0';
await state.getVersions('2.0');
assert.deepEqual(requests, [
{ appInstallID: 17, loadCompose: false },
{ appInstallID: 17, loadCompose: true, updateVersion: '3.0' },
{ appInstallID: 17, loadCompose: true, updateVersion: '2.0' },
]);
assert.deepEqual(
state.versions.value.map((item) => item.version),
['3.0', '2.0'],
);
assert.equal(state.operateReq.detailId, 2);
assert.equal(state.composeReady.value, true);
assert.equal(isDisabled(state), false);
assert.equal(state.calls.params.length, 1);
});
test('a failed target switch cannot submit the previous target and can be retried', async () => {
let fail = true;
const state = drawer({
getAppUpdateVersions: async (req) => {
if (req.updateVersion === '3.0' && fail) throw new Error('download failed');
return { data: [target(), target('3.0', 3, 'services:\n app:\n image: demo:3')] };
},
});
await state.getVersions('');
state.operateReq.version = '3.0';
await state.getVersions('3.0');
assert.equal(state.operateReq.detailId, 0);
assert.equal(state.oldContent.value, 'original custom instance compose');
assert.equal(isDisabled(state), true);
await state.onOperate();
assert.equal(state.calls.confirmations.length, 0);
fail = false;
await state.getVersions('3.0');
assert.equal(state.operateReq.detailId, 3);
assert.equal(state.composeReady.value, true);
assert.equal(state.calls.params.length, 1, 'switching targets reuses the verified current configuration');
assert.equal(isDisabled(state), false);
});
test('empty and missing target Compose cannot become valid previews', async () => {
for (const data of [[target('2.0', 2, '')], [target('2.0', 2, ' ')], [target('different', 3)]]) {
const state = drawer({
getAppUpdateVersions: async (req) => ({ data: req.loadCompose ? data : [target()] }),
});
await state.getVersions('2.0');
assert.equal(state.composeReady.value, false);
assert.equal(state.operateReq.detailId, 0);
assert.equal(state.oldContent.value, 'original custom instance compose');
assert.deepEqual(state.calls.errors, ['container.contentEmpty']);
}
});
test('out-of-order responses cannot overwrite the latest selection or enable it too soon', async () => {
const pending = new Map();
const state = drawer({
getAppUpdateVersions: (req) => new Promise((resolve) => pending.set(req.updateVersion, resolve)),
});
state.currentComposeReady.value = true;
state.versions.value = [target(), target('3.0', 3)];
const first = state.getVersions('2.0');
const latest = state.getVersions('3.0');
pending.get('2.0')({ data: [target()] });
await first;
assert.equal(state.loadingVersions.value, true);
assert.equal(state.composeReady.value, false);
pending.get('3.0')({ data: [target('3.0', 3)] });
await latest;
assert.equal(state.operateReq.version, '3.0');
assert.equal(state.operateReq.detailId, 3);
assert.equal(state.composeReady.value, true);
});
test('closing a drawer invalidates in-flight template responses', async () => {
let resolve;
const state = drawer({ getAppUpdateVersions: () => new Promise((done) => (resolve = done)) });
state.currentComposeReady.value = true;
state.versions.value = [target()];
const request = state.getVersions('2.0');
state.handleClose();
resolve({ data: [target()] });
await request;
assert.equal(state.open.value, false);
assert.equal(state.composeReady.value, false);
assert.equal(state.operateReq.detailId, 0);
});
test('a late settings response cannot reopen a closed upgrade drawer', async () => {
let resolve;
const state = drawer({ getAppStoreConfig: () => new Promise((done) => (resolve = done)) });
const request = state.acceptParams({ id: 17 }, 'upgrade');
state.handleClose();
resolve({ data: {} });
await request;
assert.equal(state.open.value, false);
assert.equal(state.calls.params.length, 0);
assert.equal(state.calls.versions.length, 0);
});
test('ignore version requests skip both current and target Compose loading', async () => {
const state = drawer();
state.operateReq.operate = 'ignore';
await state.getVersions('');
assert.equal(state.calls.params.length, 0);
assert.equal(state.calls.versions[0][0].loadCompose, false);
const source = view('ignore/create').scriptSetup.content;
const ast = ts.createSourceFile('ignore.ts', source, ts.ScriptTarget.Latest, true);
const declaration = ast.statements
.filter(ts.isVariableStatement)
.flatMap((statement) => statement.declarationList.declarations)
.find((item) => item.name.getText(ast) === 'getVersions');
const compiled = ts.transpileModule(`const getVersions = ${declaration.initializer.getText(ast)};`, {
compilerOptions: { target: ts.ScriptTarget.ESNext },
}).outputText;
let request;
const getVersions = new Function(
'getAppUpdateVersions',
'appInstallID',
'versions',
'req',
`${compiled}\nreturn getVersions;`,
)(
async (req) => {
request = req;
return { data: [target()] };
},
ref(17),
ref([]),
{},
);
await getVersions();
assert.deepEqual(request, { appInstallID: 17, loadCompose: false });
});
test('blank custom Compose is rejected before the upgrade confirmation', async () => {
const state = drawer();
await state.getVersions('');
state.useNewCompose.value = true;
state.newCompose.value = ' ';
await state.onOperate();
assert.deepEqual(state.calls.errors, ['container.contentEmpty']);
assert.equal(state.calls.confirmations.length, 0);
});
test('Feishu legacy transition still enforces the required backup', async () => {
const state = drawer({
getAppUpdateVersions: async () => ({ data: [{ ...target(), feishuLegacy: true, feishuConfigured: true }] }),
});
state.operateReq.backup = false;
await state.getVersions('');
assert.equal(state.feishuLegacy.value, true);
assert.equal(state.feishuConfigured.value, true);
assert.equal(state.operateReq.backup, true);
});
test('explicit current-configuration loading preserves child-node routing', () => {
const source = readFileSync(new URL('../src/api/modules/app.ts', import.meta.url), 'utf8');
const ast = ts.createSourceFile('app-api.ts', source, ts.ScriptTarget.Latest, true);
const declaration = ast.statements
.filter(ts.isVariableStatement)
.flatMap((statement) => statement.declarationList.declarations)
.find((item) => item.name.getText(ast) === 'getAppInstallParams');
const compiled = ts.transpileModule(`const getAppInstallParams = ${declaration.initializer.getText(ast)};`, {
compilerOptions: { target: ts.ScriptTarget.ESNext },
}).outputText;
const request = new Function('http', `${compiled}\nreturn getAppInstallParams;`)({ get: (url) => url });
assert.equal(request(17), 'apps/installed/params/17');
assert.equal(request(17, 'child & node'), 'apps/installed/params/17?operateNode=child%20%26%20node');
});