Skip to content

Commit 976ea2f

Browse files
authored
Add test apps template and fireci commands to measure sdk startup times. (#2611)
1 parent 3f0779e commit 976ea2f

36 files changed

+1059
-13
lines changed

ci/fireci/fireci/dir_utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Copyright 2021 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import contextlib
16+
import logging
17+
import os
18+
19+
_logger = logging.getLogger('fireci.dir_utils')
20+
21+
22+
@contextlib.contextmanager
23+
def chdir(directory):
24+
"""Change working dir to `directory` and restore to original afterwards."""
25+
_logger.debug(f'Changing directory to: {directory} ...')
26+
original_dir = os.getcwd()
27+
os.chdir(directory)
28+
try:
29+
yield
30+
finally:
31+
_logger.debug(f'Restoring directory to: {original_dir} ...')
32+
os.chdir(original_dir)

ci/fireci/fireciplugins/fireperf.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from fireci import ci_command
2222
from fireci import gradle
23+
from fireci.dir_utils import chdir
2324

2425
_logger = logging.getLogger('fireci.fireperf')
2526

@@ -60,19 +61,6 @@ def fireperf_e2e_test(target_environment, plugin_repo_dir):
6061
gradle.run(*fireperf_e2e_test_gradle_command)
6162

6263

63-
@contextlib.contextmanager
64-
def chdir(directory):
65-
"""Change working dir to `directory` and restore to original afterwards."""
66-
_logger.debug(f'Changing directory to: {directory} ...')
67-
original_dir = os.getcwd()
68-
os.chdir(directory)
69-
try:
70-
yield
71-
finally:
72-
_logger.debug(f'Restoring directory to: {original_dir} ...')
73-
os.chdir(original_dir)
74-
75-
7664
def _find_fireperf_plugin_version():
7765
local_maven_repo_dir = pathlib.Path.home().joinpath('.m2', 'repository')
7866
artifacts_path = local_maven_repo_dir.joinpath('com', 'google', 'firebase', 'perf-plugin')
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# Copyright 2021 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import asyncio
16+
import glob
17+
import json
18+
import logging
19+
import os
20+
import random
21+
import shutil
22+
import sys
23+
24+
import click
25+
import pystache
26+
import yaml
27+
28+
from fireci import ci_command
29+
from fireci.dir_utils import chdir
30+
31+
_logger = logging.getLogger('fireci.macrobenchmark')
32+
33+
34+
@ci_command()
35+
def macrobenchmark():
36+
"""Measures app startup times for Firebase SDKs."""
37+
asyncio.run(_launch_macrobenchmark_test())
38+
39+
40+
async def _launch_macrobenchmark_test():
41+
_logger.info('Starting macrobenchmark test...')
42+
43+
artifact_versions, config, _ = await asyncio.gather(
44+
_parse_artifact_versions(),
45+
_parse_config_yaml(),
46+
_create_gradle_wrapper()
47+
)
48+
49+
with chdir('macrobenchmark'):
50+
runners = [MacrobenchmarkTest(k, v, artifact_versions) for k, v in config.items()]
51+
results = await asyncio.gather(*[x.run() for x in runners], return_exceptions=True)
52+
53+
if any(map(lambda x: isinstance(x, Exception), results)):
54+
_logger.error(f'Exceptions: {[x for x in results if (isinstance(x, Exception))]}')
55+
raise click.ClickException('Macrobenchmark test failed with above errors.')
56+
57+
_logger.info('Macrobenchmark test finished.')
58+
59+
60+
async def _parse_artifact_versions():
61+
proc = await asyncio.subprocess.create_subprocess_exec('./gradlew', 'assembleAllForSmokeTests')
62+
await proc.wait()
63+
64+
with open('build/m2repository/changed-artifacts.json') as json_file:
65+
artifacts = json.load(json_file)
66+
return dict(_artifact_key_version(x) for x in artifacts['headGit'])
67+
68+
69+
def _artifact_key_version(artifact):
70+
group_id, artifact_id, version = artifact.split(':')
71+
return f'{group_id}:{artifact_id}', version
72+
73+
74+
async def _parse_config_yaml():
75+
with open('macrobenchmark/config.yaml') as yaml_file:
76+
return yaml.safe_load(yaml_file)
77+
78+
79+
async def _create_gradle_wrapper():
80+
with open('macrobenchmark/settings.gradle', 'w'):
81+
pass
82+
83+
proc = await asyncio.subprocess.create_subprocess_exec(
84+
'./gradlew',
85+
'wrapper',
86+
'--gradle-version',
87+
'7.0',
88+
'--project-dir',
89+
'macrobenchmark'
90+
)
91+
await proc.wait()
92+
93+
94+
class MacrobenchmarkTest:
95+
"""Builds the test based on configurations and runs the test on FTL."""
96+
def __init__(
97+
self,
98+
sdk_name,
99+
test_app_config,
100+
artifact_versions,
101+
logger=_logger
102+
):
103+
self.sdk_name = sdk_name
104+
self.test_app_config = test_app_config
105+
self.artifact_versions = artifact_versions
106+
self.logger = MacrobenchmarkLoggerAdapter(logger, sdk_name)
107+
self.test_app_dir = os.path.join('test-apps', test_app_config['name'])
108+
109+
async def run(self):
110+
"""Starts the workflow of src creation, apks assembling, and FTL testing in order."""
111+
await self._create_test_src()
112+
await self._assemble_apks()
113+
await self._upload_apks_to_ftl()
114+
115+
async def _create_test_src(self):
116+
app_name = self.test_app_config['name']
117+
app_id = self.test_app_config['application-id']
118+
self.logger.info(f'Creating test app "{app_name}" with application-id "{app_id}"...')
119+
120+
mustache_context = {
121+
'application-id': app_id,
122+
'plugins': self.test_app_config['plugins'] if 'plugins' in self.test_app_config else [],
123+
'dependencies': [
124+
{
125+
'key': x,
126+
'version': self.artifact_versions[x]
127+
} for x in self.test_app_config['dependencies']
128+
] if 'dependencies' in self.test_app_config else [],
129+
}
130+
131+
if app_name != 'baseline':
132+
mustache_context['plugins'].append('com.google.gms.google-services')
133+
134+
shutil.copytree('template', self.test_app_dir)
135+
with chdir(self.test_app_dir):
136+
renderer = pystache.Renderer()
137+
mustaches = glob.glob('**/*.mustache', recursive=True)
138+
for mustache in mustaches:
139+
result = renderer.render_path(mustache, mustache_context)
140+
original_name = mustache[:-9] # TODO(yifany): mustache.removesuffix('.mustache')
141+
with open(original_name, 'w') as file:
142+
file.write(result)
143+
144+
async def _assemble_apks(self):
145+
executable = './gradlew'
146+
args = ['assemble', 'assembleAndroidTest', '--project-dir', self.test_app_dir]
147+
await self._exec_subprocess(executable, args)
148+
149+
async def _upload_apks_to_ftl(self):
150+
app_apk_path = glob.glob(f'{self.test_app_dir}/app/**/*.apk', recursive=True)[0]
151+
test_apk_path = glob.glob(f'{self.test_app_dir}/benchmark/**/*.apk', recursive=True)[0]
152+
153+
self.logger.info(f'App apk: {app_apk_path}')
154+
self.logger.info(f'Test apk: {test_apk_path}')
155+
156+
ftl_environment_variables = [
157+
'clearPackageData=true',
158+
'additionalTestOutputDir=/sdcard/Download',
159+
'no-isolated-storage=true',
160+
]
161+
executable = 'gcloud'
162+
args = ['firebase', 'test', 'android', 'run']
163+
args += ['--type', 'instrumentation']
164+
args += ['--app', app_apk_path]
165+
args += ['--test', test_apk_path]
166+
args += ['--device', 'model=flame,version=30,locale=en,orientation=portrait']
167+
args += ['--directories-to-pull', '/sdcard/Download']
168+
args += ['--results-bucket', 'gs://fireescape-macrobenchmark']
169+
args += ['--environment-variables', ','.join(ftl_environment_variables)]
170+
args += ['--timeout', '30m']
171+
args += ['--project', 'fireescape-c4819']
172+
173+
await self._exec_subprocess(executable, args)
174+
175+
async def _exec_subprocess(self, executable, args):
176+
command = " ".join([executable, *args])
177+
self.logger.info(f'Executing command: "{command}"...')
178+
179+
proc = await asyncio.subprocess.create_subprocess_exec(
180+
executable,
181+
*args,
182+
stdout=asyncio.subprocess.PIPE,
183+
stderr=asyncio.subprocess.PIPE
184+
)
185+
await asyncio.gather(
186+
self._stream_output(executable, proc.stdout),
187+
self._stream_output(executable, proc.stderr)
188+
)
189+
190+
await proc.communicate()
191+
if proc.returncode == 0:
192+
self.logger.info(f'"{command}" finished.')
193+
else:
194+
message = f'"{command}" exited with return code {proc.returncode}.'
195+
self.logger.error(message)
196+
raise click.ClickException(message)
197+
198+
async def _stream_output(self, executable, stream: asyncio.StreamReader):
199+
async for line in stream:
200+
self.logger.info(f'[{executable}] {line.decode("utf-8").strip()}')
201+
202+
203+
class MacrobenchmarkLoggerAdapter(logging.LoggerAdapter):
204+
"""Decorates log messages for a sdk to make them more distinguishable."""
205+
206+
reset_code = '\x1b[m'
207+
208+
@staticmethod
209+
def random_color_code():
210+
code = random.randint(16, 231) # https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit
211+
return f'\x1b[38;5;{code}m'
212+
213+
def __init__(self, logger, sdk_name, color_code=None):
214+
super().__init__(logger, {})
215+
self.sdk_name = sdk_name
216+
self.color_code = self.random_color_code() if color_code is None else color_code
217+
218+
def process(self, msg, kwargs):
219+
colored = f'{self.color_code}[{self.sdk_name}]{self.reset_code} {msg}'
220+
uncolored = f'[{self.sdk_name}] {msg}'
221+
return colored if sys.stderr.isatty() else uncolored, kwargs

ci/fireci/setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
install_requires=[
2828
'click==7.0',
2929
'PyGithub==1.43.8',
30+
'pystache==0.5.4',
3031
'requests==2.23.0',
32+
'PyYAML==5.4.1',
3133
],
3234
packages=find_packages(exclude=['tests']),
3335
entry_points={

macrobenchmark/config.yaml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
baseline:
2+
name: baseline
3+
application-id: com.google.firebase.benchmark.baseline
4+
5+
firebase-config:
6+
name: config
7+
application-id: com.google.firebase.benchmark.config
8+
dependencies:
9+
- com.google.firebase:firebase-config-ktx
10+
11+
firebase-crashlytics:
12+
name: crash
13+
application-id: com.google.firebase.benchmark.crash
14+
dependencies:
15+
- com.google.firebase:firebase-crashlytics-ktx
16+
plugins:
17+
- com.google.firebase.crashlytics
18+
19+
firebase-database:
20+
name: database
21+
application-id: com.google.firebase.benchmark.database
22+
dependencies:
23+
- com.google.firebase:firebase-database-ktx
24+
25+
firebase-dynamic-links:
26+
name: dynamiclinks
27+
application-id: com.google.firebase.benchmark.dynamiclinks
28+
dependencies:
29+
- com.google.firebase:firebase-dynamic-links-ktx
30+
31+
firebase-firestore:
32+
name: firestore
33+
application-id: com.google.firebase.benchmark.firestore
34+
dependencies:
35+
- com.google.firebase:firebase-firestore-ktx
36+
37+
firebase-functions:
38+
name: functions
39+
application-id: com.google.firebase.benchmark.functions
40+
dependencies:
41+
- com.google.firebase:firebase-functions-ktx
42+
43+
firebase-inappmessaging-display:
44+
name: inappmessaging
45+
application-id: com.google.firebase.benchmark.inappmessaging
46+
dependencies:
47+
- com.google.firebase:firebase-inappmessaging-ktx
48+
- com.google.firebase:firebase-inappmessaging-display-ktx
49+
50+
firebase-messaging:
51+
name: messaging
52+
application-id: com.google.firebase.benchmark.messaging
53+
dependencies:
54+
- com.google.firebase:firebase-messaging-ktx
55+
56+
firebase-perf:
57+
name: perf
58+
application-id: com.google.firebase.benchmark.perf
59+
dependencies:
60+
- com.google.firebase:firebase-perf-ktx
61+
plugins:
62+
- com.google.firebase.firebase-perf
63+
64+
firebase-storage:
65+
name: storage
66+
application-id: com.google.firebase.benchmark.storage
67+
dependencies:
68+
- com.google.firebase:firebase-storage-ktx
69+
70+
71+
72+
# TODO(yifany): google3 sdks, customizing FTL devices
73+
# auth
74+
# analytics
75+
# combined
76+
# - crashlytics + analytics
77+
# - crashlytics + fireperf
78+
# - auth + firestore
79+
# - ...

0 commit comments

Comments
 (0)