Compare commits

..
Author SHA1 Message Date
whyour 13aa99f67d fix(db): repair invalid environment labels before startup
Normalize malformed Envs.labels values to JSON arrays during schema migration so ORM reads cannot abort HTTP worker initialization. Preserve valid label arrays and cover corrupt legacy rows. Fixes #3128.
2026-11-09 01:12:53 +08:00
Henglie 13e0ff55d4 fix(notify): bound notification requests and thread waits (#3129)
Add a 10s timeout to every requests call in sample/notify.py so a
non-responsive push service cannot block a task indefinitely. Mark
notification threads as daemon and join them with a bounded timeout,
recording channels that do not finish as failures.

Add a regression test covering missing timeouts and send() returning
after a hung channel.
2026-11-09 01:11:32 +08:00
whyour 2bf814e562 feat(editor): add word wrap toggle for config and scripts 2026-11-08 03:04:17 +08:00
13 changed files with 827 additions and 396 deletions
+11
View File
@@ -66,5 +66,16 @@ export async function migrateSchema(database: Sequelize): Promise<void> {
);
}
}
// Older databases can contain non-JSON values in Envs.labels. Sequelize
// parses this column on every environment read, including HTTP startup.
await database.query(
`UPDATE "Envs" SET "labels" = '[]'
WHERE CASE WHEN json_valid("labels")
THEN json_type("labels") <> 'array'
ELSE 1
END`,
{ transaction },
);
});
}
+4 -4
View File
@@ -15,7 +15,7 @@
"@types/node": "22.19.0",
"commander": "15.0.0",
"esbuild": "0.28.2",
"tar": "7.5.21",
"tar": "7.5.20",
"typescript": "5.2.2"
},
"engines": {
@@ -573,9 +573,9 @@
}
},
"node_modules/tar": {
"version": "7.5.21",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.21.tgz",
"integrity": "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==",
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
+1 -1
View File
@@ -27,7 +27,7 @@
"esbuild": "0.28.2",
"typescript": "5.2.2",
"@types/node": "22.19.0",
"tar": "7.5.21"
"tar": "7.5.20"
},
"license": "Apache-2.0",
"repository": {
+6 -10
View File
@@ -116,10 +116,6 @@
"@codemirror/view": "6.39.16",
"proxy-addr": "2.0.8",
"websocket-driver": "0.7.5",
"body-parser": "1.20.9",
"lodash": "4.18.1",
"protobufjs": "7.6.1",
"undici@6": "6.29.1",
"brace-expansion@1": "1.1.21",
"brace-expansion@2": "2.1.7"
}
@@ -131,7 +127,7 @@
"@grpc/proto-loader": "^0.8.0",
"@keyv/sqlite": "^4.0.1",
"@otplib/preset-default": "^12.0.1",
"body-parser": "^1.20.9",
"body-parser": "^1.20.3",
"celebrate": "^15.0.3",
"chokidar": "^4.0.1",
"compression": "1.8.2",
@@ -152,10 +148,10 @@
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"keyv": "^5.2.3",
"lodash": "^4.18.1",
"multer": "2.5.0",
"lodash": "^4.17.21",
"multer": "2.4.0",
"node-cron": "4.6.0",
"nodemailer": "^10.1.4",
"nodemailer": "^10.0.9",
"p-queue-cjs": "7.3.4",
"proper-lockfile": "^4.1.2",
"ps-tree": "^1.2.0",
@@ -166,11 +162,11 @@
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
"toad-scheduler": "^3.0.1",
"typedi": "^0.10.0",
"undici": "^7.30.1",
"undici": "^7.9.0",
"uuid": "^11.0.3",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"tar": "7.5.21"
"tar": "7.5.20"
},
"devDependencies": {
"@ant-design/icons": "^5.0.1",
+542 -345
View File
File diff suppressed because it is too large Load Diff
+78 -30
View File
@@ -18,6 +18,11 @@ from email.utils import formataddr
import requests
# 单次 HTTP 请求的默认超时(秒),避免推送服务无响应时永久阻塞
REQUEST_TIMEOUT = 10
# 等待推送线程结束的最长时间(秒),超时后跳过并继续退出
NOTIFY_JOIN_TIMEOUT = 30
# 原先的 print 函数和主线程的锁
_print = print
mutex = threading.Lock()
@@ -199,7 +204,7 @@ def bark(title: str, content: str) -> Optional[bool]:
data[bark_params.get(pair[0])] = pair[1]
headers = {"Content-Type": "application/json;charset=utf-8"}
response = requests.post(
url=url, data=json.dumps(data), headers=headers, timeout=15
url=url, data=json.dumps(data), headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response["code"] == 200:
@@ -241,7 +246,7 @@ def dingding_bot(title: str, content: str) -> Optional[bool]:
headers = {"Content-Type": "application/json;charset=utf-8"}
data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
response = requests.post(
url=url, data=json.dumps(data), headers=headers, timeout=15
url=url, data=json.dumps(data), headers=headers, timeout=REQUEST_TIMEOUT
).json()
if not response["errcode"]:
@@ -278,7 +283,9 @@ def feishu_bot(title: str, content: str) -> Optional[bool]:
data["timestamp"] = timestamp
data["sign"] = sign
response = requests.post(url, data=json.dumps(data)).json()
response = requests.post(
url, data=json.dumps(data), timeout=REQUEST_TIMEOUT
).json()
if response.get("StatusCode") == 0 or response.get("code") == 0:
audit_results.append(True)
@@ -299,7 +306,7 @@ def go_cqhttp(title: str, content: str) -> Optional[bool]:
print("go-cqhttp 服务启动")
url = f'{push_config.get("GOBOT_URL")}?access_token={push_config.get("GOBOT_TOKEN")}&{push_config.get("GOBOT_QQ")}&message=标题:{title}\n内容:{content}'
response = requests.get(url).json()
response = requests.get(url, timeout=REQUEST_TIMEOUT).json()
if response["status"] == "ok":
audit_results.append(True)
@@ -325,7 +332,7 @@ def gotify(title: str, content: str) -> Optional[bool]:
"message": content,
"priority": push_config.get("GOTIFY_PRIORITY"),
}
response = requests.post(url, data=data).json()
response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT).json()
if response.get("id"):
audit_results.append(True)
@@ -348,7 +355,9 @@ def iGot(title: str, content: str) -> Optional[bool]:
url = f'https://push.hellyw.com/{push_config.get("IGOT_PUSH_KEY")}'
data = {"title": title, "content": content}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
response = requests.post(url, data=data, headers=headers).json()
response = requests.post(
url, data=data, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response["ret"] == 0:
audit_results.append(True)
@@ -377,7 +386,7 @@ def serverJ(title: str, content: str) -> Optional[bool]:
else:
url = f'https://sctapi.ftqq.com/{push_config.get("PUSH_KEY")}.send'
response = requests.post(url, data=data).json()
response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT).json()
if response.get("errno") == 0 or response.get("code") == 0:
audit_results.append(True)
@@ -406,7 +415,7 @@ def pushdeer(title: str, content: str) -> Optional[bool]:
if push_config.get("DEER_URL"):
url = push_config.get("DEER_URL")
response = requests.post(url, data=data).json()
response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT).json()
if len(response.get("content").get("result")) > 0:
audit_results.append(True)
@@ -427,7 +436,7 @@ def chat(title: str, content: str) -> Optional[bool]:
print("chat 服务启动")
data = "payload=" + json.dumps({"text": title + "\n" + content})
url = push_config.get("CHAT_URL") + push_config.get("CHAT_TOKEN")
response = requests.post(url, data=data)
response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT)
if response.status_code == 200:
audit_results.append(True)
@@ -461,7 +470,9 @@ def pushplus_bot(title: str, content: str) -> Optional[bool]:
}
body = json.dumps(data).encode(encoding="utf-8")
headers = {"Content-Type": "application/json"}
response = requests.post(url=url, data=body, headers=headers).json()
response = requests.post(
url=url, data=body, headers=headers, timeout=REQUEST_TIMEOUT
).json()
code = response["code"]
if code == 200:
@@ -477,7 +488,9 @@ def pushplus_bot(title: str, content: str) -> Optional[bool]:
else:
url_old = "http://pushplus.hxtrip.com/send"
headers["Accept"] = "application/json"
response = requests.post(url=url_old, data=body, headers=headers).json()
response = requests.post(
url=url_old, data=body, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response["code"] == 200:
audit_results.append(True)
@@ -513,7 +526,9 @@ def weplus_bot(title: str, content: str) -> Optional[bool]:
}
body = json.dumps(data).encode(encoding="utf-8")
headers = {"Content-Type": "application/json"}
response = requests.post(url=url, data=body, headers=headers).json()
response = requests.post(
url=url, data=body, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response["code"] == 200:
audit_results.append(True)
@@ -535,7 +550,9 @@ def qmsg_bot(title: str, content: str) -> Optional[bool]:
url = f'https://qmsg.zendee.cn/{push_config.get("QMSG_TYPE")}/{push_config.get("QMSG_KEY")}'
payload = {"msg": f'{title}\n\n{content.replace("----", "-")}'.encode("utf-8")}
response = requests.post(url=url, params=payload).json()
response = requests.post(
url=url, params=payload, timeout=REQUEST_TIMEOUT
).json()
if response["code"] == 0:
audit_results.append(True)
@@ -599,7 +616,7 @@ class WeCom:
"corpid": self.CORPID,
"corpsecret": self.CORPSECRET,
}
req = requests.post(url, params=values)
req = requests.post(url, params=values, timeout=REQUEST_TIMEOUT)
data = json.loads(req.text)
return data["access_token"]
@@ -615,7 +632,9 @@ class WeCom:
"safe": "0",
}
send_msges = bytes(json.dumps(send_values), "utf-8")
respone = requests.post(send_url, send_msges)
respone = requests.post(
send_url, send_msges, timeout=REQUEST_TIMEOUT
)
respone = respone.json()
return respone["errmsg"]
@@ -641,7 +660,9 @@ class WeCom:
},
}
send_msges = bytes(json.dumps(send_values), "utf-8")
respone = requests.post(send_url, send_msges)
respone = requests.post(
send_url, send_msges, timeout=REQUEST_TIMEOUT
)
respone = respone.json()
return respone["errmsg"]
@@ -663,7 +684,7 @@ def wecom_bot(title: str, content: str) -> Optional[bool]:
headers = {"Content-Type": "application/json;charset=utf-8"}
data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}}
response = requests.post(
url=url, data=json.dumps(data), headers=headers, timeout=15
url=url, data=json.dumps(data), headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response["errcode"] == 0:
@@ -711,7 +732,11 @@ def telegram_bot(title: str, content: str) -> Optional[bool]:
)
proxies = {"http": proxyStr, "https": proxyStr}
response = requests.post(
url=url, headers=headers, params=payload, proxies=proxies
url=url,
headers=headers,
params=payload,
proxies=proxies,
timeout=REQUEST_TIMEOUT,
).json()
if response["ok"]:
@@ -752,7 +777,9 @@ def aibotk(title: str, content: str) -> Optional[bool]:
}
body = json.dumps(data).encode(encoding="utf-8")
headers = {"Content-Type": "application/json"}
response = requests.post(url=url, data=body, headers=headers).json()
response = requests.post(
url=url, data=body, headers=headers, timeout=REQUEST_TIMEOUT
).json()
print(response)
if response["code"] == 0:
audit_results.append(True)
@@ -836,7 +863,7 @@ def pushme(title: str, content: str) -> Optional[bool]:
"date": push_config.get("date") if push_config.get("date") else "",
"type": push_config.get("type") if push_config.get("type") else "",
}
response = requests.post(url, data=data)
response = requests.post(url, data=data, timeout=REQUEST_TIMEOUT)
if response.status_code == 200 and response.text == "success":
audit_results.append(True)
@@ -883,7 +910,9 @@ def chronocat(title: str, content: str) -> Optional[bool]:
}
],
}
response = requests.post(url, headers=headers, data=json.dumps(data))
response = requests.post(
url, headers=headers, data=json.dumps(data), timeout=REQUEST_TIMEOUT
)
if response.status_code == 200:
if chat_type == 1:
audit_results.append(True)
@@ -936,7 +965,9 @@ def ntfy(title: str, content: str) -> Optional[bool]:
headers['Actions'] = encode_rfc2047(push_config.get("NTFY_ACTIONS"))
url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC")
response = requests.post(url, data=data, headers=headers)
response = requests.post(
url, data=data, headers=headers, timeout=REQUEST_TIMEOUT
)
if response.status_code == 200: # 使用 response.status_code 进行检查
audit_results.append(True)
print("Ntfy 推送成功!")
@@ -995,7 +1026,9 @@ def wxpusher_bot(title: str, content: str) -> Optional[bool]:
}
headers = {"Content-Type": "application/json"}
response = requests.post(url=url, json=data, headers=headers).json()
response = requests.post(
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response.get("code") == 1000:
audit_results.append(True)
@@ -1046,7 +1079,9 @@ def wxpusher_spt(title: str, content: str) -> Optional[bool]:
data["sptList"] = spts
headers = {"Content-Type": "application/json"}
response = requests.post(url=url, json=data, headers=headers).json()
response = requests.post(
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response.get("code") == 1000:
audit_results.append(True)
@@ -1080,7 +1115,9 @@ def wpush(title: str, content: str) -> Optional[bool]:
data["topic_code"] = push_config.get("WPUSH_TOPIC_CODE")
headers = {"Content-Type": "application/json"}
response = requests.post(url=url, json=data, headers=headers, timeout=15).json()
response = requests.post(
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response.get("code") == 0:
audit_results.append(True)
@@ -1121,7 +1158,9 @@ def openilink(title: str, content: str) -> Optional[bool]:
if push_config.get("OPENILINK_CONTEXT_TOKEN"):
data["context_token"] = push_config.get("OPENILINK_CONTEXT_TOKEN")
response = requests.post(url=url, json=data, headers=headers).json()
response = requests.post(
url=url, json=data, headers=headers, timeout=REQUEST_TIMEOUT
).json()
if response.get("ok"):
audit_results.append(True)
@@ -1215,7 +1254,7 @@ def custom_notify(title: str, content: str) -> Optional[bool]:
"$title", urllib.parse.quote_plus(title)
).replace("$content", urllib.parse.quote_plus(content))
response = requests.request(
method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=15, data=body
method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=REQUEST_TIMEOUT, data=body
)
if response.status_code == 200:
@@ -1233,7 +1272,7 @@ def one() -> str:
:return:
"""
url = "https://v1.hitokoto.cn/"
res = requests.get(url).json()
res = requests.get(url, timeout=REQUEST_TIMEOUT).json()
return res["hitokoto"] + " ----" + res["from"]
@@ -1374,11 +1413,20 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
notify_function = add_notify_function()
results = []
ts = [
threading.Thread(target=audited_notification, args=(mode, title, content, results), name=mode.__name__)
threading.Thread(
target=audited_notification,
args=(mode, title, content, results),
name=mode.__name__,
daemon=True,
)
for mode in notify_function
]
[t.start() for t in ts]
[t.join() for t in ts]
for t in ts:
t.join(timeout=NOTIFY_JOIN_TIMEOUT)
if t.is_alive():
results.append((t.name, False))
print(f"{t.name} 推送超时(>{NOTIFY_JOIN_TIMEOUT}s),已跳过,任务继续结束")
report_notifications(results, title, content)
+1
View File
@@ -508,6 +508,7 @@
"脚本(只读)": "Scripts (read only)",
"自动删除任务": "Automatically Delete Tasks",
"自动拆分": "Auto Split",
"自动换行": "Word wrap",
"自动添加任务": "Automatically Add Tasks",
"自动生成": "Auto-generated",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate",
+1
View File
@@ -508,6 +508,7 @@
"脚本(只读)": "脚本(只读)",
"自动删除任务": "自动删除任务",
"自动拆分": "自动拆分",
"自动换行": "自动换行",
"自动添加任务": "自动添加任务",
"自动生成": "自动生成",
"自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成",
+27 -3
View File
@@ -6,7 +6,7 @@ import React, {
useEffect,
useRef,
} from 'react';
import { Button, message, Modal, TreeSelect } from 'antd';
import { Button, message, Modal, Space, Switch, TreeSelect } from 'antd';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import { request } from '@/utils/http';
@@ -15,6 +15,7 @@ import CodeMirror from '@uiw/react-codemirror';
import { useOutletContext } from '@umijs/max';
import { SharedContext } from '@/layouts';
import { langs } from '@uiw/codemirror-extensions-langs';
import { EditorView } from '@codemirror/view';
import { useHotkeys } from 'react-hotkeys-hook';
import { getEditorMode } from '@/utils';
@@ -28,6 +29,11 @@ const Config = () => {
const editorRef = useRef<any>(null);
const [confirmLoading, setConfirmLoading] = useState(false);
const [language, setLanguage] = useState<string>('shell');
const [wordWrap, setWordWrap] = useState(
() =>
(localStorage.getItem('qinglong_editor_word_wrap') ??
localStorage.getItem('qinglong_config_word_wrap')) === 'true',
);
const getConfig = (name: string) => {
request
@@ -104,6 +110,20 @@ const Config = () => {
defaultValue="config.sh"
onSelect={onSelect}
/>,
<Space key="word-wrap" size={8}>
<span>{intl.get('自动换行')}</span>
<Switch
aria-label={intl.get('自动换行')}
checked={wordWrap}
onChange={(checked) => {
setWordWrap(checked);
localStorage.setItem(
'qinglong_editor_word_wrap',
String(checked),
);
}}
/>
</Space>,
<Button
key="1"
loading={confirmLoading}
@@ -121,7 +141,10 @@ const Config = () => {
<CodeMirror
value={value}
theme={theme.includes('dark') ? 'dark' : 'light'}
extensions={[langs.shell()]}
extensions={[
langs.shell(),
...(wordWrap ? [EditorView.lineWrapping] : []),
]}
onChange={(value) => {
setValue(value);
}}
@@ -136,7 +159,8 @@ const Config = () => {
lineNumbersMinChars: 3,
folding: false,
glyphMargin: false,
accessibilitySupport: 'off'
accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}}
onMount={(editor) => {
editorRef.current = editor;
+25 -1
View File
@@ -6,7 +6,16 @@ import React, {
useCallback,
useReducer,
} from 'react';
import { Drawer, Button, Tabs, Badge, Select, TreeSelect } from 'antd';
import {
Drawer,
Button,
Tabs,
Badge,
Select,
Space,
Switch,
TreeSelect,
} from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import SplitPane from 'react-split-pane';
@@ -24,11 +33,15 @@ const EditModal = ({
treeData,
currentNode,
content,
wordWrap,
onWordWrapChange,
handleCancel,
}: {
treeData?: any;
content?: string;
currentNode: any;
wordWrap: boolean;
onWordWrapChange: (checked: boolean) => void;
handleCancel: () => void;
}) => {
const [value, setValue] = useState('');
@@ -219,6 +232,16 @@ const EditModal = ({
</>
}
width={'100%'}
extra={
<Space size={8}>
<span>{intl.get('自动换行')}</span>
<Switch
aria-label={intl.get('自动换行')}
checked={wordWrap}
onChange={onWordWrapChange}
/>
</Space>
}
headerStyle={{ padding: '11px 24px' }}
onClose={cancel}
open={true}
@@ -241,6 +264,7 @@ const EditModal = ({
lineNumbersMinChars: 3,
glyphMargin: false,
accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}}
onMount={(editor) => {
editorRef.current = editor;
+28 -1
View File
@@ -15,6 +15,7 @@ import {
PlusOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import { EditorView } from '@codemirror/view';
import Editor from '@monaco-editor/react';
import CodeMirror from '@uiw/react-codemirror';
import { history, useOutletContext } from '@umijs/max';
@@ -26,6 +27,8 @@ import {
MenuProps,
message,
Modal,
Space,
Switch,
Tooltip,
Tree,
TreeSelect,
@@ -70,6 +73,11 @@ const Script = () => {
const [showMonaco, setShowMonaco] = useState(true);
const [historyVisible, setHistoryVisible] = useState(false);
const [saving, setSaving] = useState(false);
const [wordWrap, setWordWrap] = useState(
() =>
(localStorage.getItem('qinglong_editor_word_wrap') ??
localStorage.getItem('qinglong_config_word_wrap')) === 'true',
);
const savingRef = useRef(false);
const savedContentRef = useRef('');
const currentKeyRef = useRef(currentNode?.key);
@@ -544,6 +552,11 @@ const Script = () => {
});
};
const changeWordWrap = (checked: boolean) => {
setWordWrap(checked);
localStorage.setItem('qinglong_editor_word_wrap', String(checked));
};
return (
<PageContainer
className="ql-container-wrapper log-wrapper"
@@ -567,6 +580,14 @@ const Script = () => {
}
loading={loading}
extra={[
<Space key="word-wrap" size={8}>
<span>{intl.get('自动换行')}</span>
<Switch
aria-label={intl.get('自动换行')}
checked={wordWrap}
onChange={changeWordWrap}
/>
</Space>,
...(currentNode?.type === 'file' && showMonaco
? [
<Button
@@ -734,6 +755,7 @@ const Script = () => {
lineNumbersMinChars: 3,
glyphMargin: false,
accessibilitySupport: 'off',
wordWrap: wordWrap ? 'on' : 'off',
}}
onMount={(editor) => {
editorRef.current = editor;
@@ -747,7 +769,10 @@ const Script = () => {
{isPhone && (
<CodeMirror
value={value}
extensions={getCodeMirrorExtensions(mode)}
extensions={[
...getCodeMirrorExtensions(mode),
...(wordWrap ? [EditorView.lineWrapping] : []),
]}
theme={theme.includes('dark') ? 'dark' : 'light'}
readOnly={!isEditing || saving}
onChange={(value) => {
@@ -760,6 +785,8 @@ const Script = () => {
treeData={data}
currentNode={currentNode}
content={value}
wordWrap={wordWrap}
onWordWrapChange={changeWordWrap}
handleCancel={() => {
setIsLogModalVisible(false);
}}
+69
View File
@@ -0,0 +1,69 @@
const test = require('node:test'),
assert = require('node:assert/strict'),
fs = require('node:fs'),
path = require('node:path'),
{ spawnSync } = require('node:child_process');
const notifyFile = path.resolve(
process.env.NOTIFY_PY_PATH || 'sample/notify.py',
);
const source = fs.readFileSync(notifyFile, 'utf8');
function httpCalls(text) {
const calls = [];
const pattern = /requests\.(?:get|post|request)\s*\(/g;
let match;
while ((match = pattern.exec(text))) {
let depth = 0;
let end = match.index + match[0].length - 1;
for (; end < text.length; end++) {
if (text[end] === '(') depth++;
else if (text[end] === ')' && --depth === 0) break;
}
calls.push(text.slice(match.index, end + 1));
}
return calls;
}
test('sample/notify.py sends every HTTP request with a timeout', () => {
const calls = httpCalls(source);
assert.ok(
calls.length >= 27,
`expected at least 27 HTTP calls, got ${calls.length}`,
);
const missing = calls.filter((call) => !/\btimeout\s*=/.test(call));
assert.deepEqual(missing, []);
});
test('notify.send returns after a bounded wait when a channel hangs', () => {
const script = `
import os, sys, time, types
requests = types.ModuleType("requests")
requests.get = requests.post = requests.request = lambda *a, **k: None
sys.modules["requests"] = requests
sys.path.insert(0, ${JSON.stringify(path.dirname(notifyFile))})
os.environ.pop("QL_DIR", None)
import notify
notify.NOTIFY_JOIN_TIMEOUT = 1
notify.push_config = {key: "" for key in notify.push_config}
notify.push_config["HITOKOTO"] = False
notify.push_config["CONSOLE"] = True
def hang(*args, **kwargs):
time.sleep(60)
notify.console = hang
start = time.monotonic()
notify.send("title", "content")
elapsed = time.monotonic() - start
assert elapsed < 5, elapsed
print("elapsed=%.2f" % elapsed)
`;
const python = process.platform === 'win32' ? 'python' : 'python3';
const result = spawnSync(python, ['-'], {
input: script,
encoding: 'utf8',
timeout: 15000,
env: { ...process.env, QL_DIR: '' },
});
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /elapsed=/);
});
+34 -1
View File
@@ -3,7 +3,7 @@ const test = require('node:test');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { Sequelize, QueryTypes } = require('sequelize');
const { Sequelize, DataTypes, QueryTypes } = require('sequelize');
const { migrateSchema } = require('../../back/shared/schemaMigrations');
const load = require('../helpers/load-security-module.cjs');
@@ -46,6 +46,39 @@ test('legacy database upgrades without losing rows and repeated migration is ide
assert.equal(applied.length, 15);
});
test('migration repairs malformed environment labels before ORM reads', async (t) => {
const database = await createLegacy();
t.after(() => database.close());
await migrateSchema(database);
for (const [id, labels] of [
[1, ''],
[2, null],
[3, '{invalid'],
[4, '{}'],
[5, '["keep"]'],
]) {
await database.query('INSERT INTO "Envs" (id, name, labels) VALUES (?, ?, ?)', {
replacements: [id, `env-${id}`, labels],
});
}
const EnvModel = database.define('Env', {
name: DataTypes.STRING,
labels: DataTypes.JSON,
}, { timestamps: false });
await assert.rejects(EnvModel.findAll(), /Unexpected end of JSON input/);
await migrateSchema(database);
const rows = await EnvModel.findAll({ order: [['id', 'ASC']] });
assert.deepEqual(rows.map((row) => row.labels), [[], [], [], [], ['keep']]);
await migrateSchema(database);
const repaired = await database.query('SELECT id, labels FROM "Envs" ORDER BY id', {
type: QueryTypes.SELECT,
});
assert.deepEqual(repaired.map(({ labels }) => labels), [
'[]', '[]', '[]', '[]', '["keep"]',
]);
});
test('migration failure rolls back added columns and can be retried after repair', async (t) => {
const database = await createLegacy(':memory:', true);
t.after(() => database.close());