Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/chainguard/self.precomputed-fixtures.sts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
issuer: https://token.actions.githubusercontent.com
subject: repo:DataDog/dd-sdk-reactnative:ref:refs/heads/develop
claim_pattern:
event_name: "(schedule|workflow_dispatch)"
workflow_ref: 'DataDog/dd-sdk-reactnative/\.github/workflows/precomputed-fixtures\.yml@refs/heads/develop'
repository: "DataDog/dd-sdk-reactnative"
permissions:
contents: write
pull_requests: write
86 changes: 86 additions & 0 deletions .github/workflows/precomputed-fixtures.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Precomputed flag fixtures
on:
pull_request:
paths:
- 'ci/*precomputed*'
- 'packages/core/src/flags/__tests__/fixtures/precomputed/**'
- '.github/workflows/precomputed-fixtures.yml'
schedule:
- cron: '23 8 * * 1'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: precomputed-fixtures-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Verify the local snapshot
run: python3 ci/sync-precomputed-fixtures.py --verify
- name: Compare with the recorded upstream commit
run: python3 ci/sync-precomputed-fixtures.py --check
update:
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write
steps:
- uses: DataDog/dd-octo-sts-action@c85802a58e202b7e764b5441e4378c76043c8108 # v1.0.5
id: token
with:
scope: DataDog/dd-sdk-reactnative
policy: self.precomputed-fixtures
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Import upstream fixtures
id: sync
run: python3 ci/sync-precomputed-fixtures.py --ref main
- name: Prepare a draft update
id: prepare
if: steps.sync.outputs.changed == 'true'
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
SOURCE_COMMIT: ${{ steps.sync.outputs.source_commit }}
run: |
existing=$(gh pr list --state open --search '"Update precomputed flag fixtures" in:title' --json number --jq 'length')
if [ "$existing" != 0 ]; then
echo 'An update PR is already open. Merge or close it before the next update.'
exit 0
fi
branch="automation/precomputed-fixtures-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
git add -- 'packages/core/src/flags/__tests__/fixtures/precomputed'
echo "branch=$branch" >> "$GITHUB_OUTPUT"
echo 'ready=true' >> "$GITHUB_OUTPUT"
python3 - <<'BODY'
import os
from pathlib import Path
template = Path('ci/precomputed-fixtures-pr.md').read_text()
Path('/tmp/precomputed-pr.md').write_text(template.replace('SOURCE_COMMIT', os.environ['SOURCE_COMMIT']))
BODY
- name: Create a signed fixture commit
if: steps.prepare.outputs.ready == 'true'
uses: DataDog/commit-headless@d6afabfd690ea1dbdd7d368acbefd71304928699 # action/v3.4.1
with:
command: commit
token: ${{ steps.token.outputs.token }}
branch: ${{ steps.prepare.outputs.branch }}
head-sha: ${{ github.sha }}
create-branch: true
force: false
message: 'FFL-3568: Update precomputed flag fixtures'
- name: Open the draft pull request
if: steps.prepare.outputs.ready == 'true'
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
UPDATE_BRANCH: ${{ steps.prepare.outputs.branch }}
run: |
gh pr create --draft --base develop --head "$UPDATE_BRANCH" --title 'FFL-3568: Update precomputed flag fixtures' --body-file /tmp/precomputed-pr.md
18 changes: 18 additions & 0 deletions ci/precomputed-fixtures-pr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
### What does this PR do?

Refresh the shared precomputed response fixtures to `SOURCE_COMMIT`.

### Motivation

Keep mobile response tests aligned with the canonical JSON contract. Ref: FFL-3568.

### Additional Notes

The existing fixture loop runs new cases in normal SDK CI. Review those results before merge.

### Review checklist (to be filled by reviewers)

- [ ] Feature or bugfix MUST have appropriate tests
- [ ] Make sure you discussed the feature or bugfix with the maintaining team in an Issue
- [ ] Make sure each commit and the PR mention the Issue number (cf the [CONTRIBUTING](CONTRIBUTING.md) doc)
- [ ] If this PR is auto-generated, please make sure also to manually update the code related to the change
3 changes: 3 additions & 0 deletions ci/precomputed-fixtures.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"destination": "packages/core/src/flags/__tests__/fixtures/precomputed"
}
178 changes: 178 additions & 0 deletions ci/sync-precomputed-fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
# Unless explicitly stated otherwise, all files in this repository are licensed under the
# Apache License Version 2.0. This product includes software developed at Datadog
# (https://www.datadoghq.com/). Copyright 2025-Present Datadog, Inc.
"""Import a reproducible precompute test snapshot; never edit imported JSON."""
import argparse
import hashlib
import json
import os
import re
import subprocess
import tempfile
import uuid
from pathlib import Path

REPOSITORY = 'https://github.com/DataDog/ffe-system-test-data.git'
PREFIX = 'precomputed-assignments/'
FILES = {
'schemas/precomputed-assignment.schema.json': 'schema.json',
'precomputed-assignments/README.md': 'README.md',
'LICENSE': 'LICENSE',
'NOTICE': 'NOTICE',
}


def git(source, *args):
return subprocess.check_output(['git', '-C', str(source), *args])


def snapshot(source, ref):
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/-]*', ref) or '..' in ref:
raise ValueError('Invalid fixture revision')
sha = git(source, 'rev-parse', '--verify', ref + '^{commit}').decode().strip()
if not re.fullmatch(r'[0-9a-f]{40}', sha):
raise ValueError('Expected a full source commit SHA')
result = {}
for entry in git(source, 'ls-tree', '-rz', '--full-tree', sha).split(b'\0'):
if not entry:
continue
metadata, raw_path = entry.split(b'\t', 1)
path = raw_path.decode()
target = FILES.get(path)
if path.startswith(PREFIX + 'cases/') and path.endswith('.json'):
target = path[len(PREFIX):]
if target is None:
continue
mode, kind, blob = metadata.decode().split()
if mode != '100644' or kind != 'blob':
raise ValueError('Fixture entries must be ordinary files: ' + path)
result[target] = git(source, 'cat-file', 'blob', blob)
cases = sorted(p for p in result if p.startswith('cases/'))
if not cases or not all(p in result for p in FILES.values()):
raise ValueError('Missing or empty precomputed fixture corpus')
for path in cases:
data = json.loads(result[path])
if not isinstance(data, dict) or data.get('name') != Path(path).stem:
raise ValueError('Invalid fixture identity: ' + path)
if not isinstance(data.get('evaluations'), list):
raise TypeError('Missing evaluations: ' + path)
result['index.json'] = (json.dumps(cases, indent=2) + '\n').encode()
result['SOURCE.json'] = (json.dumps({
'repository': REPOSITORY,
'commit': sha,
'files': {p: hashlib.sha256(v).hexdigest() for p, v in sorted(result.items())},
}, indent=2) + '\n').encode()
return result


def read_snapshot(destination):
if not destination.exists():
return {}
result = {}
for path in destination.rglob('*'):
if path.is_symlink():
raise ValueError('Snapshot must not contain symbolic links')
if path.is_file() and path.suffix != '.meta':
result[path.relative_to(destination).as_posix()] = path.read_bytes()
return result


def verify(destination):
content = read_snapshot(destination)
metadata = json.loads(content.pop('SOURCE.json'))
if metadata['repository'] != REPOSITORY or not re.fullmatch(r'[0-9a-f]{40}', metadata['commit']):
raise ValueError('Invalid source provenance')
hashes = {p: hashlib.sha256(v).hexdigest() for p, v in sorted(content.items())}
if hashes != metadata['files']:
raise ValueError('Snapshot content differs from its recorded checksums')
cases = sorted(p for p in content if p.startswith('cases/') and p.endswith('.json'))
if not cases or json.loads(content['index.json']) != cases:
raise ValueError('Missing, empty, or stale fixture index')
print(f"Verified {len(cases)} fixtures at {metadata['commit']}")


def unity_metadata(destination):
for asset in sorted(destination.rglob('*')):
if asset.suffix == '.meta':
continue
meta = asset.with_name(asset.name + '.meta')
if not meta.exists():
guid = uuid.uuid5(uuid.NAMESPACE_URL, 'ffe-precomputed/' + asset.relative_to(destination).as_posix()).hex
folder = 'folderAsset: yes\n' if asset.is_dir() else ''
importer = 'DefaultImporter' if asset.is_dir() or asset.suffix != '.json' else 'TextScriptImporter'
meta.write_text(f'fileFormatVersion: 2\nguid: {guid}\n{folder}{importer}:\n externalObjects: {{}}\n userData:\n assetBundleName:\n assetBundleVariant:\n')


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--ref', default='main')
parser.add_argument('--source', type=Path, help='Read a local Git checkout at the requested committed revision')
parser.add_argument('--verify', action='store_true', help='Verify local checksums and discovery index without network access')
parser.add_argument('--check', action='store_true', help='Compare with the recorded upstream SHA; do not write')
args = parser.parse_args()
root = Path(__file__).resolve().parent.parent
config = json.loads((root / 'ci/precomputed-fixtures.json').read_text())
relative = Path(config['destination'])
if relative.is_absolute() or '..' in relative.parts:
raise ValueError('Destination must be inside the repository')
destination = root / relative
if not destination.resolve().is_relative_to(root):
raise ValueError('Destination escapes the repository')
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/-]*', args.ref) or '..' in args.ref:
raise ValueError('Invalid fixture revision')
if args.verify:
verify(destination)
return
before = read_snapshot(destination)
ref = json.loads(before['SOURCE.json'])['commit'] if args.check else args.ref
with tempfile.TemporaryDirectory(prefix='ffe-precompute-') as temporary:
source = args.source
if source is None:
source = Path(temporary) / 'source'
subprocess.run(['git', 'init', '-q', str(source)], check=True)
subprocess.run(['git', '-C', str(source), 'fetch', '-q', '--depth=1', REPOSITORY, ref], check=True)
ref = 'FETCH_HEAD'
after = snapshot(source, ref)
changed = {p:v for p,v in before.items() if p != 'SOURCE.json'} != {p:v for p,v in after.items() if p != 'SOURCE.json'}
if args.check:
if before != after:
raise ValueError('Snapshot differs from its recorded source commit')
elif changed:
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.precompute-', dir=destination.parent) as staging:
staged = Path(staging) / 'snapshot'
staged.mkdir()
for path, content in after.items():
output = staged / path
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(content)
# Preserve Unity's existing asset identities when refreshing a snapshot.
if destination.exists():
for meta in destination.rglob('*.meta'):
rel = meta.relative_to(destination)
asset = staged / str(rel)[:-5]
if asset.exists():
(staged / rel).write_bytes(meta.read_bytes())
backup = Path(staging) / 'previous'
destination.rename(backup)
try:
staged.rename(destination)
except BaseException:
backup.rename(destination)
raise
else:
staged.rename(destination)
if config.get('unityMetadata') and not args.check:
unity_metadata(destination)
selected = before if args.check or not changed else after
metadata = json.loads(selected['SOURCE.json'])
count = len(json.loads(selected['index.json']))
print(f"FFE precomputed fixtures: {metadata['commit']}; {count} files; changed={str(changed).lower()}")
if os.environ.get('GITHUB_OUTPUT'):
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f"changed={str(changed).lower()}\nsource_commit={metadata['commit']}\nfixture_count={count}\n")


if __name__ == '__main__':
main()
53 changes: 53 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,56 @@ of your [application sandbox][4], which can't be read by any other app installed
[2]: https://raw.githubusercontent.com/DataDog/dd-sdk-reactnative/main/docs/image_reactnative.png
[3]: https://source.android.com/security/app-sandbox
[4]: https://support.apple.com/guide/security/security-of-runtime-process-sec15bfe098e/web

## Shared precomputed response tests

The Flags module uses the JSON contract from [ffe-system-test-data](https://github.com/DataDog/ffe-system-test-data/tree/main/precomputed-assignments).
This keeps response behavior consistent across mobile SDKs. The snapshot is test data only.
It adds no runtime dependency and requires no Git submodule.

The generated snapshot is at `packages/core/src/flags/__tests__/fixtures/precomputed`. `SOURCE.json` records the source commit and file digests.
The runner discovers all nested JSON cases.
Adding a case requires an upstream fixture change and snapshot refresh. It requires no runner or filename-list change.
Missing or empty fixtures fail the suite. Shared `_include` and `_skip` fields describe API applicability.
Do not add local exclusions to hide failures.

Numeric getters use lossless conversion regardless of the `integer`, `float`, or `number` wire label.
Integer getters accept whole values within their native range. Double getters reject integer values that lose precision.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude, working with Aaron:

[P2] The Number getter returns rounded values.

This line says Double getters reject integer values that lose precision. The offline path returns rounded values with TARGETING_MATCH.

A local probe used configurationFromString, setConfiguration, and getNumberDetails. All three numeric labels returned the same results:

  • 9007199254740993 → 9007199254740992
  • 9223372036854775807 → 9223372036854776000
  • 9007199254740993.5 → 9007199254740994

This behavior predates this PR. JSON.parse rounds the input before decoding. The shared contract requires the default and TYPE_MISMATCH for inexact conversions.

The three numeric-boundaries-* fixtures exclude React Native at the case level. Their evaluation filters permit 27 Number reads. A local probe bypassed the case filters. Eighteen results matched; nine differed because the getter accepted rounded values.

Could we detect precision loss before parsing, or document a React Native exception in the shared contract and correct this section? Please also narrow the fixture filters so the other 18 reads run.

Fractional Integer reads, range overflow, and inexact conversions return `TYPE_MISMATCH`.
React Native exposes one Number getter. Its tests exclude cases that require a separate Integer getter or native integer precision.
The service keeps sending `number`. This work adds no numeric capability.

Upstream CI requires a generated obfuscated companion for each applicable plain fixture. It rejects missing, stale, or orphaned companions. Edit the plain case upstream and regenerate; do not maintain two sets of expectations. Dedicated malformed-encoding cases and fixed digest vectors remain separate.

Cases cover values, defaults, errors, reasons, variants, emission gates, duplicate evaluations, and numeric boundaries.
Obfuscation cases cover fixed digests, salts, Unicode, whitespace, malformed metadata, and plaintext event identity.
React Native has one Number getter. Cross-type Integer/Double cases are excluded by the shared contract. The suite checks the real decoder, public getters, and native tracking arguments. It does not validate native event serialization or delivery. Encoded responses are rejected because this decoder does not support key obfuscation.

Run from the repository root:

```sh
python3 ci/sync-precomputed-fixtures.py --verify
yarn jest --runInBand --runTestsByPath packages/core/src/flags/__tests__/CanonicalPrecomputed.test.ts
```

Refresh the snapshot from a reviewed upstream commit:

```sh
python3 ci/sync-precomputed-fixtures.py --ref <full-upstream-sha>
python3 ci/sync-precomputed-fixtures.py --check
```

The importer copies committed bytes, regenerates the index, and removes deleted cases.
It preserves Unity asset GUIDs where applicable. Do not edit imported JSON or its checksums.
Use `--source <local-checkout> --ref <commit>` to test an upstream fixture branch before publication.
`--verify` checks local integrity without networking. `--check` also compares the recorded upstream commit.

The weekly `Precomputed flag fixtures` workflow opens a draft update PR when fixture content changes.
It uses a short-lived token restricted to the default branch and this workflow.
It waits when an update PR is already open. Normal SDK CI must pass before merge.
Merge the upstream fixture contract before merging its consumer PRs.
The update workflow never merges or publishes an SDK release.

The JavaScript decoder preserves safe numeric serial IDs as bridge strings, including zero.
The Android bridge can forward them. The pinned iOS SDK (3.16.1) lacks serial-ID support.
End-to-end serial-ID parity on iOS requires a native SDK release and bridge update.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"main": "lib/commonjs/index",
"files": [
"src/**",
"!src/flags/__tests__/**",
"lib/**",
"scripts/**",
"android/build.gradle",
Expand Down
Loading
Loading