diff --git a/tests/xunit/test_delete.py b/tests/xunit/test_delete.py index 1b028be..e67174c 100644 --- a/tests/xunit/test_delete.py +++ b/tests/xunit/test_delete.py @@ -42,6 +42,27 @@ class TestXunitDelete(unittest.TestCase): cur = self.xfstestsdb.sql("SELECT COUNT(*) FROM xunit_properties") self.assertEqual(cur.fetchone()["COUNT(*)"], 0) + cur = self.xfstestsdb.sql("SELECT COUNT(*) FROM testcases") + self.assertEqual(cur.fetchone()["COUNT(*)"], 0) + + cur = self.xfstestsdb.sql("SELECT COUNT(*) FROM messages") + self.assertEqual(cur.fetchone()["COUNT(*)"], 0) + + @unittest.mock.patch("sys.stdout", new_callable=io.StringIO) + def test_delete_testcase(self, mock_stdout: io.StringIO): + """Test that messages get cleaned up when a testcase is deleted.""" + self.xfstestsdb.run(["new", "/dev/test"]) + self.xfstestsdb.run(["xunit", "read", "1", str(tests.xunit.XUNIT_1)]) + self.xfstestsdb.sql("DELETE FROM testcases WHERE status=?", "skipped") + + cur = self.xfstestsdb.sql("SELECT * FROM messages ORDER BY rowid") + self.assertListEqual([row["message"] for row in cur.fetchall()], + ["- output mismatch (see somefile)", + "there was a problem with '$SCRATCH_DEV'", + "--- test/09.out\n" + + "+++ results/test/09.out.bad\n" + + "there was a problem with '$SCRATCH_MNT'"]) + @unittest.mock.patch("sys.stderr", new_callable=io.StringIO) def test_delete_error(self, mock_stderr: io.StringIO): """Test the `xfstestsdb xunit delete` command with invalid input.""" diff --git a/tests/xunit/test_read.py b/tests/xunit/test_read.py index 68a0c03..dbe22f8 100644 --- a/tests/xunit/test_read.py +++ b/tests/xunit/test_read.py @@ -98,6 +98,56 @@ class TestXunitRead(unittest.TestCase): "OVL_LOWER": "ovl-lower", "OVL_WORK": "ovl-work"}) + @unittest.mock.patch("sys.stdout", new_callable=io.StringIO) + def test_read_file_testcases(self, mock_stdout: io.StringIO): + """Test reading a file's testcases into the database.""" + self.xfstestsdb.run(["new", "/dev/vda1"]) + self.xfstestsdb.run(["xunit", "read", "1", str(tests.xunit.XUNIT_1)]) + + cur = self.xfstestsdb.sql("SELECT * FROM testcases ORDER BY testcase") + self.assertListEqual([[c for c in row] for row in cur.fetchall()], + [[1, "test/01", "passed", 1, None, None, None], + [1, "test/02", "skipped", 0, 1, None, None], + [1, "test/03", "skipped", 0, 2, None, None], + [1, "test/04", "passed", 4, None, None, None], + [1, "test/05", "passed", 5, None, None, None], + [1, "test/06", "passed", 6, None, None, None], + [1, "test/07", "skipped", 0, 3, None, None], + [1, "test/08", "passed", 8, None, None, None], + [1, "test/09", "failure", 9, 4, 5, 6], + [1, "test/10", "passed", 10, None, None, None]]) + + @unittest.mock.patch("sys.stdout", new_callable=io.StringIO) + def test_read_file_testcases_view(self, mock_stdout: io.StringIO): + """Test reading a file's testcases into the database.""" + self.xfstestsdb.run(["new", "/dev/vda1"]) + self.xfstestsdb.run(["xunit", "read", "1", str(tests.xunit.XUNIT_1)]) + + rows = self.xfstestsdb.sql("""SELECT * FROM testcases_view + ORDER BY testcase""").fetchall() + for row in rows: + self.assertTupleEqual(row[:4], (1, 1, "/dev/vda1", "test-1")) + + self.assertListEqual([row[4:] for row in rows], + [("test/01", "passed", 1, None, None, None), + ("test/02", "skipped", 0, + "skipped on $TEST_DEV", None, None), + ("test/03", "skipped", 0, + "skipped on $TEST_DIR too", None, None), + ("test/04", "passed", 4, None, None, None), + ("test/05", "passed", 5, None, None, None), + ("test/06", "passed", 6, None, None, None), + ("test/07", "skipped", 0, + "fstype \"myfs\" gets skipped", None, None), + ("test/08", "passed", 8, None, None, None), + ("test/09", "failure", 9, + "- output mismatch (see somefile)", + "there was a problem with '$SCRATCH_DEV'", + "--- test/09.out\n" + + "+++ results/test/09.out.bad\n" + + "there was a problem with '$SCRATCH_MNT'"), + ("test/10", "passed", 10, None, None, None)]) + @unittest.mock.patch("sys.stdout", new_callable=io.StringIO) def test_read_file_named(self, mock_stdout: io.StringIO): """Test reading a file with the --name= option.""" diff --git a/xfstestsdb/xfstestsdb.sql b/xfstestsdb/xfstestsdb.sql index ee46b60..5523cec 100644 --- a/xfstestsdb/xfstestsdb.sql +++ b/xfstestsdb/xfstestsdb.sql @@ -119,3 +119,71 @@ CREATE TRIGGER cleanup_xunit_properties (SELECT 1 FROM link_xunit_props WHERE propid = xunit_properties.rowid); END; + + +/*********************************************** + * * + * Table for managing test cases * + * * + ***********************************************/ + +CREATE TABLE messages ( + messageid INTEGER PRIMARY KEY, + message TEXT NOT NULL, + UNIQUE (message) +); + +CREATE TABLE testcases ( + xunitid INTEGER NOT NULL, + testcase TEXT NOT NULL, + status TEXT NOT NULL, + time INTEGER NOT NULL, + messageid INTEGER DEFAULT NULL, + stdoutid INTEGER DEFAULT NULL, + stderrid INTEGER DEFAULT NULL, + PRIMARY KEY (xunitid, testcase), + FOREIGN KEY (xunitid) REFERENCES xunits (xunitid) + ON DELETE CASCADE + ON UPDATE CASCADE, + FOREIGN KEY (messageid) REFERENCES messages (messageid), + FOREIGN KEY (stdoutid) REFERENCES messages (messageid), + FOREIGN KEY (stderrid) REFERENCES messages (messageid), + CHECK (status IN ("passed", "failure", "skipped")) +); + +CREATE VIEW testcases_view AS + SELECT runid, xunitid, device, xunits.name as xunit, testcase, status, + testcases.time, msg.message as message, + stdout.message as stdout, stderr.message as stderr + FROM testcases + JOIN xunits USING (xunitid) + JOIN xfstests_runs USING (runid) + LEFT JOIN messages msg USING (messageid) + LEFT JOIN messages stdout ON stdout.messageid = testcases.stdoutid + LEFT JOIN messages stderr ON stderr.messageid = testcases.stderrid; + +CREATE TRIGGER insert_testcase + INSTEAD OF INSERT ON testcases_view + BEGIN + INSERT OR IGNORE INTO messages (message) VALUES (NEW.message); + INSERT OR IGNORE INTO messages (message) VALUES (NEW.stdout); + INSERT OR IGNORE INTO messages (message) VALUES (NEW.stderr); + INSERT INTO testcases (xunitid, testcase, status, time, + messageid, stdoutid, stderrid) + VALUES ((SELECT xunitid FROM xunits + WHERE runid = NEW.runid AND name = NEW.xunit), + NEW.testcase, NEW.status, NEW.time, + (SELECT messageid FROM messages WHERE message = NEW.message), + (SELECT messageid FROM messages WHERE message = NEW.stdout), + (SELECT messageid FROM messages WHERE message = NEW.stderr)); + END; + +CREATE TRIGGER cleanup_testcase_messages + AFTER DELETE ON testcases + BEGIN + DELETE FROM messages WHERE NOT EXISTS + (SELECT 1 FROM testcases WHERE + messageid = messages.rowid + OR stdoutid = messages.rowid + OR stderrid = messages.rowid); + END; diff --git a/xfstestsdb/xunit/read.py b/xfstestsdb/xunit/read.py index 61a0900..b52f55c 100644 --- a/xfstestsdb/xunit/read.py +++ b/xfstestsdb/xunit/read.py @@ -4,7 +4,9 @@ import argparse import datetime import dateutil.tz import errno +import html import pathlib +import re import sys import xml.etree.ElementTree from .. import commands @@ -27,6 +29,41 @@ class Command(commands.Command): type=argparse.FileType('r'), help="path to the xunit file to be read") + def sanitize_message(self, text: str, properties: dict) -> str | None: + """Replace text with xunit property names to reduce duplication.""" + if text is not None: + for key in ["TEST_DEV", "TEST_DIR", "SCRATCH_DEV", "SCRATCH_MNT"]: + if (value := properties.get(key)): + text = re.sub(value, f"${key}", text) + return text.strip() + + def elm_testcase(self, runid: int, xunit: str, + elm: xml.etree.ElementTree.Element, + properties: dict) -> tuple: + """Extract testcase data for an xml element.""" + status = "passed" + message = None + stdout = None + stderr = None + + for e in elm: + match e.tag: + case "failure" | "skipped": status = e.tag + case "system-out": stdout = html.unescape(e.text) + case "system-err": + stderr = re.sub(r"([-+]{3}\s\S+)(\s.*\n)", + lambda m: f"{m.group(1)}\n", + html.unescape(e.text)) + stderr = re.sub(r"[+]{3} /?(.*)/(\S+/\d+.*)\n", + lambda m: f"+++ results/{m.group(2)}\n", + stderr) + message = elm[0].attrib["message"] + + return (runid, xunit, elm.attrib["name"], status, elm.attrib["time"], + self.sanitize_message(message, properties), + self.sanitize_message(stdout, properties), + self.sanitize_message(stderr, properties)) + def do_command(self, args: argparse.Namespace) -> None: """Read in an xunit file.""" if self.sql("SELECT rowid FROM xfstests_runs WHERE rowid=?", @@ -40,6 +77,9 @@ class Command(commands.Command): root = xml.etree.ElementTree.parse(args.file[0]).getroot() properties = {e.attrib["name"]: e.attrib["value"] for e in root[0]} + testcases = [self.elm_testcase(args.runid[0], xunitname, + elm, properties) + for elm in root[1:]] timestamp = datetime.datetime.fromisoformat(root.attrib["timestamp"]) timestamp = timestamp.astimezone(dateutil.tz.tzutc()) @@ -63,5 +103,10 @@ class Command(commands.Command): *[(args.runid[0], xunitname, key, value) for (key, value) in properties.items()]) + self.sql.executemany("""INSERT INTO testcases_view + (runid, xunit, testcase, status, + time, message, stdout, stderr) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", *testcases) + print(f"added '{xunitname}' with {root.attrib['tests']} testcases " f"to run #{args.runid[0]}")