buninu-linux 0.13.56 → 0.15.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/linux-firmware/LICENCE.nvidia +131 -0
- package/LICENSES/linux-firmware/LICENSE.amdgpu +51 -0
- package/LICENSES/linux-firmware/LICENSE.amdisp +31 -0
- package/LICENSES/linux-firmware/LICENSE.i915 +39 -0
- package/LICENSES/linux-firmware/LICENSE.radeon +51 -0
- package/LICENSES/linux-firmware/LICENSE.xe +39 -0
- package/LICENSES/linux-firmware/WHENCE +10280 -0
- package/NOTICE.md +64 -46
- package/README.md +410 -96
- package/README.zh.md +2034 -0
- package/build-image.sh +10 -1
- package/build-uki.sh +3 -1
- package/fetch-alpine.sh +15 -1
- package/graphics.md +31 -0
- package/index.js +28 -11
- package/initramfs/bin/buninu-browser +4 -1
- package/initramfs/bin/buninu-land +1 -1
- package/initramfs/bin/bunterm +3 -3
- package/initramfs/bin/chroot +13 -0
- package/initramfs/bin/chvt +11 -0
- package/initramfs/bin/dhclient +8 -0
- package/initramfs/bin/mount +4 -0
- package/initramfs/bin/ping +8 -0
- package/initramfs/bin/umount +6 -0
- package/initramfs/bin/xdg-open +33 -0
- package/initramfs/buninu/CHANGELOG.md +19 -0
- package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +4 -0
- package/initramfs/buninu/apps/bunmsh/package.json +1 -1
- package/initramfs/buninu/apps/bunmsh/src/main.js +1 -1
- package/initramfs/buninu/apps/jsgotty/README.en.md +40 -0
- package/initramfs/buninu/apps/jsgotty/README.md +40 -0
- package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
- package/initramfs/buninu/apps/jsgotty/package.json +1 -1
- package/initramfs/buninu/apps/jsgotty/static/index.html +1 -0
- package/initramfs/buninu/apps/jsgotty/static/js/gotty.js +82 -17
- package/initramfs/buninu/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
- package/initramfs/buninu/apps/jsgotty/tests/android-alt.test.js +115 -0
- package/initramfs/buninu/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
- package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
- package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/README.md +20 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
- package/initramfs/buninu/package.json +1 -1
- package/initramfs/init.js +27 -10
- package/initramfs/lib/bunterm/clipboard.js +14 -0
- package/initramfs/lib/bunterm/commands.js +37 -0
- package/initramfs/lib/bunterm/fps.js +38 -0
- package/initramfs/lib/bunterm/images.js +9 -5
- package/initramfs/lib/bunterm/input.js +58 -15
- package/initramfs/lib/bunterm/keyboard.js +62 -0
- package/initramfs/lib/bunterm/keycodes.js +104 -0
- package/initramfs/lib/bunterm/kitty-keyboard.cjs +195 -0
- package/initramfs/lib/bunterm/raw-protocol.js +33 -0
- package/initramfs/lib/bunterm/renderer.js +90 -21
- package/initramfs/lib/bunterm/selection.js +60 -0
- package/initramfs/lib/bunterm/terminal.js +147 -12
- package/initramfs/lib/bunterm/vt-menu.js +181 -0
- package/initramfs/lib/bunterm/vt.js +27 -1
- package/initramfs/lib/canvas.js +99 -0
- package/initramfs/lib/chvt.js +31 -0
- package/initramfs/lib/dhclient.js +135 -0
- package/initramfs/lib/dhcp-start.js +85 -0
- package/initramfs/lib/dhcp-transport.js +44 -0
- package/initramfs/lib/dhcp.js +124 -0
- package/initramfs/lib/dlopen.js +58 -11
- package/initramfs/lib/fbdev.js +21 -3
- package/initramfs/lib/gpu.js +77 -0
- package/initramfs/lib/ping.js +110 -0
- package/initramfs/lib/raw-image.js +47 -0
- package/initramfs/lib/xdg-open.js +42 -0
- package/initramfs/usr/share/doc/buninu-linux/bunterm.md +86 -12
- package/initramfs/usr/share/doc/buninu-linux/chvt.md +34 -0
- package/initramfs/usr/share/doc/buninu-linux/dhclient.md +82 -0
- package/initramfs/usr/share/doc/buninu-linux/ping.md +91 -0
- package/initramfs/usr/share/doc/buninu-linux/xdg-open.md +43 -0
- package/package.json +2 -2
- package/scripts/arch.js +7 -5
- package/scripts/arch.sh +5 -5
- package/scripts/build.js +16 -3
- package/scripts/fetch-gpu.js +105 -0
- package/scripts/fetch.js +11 -1
- package/scripts/gpu-firmware.json +22 -0
- package/scripts/modules.js +5 -1
- package/scripts/npm-packages.md +17 -17
- package/test/bunterm-fps.test.js +17 -0
- package/test/bunterm-keyboard.test.js +116 -0
- package/test/bunterm-next-vt.test.js +9 -0
- package/test/bunterm-raw-protocol.test.js +19 -0
- package/test/bunterm-renderer.test.js +158 -0
- package/test/bunterm-selection.test.js +38 -0
- package/test/bunterm-vt-menu.test.js +178 -0
- package/test/chroot-bind.test.js +40 -2
- package/test/chvt.test.js +46 -0
- package/test/dhcp-start.test.js +67 -0
- package/test/dhcp.test.js +99 -0
- package/test/fbdev-sync.test.js +33 -0
- package/test/fetch-npm.test.js +1 -1
- package/test/gpu.test.js +155 -0
- package/test/image-size.test.js +17 -0
- package/test/libc.test.js +41 -0
- package/test/manual-bunterm-image-paths.js +31 -0
- package/test/mount-libc-lifetime.test.js +50 -0
- package/test/no-gpu.test.js +43 -0
- package/test/ping.test.js +80 -0
- package/test/readme-cli.test.js +29 -0
- package/test/stage-npm.test.js +11 -4
- package/test/xdg-open.test.js +55 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { createVtMenu } from "../initramfs/lib/bunterm/vt-menu.js";
|
|
3
|
+
import { createCommands } from "../initramfs/lib/bunterm/commands.js";
|
|
4
|
+
const report = (x, y, pressed = [], released = []) => ({ x, y, pressed, released, buttons: {left: pressed.includes("left")}, wheel: 0, moved: false });
|
|
5
|
+
function fixture(extra = {}) {
|
|
6
|
+
const selected = [];
|
|
7
|
+
const menu = createVtMenu({ width: 800, height: 600, rowHeight: 16,
|
|
8
|
+
list: () => [{number:1,label:"initial"}, {number:3,label:"bunterm"}],
|
|
9
|
+
select: n => selected.push(n), changed() {}, ...extra });
|
|
10
|
+
return {menu, selected};
|
|
11
|
+
}
|
|
12
|
+
test("new bunterm is below the tty list, inherits live font size, and prevents double launch", async () => {
|
|
13
|
+
let size=13, finish; const calls=[];
|
|
14
|
+
const {menu,selected}=fixture({controls:{fontSize:()=>size,execute:()=>""},
|
|
15
|
+
create:n=>{calls.push(n);return new Promise(resolve=>{finish=resolve;});}});
|
|
16
|
+
menu.pointer(report(0,0,["left"]));
|
|
17
|
+
const labels=[];
|
|
18
|
+
menu.draw({cellWidth:8,cellHeight:16,CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
|
|
19
|
+
paintFor:c=>c,drawText:text=>labels.push(text),
|
|
20
|
+
canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
|
|
21
|
+
expect(labels.indexOf("+ 新增 bunterm / New bunterm")).toBe(labels.indexOf("bunterm",2)+1);
|
|
22
|
+
size=20;
|
|
23
|
+
menu.input(Buffer.from("\x1b[B".repeat(5)+"\r\r"));
|
|
24
|
+
await Bun.sleep(0);
|
|
25
|
+
expect(calls).toEqual([20]);expect(selected).toEqual([]);
|
|
26
|
+
finish();await Bun.sleep(0);expect(menu.visible).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("failed new bunterm launch keeps the menu usable", async () => {
|
|
30
|
+
const {menu}=fixture({controls:{fontSize:()=>13,execute:()=>""},create:()=>{throw Error("No next tty available");}});
|
|
31
|
+
menu.pointer(report(0,0,["left"]));
|
|
32
|
+
menu.input(Buffer.from("\x1b[B".repeat(5)+"\r"));
|
|
33
|
+
await Bun.sleep(0);expect(menu.visible).toBe(true);
|
|
34
|
+
const labels=[];
|
|
35
|
+
menu.draw({cellWidth:8,cellHeight:16,CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
|
|
36
|
+
paintFor:c=>c,drawText:text=>labels.push(text),
|
|
37
|
+
canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
|
|
38
|
+
expect(labels).toContain("No next tty available");
|
|
39
|
+
menu.input(Buffer.from("\x1b"));expect(menu.visible).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
test("only the top-left four pixels open the menu; open and selection clicks never leak", () => {
|
|
42
|
+
const {menu,selected} = fixture();
|
|
43
|
+
expect(menu.pointer(report(2,1,["left"]))).toBe(false);
|
|
44
|
+
expect(menu.pointer(report(1,2,["left"]))).toBe(false);
|
|
45
|
+
expect(menu.pointer(report(1,1,["right"]))).toBe(false);
|
|
46
|
+
expect(menu.pointer(report(1,1,["left"]))).toBe(true);
|
|
47
|
+
expect(menu.visible).toBe(true);
|
|
48
|
+
expect(menu.pointer(report(1,1,[],["left"]))).toBe(true);
|
|
49
|
+
expect(menu.pointer(report(20,55,["left"]))).toBe(true);
|
|
50
|
+
expect(selected).toEqual([3]);
|
|
51
|
+
expect(menu.visible).toBe(false);
|
|
52
|
+
expect(menu.pointer(report(20,55,[],["left"]))).toBe(true);
|
|
53
|
+
expect(menu.pointer(report(30,60))).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
test("menu consumes application input, supports keyboard selection and dismissal", () => {
|
|
56
|
+
const {menu,selected} = fixture();
|
|
57
|
+
menu.pointer(report(0,0,["left"]));
|
|
58
|
+
expect(menu.input(Buffer.from("shell command"))).toBe(true);
|
|
59
|
+
menu.input(Buffer.from("\x1b[B"));
|
|
60
|
+
menu.input(Buffer.from("\r"));
|
|
61
|
+
expect(selected).toEqual([3]);
|
|
62
|
+
menu.pointer(report(0,0,[],["left"]));
|
|
63
|
+
menu.pointer(report(0,0,["left"]));
|
|
64
|
+
menu.input(Buffer.from("\x1b"));
|
|
65
|
+
expect(menu.visible).toBe(false);
|
|
66
|
+
expect(menu.input(Buffer.from("x"))).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
test("short screens scroll selection; renderer overlays are clipped", () => {
|
|
69
|
+
const {menu,selected} = fixture({height:72});
|
|
70
|
+
menu.pointer(report(0,0,["left"]));
|
|
71
|
+
menu.pointer({...report(10,10), wheel:-1});
|
|
72
|
+
const text=[];
|
|
73
|
+
menu.draw({cellWidth:8, cellHeight:16, CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
|
|
74
|
+
paintFor: c=>c, drawText: t=>text.push(t),
|
|
75
|
+
canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
|
|
76
|
+
expect(text).toContain("bunterm");
|
|
77
|
+
expect(text).not.toContain("initial");
|
|
78
|
+
menu.input(Buffer.from("\r"));
|
|
79
|
+
expect(selected).toEqual([3]);
|
|
80
|
+
});
|
|
81
|
+
test("outside click dismisses without selecting or leaking its release", () => {
|
|
82
|
+
const {menu,selected} = fixture();
|
|
83
|
+
menu.pointer(report(0,0,["left"]));
|
|
84
|
+
menu.pointer(report(0,0,[],["left"]));
|
|
85
|
+
expect(menu.pointer(report(700,300,["left"]))).toBe(true);
|
|
86
|
+
expect(menu.visible).toBe(false);
|
|
87
|
+
expect(menu.pointer(report(700,300,[],["left"]))).toBe(true);
|
|
88
|
+
expect(selected).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("Escape after releasing the opener does not eat the next application click", () => {
|
|
92
|
+
const {menu} = fixture();
|
|
93
|
+
menu.pointer(report(0,0,["left"]));
|
|
94
|
+
menu.pointer(report(0,0,[],["left"]));
|
|
95
|
+
menu.input(Buffer.from("\x1b"));
|
|
96
|
+
expect(menu.pointer(report(100,100,["left"]))).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("font and clipboard buttons dispatch local commands; UTF-8 command editing survives split reads", async () => {
|
|
100
|
+
const calls=[];
|
|
101
|
+
const {menu}=fixture({controls:{fontSize:()=>13,execute:async line=>{calls.push(line);return "OK";}}});
|
|
102
|
+
menu.pointer(report(0,0,["left"]));
|
|
103
|
+
menu.pointer(report(0,0,[],["left"]));
|
|
104
|
+
for (const [x,y] of [[10,30],[400,30],[10,55],[300,55],[10,80]]) {
|
|
105
|
+
menu.pointer(report(x,y,["left"]));
|
|
106
|
+
menu.pointer(report(x,y,[],["left"]));
|
|
107
|
+
}
|
|
108
|
+
const bytes=Buffer.from("font 16中文");
|
|
109
|
+
menu.input(bytes.subarray(0,9)); menu.input(bytes.subarray(9));
|
|
110
|
+
menu.input(Buffer.from("\x7f\x7f\r")); // remove the two full code points
|
|
111
|
+
await new Promise(resolve=>setTimeout(resolve,0));
|
|
112
|
+
expect(calls).toEqual(["font -","font +","copy","paste","font 16"]);
|
|
113
|
+
expect(menu.visible).toBe(true);
|
|
114
|
+
menu.input(Buffer.from("\x1b"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("command registry validates font/VT arguments and is extensible", async () => {
|
|
118
|
+
let size=13; const calls=[];
|
|
119
|
+
const c=createCommands({fontSize:()=>size,setFontSize:n=>{if(!Number.isInteger(n)||n<6||n>96)throw Error("size");size=n;},
|
|
120
|
+
copy:()=>calls.push("copy"),paste:()=>calls.push("paste"),switchVT:n=>calls.push(n)});
|
|
121
|
+
expect(await c.execute("font +")).toBe("Font: 14");
|
|
122
|
+
await c.execute("font 18"); expect(size).toBe(18);
|
|
123
|
+
await expect(c.execute("font nope")).rejects.toThrow();
|
|
124
|
+
await expect(c.execute("vt 64")).rejects.toThrow();
|
|
125
|
+
await expect(c.execute("copy extra")).rejects.toThrow();
|
|
126
|
+
await expect(c.execute("unknown")).rejects.toThrow("try help");
|
|
127
|
+
await c.execute("copy"); await c.execute("paste"); await c.execute("vt 3");
|
|
128
|
+
expect(calls).toEqual(["copy","paste",3]);
|
|
129
|
+
c.commands.set("example", args=>args.join(" "));
|
|
130
|
+
expect(await c.execute("example hello world")).toBe("hello world");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("menu labels current VT and renders a real left-aligned command field with centered font value", () => {
|
|
134
|
+
const {menu}=fixture({list:()=>[{number:1,label:"tty1 — initial console",current:true}],
|
|
135
|
+
controls:{fontSize:()=>13,execute:()=>""}});
|
|
136
|
+
menu.pointer(report(0,0,["left"]));
|
|
137
|
+
const labels=[];
|
|
138
|
+
menu.draw({cellWidth:8,cellHeight:16,CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
|
|
139
|
+
paintFor:c=>c,drawText:(text,x,y,cells,color,options)=>labels.push({text,align:options.align}),
|
|
140
|
+
canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
|
|
141
|
+
expect(labels).toContainEqual({text:"tty1 — initial console (current)",align:"left"});
|
|
142
|
+
expect(labels).toContainEqual({text:"Click to type a command…",align:"left"});
|
|
143
|
+
expect(labels).toContainEqual({text:"🔤 字體 Font 13",align:"center"});
|
|
144
|
+
expect(labels).toContainEqual({text:"📋 複製 Copy",align:"left"});
|
|
145
|
+
expect(labels).toContainEqual({text:"📥 貼上 Paste",align:"left"});
|
|
146
|
+
expect(labels.some(x=>x.text===">")).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("font buttons fit equally and preserve the right margin as fonts grow", () => {
|
|
150
|
+
const {menu}=fixture({controls:{fontSize:()=>20,execute:()=>""}});
|
|
151
|
+
menu.pointer(report(0,0,["left"]));
|
|
152
|
+
for(const cellWidth of [8,12,19,58]) {
|
|
153
|
+
const labels=[];
|
|
154
|
+
menu.draw({cellWidth,cellHeight:24,CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
|
|
155
|
+
paintFor:c=>c,drawText:(text,x,y,cells,color,options)=>labels.push({text,x,cells,align:options.align}),
|
|
156
|
+
canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
|
|
157
|
+
const plus=labels.find(x=>x.text==="[+]"),minus=labels.find(x=>x.text==="[-]");
|
|
158
|
+
expect(plus.align).toBe("center");expect(minus.align).toBe("center");
|
|
159
|
+
expect(plus.cells).toBe(minus.cells);
|
|
160
|
+
expect(plus.x+plus.cells*cellWidth).toBe(412);
|
|
161
|
+
expect(minus.x).toBe(8);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('header FPS control toggles without selecting a tty or leaking the click',()=>{
|
|
166
|
+
let enabled=false;
|
|
167
|
+
const {menu,selected}=fixture({controls:{fontSize:()=>13,execute:()=>'',fpsEnabled:()=>enabled,toggleFps:()=>{enabled=!enabled;}}});
|
|
168
|
+
menu.pointer(report(0,0,['left']));
|
|
169
|
+
menu.pointer(report(0,0,[],['left']));
|
|
170
|
+
expect(menu.pointer(report(380,12,['left']))).toBe(true);
|
|
171
|
+
expect(enabled).toBe(true);expect(menu.visible).toBe(true);expect(selected).toEqual([]);
|
|
172
|
+
menu.pointer(report(380,12,[],['left']));
|
|
173
|
+
const labels=[];
|
|
174
|
+
menu.draw({cellWidth:8,cellHeight:16,CanvasKit:{XYWHRect:(...r)=>r,ClipOp:{Intersect:0}},
|
|
175
|
+
paintFor:c=>c,drawText:text=>labels.push(text),canvas:{save(){},restore(){},clipRect(){},drawRect(){}}});
|
|
176
|
+
expect(labels).toContain('[FPS ✓]');
|
|
177
|
+
menu.pointer(report(380,12,['left']));expect(enabled).toBe(false);
|
|
178
|
+
});
|
package/test/chroot-bind.test.js
CHANGED
|
@@ -39,8 +39,21 @@ async function invoke(argumentsFor,env={}) {
|
|
|
39
39
|
const mock=join(dir,'libc.js');
|
|
40
40
|
writeFileSync(mock,`import {appendFileSync} from 'node:fs';
|
|
41
41
|
const record=(name,...args)=>appendFileSync(${JSON.stringify(log)},JSON.stringify([name,...args.map(a=>typeof a==='bigint'?String(a):a)])+'\\n');
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
let entered=false;
|
|
43
|
+
const bound=new Map();
|
|
44
|
+
const functions={mount:(...a)=>{record('mount',...a);return a[0]?.endsWith('/fail')?-1:0;},
|
|
45
|
+
umount2:(...a)=>{record('umount',...a);return process.env.MOCK_UNMOUNT_FAIL?-1:0;},
|
|
46
|
+
chroot:(...a)=>{record('chroot',...a);entered=true;return 0;},
|
|
47
|
+
strerror:()=> 'mock mount denied',errno:()=>process.env.MOCK_UNMOUNT_FAIL?5:1};
|
|
48
|
+
export const libc={symbols:new Proxy({}, {get(_target,name){
|
|
49
|
+
if(!bound.has(name)){
|
|
50
|
+
record('resolve',name);
|
|
51
|
+
if(entered||process.env.MOCK_MISSING_SYMBOL===name)throw Error('Cannot load libc symbol '+name);
|
|
52
|
+
bound.set(name,functions[name]);
|
|
53
|
+
}
|
|
54
|
+
return bound.get(name);
|
|
55
|
+
}})};
|
|
56
|
+
export const ptr=x=>x,cString=x=>x,errno=()=>libc.symbols.errno(),strerror=code=>libc.symbols.strerror(code),Errno={EINVAL:22,EPERM:1};`);
|
|
44
57
|
const source=readFileSync(new URL('../initramfs/bin/chroot',import.meta.url),'utf8')
|
|
45
58
|
.replace('"../lib/dlopen.js"',JSON.stringify(mock))
|
|
46
59
|
.replace('"../lib/chroot-options.js"',JSON.stringify(new URL('../initramfs/lib/chroot-options.js',import.meta.url).pathname))
|
|
@@ -68,3 +81,28 @@ test('shared tmp replaces default tmpfs and bind failure cleans up even with -k'
|
|
|
68
81
|
expect(failure.calls.some(c=>c[0]==='chroot')).toBe(false);
|
|
69
82
|
expect(failure.calls.filter(c=>c[0]==='umount').map(c=>c[1])).toEqual([failure.root+'/first']);
|
|
70
83
|
});
|
|
84
|
+
|
|
85
|
+
test('cleanup symbols are resolved before mounting and remain usable after chroot',async()=>{
|
|
86
|
+
const r=await invoke(({host})=>['-n','-b',`${host}:/work`],{MOCK_UNMOUNT_FAIL:'1'});
|
|
87
|
+
expect(r.status).toBe(0);
|
|
88
|
+
expect(r.stderr).toContain('chroot: unmount /work: mock mount denied');
|
|
89
|
+
expect(r.stderr).not.toContain('Cannot load libc symbol');
|
|
90
|
+
const firstMount=r.calls.findIndex(c=>c[0]==='mount');
|
|
91
|
+
for(const name of ['umount2','strerror','errno']){
|
|
92
|
+
const index=r.calls.findIndex(c=>c[0]==='resolve'&&c[1]===name);
|
|
93
|
+
expect(index).toBeGreaterThanOrEqual(0);expect(index).toBeLessThan(firstMount);
|
|
94
|
+
}
|
|
95
|
+
expect(r.calls.filter(c=>c[0]==='umount').map(c=>c[2])).toEqual([0,2]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('missing cleanup symbol fails before any mount, including keep-mounts',async()=>{
|
|
99
|
+
const r=await invoke(({host})=>['-n','-k','-b',`${host}:/work`],{MOCK_MISSING_SYMBOL:'umount2'});
|
|
100
|
+
expect(r.status).toBe(125);expect(r.stderr).toContain('Cannot load libc symbol umount2');
|
|
101
|
+
expect(r.calls.some(c=>['mount','chroot','umount'].includes(c[0]))).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('no-mount chroot without binds does not require umount2',async()=>{
|
|
105
|
+
const r=await invoke(()=>['-n'],{MOCK_MISSING_SYMBOL:'umount2'});
|
|
106
|
+
expect(r.status).toBe(0);
|
|
107
|
+
expect(r.calls.some(c=>c[0]==='resolve'&&c[1]==='umount2')).toBe(false);
|
|
108
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { parseChvt, switchVt } from "../initramfs/lib/chvt.js";
|
|
3
|
+
|
|
4
|
+
test("chvt accepts VT numbers and help, rejects invalid input before opening devices", async () => {
|
|
5
|
+
expect(parseChvt(["2"])).toEqual({ number: 2 });
|
|
6
|
+
expect(parseChvt(["63"])).toEqual({ number: 63 });
|
|
7
|
+
for (const flag of ["-h", "--help"]) expect(parseChvt([flag]).help).toBe(true);
|
|
8
|
+
for (const args of [[], ["0"], ["64"], ["-1"], ["2x"], ["1", "2"]]) expect(() => parseChvt(args)).toThrow();
|
|
9
|
+
await expect(switchVt(0, {})).rejects.toThrow();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("chvt skips PTY/non-VT consoles, activates only a validated VT and closes descriptors", async () => {
|
|
13
|
+
const events = [];
|
|
14
|
+
await switchVt(2, {
|
|
15
|
+
openConsole([path]) {
|
|
16
|
+
events.push(path);
|
|
17
|
+
if (path === "/dev/tty0") throw Error("missing");
|
|
18
|
+
return { path, close: () => events.push(`close ${path}`) };
|
|
19
|
+
},
|
|
20
|
+
activeConsole(device) { if (device.path === "/dev/tty") throw Error("not a VT"); },
|
|
21
|
+
activateConsole(device, number) { events.push(`activate ${device.path} ${number}`); },
|
|
22
|
+
});
|
|
23
|
+
expect(events).toEqual(["/dev/tty0", "/dev/tty", "close /dev/tty", "/dev/console", "activate /dev/console 2", "close /dev/console"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("chvt closes console on failed switch without overriding VT owner or trying another switch", async () => {
|
|
27
|
+
let closed = 0, opened = 0;
|
|
28
|
+
await expect(switchVt(1, {
|
|
29
|
+
openConsole() { opened++; return { close() { closed++; } }; },
|
|
30
|
+
activeConsole() { return 2; },
|
|
31
|
+
activateConsole() { throw Error("switch denied"); },
|
|
32
|
+
})).rejects.toThrow("switch denied");
|
|
33
|
+
expect(opened).toBe(1);
|
|
34
|
+
expect(closed).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("chvt CLI help and invalid arguments work without a Linux console", () => {
|
|
38
|
+
for (const flag of ["-h", "--help"]) {
|
|
39
|
+
const result = Bun.spawnSync([process.execPath, "initramfs/bin/chvt", flag]);
|
|
40
|
+
expect(result.exitCode).toBe(0);
|
|
41
|
+
expect(result.stdout.toString()).toContain("bunterm");
|
|
42
|
+
}
|
|
43
|
+
const invalid = Bun.spawnSync([process.execPath, "initramfs/bin/chvt", "0"]);
|
|
44
|
+
expect(invalid.exitCode).toBe(1);
|
|
45
|
+
expect(invalid.stderr.toString()).toContain("usage:");
|
|
46
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {createDhcpLauncher} from '../initramfs/lib/dhcp-start.js';
|
|
3
|
+
const pause=ms=>Bun.sleep(ms);
|
|
4
|
+
function fakeChild(){
|
|
5
|
+
let resolve,reject,stdout;
|
|
6
|
+
const exited=new Promise((yes,no)=>{resolve=yes;reject=no;});
|
|
7
|
+
return {exited,resolve,reject,kills:[],kill(signal){this.kills.push(signal);},
|
|
8
|
+
stdout:new ReadableStream({start(c){stdout=c;}}),stderr:null,
|
|
9
|
+
output(line){stdout.enqueue(Buffer.from(line));},end(){stdout.close();}};
|
|
10
|
+
}
|
|
11
|
+
test('launch returns immediately, isolates spawn failures and reuses running children',async()=>{
|
|
12
|
+
const calls=[],logs=[],children=[];
|
|
13
|
+
const launch=createDhcpLauncher({spawn(args){calls.push(args);if(args.at(-1)==='bad')throw new Error('spawn denied');const child=fakeChild();children.push(child);return child;},log:s=>logs.push(s),watchdogMs:500});
|
|
14
|
+
expect(launch(['bad','eth0','eth1'])).toEqual(['eth0','eth1']);
|
|
15
|
+
expect(launch(['eth0','eth1'])).toEqual([]);expect(calls.length).toBe(3);
|
|
16
|
+
expect(calls[2]).toContain('--no-dns');expect(calls[2]).toContain('--metric');
|
|
17
|
+
expect(logs.some(s=>s.includes('spawn denied'))).toBe(true);
|
|
18
|
+
for(const child of children){child.end();child.resolve(1);}await pause(5);
|
|
19
|
+
expect(logs.some(s=>s.includes('(exit 1); other interfaces are unaffected'))).toBe(true);
|
|
20
|
+
expect(launch(['eth0'])).toEqual(['eth0']);children.at(-1).end();children.at(-1).resolve(0);
|
|
21
|
+
});
|
|
22
|
+
test('watchdog terminates hung acquisition and escalates if SIGTERM does not exit',async()=>{
|
|
23
|
+
const child=fakeChild(),logs=[];
|
|
24
|
+
const launch=createDhcpLauncher({spawn:()=>child,log:s=>logs.push(s),watchdogMs:10,killGraceMs:10});
|
|
25
|
+
launch(['eth0']);await pause(60);
|
|
26
|
+
expect(child.kills).toEqual(['SIGTERM','SIGKILL']);child.end();child.resolve(137);await pause(5);
|
|
27
|
+
expect(logs.some(s=>s.includes('timed out; other interfaces are unaffected'))).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
test('successful one-shot exits and clears the watchdog',async()=>{
|
|
30
|
+
const child=fakeChild();const launch=createDhcpLauncher({spawn:()=>child,log:()=>{},watchdogMs:20,killGraceMs:5});
|
|
31
|
+
launch(['eth0']);child.output('dhclient: bound to 10.0.2.15/24, lease 86400s\n');child.end();child.resolve(0);await pause(50);
|
|
32
|
+
expect(child.kills).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
test('stream rejection, exit rejection, kill failure and throwing logger cannot escape',async()=>{
|
|
35
|
+
const child=fakeChild(),logs=[];
|
|
36
|
+
child.stderr=new ReadableStream({start(c){c.error(null);}});
|
|
37
|
+
child.kill=()=>{throw null;};
|
|
38
|
+
const launch=createDhcpLauncher({spawn:()=>child,log:s=>logs.push(s),watchdogMs:5,killGraceMs:5});
|
|
39
|
+
expect(()=>launch(['eth0'])).not.toThrow();await pause(30);child.end();child.reject(null);await pause(5);
|
|
40
|
+
expect(logs.some(s=>s.startsWith('[dhcp eth0] failed: timed out'))).toBe(true);
|
|
41
|
+
const bad=createDhcpLauncher({spawn(){throw null;},log(){throw new Error('console failed');}});
|
|
42
|
+
expect(()=>bad(['eth0'])).not.toThrow();expect(()=>bad(null)).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('usb0 success and late eth0 failure each produce one correctly attributed result',async()=>{
|
|
46
|
+
const children={eth0:fakeChild(),usb0:fakeChild()},logs=[];let stderr;
|
|
47
|
+
children.eth0.stderr=new ReadableStream({start(c){stderr=c;}});
|
|
48
|
+
const launch=createDhcpLauncher({spawn:args=>children[args.at(-1)],log:s=>logs.push(s),watchdogMs:500});
|
|
49
|
+
launch(['eth0','usb0']);
|
|
50
|
+
children.usb0.output('dhclient: bound to 192.168.42.2/24, lease 3600s\n');children.usb0.end();children.usb0.resolve(0);
|
|
51
|
+
await pause(5);
|
|
52
|
+
children.eth0.resolve(1);children.eth0.end();
|
|
53
|
+
// Exit notification precedes delivery of the error; the final result must wait.
|
|
54
|
+
await pause(5);stderr.enqueue(Buffer.from('dhclient: no DHCP lease received before timeout\n'));stderr.close();await pause(10);
|
|
55
|
+
expect(logs.filter(s=>s.includes('configured '))).toEqual(['[dhcp usb0] configured 192.168.42.2/24; command finished']);
|
|
56
|
+
expect(logs.filter(s=>s.includes('failed:'))).toEqual(['[dhcp eth0] failed: no DHCP lease received before timeout (exit 1); other interfaces are unaffected']);
|
|
57
|
+
expect(logs.some(s=>s.includes('usb0')&&s.includes('failed'))).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('unterminated output cannot hold the result open or prevent later invocations',async()=>{
|
|
61
|
+
const children=[],logs=[];
|
|
62
|
+
const launch=createDhcpLauncher({spawn(){const c=fakeChild();children.push(c);return c;},log:s=>logs.push(s),watchdogMs:500,outputGraceMs:10});
|
|
63
|
+
launch(['usb0']);children[0].output('dhclient: bound to 192.168.42.2/24, lease 3600s\n');children[0].resolve(0);
|
|
64
|
+
await pause(30);
|
|
65
|
+
expect(logs.some(s=>s.includes('configured 192.168.42.2/24'))).toBe(true);
|
|
66
|
+
expect(launch(['usb0'])).toEqual(['usb0']);children[1].end();children[1].resolve(0);
|
|
67
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {mkdtempSync,readFileSync,writeFileSync,rmSync} from 'node:fs';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {TYPE,encode,decode,leaseFrom,wrapIPv4,unwrapIPv4,checksum,DhcpClient,NakError} from '../initramfs/lib/dhcp.js';
|
|
5
|
+
import {parseDhclient,Configuration,manageLease} from '../initramfs/lib/dhclient.js';
|
|
6
|
+
const mac=Buffer.from('020000000001','hex');
|
|
7
|
+
function reply(request,type,{ip='10.20.0.10',server=[10,20,0,1],duration=120}={}){
|
|
8
|
+
const b=Buffer.from(request.subarray(0,240));b[0]=2;Buffer.from(ip.split('.').map(Number)).copy(b,16);
|
|
9
|
+
const lease=Buffer.alloc(4);lease.writeUInt32BE(duration);
|
|
10
|
+
return Buffer.concat([b,Buffer.from([53,1,type,54,4,...server,1,4,255,255,255,0,51,4]),lease,Buffer.from([3,4,...server,6,4,1,1,1,1,255])]);
|
|
11
|
+
}
|
|
12
|
+
test('DHCP CLI validates options before opening sockets',()=>{
|
|
13
|
+
expect(parseDhclient(['-4','-d','-1','-v','-timeout','8','--no-dns','-H','buninu','eth0'])).toMatchObject({interface:'eth0',once:true,timeout:8,dns:false});
|
|
14
|
+
expect(parseDhclient(['usb0']).interface).toBe('usb0');
|
|
15
|
+
expect(parseDhclient(['--metric','120','usb0']).metric).toBe(120);
|
|
16
|
+
for(const args of [['--metric','32767','eth0'],[],['-6','eth0'],['-timeout','NaN','eth0'],['-r','-1','eth0'],['eth0','eth1'],['../bad']])expect(()=>parseDhclient(args)).toThrow();
|
|
17
|
+
});
|
|
18
|
+
test('codec validates xid, MAC, truncation, options, mask and lease timers',()=>{
|
|
19
|
+
const req=encode({type:TYPE.DISCOVER,xid:123,mac}),ack=reply(req,TYPE.ACK);
|
|
20
|
+
expect(req.length).toBeGreaterThanOrEqual(300);expect(req.readUInt16BE(10)).toBe(0x8000);
|
|
21
|
+
const parsed=decode(ack,{xid:123,mac});expect(parsed.type).toBe(TYPE.ACK);
|
|
22
|
+
expect(decode(ack,{xid:456,mac})).toBeNull();expect(decode(ack,{mac:Buffer.alloc(6)})).toBeNull();
|
|
23
|
+
expect(decode(ack.subarray(0,ack.length-3))).toBeNull();
|
|
24
|
+
expect(leaseFrom(parsed,null,1000)).toMatchObject({prefix:24,renewAt:61000,rebindAt:106000,expiresAt:121000});
|
|
25
|
+
expect(()=>leaseFrom({...parsed,mask:'255.0.255.0'},null,0)).toThrow();
|
|
26
|
+
expect(leaseFrom({...parsed,t1:200,t2:100},null,0).renewAt).toBe(60000);
|
|
27
|
+
});
|
|
28
|
+
test('packet transport checks header checksum, fragmentation, ports and UDP checksum',()=>{
|
|
29
|
+
const payload=reply(encode({type:1,xid:1,mac}),5);
|
|
30
|
+
const packet=wrapIPv4(payload,'10.20.0.1');packet.writeUInt16BE(67,20);packet.writeUInt16BE(68,22);
|
|
31
|
+
expect(unwrapIPv4(packet)).toEqual(payload);
|
|
32
|
+
const bad=Buffer.from(packet);bad[12]^=1;expect(unwrapIPv4(bad)).toBeNull();
|
|
33
|
+
const fragment=Buffer.from(packet);fragment.writeUInt16BE(0x2000,6);fragment.writeUInt16BE(0,10);fragment.writeUInt16BE(checksum(fragment.subarray(0,20)),10);expect(unwrapIPv4(fragment)).toBeNull();
|
|
34
|
+
packet.writeUInt16BE(123,26);expect(unwrapIPv4(packet)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
test('DORA retries, ignores unrelated replies, then renews and releases via unicast',async()=>{
|
|
37
|
+
let time=0,discover=0;const queue=[],sent=[];
|
|
38
|
+
const transport={send(b,server){sent.push({b,server});const t=b[242];
|
|
39
|
+
if(t===1&&++discover===1)return;
|
|
40
|
+
if(t===1){const wrong=reply(b,2);wrong.writeUInt32BE(999,4);queue.push(wrong,reply(b,2));}
|
|
41
|
+
if(t===3)queue.push(reply(b,5));
|
|
42
|
+
},receive(){return queue.shift();}};
|
|
43
|
+
const client=new DhcpClient({transport,mac,timeout:10,now:()=>time,sleep:async ms=>{time+=ms;},random:()=>.5});
|
|
44
|
+
const lease=await client.acquire();expect(lease.address).toBe('10.20.0.10');expect(discover).toBe(2);
|
|
45
|
+
time=lease.renewAt;const renewed=await client.renew(lease);expect(renewed.address).toBe(lease.address);
|
|
46
|
+
const renew=sent.at(-1);expect(renew.server).toBe('10.20.0.1');expect(renew.b.subarray(12,16)).toEqual(Buffer.from([10,20,0,10]));
|
|
47
|
+
expect(renew.b.readUInt16BE(10)).toBe(0);
|
|
48
|
+
client.release(renewed);expect(sent.at(-1).b[242]).toBe(7);expect(sent.at(-1).server).toBe(lease.server);
|
|
49
|
+
});
|
|
50
|
+
test('acquisition times out and NAK is surfaced during renewal',async()=>{
|
|
51
|
+
let time=0;const queue=[];
|
|
52
|
+
const client=new DhcpClient({transport:{send(){},receive:()=>queue.shift()},mac,timeout:.05,now:()=>time,sleep:async ms=>{time+=ms;}});
|
|
53
|
+
expect(await client.acquire()).toBeNull();
|
|
54
|
+
client.transport.send=b=>queue.push(reply(b,6));
|
|
55
|
+
await expect(client.renew({address:'10.20.0.10',server:'10.20.0.1',rebindAt:1000})).rejects.toBeInstanceOf(NakError);
|
|
56
|
+
});
|
|
57
|
+
test('configuration uses existing ip commands, preserves DNS across renewal and restores it',async()=>{
|
|
58
|
+
const dir=mkdtempSync(`${tmpdir()}/dhcp-test-`),path=`${dir}/resolv.conf`,calls=[];
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(path,'nameserver 9.9.9.9\n');
|
|
61
|
+
const config=new Configuration({interface:'eth0',dns:true,resolv:path,metric:100},async args=>calls.push(args));
|
|
62
|
+
const lease={address:'10.20.0.10',prefix:24,routers:['10.20.0.1'],dns:['1.1.1.1']};
|
|
63
|
+
await config.apply(lease);const renewed={...lease,dns:['8.8.8.8']};await config.apply(renewed,lease);
|
|
64
|
+
expect(calls[0]).toEqual(['addr','replace','10.20.0.10/24','brd','+','dev','eth0']);
|
|
65
|
+
expect(readFileSync(path,'utf8')).toContain('8.8.8.8');await config.clear(renewed);
|
|
66
|
+
expect(calls).toContainEqual(['route','replace','default','via','10.20.0.1','dev','eth0','metric','100']);
|
|
67
|
+
expect(calls).toContainEqual(['route','del','default','via','10.20.0.1','dev','eth0','metric','100']);
|
|
68
|
+
expect(readFileSync(path,'utf8')).toBe('nameserver 9.9.9.9\n');
|
|
69
|
+
await config.apply(lease);writeFileSync(path,'external edit\n');await config.clear(lease);expect(readFileSync(path,'utf8')).toBe('external edit\n');
|
|
70
|
+
}finally{rmSync(dir,{recursive:true,force:true});}
|
|
71
|
+
});
|
|
72
|
+
test('default invocation configures once and returns without starting renewal',async()=>{
|
|
73
|
+
const events=[],lease={address:'10.20.0.10',prefix:24,seconds:60};
|
|
74
|
+
const client={async acquire(){events.push('acquire');return lease;},renew(){throw new Error('must not renew');},release(){events.push('release');}};
|
|
75
|
+
await manageLease(client,{async apply(){events.push('apply');},async clear(){events.push('clear');}},{log:()=>{}});
|
|
76
|
+
expect(events).toEqual(['acquire','apply']);
|
|
77
|
+
expect(parseDhclient(['usb0']).once).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
test('one-shot keeps a successful lease but cleans up failed configuration',async()=>{
|
|
80
|
+
const events=[],client={async acquire(){return {address:'10.0.0.2',prefix:24,seconds:60};},release(){events.push('release');}};
|
|
81
|
+
const config={async apply(){},async clear(){events.push('clear');}};
|
|
82
|
+
await manageLease(client,config,{once:true,log:()=>{}});expect(events).toEqual([]);
|
|
83
|
+
config.apply=async()=>{throw new Error('route failed');};
|
|
84
|
+
await expect(manageLease(client,config,{once:true,log:()=>{}})).rejects.toThrow('route failed');expect(events).toEqual(['release','clear']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('runtime libc sendto delivers a UDP datagram on loopback',async()=>{
|
|
88
|
+
const {libc,ptr,check}=await import('../initramfs/lib/dlopen.js');
|
|
89
|
+
let received;
|
|
90
|
+
const server=await Bun.udpSocket({hostname:'127.0.0.1',port:0,socket:{data(_socket,data){received=Buffer.from(data);}}});
|
|
91
|
+
const fd=check('socket',libc.symbols.socket(2,2,17));
|
|
92
|
+
try{
|
|
93
|
+
const target=Buffer.alloc(16);target.writeUInt16LE(2);target.writeUInt16BE(server.port,2);target.set([127,0,0,1],4);
|
|
94
|
+
const data=Buffer.from('dhcp transport libc probe');
|
|
95
|
+
expect(check('sendto',Number(libc.symbols.sendto(fd,ptr(data),data.length,0,ptr(target),16)))).toBe(data.length);
|
|
96
|
+
for(let i=0;i<100&&!received;i++)await Bun.sleep(5);
|
|
97
|
+
expect(received).toEqual(data);
|
|
98
|
+
}finally{libc.symbols.close(fd);server.close();}
|
|
99
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { syncFramebuffer, flush } from "../initramfs/lib/fbdev.js";
|
|
3
|
+
const display = () => ({ address: 0x10000, memoryLength: 4096, path: "/dev/fb0" });
|
|
4
|
+
test("unsupported framebuffer msync is probed once per mapping", () => {
|
|
5
|
+
for (const code of [22, 95]) {
|
|
6
|
+
const fb = display(); let calls = 0;
|
|
7
|
+
const native = { msync(...args) { calls++; expect(args).toEqual([0x10000, 4096, 4]); return -1; }, errno: () => code };
|
|
8
|
+
syncFramebuffer(fb, native); syncFramebuffer(fb, native);
|
|
9
|
+
expect(calls).toBe(1);
|
|
10
|
+
expect(fb.msyncUnsupported).toBe(true);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
test("supported sync continues every frame and retries EINTR", () => {
|
|
14
|
+
const fb = display(); let calls = 0;
|
|
15
|
+
const native = { msync() { return ++calls === 1 ? -1 : 0; }, errno: () => 4 };
|
|
16
|
+
syncFramebuffer(fb, native); syncFramebuffer(fb, native);
|
|
17
|
+
expect(calls).toBe(3);
|
|
18
|
+
expect(fb.msyncUnsupported).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
test("real mapping and I/O failures remain visible", () => {
|
|
21
|
+
for (const code of [5, 12, 9]) {
|
|
22
|
+
const fb = display();
|
|
23
|
+
expect(() => syncFramebuffer(fb, {msync: () => -1, errno: () => code, strerror: () => `error ${code}`})).toThrow(`error ${code}`);
|
|
24
|
+
expect(fb.msyncUnsupported).toBeUndefined();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
test("pixel writes continue on unsupported sync and respect framebuffer offsets and stride", () => {
|
|
28
|
+
const fb = {...display(), msyncUnsupported: true, width: 2, height: 2, bytesPerPixel: 1,
|
|
29
|
+
stride: 4, xOffset: 1, yOffset: 1, pixels: new Uint8Array([1,2,0,0,3,4,0,0]), memory: new Uint8Array(16)};
|
|
30
|
+
flush(fb);
|
|
31
|
+
expect([...fb.memory]).toEqual([0,0,0,0,0,1,2,0,0,3,4,0,0,0,0,0]);
|
|
32
|
+
fb.pixels[0] = 9; flush(fb); expect(fb.memory[5]).toBe(9);
|
|
33
|
+
});
|
package/test/fetch-npm.test.js
CHANGED
|
@@ -23,7 +23,7 @@ const url = `http://127.0.0.1:${server.port}/archive`;
|
|
|
23
23
|
|
|
24
24
|
test("five exact registry pins share an architecture-independent cache", () => {
|
|
25
25
|
expect(npmPackages.map(({ name, version }) => `${name}@${version}`)).toEqual([
|
|
26
|
-
"buninu-browser@0.2.
|
|
26
|
+
"buninu-browser@0.2.4", "buninu-land@0.1.4", "@drxiaozhi/casty@1.3.7",
|
|
27
27
|
"@drxiaozhi/jspulse@0.2.1", "@drxiaozhi/jspinyin@0.1.1",
|
|
28
28
|
]);
|
|
29
29
|
expect(basename(npmDownloadsDir)).toBe("npm");
|
package/test/gpu.test.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { test, expect, afterEach } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { configureGpu, framebufferUsers, isGpuPath } from "../initramfs/lib/gpu.js";
|
|
6
|
+
import { extractFirmware, firmwareLinkTarget } from "../scripts/fetch-gpu.js";
|
|
7
|
+
import { readTar } from "../scripts/fetch.js";
|
|
8
|
+
import { requests, select } from "../scripts/modules.js";
|
|
9
|
+
|
|
10
|
+
const dirs = [];
|
|
11
|
+
const temporary = () => { const p = fs.mkdtempSync(join(tmpdir(), "buninu-gpu-test-")); dirs.push(p); return p; };
|
|
12
|
+
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
|
|
13
|
+
const archive = (files, links = {}) => ({ entries: () => Object.entries(files).map(([n,b]) => [n, Buffer.from(b)]), links: () => Object.entries(links) });
|
|
14
|
+
const soft = target => ({ target, hard: false });
|
|
15
|
+
|
|
16
|
+
test("firmware preserves directory aliases, cross-package links and hardlinks", () => {
|
|
17
|
+
const root = temporary();
|
|
18
|
+
const files = extractFirmware([
|
|
19
|
+
archive({ "lib/firmware/nvidia/ad102/gsp.bin.zst": "firmware", "lib/firmware/amdgpu/a.bin.zst": "amd" }),
|
|
20
|
+
archive({}, {
|
|
21
|
+
"lib/firmware/nvidia/ad103": soft("ad102"),
|
|
22
|
+
"lib/firmware/nvidia/alias.bin.zst": soft("ad103/gsp.bin.zst"),
|
|
23
|
+
"lib/firmware/radeon/alias.bin.zst": soft("../amdgpu/a.bin.zst"),
|
|
24
|
+
"lib/firmware/nvidia/hard.bin.zst": { target: "lib/firmware/nvidia/ad102/gsp.bin.zst", hard: true },
|
|
25
|
+
}),
|
|
26
|
+
], root);
|
|
27
|
+
expect(files).toHaveLength(6);
|
|
28
|
+
expect(fs.readFileSync(join(root, "lib/firmware/nvidia/ad103/gsp.bin.zst"), "utf8")).toBe("firmware");
|
|
29
|
+
expect(fs.readFileSync(join(root, "lib/firmware/nvidia/alias.bin.zst"), "utf8")).toBe("firmware");
|
|
30
|
+
expect(fs.readFileSync(join(root, "lib/firmware/nvidia/hard.bin.zst"), "utf8")).toBe("firmware");
|
|
31
|
+
expect(fs.readFileSync(join(root, "lib/firmware/radeon/alias.bin.zst"), "utf8")).toBe("amd");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("firmware rejects escaping, missing and cyclic links before extraction", () => {
|
|
35
|
+
for (const target of ["/etc/passwd", "../../../etc/passwd", "..\\etc\\passwd"])
|
|
36
|
+
expect(() => firmwareLinkTarget("lib/firmware/nvidia/x", soft(target))).toThrow();
|
|
37
|
+
for (const links of [
|
|
38
|
+
{ "lib/firmware/a": soft("missing") },
|
|
39
|
+
{ "lib/firmware/a": soft("b"), "lib/firmware/b": soft("a") },
|
|
40
|
+
{ "lib/firmware/a": soft("a/child") },
|
|
41
|
+
]) expect(() => extractFirmware([archive({}, links)], temporary())).toThrow();
|
|
42
|
+
expect(() => extractFirmware([archive({"lib/firmware/../../escape": "bad"})], temporary())).toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test.skipIf(!Bun.which("tar"))("APK tar reader retains GNU long symlinks and hardlinks", () => {
|
|
46
|
+
const root = temporary(), tree = join(root, "tree"); fs.mkdirSync(tree);
|
|
47
|
+
const name = "x".repeat(120);
|
|
48
|
+
fs.writeFileSync(join(tree, name), "firmware");
|
|
49
|
+
fs.linkSync(join(tree, name), join(tree, "hard"));
|
|
50
|
+
fs.symlinkSync(name, join(tree, "alias"));
|
|
51
|
+
const pkg = join(root, "package.tar.gz");
|
|
52
|
+
expect(Bun.spawnSync(["tar", "--format=gnu", "-czf", pkg, "-C", tree, name, "hard", "alias"]).exitCode).toBe(0);
|
|
53
|
+
const tar = readTar(pkg), links = new Map(tar.links());
|
|
54
|
+
expect(tar.get(name).toString()).toBe("firmware");
|
|
55
|
+
expect(links.get("alias")).toEqual({ target: name, hard: false });
|
|
56
|
+
expect(links.get("hard")).toEqual({ target: name, hard: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const runtime = () => {
|
|
60
|
+
const root = temporary(), calls = [], logs = [];
|
|
61
|
+
fs.mkdirSync(join(root, "proc/1/fd"), { recursive:true });
|
|
62
|
+
fs.writeFileSync(join(root, "proc/1/maps"), "");
|
|
63
|
+
fs.mkdirSync(join(root, "dev/dri"), { recursive:true });
|
|
64
|
+
fs.writeFileSync(join(root, "dev/dri/renderD129"), "node");
|
|
65
|
+
fs.writeFileSync(join(root, "dev/fb0"), "node");
|
|
66
|
+
const io = {...fs,
|
|
67
|
+
statSync: path => path.includes("renderD") ? { isCharacterDevice: () => true } : fs.statSync(path),
|
|
68
|
+
chownSync: (path, uid, gid) => calls.push(["chown", uid, gid]),
|
|
69
|
+
chmodSync: (path, mode) => calls.push(["chmod", mode]),
|
|
70
|
+
};
|
|
71
|
+
const options = { root, io, env: {}, log: s => logs.push(s),
|
|
72
|
+
tryModprobe: name => calls.push(["transport", name]),
|
|
73
|
+
autoload: options => {
|
|
74
|
+
const candidates = [ ["amdgpu", "kernel/drivers/gpu/drm/amd/amdgpu/amdgpu.ko"], ["simpledrm", "kernel/drivers/gpu/drm/sysfb/simpledrm.ko"], ["e1000", "kernel/drivers/net/e1000.ko"] ];
|
|
75
|
+
const modules = candidates.filter(x => options.acceptModule(...x)).map(x => x[0]);
|
|
76
|
+
calls.push(["autoload", modules]);
|
|
77
|
+
return [{ bus:"pci", device:"0000:01:00.0", modules, loaded:modules, error:null }];
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
return { root, calls, logs, options };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
test("cfg.gpu matches only GPU leaf drivers and configures actual render node names", () => {
|
|
84
|
+
const { options, calls } = runtime(); options.env.BUNINU_RENDER_GID = "107";
|
|
85
|
+
const result = configureGpu(options);
|
|
86
|
+
expect(result.errors).toEqual([]);
|
|
87
|
+
expect(result.renderNodes).toEqual([{path:"/dev/dri/renderD129", driver:"unknown", gid:107}]);
|
|
88
|
+
expect(calls).toContainEqual(["autoload", ["amdgpu"]]);
|
|
89
|
+
expect(calls).toContainEqual(["chown", 0, 107]);
|
|
90
|
+
expect(calls).toContainEqual(["chmod", 0o660]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("virtio render nodes report the GPU child driver rather than the PCI transport", () => {
|
|
94
|
+
const { root, options } = runtime();
|
|
95
|
+
const device = join(root, "sys/class/drm/renderD129/device");
|
|
96
|
+
fs.mkdirSync(join(device, "virtio0"), {recursive:true});
|
|
97
|
+
fs.symlinkSync("/sys/bus/pci/drivers/virtio-pci", join(device, "driver"));
|
|
98
|
+
fs.symlinkSync("/sys/bus/virtio/drivers/virtio_gpu", join(device, "virtio0/driver"));
|
|
99
|
+
expect(configureGpu(options).renderNodes[0].driver).toBe("virtio_gpu");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("no matching GPU and no render node is diagnosed without claiming acceleration", () => {
|
|
103
|
+
const { root, options, logs } = runtime();
|
|
104
|
+
fs.unlinkSync(join(root, "dev/dri/renderD129"));
|
|
105
|
+
options.autoload = () => [];
|
|
106
|
+
const result = configureGpu(options);
|
|
107
|
+
expect(result.errors).toEqual([]);
|
|
108
|
+
expect(result.renderNodes).toEqual([]);
|
|
109
|
+
expect(logs.some(s=>s.includes("no accessible render node"))).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("framebuffer fd or surviving mmap prevents GPU takeover before loading anything", () => {
|
|
113
|
+
for (const mapped of [false, true]) {
|
|
114
|
+
const { root, options, calls } = runtime();
|
|
115
|
+
if (mapped) fs.writeFileSync(join(root, "proc/1/maps"), "abc-def rw-s 00000000 00:01 3 /dev/fb0\n");
|
|
116
|
+
else fs.symlinkSync("/dev/fb0", join(root, "proc/1/fd/4"));
|
|
117
|
+
expect(framebufferUsers(options)).toEqual(["1"]);
|
|
118
|
+
expect(configureGpu(options).blocked).toBe(true);
|
|
119
|
+
expect(calls).toEqual([]);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("missing hardware, driver failure and invalid permissions do not escape cfg.gpu", () => {
|
|
124
|
+
const { options, calls } = runtime();
|
|
125
|
+
options.env.BUNINU_RENDER_GID = "bad";
|
|
126
|
+
expect(configureGpu(options).errors.length).toBe(1); expect(calls).toEqual([]);
|
|
127
|
+
options.env = {};
|
|
128
|
+
options.autoload = () => { throw Error("driver failed"); };
|
|
129
|
+
expect(configureGpu(options).errors).toEqual(["driver failed"]);
|
|
130
|
+
options.autoload = () => []; options.io.chownSync = () => { throw Error("permission denied"); };
|
|
131
|
+
expect(configureGpu(options).errors[0]).toContain("permission denied");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("cfg.all's actual loader excludes GPU modules and still loads network/sound", () => {
|
|
135
|
+
const source = fs.readFileSync(new URL("../initramfs/init.js", import.meta.url), "utf8");
|
|
136
|
+
const body = source.match(/const configureModules = \(\) => \{([\s\S]*?)\n\};/)[1];
|
|
137
|
+
const chosen = [];
|
|
138
|
+
const require = () => ({ isGpuPath, modprobeAll: ({accept}) => {
|
|
139
|
+
for (const path of ["kernel/drivers/gpu/drm/amd/amdgpu/amdgpu.ko", "kernel/drivers/net/e1000.ko", "kernel/sound/snd.ko"])
|
|
140
|
+
if (accept("",path)) chosen.push(path);
|
|
141
|
+
return {inserted:chosen,errors:[]};
|
|
142
|
+
}});
|
|
143
|
+
new Function("require", "console", body)(require, {log(){}});
|
|
144
|
+
expect(chosen).toEqual(["kernel/drivers/net/e1000.ko", "kernel/sound/snd.ko"]);
|
|
145
|
+
expect(source).toContain("gpu: { enumerable: true, get: configureGraphics }");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("GPU leaves are optional for kernels without them and their dependencies are selected", () => {
|
|
149
|
+
const target = {arch:"x86_64",flavor:"lts",real:false};
|
|
150
|
+
const dep = requests(target).filter(x=>!x.optional).map(({name})=>`kernel/${name}.ko.gz:`);
|
|
151
|
+
dep.push("kernel/drivers/gpu/drm/amd/amdgpu/amdgpu.ko.gz: kernel/drivers/gpu/drm/ttm/ttm.ko.gz", "kernel/drivers/gpu/drm/ttm/ttm.ko.gz:");
|
|
152
|
+
const result = select({...target,dep:dep.join("\n"),builtin:"",packageName:"test"});
|
|
153
|
+
expect(result.selected).toContain("kernel/drivers/gpu/drm/ttm/ttm.ko.gz");
|
|
154
|
+
expect(result.notes.some(x=>x.includes("i915"))).toBe(true);
|
|
155
|
+
});
|