Check the content envelope against envelopes the iPhone emitted #147
@@ -1 +1 @@
|
||||
d8e1826a946fe5a1f956a3bb42dcb55969e3a890
|
||||
588929cca79e252c62fb8d50a77218bb4a6f884a
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user