diff --git a/.maestro/README.md b/.maestro/README.md index e4479621f..0de15b0ff 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -46,6 +46,22 @@ The organization and generated config are platform-neutral; the current runner only provisions Android. No native Go Live scenario exists: native builds hide those controls, and `logged-out/tabs.yaml` checks they stay hidden. +## Chat timestamp coverage + +`logged-in/chat-timezone.yaml` sends a unique message, verifies it survives a +relaunch, then reads the actual `place.stream.chat.message` record from the PDS. +It requires a numeric RFC 3339 offset matching the device, and checks that the +timestamp represents the send instant rather than UTC mislabeled as local time. +The shared flow works on iOS and Android; the Android runner supplies +`ACCOUNT_DID`, `PDS_HTTP_URL` (the harness's loopback PDS listener), and +`DEVICE_UTC_OFFSET` (`+HH:MM`, read with `adb shell date +%z`). +Maestro's JavaScript runs on the host, so its own local time zone is not a device +offset oracle. An iOS invocation must supply the same variables using the +simulator's offset (normally the Mac host's). + +The Web counterpart, `js/e2e-web/flows/chat-timezone.spec.ts`, exercises both the +Expo and Vite send paths in Los Angeles, Kathmandu, and UTC. + ## Profile coverage `logged-in/chat-profile.yaml` taps the signed-in chat author's name, checks that the profile diff --git a/.maestro/logged-in/chat-timezone.js b/.maestro/logged-in/chat-timezone.js new file mode 100644 index 000000000..ddb9f9cbb --- /dev/null +++ b/.maestro/logged-in/chat-timezone.js @@ -0,0 +1,36 @@ +// The chat record chat-timezone.yaml sent, as the account's PDS stores it. +const res = http.get( + `${PDS_HTTP_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(ACCOUNT_DID)}&collection=place.stream.chat.message&limit=100`, +); +if (!res.ok) { + throw new Error(`listRecords: HTTP ${res.status}: ${res.body}`); +} +const text = `timezone check ${output.timezoneSentAfter}`; +const record = json(res.body).records.find((r) => r.value.text === text); +if (!record) { + throw new Error(`no place.stream.chat.message record says "${text}"`); +} +const { createdAt } = record.value; +console.log(`createdAt ${createdAt}, device offset ${DEVICE_UTC_OFFSET}`); + +// RFC 3339 with the device's numeric offset. Z, as toISOString() writes, +// would drop the sender's local wall-clock time. +const offset = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?([+-]\d{2}:\d{2})$/.exec( + createdAt, + )?.[1]; +if (offset !== DEVICE_UTC_OFFSET) { + throw new Error( + `createdAt ${createdAt} should end in the device's offset ${DEVICE_UTC_OFFSET}`, + ); +} + +// And the instant it was sent. The device's clock may drift from this host's +// a little; a wrong offset for any zone but UTC is off by 30 minutes or more. +const skew = 2 * 60 * 1000; +const sent = Date.parse(createdAt); +if (!(sent >= output.timezoneSentAfter - skew && sent <= Date.now() + skew)) { + throw new Error( + `createdAt ${createdAt} is not when the message was sent, after ${new Date(output.timezoneSentAfter).toISOString()}`, + ); +} diff --git a/.maestro/logged-in/chat-timezone.yaml b/.maestro/logged-in/chat-timezone.yaml new file mode 100644 index 000000000..8d34ed236 --- /dev/null +++ b/.maestro/logged-in/chat-timezone.yaml @@ -0,0 +1,54 @@ +appId: ${APP_ID} +--- +# A chat record's createdAt carries the sending device's own UTC offset +# (+HH:MM, never Z) and still names the instant it was sent. The runner passes +# the offset of whatever zone the device is in; this flow never changes it. +# chat-timezone.js reads the record back from the PDS, so neither the +# optimistic echo nor the app's rendering can pass it. The runner configures +# the harness and signs in before running any logged-in flow. +- launchApp +- extendedWaitUntil: + visible: + id: "home-stream-card" + timeout: 30000 +- tapOn: + id: "home-stream-card" +# chat needs the stream's context, which arrives over its websocket along +# with this system message +- extendedWaitUntil: + visible: + text: ".*e2e test stream.*" + timeout: 30000 +# The earliest the record can be stamped, which also makes the message unique +# among the account's records. +- evalScript: ${output.timezoneSentAfter = Date.now()} +# resend a send the page wasn't ready for, as in chat-reply +- retry: + maxRetries: 3 + commands: + - tapOn: + id: "chat-input" + - eraseText: 50 + - inputText: "timezone check ${output.timezoneSentAfter}" + - pressKey: Enter + - extendedWaitUntil: + visible: + # the row's text is the whole line, "@: " + text: ".*: timezone check ${output.timezoneSentAfter}" + timeout: 15000 +- takeScreenshot: chat-timezone-sent +# Relaunch, so the message comes from the node's chat history: only a record +# that round-tripped through the PDS and firehose can show up here. +- launchApp +- extendedWaitUntil: + visible: + id: "home-stream-card" + timeout: 30000 +- tapOn: + id: "home-stream-card" +- extendedWaitUntil: + visible: + text: ".*: timezone check ${output.timezoneSentAfter}" + timeout: 30000 +- takeScreenshot: chat-timezone-history +- runScript: chat-timezone.js diff --git a/hack/e2e-local.sh b/hack/e2e-local.sh index 993655b59..8ecccd527 100755 --- a/hack/e2e-local.sh +++ b/hack/e2e-local.sh @@ -137,8 +137,16 @@ adb_ shell pm grant "$APP_ID" android.permission.POST_NOTIFICATIONS 2>/dev/null # Chrome reads its command line and the trust store at startup adb_ shell am force-stop com.android.chrome || true +# The app stamps chat records with the device's own UTC offset, in whatever +# zone the device is set to; flows that check those records need it, as +# RFC 3339 writes it (+HH:MM). Maestro runs on this host, so its scripts +# read stored records from the PDS's plain-HTTP listener. +device_offset="$(adb_ shell date +%z | tr -d '\r')" +[[ "$device_offset" =~ ^[+-][0-9]{4}$ ]] || { echo "can't read $DEVICE's UTC offset: '$device_offset'"; exit 1; } MAESTRO_ARGS=(-e APP_ID="$APP_ID" -e SERVER_URL="$SERVER_HTTPS_URL" - -e ACCOUNT_HANDLE="$ACCOUNT_HANDLE" -e ACCOUNT_PASSWORD="$ACCOUNT_PASSWORD") + -e ACCOUNT_HANDLE="$ACCOUNT_HANDLE" -e ACCOUNT_PASSWORD="$ACCOUNT_PASSWORD" + -e ACCOUNT_DID="$ACCOUNT_DID" -e PDS_HTTP_URL="$PDS_HTTP_URL" + -e DEVICE_UTC_OFFSET="${device_offset:0:3}:${device_offset:3}") # --- run the flows --------------------------------------------------------- # takeScreenshot paths are relative to maestro's cwd, so run from the diff --git a/hack/lib/e2e-harness.sh b/hack/lib/e2e-harness.sh index 2d6425fd4..f028dd237 100644 --- a/hack/lib/e2e-harness.sh +++ b/hack/lib/e2e-harness.sh @@ -8,8 +8,9 @@ # The caller's cwd must be the repo root. On return the harness is up and its # variables are exported: SERVER_URL, ACCOUNT_HANDLE, ACCOUNT_DID, # ACCOUNT_PASSWORD, VIDEO_URI (the account's test VOD), E2E_FIXTURE_MP4 (the -# local file the harness streams into the node), and in HTTPS mode also -# SERVER_HTTPS_URL, PDS_HTTPS_URL, +# local file the harness streams into the node), PDS_HTTP_URL (the PDS's +# plain-HTTP listener, for checking stored records from the host), and in +# HTTPS mode also SERVER_HTTPS_URL, PDS_HTTPS_URL, # E2E_PROXY_URL, E2E_TLS_SPKI and E2E_TLS_CA. An EXIT trap stops it; a caller # with its own EXIT work calls e2e_harness_stop from its trap instead. # @@ -80,7 +81,7 @@ e2e_harness_start() { # shellcheck disable=SC1090 . "$E2E_ENVFILE" export SERVER_URL ACCOUNT_HANDLE ACCOUNT_DID ACCOUNT_PASSWORD VIDEO_URI - export E2E_FIXTURE_MP4 + export E2E_FIXTURE_MP4 PDS_HTTP_URL # only set in HTTPS mode export SERVER_HTTPS_URL PDS_HTTPS_URL E2E_PROXY_URL E2E_TLS_SPKI E2E_TLS_CA echo "harness up: SERVER_URL=$SERVER_URL ACCOUNT_HANDLE=$ACCOUNT_HANDLE${SERVER_HTTPS_URL:+ SERVER_HTTPS_URL=$SERVER_HTTPS_URL}" diff --git a/js/components/src/livestream-store/hooks.tsx b/js/components/src/livestream-store/hooks.tsx index 522699946..8d0e35f0b 100644 --- a/js/components/src/livestream-store/hooks.tsx +++ b/js/components/src/livestream-store/hooks.tsx @@ -4,6 +4,7 @@ import { ComAtprotoModerationCreateReport } from "@atproto/api"; import { detectChatFacets, + formatLocalTimestamp, type LivestreamState, type NewChatMessage, reduceChat, @@ -97,11 +98,14 @@ export const useCreateChatMessage = () => { } const facets = await detectChatFacets(pdsAgent, msg.text); + // The formatter constructs an RFC 3339 datetime from the device clock. + const createdAt = + formatLocalTimestamp() as place.stream.chat.message.Main["createdAt"]; const record: place.stream.chat.message.Main = { $type: "place.stream.chat.message", text: msg.text, - createdAt: new Date().toISOString() as any, + createdAt, streamer: streamerProfile.did as any, facets, ...(msg.reply diff --git a/js/core/src/index.ts b/js/core/src/index.ts index 4bb46052f..f351db6b7 100644 --- a/js/core/src/index.ts +++ b/js/core/src/index.ts @@ -11,6 +11,7 @@ export * from "./lib/browser"; export * from "./lib/did"; export * from "./lib/facet"; export * from "./lib/game"; +export * from "./lib/local-timestamp"; export * from "./lib/pds-hosts"; export { getPlatform } from "./lib/platform"; export type { CorePlatform } from "./lib/platform"; diff --git a/js/core/src/lib/local-timestamp.test.ts b/js/core/src/lib/local-timestamp.test.ts new file mode 100644 index 000000000..0004024f4 --- /dev/null +++ b/js/core/src/lib/local-timestamp.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; +import { formatLocalTimestamp } from "./local-timestamp"; + +describe("formatLocalTimestamp", () => { + it.each([ + [480, "2026-01-01T16:30:45.123-08:00"], + [420, "2026-01-01T17:30:45.123-07:00"], + [-345, "2026-01-02T06:15:45.123+05:45"], + [210, "2026-01-01T21:00:45.123-03:30"], + [0, "2026-01-02T00:30:45.123+00:00"], + ])("preserves the instant with device offset %i", (offset, expected) => { + const date = new Date("2026-01-02T00:30:45.123Z"); + // The OS supplies the offset; keep the test independent of the host zone. + vi.spyOn(date, "getTimezoneOffset").mockReturnValue(offset); + const timestamp = formatLocalTimestamp(date); + expect(timestamp).toBe(expected); + expect(Date.parse(timestamp)).toBe(date.getTime()); + expect(date.toISOString()).toBe("2026-01-02T00:30:45.123Z"); + }); +}); diff --git a/js/core/src/lib/local-timestamp.ts b/js/core/src/lib/local-timestamp.ts new file mode 100644 index 000000000..2d5bf963e --- /dev/null +++ b/js/core/src/lib/local-timestamp.ts @@ -0,0 +1,12 @@ +// RFC 3339 with the device's offset at this instant, including DST. +// Unlike toISOString(), this preserves the sender's local wall-clock time. +export function formatLocalTimestamp(date: Date = new Date()): string { + const offset = date.getTimezoneOffset(); + const local = new Date(date.getTime() - offset * 60_000) + .toISOString() + .slice(0, -1); + const magnitude = Math.abs(offset); + const hours = String(Math.floor(magnitude / 60)).padStart(2, "0"); + const minutes = String(magnitude % 60).padStart(2, "0"); + return `${local}${offset > 0 ? "-" : "+"}${hours}:${minutes}`; +} diff --git a/js/core/src/livestream-store/chat-reducer.test.ts b/js/core/src/livestream-store/chat-reducer.test.ts index 412a0cbe4..b21505da7 100644 --- a/js/core/src/livestream-store/chat-reducer.test.ts +++ b/js/core/src/livestream-store/chat-reducer.test.ts @@ -87,16 +87,16 @@ describe("reduceChat", () => { expect(key).toContain("1704067200000"); }); - it("sorts messages by timestamp", () => { + it("sorts messages by instant across local UTC offsets", () => { const state = makeState(); const early = makeMsg({ text: "first", - createdAt: "2024-01-01T00:00:01.000Z", + createdAt: "2024-01-01T05:45:01.000+05:45", uri: "at://a/1", }); const late = makeMsg({ text: "second", - createdAt: "2024-01-01T00:00:02.000Z", + createdAt: "2023-12-31T20:30:02.000-03:30", uri: "at://a/2", }); const result = reduceChat(state, [late, early], [], []); diff --git a/js/docs/src/content/docs/developers.mdx b/js/docs/src/content/docs/developers.mdx index bf98ad928..def2b8e0e 100644 --- a/js/docs/src/content/docs/developers.mdx +++ b/js/docs/src/content/docs/developers.mdx @@ -38,3 +38,19 @@ import { Card, CardGrid } from "@astrojs/starlight/components"; community](https://discord.stream.place) + +## Chat message timestamps + +Chat messages (`place.stream.chat.message`) sent by Streamplace include the +sending device's local UTC offset in `createdAt`, for example +`2026-10-17T13:30:00.123-07:00`. The offset is read at send time, including +daylight-saving adjustments; there is no account-wide time zone setting. Devices +set to UTC send `+00:00`. Existing records ending in `Z` remain valid. + +The node preserves the original timestamp in the record and compares instants +for chat ordering and freshness. Consumers should parse RFC 3339 timestamps, not +compare their strings or assume a trailing `Z`. + +The offset gives bots the sender's local wall-clock time, not an IANA time zone +such as `America/Los_Angeles`. It cannot determine daylight-saving transitions +for future scheduling; ask for a named zone when those rules matter. diff --git a/js/e2e-web/flows/chat-timezone.spec.ts b/js/e2e-web/flows/chat-timezone.spec.ts new file mode 100644 index 000000000..849d02203 --- /dev/null +++ b/js/e2e-web/flows/chat-timezone.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "@playwright/test"; +import { loginThroughPds } from "./login"; + +const HTTPS_URL = process.env.SERVER_HTTPS_URL; +test.skip(!HTTPS_URL, "harness started without its HTTPS hostnames"); + +for (const timezoneId of ["America/Los_Angeles", "Asia/Kathmandu", "UTC"]) { + test.describe(timezoneId, () => { + test.use({ timezoneId }); + + test("chat preserves the sending device's UTC offset", async ({ page }) => { + await loginThroughPds(page); + // Both bundles have their own send hook. Switching to the web beta + // should preserve the session, but each hook must stamp local time. + for (const frontend of ["app", "web"]) { + if (frontend === "web") { + await page + .context() + .addCookies([{ name: "sp_web_beta", value: "1", url: HTTPS_URL! }]); + await page.goto( + `${HTTPS_URL}/chat-popout/${process.env.ACCOUNT_DID}`, + ); + } else { + await page.goto(`${HTTPS_URL}/`); + await page.getByTestId("home-stream-card").first().click(); + } + await expect( + page.getByText("Now streaming - e2e test stream").first(), + ).toBeVisible(); + + const message = `timezone check ${frontend} ${timezoneId} ${Date.now()}`; + const responsePromise = page.waitForResponse((response) => { + const request = response.request(); + return ( + request.method() === "POST" && + new URL(response.url()).pathname === + "/xrpc/com.atproto.repo.createRecord" && + request.postData()?.includes(message) === true + ); + }); + const before = Date.now(); + const input = + frontend === "web" + ? page.locator('[contenteditable="true"]') + : page.getByPlaceholder("Type a message..."); + await input.fill(message); + await input.press("Enter"); + const response = await responsePromise; + expect(response.ok()).toBe(true); + const { createdAt } = response.request().postDataJSON().record; + expect(typeof createdAt).toBe("string"); + expect(Date.parse(createdAt)).toBeGreaterThanOrEqual(before); + expect(Date.parse(createdAt)).toBeLessThanOrEqual(Date.now()); + const offset = await page.evaluate( + (timestamp) => new Date(timestamp).getTimezoneOffset(), + createdAt, + ); + const magnitude = Math.abs(offset); + const expectedOffset = `${offset > 0 ? "-" : "+"}${String( + Math.floor(magnitude / 60), + ).padStart(2, "0")}:${String(magnitude % 60).padStart(2, "0")}`; + expect(createdAt.endsWith(expectedOffset)).toBe(true); + + // Only the indexed history can restore a message after a reload; + // the optimistic local echo alone must not satisfy this test. + await expect(page.getByText(message).first()).toBeVisible(); + await page.reload(); + await expect(page.getByText(message).first()).toBeVisible(); + } + }); + }); +} diff --git a/js/web/src/hooks/use-chat-send.ts b/js/web/src/hooks/use-chat-send.ts index 3d819a006..1a6741920 100644 --- a/js/web/src/hooks/use-chat-send.ts +++ b/js/web/src/hooks/use-chat-send.ts @@ -1,6 +1,7 @@ // Optimistic chat send: adds a local message immediately, POSTs to PDS, rolls back on failure. import { detectChatFacets, + formatLocalTimestamp, reduceChat, type ChatFacet, type LivestreamStore, @@ -43,7 +44,9 @@ export function useChatSend(store: LivestreamStore) { } const localUri = `local-${Date.now()}`; - const createdAt = new Date().toISOString(); + // The formatter constructs an RFC 3339 datetime from the device clock. + const createdAt = + formatLocalTimestamp() as ChatMessageViewHydrated["record"]["createdAt"]; const replyRef = replyTo ? { @@ -59,12 +62,12 @@ export function useChatSend(store: LivestreamStore) { record: { $type: "place.stream.chat.message", text: trimmed, - createdAt: createdAt as any, + createdAt, streamer: streamerDid as any, ...(replyRef ? { reply: replyRef } : {}), ...(facets ? { facets } : {}), }, - indexedAt: createdAt as any, + indexedAt: createdAt, ...(replyTo ? { replyTo: { diff --git a/pkg/atproto/chat_message_test.go b/pkg/atproto/chat_message_test.go index 2935f935c..9af9c6775 100644 --- a/pkg/atproto/chat_message_test.go +++ b/pkg/atproto/chat_message_test.go @@ -9,7 +9,6 @@ import ( "testing" "time" - "github.com/bluesky-social/indigo/util" "github.com/cenkalti/backoff" glex "github.com/streamplace/glex/runtime" "github.com/stretchr/testify/require" @@ -79,10 +78,18 @@ func TestChatMessage(t *testing.T) { } }() + // Each client stamps createdAt in its own device-local offset. The strings + // sort opposite to the instants -- the earliest message has the lexically + // largest string -- so anything ordering by string, or reading an offset + // as Z, gets this wrong. + const localLayout = "2006-01-02T15:04:05.000Z07:00" + nepal := time.FixedZone("", 5*3600+45*60) + newfoundland := time.FixedZone("", -(3*3600 + 30*60)) + now := time.Now() msg := placestream.ChatMessage{ LexiconTypeID: "place.stream.chat.message", Text: "Hello, world!", - CreatedAt: time.Now().Add(-time.Second).Format(util.ISO8601), + CreatedAt: now.Add(-2 * time.Second).In(nepal).Format(localLayout), Streamer: user.DID, } @@ -96,7 +103,7 @@ func TestChatMessage(t *testing.T) { msg2 := placestream.ChatMessage{ LexiconTypeID: "place.stream.chat.message", Text: "Hello, world 2!", - CreatedAt: time.Now().Format(util.ISO8601), + CreatedAt: now.In(newfoundland).Format(localLayout), Streamer: user.DID, } @@ -107,48 +114,51 @@ func TestChatMessage(t *testing.T) { }) require.NoError(t, err) + // Legacy clients still stamp UTC with a Z. + msg3 := placestream.ChatMessage{ + LexiconTypeID: "place.stream.chat.message", + Text: "Hello, world 3!", + CreatedAt: now.Add(-time.Second).UTC().Format(localLayout), + Streamer: user.DID, + } + + _, err = comatproto.RepoCreateRecord(ctx, user2.XRPC, &comatproto.RepoCreateRecord_Input{ + Collection: "place.stream.chat.message", + Repo: user2.DID, + Record: &glex.LexiconTypeDecoder{Val: &msg3}, + }) + require.NoError(t, err) + messages := []placestream.ChatDefs_MessageView{} err = untilNoErrors(t, func() error { messages, err = mod.MostRecentChatMessages(user.DID, time.Time{}) if err != nil { return err } - if len(messages) != 2 { - return fmt.Errorf("expected 2 messages, got %d", len(messages)) + if len(messages) != 3 { + return fmt.Errorf("expected 3 messages, got %d", len(messages)) } - if n := len(snapshotBus()); n != 2 { - return fmt.Errorf("expected 2 bus messages, got %d", n) + if n := len(snapshotBus()); n != 3 { + return fmt.Errorf("expected 3 bus messages, got %d", n) } return nil }) - // Reverse the messages slice to match expected order (most recent first) - slices.SortFunc(messages, func(a, b placestream.ChatDefs_MessageView) int { - aTime := a.Record.Val.(*placestream.ChatMessage).CreatedAt - bTime := b.Record.Val.(*placestream.ChatMessage).CreatedAt - if aTime < bTime { - return -1 - } else if aTime > bTime { - return 1 - } - return 0 - }) + require.NoError(t, err) + // History is newest first by instant. Live publication is asynchronous; + // sort only the bus snapshot to match, preserving the ordering assertion. busSnapshot := snapshotBus() slices.SortFunc(busSnapshot, func(a, b bus.Message) int { - aTime := a.(*placestream.ChatDefs_MessageView).Record.Val.(*placestream.ChatMessage).CreatedAt - bTime := b.(*placestream.ChatDefs_MessageView).Record.Val.(*placestream.ChatMessage).CreatedAt - if aTime < bTime { - return -1 - } else if aTime > bTime { - return 1 - } - return 0 + return chatInstant(t, b.(*placestream.ChatDefs_MessageView).Record.Val.(*placestream.ChatMessage)). + Compare(chatInstant(t, a.(*placestream.ChatDefs_MessageView).Record.Val.(*placestream.ChatMessage))) }) - require.Equal(t, msg.Text, messages[0].Record.Val.(*placestream.ChatMessage).Text) - require.Equal(t, msg2.Text, messages[1].Record.Val.(*placestream.ChatMessage).Text) - busMessage1 := busSnapshot[0].(*placestream.ChatDefs_MessageView) - busMessage2 := busSnapshot[1].(*placestream.ChatDefs_MessageView) - require.Equal(t, msg.Text, busMessage1.Record.Val.(*placestream.ChatMessage).Text) - require.Equal(t, msg2.Text, busMessage2.Record.Val.(*placestream.ChatMessage).Text) + for i, want := range []placestream.ChatMessage{msg2, msg3, msg} { + got := messages[i].Record.Val.(*placestream.ChatMessage) + require.Equal(t, want.Text, got.Text) + require.Equal(t, want.CreatedAt, got.CreatedAt) + busGot := busSnapshot[i].(*placestream.ChatDefs_MessageView).Record.Val.(*placestream.ChatMessage) + require.Equal(t, want.Text, busGot.Text) + require.Equal(t, want.CreatedAt, busGot.CreatedAt) + } rkey := strings.TrimPrefix(rec1.Uri, fmt.Sprintf("at://%s/place.stream.chat.message/", user.DID)) @@ -165,18 +175,20 @@ func TestChatMessage(t *testing.T) { if err != nil { return err } - if len(messages) != 1 { - return fmt.Errorf("expected 1 message, got %d", len(messages)) + if len(messages) != 2 { + return fmt.Errorf("expected 2 messages, got %d", len(messages)) } - if n := len(snapshotBus()); n != 3 { - return fmt.Errorf("expected 3 bus messages, got %d", n) + if n := len(snapshotBus()); n != 4 { + return fmt.Errorf("expected 4 bus messages, got %d", n) } return nil }) require.NoError(t, err) + // MostRecentChatMessages is newest first, by instant. require.Equal(t, msg2.Text, messages[0].Record.Val.(*placestream.ChatMessage).Text) - busMessage3 := snapshotBus()[2].(*placestream.ChatDefs_MessageView) - require.Equal(t, true, *busMessage3.Deleted) + require.Equal(t, msg3.Text, messages[1].Record.Val.(*placestream.ChatMessage).Text) + busMessage4 := snapshotBus()[3].(*placestream.ChatDefs_MessageView) + require.Equal(t, true, *busMessage4.Deleted) cancel() <-done @@ -197,3 +209,10 @@ func untilNoErrors(t *testing.T, f func() error) error { } return err } + +func chatInstant(t *testing.T, rec *placestream.ChatMessage) time.Time { + t.Helper() + instant, err := time.Parse(time.RFC3339Nano, rec.CreatedAt) + require.NoError(t, err) + return instant +} diff --git a/pkg/cmd/e2e.go b/pkg/cmd/e2e.go index ae08de804..501e07f6d 100644 --- a/pkg/cmd/e2e.go +++ b/pkg/cmd/e2e.go @@ -451,9 +451,10 @@ func runE2E(ctx context.Context, devEnvPath, httpsPDSHost, httpsStationHost stri // Print the env vars for the workflow to consume, in one write: callers // poll for SERVER_URL and then read the whole file. E2E_FIXTURE_MP4 is // the local file the ingest loop is streaming, which tests upload through - // the app to get themselves a playable VOD. - vars := fmt.Sprintf("SERVER_URL=http://%s\nACCOUNT_HANDLE=%s\nACCOUNT_DID=%s\nACCOUNT_PASSWORD=%s\nVIDEO_URI=%s\nE2E_FIXTURE_MP4=%s\n", - httpAddr, out.Handle, out.Did, password, videoURI, fixture) + // the app to get themselves a playable VOD. PDS_HTTP_URL is the PDS's own + // plain-HTTP listener, for host-side checks of the records it stores. + vars := fmt.Sprintf("SERVER_URL=http://%s\nACCOUNT_HANDLE=%s\nACCOUNT_DID=%s\nACCOUNT_PASSWORD=%s\nVIDEO_URI=%s\nE2E_FIXTURE_MP4=%s\nPDS_HTTP_URL=%s\n", + httpAddr, out.Handle, out.Did, password, videoURI, fixture, env.PDSURL) if tlsEnv != nil { // The same node over HTTPS at its public name, plus what clients // need to reach and trust it (see e2e_https.go): a browser pins the -- 2.54.0 From fefe34d54a4220fee7c0d75f8148778df1557adc Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sat, 17 Oct 2026 15:43:14 -0700 Subject: [PATCH 2/2] Use player readiness for portrait playback controls --- js/e2e-web/README.md | 4 +++- js/e2e-web/flows/chat-timezone.spec.ts | 2 ++ js/e2e-web/flows/stream.spec.ts | 5 ++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/js/e2e-web/README.md b/js/e2e-web/README.md index 965ffeb1f..2b8a2e3a9 100644 --- a/js/e2e-web/README.md +++ b/js/e2e-web/README.md @@ -62,7 +62,9 @@ image uploads are already limited to the web. `stream` also covers the narrow portrait web layout at 320 × 568, where the live video sits above chat. It reveals the player chrome, exercises mute and fullscreen entry/exit, and verifies that faded controls reveal instead of -accepting an unseen tap. +accepting an unseen tap. Its readiness check uses the video itself: accumulated +chat history can virtualize the stream's initial system message, so that row is +not a reliable player prerequisite. `player-breakpoints` exercises live playback at 23 widths in portrait and landscape, including 1080 × 722 and immediately below, at, and above the theme diff --git a/js/e2e-web/flows/chat-timezone.spec.ts b/js/e2e-web/flows/chat-timezone.spec.ts index 849d02203..fc8459a29 100644 --- a/js/e2e-web/flows/chat-timezone.spec.ts +++ b/js/e2e-web/flows/chat-timezone.spec.ts @@ -1,6 +1,8 @@ import { expect, test } from "@playwright/test"; import { loginThroughPds } from "./login"; +// Native counterpart: .maestro/logged-in/chat-timezone.yaml checks the +// sending device's offset against the actual record stored by the PDS. const HTTPS_URL = process.env.SERVER_HTTPS_URL; test.skip(!HTTPS_URL, "harness started without its HTTPS hostnames"); diff --git a/js/e2e-web/flows/stream.spec.ts b/js/e2e-web/flows/stream.spec.ts index e184cf7d2..293682b60 100644 --- a/js/e2e-web/flows/stream.spec.ts +++ b/js/e2e-web/flows/stream.spec.ts @@ -27,10 +27,9 @@ test("stream: portrait player exposes playback controls", async ({ page }) => { const card = page.getByTestId("home-stream-card").first(); await expect(card).toBeVisible({ timeout: 30_000 }); await card.click(); - await expect( - page.getByText("Now streaming - e2e test stream").first(), - ).toBeVisible({ timeout: 30_000 }); + // Chat may virtualize its initial system message once history fills up. + // Playback controls depend on the player being ready, not that chat row. const video = page.locator("video").first(); await expect(video).toBeVisible({ timeout: 30_000 }); const videoBox = await video.boundingBox();