Skip to content

Commit 235673d

Browse files
committed
Pass -PsnapshotFile for parallel tests
1 parent e11f329 commit 235673d

6 files changed

Lines changed: 166 additions & 48 deletions

File tree

‎scripts/check-test-tools.sh‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
# and device serial are replaced. Report notes when connection to the device was lost,
1010
# which fails tests regardless of code.
1111
#
12-
# Usage: scripts/check-test-tools.sh [-y] [checks] [benchmark] [minsdk]
13-
# Without steps runs all of them, -y skips confirmation.
12+
# Usage: scripts/check-test-tools.sh [-y] [-PsnapshotFile=<path>] [checks] [benchmark] [minsdk]
13+
# Without steps runs all of them, -y skips confirmation. -PsnapshotFile is the snapshot of accounts
14+
# of the device, see "Session snapshot" in tests/README.md: without it, tests with accounts are
15+
# skipped.
1416

1517
# Keeps computer awake during long builds and tests
1618
if [ -z "$TGX_AWAKE" ]; then
@@ -33,9 +35,11 @@ fail() {
3335

3436
ASSUME_YES=false
3537
STEPS=""
38+
SNAPSHOT_FILE=""
3639
for arg in "$@"; do
3740
case $arg in
3841
-y) ASSUME_YES=true ;;
42+
-PsnapshotFile=*) SNAPSHOT_FILE=${arg#-PsnapshotFile=} ;;
3943
checks|benchmark|minsdk) STEPS="${STEPS:+$STEPS }$arg" ;;
4044
*) fail "Unknown argument: $arg. Steps: checks, benchmark, minsdk" ;;
4145
esac
@@ -71,7 +75,8 @@ anonymize() {
7175
}
7276

7377
gradle() {
74-
./gradlew --console=plain --continue -Ptgx.testTools -Ptgx.checks=false "$@"
78+
./gradlew --console=plain --continue -Ptgx.testTools -Ptgx.checks=false \
79+
${SNAPSHOT_FILE:+"-PsnapshotFile=$SNAPSHOT_FILE"} "$@"
7580
}
7681

7782
run() {
@@ -237,7 +242,7 @@ cleanup() {
237242
FLAVOR=Latest
238243
ABI=arm64
239244
HAS_SNAPSHOT=false
240-
if [ -f tests/core/src/main/assets/snapshot.zip ]; then
245+
if [ -n "$SNAPSHOT_FILE" ]; then
241246
HAS_SNAPSHOT=true
242247
fi
243248
NEEDS_DEVICE=false
@@ -316,7 +321,7 @@ fi
316321
echo "Steps: $STEPS"
317322
if $NEEDS_DEVICE; then
318323
echo "Device: $MODEL, Android $RELEASE (API $SDK_INT), $ABI, flavor $FLAVOR," \
319-
"app.id $APP_ID, snapshot.zip: $HAS_SNAPSHOT"
324+
"app.id $APP_ID, snapshot: $HAS_SNAPSHOT"
320325
echo -e "${STYLE_WARN}The script:
321326
- installs builds of $APP_ID and test apps on $MODEL, clears their data
322327
and removes them after tests, also when the script is interrupted;
@@ -365,7 +370,7 @@ trap 'exit 130' INT TERM
365370
)"
366371
if $NEEDS_DEVICE; then
367372
echo "- Device: $MODEL, Android $RELEASE (API $SDK_INT), $ABI, flavor $FLAVOR," \
368-
"snapshot.zip: $HAS_SNAPSHOT"
373+
"snapshot: $HAS_SNAPSHOT"
369374
echo "- Animation scales before tests: $(animation_scales)"
370375
fi
371376
} > "$OUT/header.md"

‎tests/README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -556,13 +556,17 @@ Tests on device replace installed app and clear its data, so they must never run
556556

557557
## Test accounts
558558

559-
* Tests and checks run on production servers of Telegram with real test accounts. Messages can be sent only to **Saved Messages** or to the users authorized in `snapshot.zip`, and groups and channels created by them can include only these users. Their ids are returned by `getSnapshotUserIds()`: when there is only one, messages are sent only to **Saved Messages**.
559+
* Tests and checks run on production servers of Telegram with real test accounts. Messages can be sent only to **Saved Messages** or to the users authorized in the [snapshot](#session-snapshot), and groups and channels created by them can include only these users. Their ids are returned by `getSnapshotUserIds()`: when there is only one, messages are sent only to **Saved Messages**.
560560
* Be careful with TDLib requests that load data over network: don't send them in loops or more often than a user could. Requests answered locally are not limited.
561561

562562
### Session snapshot
563563

564-
Authorized tests restore sessions from `tests/core/src/main/assets/snapshot.zip`. It is ignored by git and must never be committed: it gives access to the accounts. Use dedicated test accounts. Snapshot includes all accounts added to the app, so several accounts can be authorized to test interaction between them: messages, groups, channels, notifications.
564+
Authorized tests restore sessions from a snapshot of accounts, a zip file outside the clone that the build takes by its full path: `-PsnapshotFile=<path>`, e.g. `./gradlew :tests:ui:connectedLatestNonMinifiedReleaseAndroidTest -PsnapshotFile=$HOME/tgx-snapshots/pixel.zip`. Without it, authorized tests fail. The snapshot gives access to the accounts: keep it out of repositories and don't make copies of it. Use dedicated test accounts. Snapshot includes all accounts added to the app, so several accounts can be authorized to test interaction between them: messages, groups, channels, notifications.
565565

566-
Sessions from the snapshot must not be used by several apps at once, otherwise Telegram may terminate them as leaked. That's why build runs tests on device one at a time and only on one device, and fails when another Gradle build on the machine is already running tests with the same accounts. Builds with snapshots of other accounts can run tests at the same time.
566+
The build adds the snapshot to assets of test APKs by a link and keeps their assets out of the build cache, so that the sessions stay only in the snapshot and in the build folders of the clone. `tests/core/src/main/assets/snapshot.zip`, where the snapshot was kept before, is no longer read: a build that finds it there fails.
567567

568-
To create the snapshot, open **Saved Messages** of the test account → attach menu → **Files** → **Session Snapshot** (available in builds with test hooks enabled or in developer mode). The snapshot is sent to the chat: download it and save as `tests/core/src/main/assets/snapshot.zip`. Then clear data of the app where the snapshot was created, so it stops using the same sessions. Don't log out instead: it terminates sessions in the snapshot too.
568+
Sessions from the snapshot must not be used by several apps at once, otherwise Telegram may terminate them as leaked. Each device can have its own snapshot of the same accounts: a snapshot made in another app has other sessions, so devices run tests at the same time, each with its own snapshot. A build runs tests on one device only, and waits while another Gradle build on the machine runs tests on the same device or with the same snapshot, found by its path.
569+
570+
A script that runs builds one at a time on the machine can let others build while tests run on a device: when it holds a lock of builds, a folder, it names it by `TGX_BUILD_LOCK` and its process by `TGX_BUILD_LOCK_PID`, written into `pid` in that folder. Tests on a device build nothing, so they remove the folder when they start, if it still has that `pid`.
571+
572+
To create the snapshot, open **Saved Messages** of the test account → attach menu → **Files** → **Session Snapshot** (available in builds with test hooks enabled or in developer mode). The snapshot is sent to the chat: download it and save it outside the clone. Then clear data of the app where the snapshot was created, so it stops using the same sessions. Don't log out instead: it terminates sessions in the snapshot too.

‎tests/buildSrc/kotlin/tgx/test/AppTests.kt‎

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import com.android.build.api.variant.AndroidComponentsExtension
1010
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
1111
import com.android.build.api.variant.TestAndroidComponentsExtension
1212
import org.gradle.api.Project
13+
import org.gradle.api.file.RegularFile
14+
import org.gradle.api.provider.Provider
1315
import org.gradle.kotlin.dsl.configure
1416
import org.gradle.kotlin.dsl.get
1517
import org.gradle.kotlin.dsl.of
1618
import org.gradle.kotlin.dsl.register
19+
import tgx.gradle.fatal
1720
import tgx.gradle.source.KeystoreSource
1821
import java.io.File
1922
import java.util.Properties
@@ -25,7 +28,21 @@ private const val TEST_MIN_SDK_VERSION = 24
2528

2629
const val TEST_DEVICE = "emulator"
2730

28-
internal const val SNAPSHOT_PATH = "tests/core/src/main/assets/snapshot.zip"
31+
// Where the snapshot of accounts was kept before -PsnapshotFile, inside the clone
32+
private const val OLD_SNAPSHOT_PATH = "tests/core/src/main/assets/snapshot.zip"
33+
34+
// Snapshot of accounts that tests start with, outside the clone: -PsnapshotFile=<full path>,
35+
// see "Session snapshot" in tests/README.md
36+
internal val Project.snapshotFile: Provider<RegularFile>
37+
get() = layout.file(
38+
providers.gradleProperty("snapshotFile").map { path ->
39+
File(path).also {
40+
if (!it.isAbsolute || !it.isFile) {
41+
fatal("-PsnapshotFile=$path isn't the full path of a snapshot")
42+
}
43+
}
44+
}
45+
)
2946

3047
// -Ptgx.checks=false builds tests without checks of a change, e.g. to measure a commit before it,
3148
// where they don't compile
@@ -122,11 +139,20 @@ fun Project.configureAppTests(
122139
inAppProcess: Boolean = false,
123140
testedVariants: (SdkVariant) -> Boolean = { it.supportsSdk(TEST_MIN_SDK_VERSION) }
124141
) {
125-
val snapshot = isolated.rootProject.projectDirectory.file(SNAPSHOT_PATH)
142+
if (isolated.rootProject.projectDirectory.file(OLD_SNAPSHOT_PATH).asFile.exists()) {
143+
fatal(
144+
"$OLD_SNAPSHOT_PATH is no longer read: move it out of the clone " +
145+
"and pass its full path by -PsnapshotFile"
146+
)
147+
}
148+
val snapshot = snapshotFile
126149
val snapshotUserIds = providers.fileContents(snapshot).asBytes
127150
.map { readSnapshotUserIds(it.inputStream()) }
128151
.orNull
129152
.orEmpty()
153+
val snapshotAsset = tasks.register<SnapshotAssetTask>("linkSnapshotAsset") {
154+
this.snapshot.set(snapshot)
155+
}
130156
val appKeystore = if (inAppProcess) findAppKeystore() else null
131157

132158
extensions.configure<TestExtension> {
@@ -202,8 +228,20 @@ fun Project.configureAppTests(
202228
snapshotUserIds.joinToString(",")
203229
)
204230
}
231+
if (snapshot.isPresent) {
232+
variant.sources.assets?.addGeneratedSourceDirectory(
233+
snapshotAsset,
234+
SnapshotAssetTask::outputDirectory
235+
)
236+
}
205237
}
206238
}
239+
// The build cache would keep copies of the snapshot outside the clone
240+
tasks
241+
.named { (it.startsWith("merge") || it.startsWith("compress")) && it.endsWith("Assets") }
242+
.configureEach {
243+
outputs.doNotCacheIf("Assets of tests have the snapshot of accounts") { true }
244+
}
207245

208246
tasks.register<DescribeHeapProfilesTask>("describeHeapProfiles") {
209247
mustRunAfter(tasks.named { it.startsWith("connected") && it.endsWith("AndroidTest") })
@@ -244,8 +282,8 @@ private fun Project.findAppKeystore(): Keystore? {
244282
}
245283

246284
/**
247-
* Tests of different modules install the same app,
248-
* and accounts from snapshot.zip must not be used by several apps at once
285+
* Tests of different modules install the same app, a phone runs tests of one build at a time,
286+
* and sessions of a snapshot must not be used by several apps at once
249287
*/
250288
@Suppress("UnstableApiUsage")
251289
private fun Project.runTestsOnDeviceOneAtATime() {
@@ -254,7 +292,7 @@ private fun Project.runTestsOnDeviceOneAtATime() {
254292
SnapshotLockService::class.java
255293
) {
256294
maxParallelUsages.set(1)
257-
parameters.snapshot.set(isolated.rootProject.projectDirectory.file(SNAPSHOT_PATH))
295+
parameters.snapshot.set(snapshotFile.orNull)
258296
parameters.locksDirectory.set(File(gradle.gradleUserHomeDir, "tgx-test-accounts"))
259297
}
260298
val adb = extensions.getByType(AndroidComponentsExtension::class.java).sdkComponents.adb
@@ -274,10 +312,10 @@ private fun Project.runTestsOnDeviceOneAtATime() {
274312
deviceTests.configureEach {
275313
usesService(snapshotLock)
276314
doFirst {
277-
snapshotLock.get().lock()
278-
if (name.startsWith("connected")) {
279-
requireSingleDevice(adb.get().asFile)
280-
}
315+
// Tests on a phone build nothing, so other builds go on while they run
316+
releaseBuildLock()
317+
val serial = if (name.startsWith("connected")) requireSingleDevice(adb.get().asFile) else null
318+
snapshotLock.get().lock(serial)
281319
}
282320
}
283321
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package tgx.test
2+
3+
import org.gradle.api.DefaultTask
4+
import org.gradle.api.file.DirectoryProperty
5+
import org.gradle.api.file.RegularFileProperty
6+
import org.gradle.api.tasks.InputFile
7+
import org.gradle.api.tasks.OutputDirectory
8+
import org.gradle.api.tasks.PathSensitive
9+
import org.gradle.api.tasks.PathSensitivity
10+
import org.gradle.api.tasks.TaskAction
11+
import org.gradle.work.DisableCachingByDefault
12+
import java.nio.file.Files
13+
14+
// Name of the snapshot in assets of tests, see SnapshotApplier
15+
private const val SNAPSHOT_ASSET = "snapshot.zip"
16+
17+
/**
18+
* Adds the snapshot of -PsnapshotFile to assets of tests by a link, not a copy, so that the
19+
* sessions stay in one file outside the clone until assets are packaged
20+
*/
21+
@DisableCachingByDefault(because = "The build cache would keep the snapshot outside the clone")
22+
abstract class SnapshotAssetTask : DefaultTask() {
23+
@get:InputFile
24+
@get:PathSensitive(PathSensitivity.NONE)
25+
abstract val snapshot: RegularFileProperty
26+
27+
@get:OutputDirectory
28+
abstract val outputDirectory: DirectoryProperty
29+
30+
@TaskAction
31+
fun link() {
32+
val directory = outputDirectory.get().asFile
33+
directory.deleteRecursively()
34+
directory.mkdirs()
35+
Files.createSymbolicLink(
36+
directory.resolve(SNAPSHOT_ASSET).toPath(),
37+
snapshot.get().asFile.toPath()
38+
)
39+
}
40+
}

‎tests/buildSrc/kotlin/tgx/test/SnapshotLockService.kt‎

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tgx.test
22

33
import org.gradle.api.file.DirectoryProperty
44
import org.gradle.api.file.RegularFileProperty
5+
import org.gradle.api.logging.Logging
56
import org.gradle.api.services.BuildService
67
import org.gradle.api.services.BuildServiceParameters
78
import tgx.gradle.fatal
@@ -10,13 +11,18 @@ import java.io.File
1011
import java.io.InputStream
1112
import java.nio.channels.FileChannel
1213
import java.nio.file.StandardOpenOption
14+
import java.security.MessageDigest
1315
import java.util.zip.ZipInputStream
1416

1517
private const val SNAPSHOT_REUSE_ERROR =
16-
"Tests use accounts from snapshot.zip, which must not be used by several apps at once, " +
17-
"otherwise Telegram may terminate their sessions as leaked."
18+
"Tests use sessions of the snapshot of -PsnapshotFile, which must not be used by several apps " +
19+
"at once, otherwise Telegram may terminate them as leaked."
1820

19-
/** Builds keep locks of accounts until they finish */
21+
/**
22+
* Builds keep locks of their phone and of the sessions of their snapshot until they finish.
23+
* A build waits while another one runs tests on the same phone or with the same snapshot:
24+
* snapshots of one account in other sessions run at the same time
25+
*/
2026
abstract class SnapshotLockService : BuildService<SnapshotLockService.Parameters>, AutoCloseable {
2127
interface Parameters : BuildServiceParameters {
2228
val snapshot: RegularFileProperty
@@ -27,34 +33,38 @@ abstract class SnapshotLockService : BuildService<SnapshotLockService.Parameters
2733
private var isLocked = false
2834

2935
@Synchronized
30-
fun lock() {
36+
fun lock(serial: String?) {
37+
// serial is the phone of connected tests, null for a managed device
3138
if (isLocked) {
3239
return
3340
}
34-
val snapshot = parameters.snapshot.get().asFile
35-
val userIds = if (snapshot.exists()) {
36-
snapshot.inputStream().use(::readSnapshotUserIds)
37-
} else {
38-
emptyList()
39-
}
4041
val directory = parameters.locksDirectory.get().asFile
4142
directory.mkdirs()
42-
for (userId in userIds) {
43-
val channel = FileChannel.open(
44-
File(directory, "$userId.lock").toPath(),
45-
StandardOpenOption.CREATE,
46-
StandardOpenOption.WRITE
47-
)
48-
if (channel.tryLock() == null) {
49-
channel.close()
50-
close()
51-
fatal("Another Gradle build is running tests with account $userId. $SNAPSHOT_REUSE_ERROR")
52-
}
53-
channels += channel
43+
// The phone first, so that a build waiting for a snapshot keeps its phone, not another one
44+
if (serial != null) {
45+
hold(File(directory, "phone-$serial.lock"), "phone $serial")
46+
}
47+
parameters.snapshot.orNull?.asFile?.let { snapshot ->
48+
// A session is never copied, so the path of its snapshot tells it
49+
val digest =
50+
MessageDigest.getInstance("SHA-256").digest(snapshot.canonicalPath.toByteArray())
51+
val name = digest.take(8).joinToString("") { "%02x".format(it) }
52+
hold(File(directory, "session-$name.lock"), "sessions of ${snapshot.path}")
5453
}
5554
isLocked = true
5655
}
5756

57+
private fun hold(file: File, what: String) {
58+
val channel =
59+
FileChannel.open(file.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)
60+
if (channel.tryLock() == null) {
61+
Logging.getLogger(SnapshotLockService::class.java)
62+
.lifecycle("Waiting for another Gradle build that runs tests with $what")
63+
channel.lock()
64+
}
65+
channels += channel
66+
}
67+
5868
override fun close() {
5969
// Closing channels releases their locks
6070
channels.forEach {
@@ -64,6 +74,17 @@ abstract class SnapshotLockService : BuildService<SnapshotLockService.Parameters
6474
}
6575
}
6676

77+
internal fun releaseBuildLock() {
78+
// Gives back the lock of builds of a script that runs builds one at a time, see TGX_BUILD_LOCK in
79+
// tests/README.md, unless another script took it again
80+
val directory = System.getenv("TGX_BUILD_LOCK")?.let(::File) ?: return
81+
val pid = System.getenv("TGX_BUILD_LOCK_PID") ?: return
82+
val owner = File(directory, "pid").takeIf { it.isFile }?.readText()?.trim()
83+
if (owner == pid) {
84+
directory.deleteRecursively()
85+
}
86+
}
87+
6788
// See TdlibAccount.FLAG_UNAUTHORIZED
6889
private const val ACCOUNT_FLAG_UNAUTHORIZED = 1
6990

@@ -79,7 +100,7 @@ internal fun readSnapshotUserIds(snapshot: InputStream): List<Long> {
79100
it.name == "tdlib_accounts.bin"
80101
}
81102
if (!hasAccounts) {
82-
fatal("tdlib_accounts.bin is missing in snapshot.zip")
103+
fatal("tdlib_accounts.bin is missing in the snapshot")
83104
}
84105
val input = DataInputStream(zip)
85106
val accountCount = input.readInt()
@@ -95,7 +116,8 @@ internal fun readSnapshotUserIds(snapshot: InputStream): List<Long> {
95116
}
96117
}
97118

98-
internal fun requireSingleDevice(adb: File) {
119+
internal fun requireSingleDevice(adb: File): String? {
120+
// Returns the serial of the device that connected tests run on, null when none is connected
99121
val selectedDevices = System.getenv("ANDROID_SERIAL")
100122
?.split(',')
101123
?.filter { it.isNotBlank() }
@@ -114,4 +136,5 @@ internal fun requireSingleDevice(adb: File) {
114136
"$SNAPSHOT_REUSE_ERROR Select one device with ANDROID_SERIAL."
115137
)
116138
}
139+
return devices.singleOrNull()
117140
}

0 commit comments

Comments
 (0)