Merge branch 'test/content-envelope-vectors' into 'main'

Check the content envelope against envelopes the iPhone emitted

See merge request paulhitt/flamenet-messenger-android!120
This commit was merged in pull request #147.
This commit is contained in:
Paul Hitt
2026-09-11 11:51:28 +00:00
4 changed files with 238 additions and 2 deletions
+1 -1
View File
@@ -1 +1 @@
d8e1826a946fe5a1f956a3bb42dcb55969e3a890
588929cca79e252c62fb8d50a77218bb4a6f884a
+4 -1
View File
@@ -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.
+30
View File
@@ -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
@@ -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")
}
}