// SPDX-License-Identifier: LGPL-2.1-or-later import cockpit from "cockpit"; import QUnit from "qunit-tests"; let dir = "/uninitialized"; QUnit.test("simple read", async assert => { const file = cockpit.file(dir + "/foo"); assert.equal(file.path, dir + "/foo", "file has path"); assert.equal(await file.read(), "1234\n", "correct result"); }); QUnit.test("read non-existent", async assert => { assert.equal(await cockpit.file(dir + "/blah").read(), null, "correct result"); }); QUnit.test("parse read", async assert => { const resp = await cockpit.file(dir + "/foo.json", { syntax: JSON }).read(); assert.deepEqual(resp, { foo: 12 }, "correct result"); }); QUnit.test("parse read error", async assert => { try { await cockpit.file(dir + "/foo.bin", { syntax: JSON }).read(); assert.ok(false, "should have failed"); } catch (error) { assert.ok(error instanceof SyntaxError, "got SyntaxError error"); } }); QUnit.test("binary read", async assert => { const resp = await cockpit.file(dir + "/foo.bin", { binary: true }).read(); assert.deepEqual(resp, new Uint8Array([0, 1, 2, 3]), "correct result"); }); QUnit.test("read non-regular", async assert => { try { await cockpit.file(dir, { binary: true }).read(); assert.ok(false, "should have failed"); } catch (error) { assert.equal((error as cockpit.BasicError).problem, "internal-error", "got error"); } }); QUnit.test("read large", async assert => { const resp = await cockpit.file(dir + "/large.bin", { binary: true }).read(); assert.equal(resp.length, 512 * 1024, "correct result"); }); QUnit.test("read too large", async assert => { try { await cockpit.file(dir + "/large.bin", { binary: true, max_read_size: 8 * 1024 }).read(); assert.ok(false, "should have failed"); } catch (error) { assert.equal((error as cockpit.BasicError).problem, "too-large", "got error"); } }); /* regression: passing 'binary: false' made cockpit-ws close the whole connection */ QUnit.test("binary false", async assert => { await cockpit.file(dir + "/foo.bin", { binary: false }).read(); assert.ok(true, "did not crash"); }); QUnit.test("simple replace", async assert => { await cockpit.file(dir + "/bar").replace("4321\n"); const res = await cockpit.spawn(["cat", dir + "/bar"]); assert.equal(res, "4321\n", "correct content"); }); QUnit.test("empty replace", async assert => { await cockpit.file(`${dir}/bar`).replace(""); const res = await cockpit.spawn(["cat", `${dir}/bar`]); assert.equal(res, "", "correct content"); }); QUnit.test("stringify replace", async assert => { await cockpit.file(dir + "/bar", { syntax: JSON }).replace({ foo: 4321 }); const res = await cockpit.spawn(["cat", dir + "/bar"]); assert.deepEqual(JSON.parse(res), { foo: 4321 }, "correct content"); }); QUnit.test("stringify replace error", async assert => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const cycle: any = { }; cycle.me = cycle; try { await cockpit.file(dir + "/bar", { syntax: JSON }).replace(cycle); assert.ok(false, "should have failed"); } catch (error) { assert.ok(error instanceof TypeError, "got stringify error"); } }); QUnit.test("binary replace", async assert => { await cockpit.file(dir + "/bar", { binary: true }).replace(new Uint8Array([3, 2, 1, 0])); const res = await cockpit.spawn(["cat", dir + "/bar"], { binary: true }); assert.deepEqual(res, new Uint8Array([3, 2, 1, 0]), "correct content"); }); QUnit.test("replace large", async assert => { const str = new Array(23 * 1023).join('abcdef12345'); await cockpit.file(dir + "/large").replace(str); const res = await cockpit.spawn(["cat", dir + "/large"]); assert.equal(res.length, str.length, "correct large length"); assert.ok(res === str, "correct large data"); }); QUnit.test("binary replace large", async assert => { const data = new Uint8Array(249 * 1023); const len = data.byteLength; for (let i = 0; i < len; i++) data[i] = i % 233; await cockpit.file(dir + "/large-binary", { binary: true }).replace(data); const res = await cockpit.spawn(["cat", dir + "/large-binary"], { binary: true }); let eq = true; assert.equal(res.byteLength, 249 * 1023, "check length"); assert.equal(res.byteLength, data.byteLength, "correct large length"); for (let i = 0; i < res.byteLength; i++) { if (res[i] !== data[i] || res[i] === undefined) { eq = false; break; } } assert.ok(eq, "got back same data"); }); QUnit.test("fsreplace1 no path", async assert => { const invalid = cockpit.channel({ payload: "fsreplace1", attrs: {} }); await assert.rejects(invalid.wait(), /attribute 'path' required/, "rejects no path"); }); QUnit.test("fsreplace1 attrs", async assert => { await cockpit.init(); assert.propContains(cockpit.info.channels, { fsreplace1: ['attrs'] }); const valid = cockpit.channel({ payload: "fsreplace1", path: `${dir}/tmp`, attrs: {} }); await valid.wait(); valid.close(); // no-op const invalid = cockpit.channel({ payload: "fsreplace1", path: `${dir}/tmp`, attrs: { foo: 1 } }); await assert.rejects(invalid.wait(), /"attrs" contains unsupported key\(s\): foo/, "rejects non-empty attrs"); }); QUnit.test("remove", async assert => { const exists = await cockpit.spawn(["bash", "-c", "test -f " + dir + "/bar && echo exists"]); assert.equal(exists, "exists\n", "exists"); await cockpit.file(dir + "/bar").replace(null); const res = await cockpit.spawn(["bash", "-c", "test -f " + dir + "/bar || echo gone"]); assert.equal(res, "gone\n", "gone"); }); QUnit.test("abort replace", async assert => { const done = assert.async(); assert.expect(0); const file = cockpit.file(dir + "/bar"); let n = 0; function start_after_two() { n += 1; if (n === 2) done(); } const first = file.replace("1234\n"); const second = file.replace("abcd\n"); try { await first; throw new Error("unexpectedly succeeded first promise"); } catch (_error) { start_after_two(); } await second; start_after_two(); }); QUnit.test("replace with tag", async assert => { const done = assert.async(); const file = cockpit.file(dir + "/barfoo"); file.read() // @ts-expect-error cannot represent read promise signature with tag .then(async (content, tag_1) => { assert.equal(content, null, "file does not exist"); assert.equal(tag_1, "-", "first tag is -"); const tag_2 = await file.replace("klmn\n", tag_1); assert.notEqual(tag_2, "-", "second tag is not -"); const tag_3 = await file.replace("KLMN\n", tag_2); assert.notEqual(tag_3, tag_2, "third tag is different"); try { await file.replace("opqr\n", tag_2); assert.ok(false, "should have failed"); } catch (error) { assert.equal((error as cockpit.BasicError).problem, "change-conflict", "wrong tag is rejected"); done(); } }); }); QUnit.test("modify", async assert => { const file = cockpit.file(dir + "/quux"); let n = 0; await file.modify(old => { n += 1; assert.equal(old, null, "no old content"); return "ABCD\n"; }); assert.equal(n, 1, "callback called once"); n = 0; await file.modify(old => { n += 1; assert.equal(old, "ABCD\n", "correct old content"); return "dcba\n"; }); assert.equal(n, 1, "callback called once"); assert.equal(await cockpit.spawn(["cat", dir + "/quux"]), "dcba\n", "correct content"); // make sure that writing "" results in an empty file, not a deleted one n = 0; await file.modify(old => { n += 1; assert.equal(old, "dcba\n", "correct old content"); return ""; }); assert.equal(n, 1, "callback called once"); assert.equal(await cockpit.spawn(["cat", dir + "/quux"]), "", "correct content"); // make sure that writing null deletes the file n = 0; await file.modify(old => { n += 1; assert.equal(old, "", "correct old content"); return null; }); assert.equal(n, 1, "callback called once"); assert.rejects(cockpit.spawn(["cat", dir + "/quux"]), /No such file or directory/, "file deleted"); }); QUnit.test("modify with conflict", async assert => { const done = assert.async(); assert.expect(6); const file = cockpit.file(dir + "/baz"); let n = 0; file .modify(old => { n += 1; assert.equal(old, null, "no old content"); return "ABCD\n"; }) .finally(() => assert.equal(n, 1, "callback called once")) // @ts-expect-error cannot represent modify promise signature with tag .then(async (content, tag) => { let n = 0; await cockpit.spawn(["bash", "-c", "sleep 1; echo XYZ > " + dir + "/baz"]); await file.modify(old => { cockpit.assert(old !== null, "old is null"); n += 1; if (n === 1) assert.equal(old, "ABCD\n", "correct old (out-of-date) content"); else assert.equal(old, "XYZ\n", "correct old (current) content"); return old.toLowerCase(); }, content, tag); assert.equal(n, 2, "callback called twice"); const res = await cockpit.spawn(["cat", dir + "/baz"]); assert.equal(res, "xyz\n", "correct content"); done(); }); }); QUnit.test("watching", assert => { const done = assert.async(); assert.expect(7); const file = cockpit.file(dir + "/foobar"); let n = 0; const watch = file.watch((content, tag) => { n += 1; if (n === 1) { assert.equal(content, null, "initially non-existent"); assert.equal(tag, "-", "empty tag"); cockpit.spawn(["bash", "-c", "cd " + dir + " && echo 1234 > foobar.tmp && mv foobar.tmp foobar"]); } else if (n === 2) { assert.equal(content, "1234\n", "correct new content"); assert.notEqual(tag, "-"); cockpit.assert(tag !== null, "tag is null"); assert.ok(tag.length > 5, "tag has a reasonable size"); cockpit.spawn(["bash", "-c", "rm " + dir + "/foobar"]); } else if (n === 3) { assert.equal(content, null, "finally non-existent"); assert.equal(tag, "-", "empty tag"); watch.remove(); done(); } }); }); QUnit.test("binary watching", assert => { const done = assert.async(); assert.expect(3); const file = cockpit.file(dir + "/foobar", { binary: true }); let n = 0; const watch = file.watch(content => { n += 1; if (n === 1) { assert.equal(content, null, "initially non-existent"); cockpit.spawn(["bash", "-c", "cd " + dir + " && echo '//8BAg==' | base64 -d > foobar.tmp && mv foobar.tmp foobar"]); } else if (n === 2) { assert.deepEqual(content, new Uint8Array([255, 255, 1, 2]), "correct new content"); cockpit.spawn(["bash", "-c", "rm " + dir + "/foobar"]); } else if (n === 3) { assert.equal(content, null, "finally non-existent"); watch.remove(); done(); } }); }); QUnit.test("syntax watching", assert => { const done = assert.async(); assert.expect(3); const file = cockpit.file(dir + "/foobar.json", { syntax: JSON }); let n = 0; const watch = file.watch((content, _tag, err) => { n += 1; if (n === 1) { assert.equal(content, null, "initially non-existent"); cockpit.spawn(["bash", "-c", "cd " + dir + " && echo '[ 1, 2, 3, 4 ]' > foobar.json.tmp && mv foobar.json.tmp foobar.json"]); } else if (n === 2) { assert.deepEqual(content, [1, 2, 3, 4], "correct new content"); cockpit.spawn(["bash", "-c", "echo 'hi-there-this-is-not-json' > " + dir + "/foobar.json"]); } else if (n === 3) { assert.ok(err instanceof SyntaxError, "got SyntaxError error"); watch.remove(); done(); } else assert.ok(false, "not reached"); }); }); QUnit.test("watching without reading", assert => { const done = assert.async(); assert.expect(7); const file = cockpit.file(dir + "/foobar"); let n = 0; const watch = file.watch((content, tag) => { n += 1; if (n === 1) { assert.equal(content, null, "initially non-existent"); assert.equal(tag, "-", "empty tag"); cockpit.spawn(["bash", "-c", "cd " + dir + " && echo 1234 > foobar.tmp && mv foobar.tmp foobar"]); } else if (n === 2) { assert.equal(content, null, "no content as reading is disabled"); assert.notEqual(tag, "-"); cockpit.assert(tag !== null, "tag is null"); assert.ok(tag.length > 5, "tag has a reasonable size"); cockpit.spawn(["bash", "-c", "rm " + dir + "/foobar"]); } else if (n === 3) { assert.equal(content, null, "finally non-existent"); assert.equal(tag, "-", "empty tag"); watch.remove(); done(); } }, { read: false }); }); QUnit.test("watching without reading pre-created", async assert => { const done = assert.async(); assert.expect(3); // Pre-create fsinfo test file const file = cockpit.file(dir + "/fsinfo"); await file.replace("1234"); const watch = file.watch((content, tag) => { assert.equal(content, null, "non-existent because read is false"); assert.notEqual(tag, null, "non empty tag"); cockpit.assert(tag !== null, "tag is null"); assert.equal(tag.startsWith("1:"), true, "tag always starts with 1:"); watch.remove(); done(); }, { read: false }); }); QUnit.test("watching directory", assert => { const done = assert.async(); assert.expect(20); let n = 0; const watch = cockpit.channel({ payload: "fswatch1", path: dir }); watch.addEventListener("message", (_event, payload) => { const msg = JSON.parse(payload); n += 1; if (n === 1) { assert.equal(msg.event, "created", "world.txt created"); assert.equal(msg.path, dir + "/world.txt"); assert.equal(msg.type, "file"); assert.notEqual(msg.tag, "-"); } else if (n === 2) { assert.equal(msg.event, "changed", "world.txt changed"); assert.equal(msg.path, dir + "/world.txt"); assert.notEqual(msg.tag, "-"); } else if (n === 3) { assert.equal(msg.event, "done-hint", "world.txt done-hint"); assert.equal(msg.path, dir + "/world.txt"); assert.notEqual(msg.tag, "-"); cockpit.spawn(["chmod", "001", `${dir}/world.txt`]); } else if (n === 4) { assert.equal(msg.event, "attribute-changed", "world.txt attribute-changed"); assert.equal(msg.path, dir + "/world.txt"); assert.notEqual(msg.tag, "-"); cockpit.spawn(["rm", `${dir}/world.txt`]); } else if (n === 5) { assert.equal(msg.event, "deleted", "world.txt deleted"); assert.equal(msg.path, dir + "/world.txt"); assert.equal(msg.tag, "-"); cockpit.spawn(["mkdir", `${dir}/somedir`]); } else if (n === 6) { assert.equal(msg.event, "created", "somedir created"); assert.equal(msg.path, dir + "/somedir"); assert.equal(msg.type, "directory"); assert.notEqual(msg.tag, "-"); watch.close(); done(); } }); // trigger the first event cockpit.spawn(["sh", "-c", `echo hello > ${dir}/world.txt`]); }); QUnit.test("watching error", async assert => { const dir = await cockpit.spawn([ 'sh', '-c', ` cd "$(mktemp -d)" echo -n "$(pwd)" mkdir dir echo dir file > dir/dir-file.txt echo do not read this > dir-file.xtx chmod 0 dir ` ]); const file = cockpit.file(`${dir}/dir/file`); try { await new Promise(resolve => { file.watch((content, tag, error) => { assert.equal(content, null); assert.equal(tag, null); cockpit.assert(error !== null, "error is null"); assert.equal(error.problem, 'access-denied'); resolve(); }); }); } finally { file.close(); await cockpit.spawn(["chmod", "-R", "u+rwX", dir]); await cockpit.spawn(["rm", "-rf", dir]); } }); QUnit.test("closing", assert => { const done = assert.async(); assert.expect(2); const file = cockpit.file(dir + "/foobarbaz"); const watch = file.watch(changed); let n = 0; function start_after_two() { n += 1; if (n === 2) { watch.remove(); done(); } } file.read() .then(() => { assert.ok(false, "read didn't complete"); }) .catch(error => { assert.equal(error.problem, "cancelled", "read got cancelled"); start_after_two(); }); function changed(_content: string | null, _tag: string | null, err: cockpit.BasicError | null) { if (err) { assert.equal(err.problem, "cancelled", "watch got cancelled"); start_after_two(); } else { assert.ok(false, "not reached"); } } file.close(); }); QUnit.test("channel options", async assert => { const data = await cockpit.file(dir + "/foo", { binary: true }).read(); assert.ok(data instanceof Uint8Array, "options applied, got binary data"); }); QUnit.test("remove testdir", async assert => { await cockpit.spawn(["rm", "-rf", dir]); assert.ok(true, "did not crash"); }); (async () => { const resp = await cockpit.spawn(["bash", "-c", "d=$(mktemp -d); echo '1234' >$d/foo; echo '{ \"foo\": 12 }' >$d/foo.json; echo -en '\\x00\\x01\\x02\\x03' >$d/foo.bin; dd if=/dev/zero of=$d/large.bin bs=1k count=512; echo $d"]); dir = resp.replace(/\n$/, ""); QUnit.start(); })();