From 53e1129a127deadf0f3c7f7be87cad96dd2b577b Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 15 Oct 2026 09:53:22 +0800 Subject: [PATCH] fix: preserve Python source state and repair dev regressions --- back/config/util.ts | 15 +- back/services/dependence.ts | 29 ++-- src/locales/en-US.json | 2 + src/locales/zh-CN.json | 2 + test/back/dependence-cache-status.test.cjs | 1 + test/back/dev-e2e-regressions.test.cjs | 153 +++++++++++++++++++++ test/front/notify-log-locales.test.cjs | 20 +++ 7 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 test/back/dev-e2e-regressions.test.cjs create mode 100644 test/front/notify-log-locales.test.cjs diff --git a/back/config/util.ts b/back/config/util.ts index 08b440d8..122319eb 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -716,17 +716,20 @@ function isPyprojectToml(name: string): boolean { return name.endsWith('pyproject.toml'); } +export function isPythonSourceDependency(name: string): boolean { + const source = name.trim(); + return ( + isGitHubUrl(source) || isRequirementsFile(source) || isPyprojectToml(source) + ); +} + export function getGetCommand(type: DependenceTypes, name: string): string { const trimmedName = name.trim(); // For Python dependencies installed from GitHub or requirements files, // we can't reliably check if they're installed, so skip the check if (type === DependenceTypes.python3) { - if ( - isGitHubUrl(trimmedName) || - isRequirementsFile(trimmedName) || - isPyprojectToml(trimmedName) - ) { + if (isPythonSourceDependency(trimmedName)) { // Return a command that will always indicate not installed // This ensures GitHub URLs and requirements files are always installed return 'echo ""'; @@ -917,7 +920,7 @@ async function _updateLinuxMirror( throw new Error('Unsupported OS type for updating mirrors.'); const target = mirror || defaults[osType]; normalizeMirror(target); - const S = isInContainer() ? 'sudo ' : ''; + const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : ''; const update = osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`; let file = diff --git a/back/services/dependence.ts b/back/services/dependence.ts index 5b756cd1..83f360e6 100644 --- a/back/services/dependence.ts +++ b/back/services/dependence.ts @@ -20,6 +20,7 @@ import { getInstallCommand, getUninstallCommand, getGetCommand, + isPythonSourceDependency, } from '../config/util'; import dayjs from 'dayjs'; import taskLimit from '../shared/pLimit'; @@ -76,21 +77,25 @@ export default class DependenceService { for (const doc of docs) { taskLimit.removeQueuedDependency(doc); } - const unInstalledDeps = docs.filter( - (x) => x.status !== DependenceStatus.installed, - ); const installedDeps = docs.filter( - (x) => x.status === DependenceStatus.installed, + (x) => + x.status === DependenceStatus.installed || + // Older list refreshes marked unprobeable sources as failed. They may + // still have installed distributions (including partial pip installs). + (x.status === DependenceStatus.installFailed && + x.type === DependenceTypes.python3 && + isPythonSourceDependency(x.name)), ); + const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x)); await this.removeDb(unInstalledDeps.map((x) => x.id!)); if (installedDeps.length) { await DependenceModel.update( { status: DependenceStatus.queued, log: [] }, - { where: { id: ids } }, + { where: { id: installedDeps.map((x) => x.id!) } }, ); - this.installDependenceOneByOne(docs, false, force); + this.installDependenceOneByOne(installedDeps, false, force); } return docs; } @@ -156,9 +161,15 @@ export default class DependenceService { }, }); const checks = await concurrentRun( - docs.map((doc) => async () => { - return (await this.isDependenceInstalled(doc)) ? undefined : doc.id; - }), + docs + .filter( + (doc) => + doc.type !== DependenceTypes.python3 || + !isPythonSourceDependency(doc.name), + ) + .map((doc) => async () => { + return (await this.isDependenceInstalled(doc)) ? undefined : doc.id; + }), 5, ); const missingIds = (checks || []).filter( diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 9b2382a1..b00789fe 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -700,6 +700,8 @@ "已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.", "保存失败": "Unable to save", "通知日志": "Notification log", + "标题": "Title", + "内容": "Content", "发送时间": "Sent at", "推送渠道": "Channel", "发送状态": "Delivery status", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index ae20332b..69e4bcb5 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -700,6 +700,8 @@ "已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间", "保存失败": "保存失败", "通知日志": "通知日志", + "标题": "标题", + "内容": "内容", "发送时间": "发送时间", "推送渠道": "推送渠道", "发送状态": "发送状态", diff --git a/test/back/dependence-cache-status.test.cjs b/test/back/dependence-cache-status.test.cjs index a23cd30a..93e7799d 100644 --- a/test/back/dependence-cache-status.test.cjs +++ b/test/back/dependence-cache-status.test.cjs @@ -61,6 +61,7 @@ test('dependency listing marks cache entries missing from disk for reinstall', a fileExist: async () => false, getGetCommand: (_type, name) => `check:${name}`, getInstallCommand: () => '', + isPythonSourceDependency: () => false, getPid: async () => 0, getUninstallCommand: () => '', killTask: async () => {}, diff --git a/test/back/dev-e2e-regressions.test.cjs b/test/back/dev-e2e-regressions.test.cjs new file mode 100644 index 00000000..a5c26c0d --- /dev/null +++ b/test/back/dev-e2e-regressions.test.cjs @@ -0,0 +1,153 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const load = require('../helpers/load-security-module.cjs'); + +const types = { nodejs: 0, python3: 1, linux: 2 }; +const statuses = { installed: 1, installFailed: 2, queued: 6, cancelled: 7 }; +const util = load('back/config/util.ts', { + './index': {}, + './const': {}, + '../loaders/logger': {}, + '../shared/utils': {}, + '../data/dependence': { DependenceTypes: types }, +}); + +function serviceFor(docs) { + const probes = [], + removed = [], + uninstalls = []; + const Service = load('back/services/dependence.ts', { + '../config': {}, + '../data/dependence': { + Dependence: class {}, + DependenceTypes: types, + DependenceStatus: statuses, + versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' }, + DependenceModel: { + findAll: async ({ where }) => + docs.filter( + (d) => + (where.status === undefined || d.status === where.status) && + (!where.id || where.id.includes(d.id)), + ), + update: async (values, { where }) => { + docs + .filter((d) => where.id.includes(d.id)) + .forEach((d) => Object.assign(d, values)); + }, + destroy: async ({ where }) => removed.push(...where.id), + }, + }, + '../config/util': { + ...util, + concurrentRun: (tasks) => Promise.all(tasks.map((fn) => fn())), + getGetCommand: (type, name) => { + probes.push(name); + return util.getGetCommand(type, name); + }, + promiseExecSuccess: async () => '', + }, + '../shared/pLimit': { removeQueuedDependency() {} }, + './sock': class {}, + '../shared/i18n': {}, + }).default; + const service = new Service({}, {}); + service.installDependenceOneByOne = (dependencies, install, force) => { + uninstalls.push({ ids: dependencies.map((d) => d.id), install, force }); + return Promise.resolve(); + }; + return { service, probes, removed, uninstalls }; +} + +test('listing preserves Python source results and still detects missing named packages', async () => { + const docs = [ + ' requirements with spaces/requirements.txt ', + 'requirements-dev.in', + 'project/pyproject.toml', + 'git+https://example.invalid/repo.git', + 'https://example.invalid/pkg.whl', + 'missing-package', + ].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 })); + const { service, probes } = serviceFor(docs); + for (let i = 0; i < 2; i++) { + await service.dependencies({ + searchValue: '', + type: 'python3', + status: '', + }); + } + assert.deepEqual( + docs.map((d) => d.status), + [1, 1, 1, 1, 1, 2], + ); + assert.deepEqual(probes, ['missing-package']); + // Source installs must continue to execute pip rather than treating the source as a package name. + assert.equal(util.getGetCommand(1, docs[0].name), 'echo ""'); +}); + +test('deletion cleans previously misclassified Python sources and only queues intended records', async () => { + const docs = [ + { id: 1, name: 'requirements.txt', type: 1, status: 2 }, + { id: 2, name: 'present-package', type: 0, status: 1 }, + { id: 3, name: 'missing-package', type: 0, status: 2 }, + { id: 4, name: 'queued/requirements.txt', type: 1, status: 6 }, + { id: 5, name: 'cancelled/requirements.txt', type: 1, status: 7 }, + { id: 6, name: 'git+https://example.invalid/repo.git', type: 1, status: 2 }, + ]; + const { service, removed, uninstalls } = serviceFor(docs); + await service.remove(docs.map((d) => d.id)); + assert.deepEqual(removed, [3, 4, 5]); + assert.deepEqual(uninstalls, [ + { ids: [1, 2, 6], install: false, force: false }, + ]); + assert.equal(docs[2].status, 2); +}); + +test('Alpine container mirror command runs without sudo and writes valid repositories', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-alpine-mirror-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const source = + 'https://dl-cdn.alpinelinux.org/alpine/v3.23/main\nhttps://dl-cdn.alpinelinux.org/alpine/v3.23/community\n'; + const api = load('back/config/util.ts', { + 'fs/promises': { readFile: async () => source }, + './index': {}, + './const': {}, + './share': {}, + './container': { isInContainer: () => true }, + '../loaders/logger': {}, + '../shared/utils': {}, + '../shared/fileAccess': {}, + '../data/dependence': {}, + }); + const previous = process.env.QL_OS_TYPE; + let command; + try { + process.env.QL_OS_TYPE = 'alpine'; + command = await api.updateLinuxMirrorFile('https://mirror.example/alpine'); + } finally { + if (previous === undefined) delete process.env.QL_OS_TYPE; + else process.env.QL_OS_TYPE = previous; + } + // Redirect the generated command's paths and executable into the fixture only. + const file = path.join(root, 'repositories'); + const update = path.join(root, 'updated'); + const sandbox = command + .replaceAll('/etc/apk/repositories', file) + .replaceAll('/etc/apk', root); + execFileSync('/bin/bash', [ + '-c', + `sudo(){ return 127; }; apk(){ test "$1" = update && touch '${update}'; }; ${sandbox}`, + ]); + assert.ok(fs.existsSync(update)); + assert.equal( + fs.readFileSync(file, 'utf8'), + source.replaceAll( + 'https://dl-cdn.alpinelinux.org/alpine', + 'https://mirror.example/alpine', + ), + ); +}); diff --git a/test/front/notify-log-locales.test.cjs b/test/front/notify-log-locales.test.cjs new file mode 100644 index 00000000..00f3be9a --- /dev/null +++ b/test/front/notify-log-locales.test.cjs @@ -0,0 +1,20 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +test('notification log column titles resolve in every supported locale', () => { + const source = fs.readFileSync( + path.join(__dirname, '../../src/pages/setting/notifyLog.tsx'), + 'utf8', + ); + const titles = [...source.matchAll(/title:\s*intl\.get\('([^']+)'\)/g)].map( + (m) => m[1], + ); + assert.ok(titles.includes('标题') && titles.includes('内容')); + for (const locale of ['zh-CN', 'en-US']) { + const messages = require(`../../src/locales/${locale}.json`); + for (const title of titles) + assert.ok(messages[title]?.trim(), `${locale}: ${title}`); + } +});