mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-11-06 16:05:21 +00:00
350 lines
14 KiB
JavaScript
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');
|
|
});
|