diff --git a/.e2e-ref b/.e2e-ref index ed4057d..17fc940 100644 --- a/.e2e-ref +++ b/.e2e-ref @@ -1 +1 @@ -d8e1826a946fe5a1f956a3bb42dcb55969e3a890 +588929cca79e252c62fb8d50a77218bb4a6f884a diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d1a69e6..ae3975d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,12 +62,14 @@ unit-tests: - export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/account-kdf-v1.json" - export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-v1.json" - export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-payload-v1.json" + - export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/content-v1.json" # Checked before the suite runs. A path that silently does not exist puts # the tests straight back where they were, and the failure would read as a # protocol bug rather than a missing file. - | for f in "$FLAMENET_VECTORS" "$FLAMENET_PQ_VECTORS" "$FLAMENET_ACCOUNT_VECTORS" \ - "$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS"; do + "$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS" \ + "$FLAMENET_CONTENT_VECTORS"; do [ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; } done # Clear old results before running, or the gate counts ghosts. The @@ -160,6 +162,7 @@ vectors-head: - export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/account-kdf-v1.json" - export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-v1.json" - export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-payload-v1.json" + - export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/content-v1.json" # Only the module that reads vectors. The rest of the suite has nothing # to say about whether flamenet-e2e moved, and running it here would double # the board's cost to answer a question it cannot answer. diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index 7623a47..a080895 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -58,6 +58,36 @@ tasks.test { events("passed", "failed", "skipped") showStandardStreams = true } + + // The vector files arrive by environment variable, and Gradle does not + // track an environment variable as an input. So a vector could move under + // a build directory that already had results in it and the task would + // report UP-TO-DATE without running a thing. + // + // That is not hypothetical. Twenty vector tests once "passed" in CI for + // weeks without ever executing, because the runner is a developer machine + // whose build directory survives between runs, and a replayed local pass + // looks exactly like a real one in the log. + // + // Declaring each file as an input is what makes a changed vector + // invalidate the task. Proven by changing one key in one vector and + // watching the suite go red without `--rerun-tasks`. + listOf( + "FLAMENET_VECTORS", + "FLAMENET_PQ_VECTORS", + "FLAMENET_ACCOUNT_VECTORS", + "FLAMENET_BACKUP_VECTORS", + "FLAMENET_BACKUP_PAYLOAD_VECTORS", + "FLAMENET_CONTENT_VECTORS", + ).forEach { name -> + val path = System.getenv(name) + if (path != null && file(path).exists()) { + inputs + .file(path) + .withPropertyName("vector-$name") + .withPathSensitivity(PathSensitivity.NONE) + } + } } // Coverage for this module. `:engine` and `:client` are plain JVM libraries diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ContentVectorTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ContentVectorTest.kt new file mode 100644 index 0000000..02392b1 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/ContentVectorTest.kt @@ -0,0 +1,203 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import java.io.File as JFile + +/** + * This engine against envelopes the iPhone actually emitted. + * + * The content layer has been guarded only by same-code round trips: this engine + * encodes and parses through its own codec, and would pass unchanged if it and + * the iPhone disagreed about every key. The property those tests are believed + * to establish is cross-platform agreement, and they cannot fail on it. That is + * how SPEC 6.18 came to specify `ment` while both clients have always sent + * `men`, and how `ctl:true` cost a whole envelope on one platform only. + * + * These envelopes were generated by running the iPhone's encoders, not by + * reading them. Parsing is asserted field by field, because "it did not crash" + * is what the old tests already proved. + */ +class ContentVectorTest { + private val vectors: JSONObject by lazy { + val candidates = + listOfNotNull( + System.getenv("FLAMENET_CONTENT_VECTORS"), + "../../engine/test/vectors/content-v1.json", + "../../../flamenet-e2e/test/vectors/content-v1.json", + "../../flamenet-e2e/test/vectors/content-v1.json", + ).map { JFile(it) } + val found = + candidates.firstOrNull { it.exists() } + ?: throw AssertionError( + "content vectors not found. Looked in: " + + candidates.joinToString(", ") { it.absolutePath }, + ) + JSONObject(found.readText()) + } + + private fun case(name: String): Content { + val cases = vectors.getJSONArray("cases") + for (i in 0 until cases.length()) { + val c = cases.getJSONObject(i) + if (c.getString("name") == name) return Content.parse(c.getString("envelope")) + } + throw AssertionError("no vector case named $name") + } + + @Test + fun `a plain text message parses as text`() { + val c = case("text") + assertEquals(Content.Kind.TEXT, c.kind) + assertEquals("still on for saturday", c.text) + assertEquals("m-1", c.messageId) + } + + @Test + fun `everything a text envelope can carry survives at once`() { + val c = case("text-reply-link-forwarded-ttl") + assertEquals("passing this on", c.text) + assertEquals("m-0", c.about?.messageId) + assertEquals(9L, c.about?.author) + assertEquals("https://example.org", c.link?.url) + assertEquals(3600, c.lifetimeSeconds) + // The iPhone writes `fwd` as the NUMBER 1, not as true. `optBoolean` + // does not coerce a number, so a parser reading only that answers false + // and the message silently loses the fact that it was passed on. + assertTrue(c.isForwarded, "fwd arrives as 1, not true") + } + + @Test + fun `mentions arrive under men, which is not what the spec says`() { + val c = case("group-text-mentions") + // SPEC 6.18 specifies `ment`. Both shipped clients send `men`. The + // clients agree; the spec is the outlier. This vector is what makes + // that checkable rather than a matter of whose reading you trust. + assertEquals(listOf(12, 34), c.mentions) + assertEquals("gid-1", c.groupId) + } + + @Test + fun `a reaction never becomes a bubble`() { + val c = case("react") + assertEquals(Content.Kind.REACT, c.kind) + assertEquals("🔥", c.reaction) + assertEquals("", c.text, "a reaction with text would render as an empty message") + assertEquals("m-0", c.about?.messageId) + } + + @Test + fun `a story reaction and a story reply both say they are about a story`() { + assertTrue(case("react-to-story").reactsToStory) + assertTrue(case("story-reply").repliesToStory) + assertEquals("s-1", case("story-reply").about?.messageId) + } + + @Test + fun `an edit names the message it replaces`() { + val c = case("edit") + assertEquals(Content.Kind.EDIT, c.kind) + assertEquals("m-1", c.editOf) + assertEquals("fixed typo", c.text) + } + + @Test + fun `a pin names its message and carries no caption`() { + val c = case("pin") + assertEquals(Content.Kind.PIN, c.kind) + assertEquals("m-1", c.pins) + } + + @Test + fun `a sticker is a sticker and not empty text`() { + val c = case("sticker") + assertEquals(Content.Kind.STICKER, c.kind) + assertEquals("classic", c.sticker?.pack) + assertEquals("wave", c.sticker?.id) + assertEquals("", c.text) + } + + @Test + fun `a story made of words keeps its expiry`() { + val c = case("text-story") + assertEquals("hello from the hill", c.text) + assertEquals(1_700_600_000.0, c.textStoryExpires) + } + + @Test + fun `an image attachment arrives whole`() { + val c = case("image") + val a = assertNotNull(c.attachment) + assertEquals("att-1", a.id) + assertEquals(4096, a.bytes) + assertEquals(800, a.width) + assertEquals(600, a.height) + assertEquals(32, a.key.size) + assertEquals(32, a.digest.size) + assertEquals("look", c.text) + } + + @Test + fun `a voice note keeps its duration, which is its whole interface`() { + val a = assertNotNull(case("audio").attachment) + assertEquals(12.5, a.duration) + assertEquals("audio/mp4", a.mime) + } + + @Test + fun `a document keeps the name and label it was filed under`() { + val c = case("file") + val a = assertNotNull(c.attachment) + // `fn`, `cls` and `cmp` are top-level envelope keys, not part of `att`, + // and none of the three appears anywhere in SPEC.md. + assertEquals("contract.pdf", a.filename) + assertEquals(3, a.classification) + assertEquals(listOf(2, 7), a.compartments) + } + + @Test + fun `a story in a set knows which one it is`() { + val c = case("story-item") + val set = assertNotNull(c.storySet) + assertEquals("set-1", set.id) + assertEquals(1, set.i) + assertEquals(3, set.n) + } + + @Test + fun `an avatar announcement is recognised as one`() { + val c = case("avatar") + assertEquals("avatar", c.kind) + assertEquals("att-1", assertNotNull(c.attachment).id) + } + + @Test + fun `every vector in the file is covered by a test here`() { + // A vector nobody asserts on is a vector that proves nothing. This + // fails when somebody adds a case and no assertion to go with it. + val cases = vectors.getJSONArray("cases") + val named = (0 until cases.length()).map { cases.getJSONObject(it).getString("name") }.toSet() + val asserted = + setOf( + "text", + "text-reply-link-forwarded-ttl", + "group-text-mentions", + "react", + "react-to-story", + "story-reply", + "edit", + "pin", + "sticker", + "text-story", + "image", + "audio", + "file", + "story-item", + "avatar", + ) + assertEquals(emptySet(), named - asserted, "vectors with no assertion in this file") + } +}