Skip to content
Merged
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
7 changes: 4 additions & 3 deletions docs/DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,10 @@ touches a compared surface, reflected in the parity matrix.
order (`pickFeatureProperties` for a Feature, `pickConfigProperties` for the
configuration) and `&`, `<`, `>` unescaped, as in TS. Within a property's object value
(a mount object, `customizations`, `portsAttributes`, …) the keys are sorted, because a
decoded Go map keeps no order, where TS keeps the order they were written in; an entry
given in another order (a base image's label written by another tool) is likewise
rewritten in pick order. The parsed metadata is the same; only the label's bytes differ
decoded Go map keeps no order, where TS keeps the order they were written in;
`containerEnv` alone keeps TS's order (the array-index keys ascending, then the others
as written). An entry given in another order (a base image's label written by another
tool) is likewise rewritten in pick order. The parsed metadata is the same; only the label's bytes differ
(`internal/imagemeta/testdata/ts-oracle/imagemeta.json` records both cases).
- **Command results write nested objects with their keys sorted.** TS writes each result
with `JSON.stringify`, so properties come in insertion order. Go matches that order for
Expand Down
55 changes: 55 additions & 0 deletions docs/parity/parity-matrix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@ initial_cases:
notes: "readLockfile rethrows JSON.parse's SyntaxError and runAsyncHandler console.errors it: 'SyntaxError: Expected ',' or '}' after property value in JSON at position 15 (line 1 column 16)' on stderr, exit 1, nothing on stdout. Go used to ignore the lockfile and print the graph."

# --- Wave A: new cases (some uncovered divergences, now fixed) ---
- id: features.resolve-dependencies-option-key-order-success
lane: contract
command: features
priority: p1
docker_required: false
setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f $WORKSPACE/.devcontainer/g; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\",\"dependsOn\":{\"./g\":{\"yy\":\"1\",\"3\":\"d\",\"bb\":\"2\"}}}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s' '{\"id\":\"g\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/g/devcontainer-feature.json; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./f\":{\"zz\":\"1\",\"10\":\"t\",\"a\":\"b\",\"2\":\"s\"}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE"
cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}"
ts_cmd: "features resolve-dependencies --workspace-folder ${WORKSPACE}"
asserts: [exit_code, stdout_text]
class: output-shape
current_status: match
notes: "installOrder's options are each Feature's value, the user's options object from devcontainer.json or the dependsOn options from devcontainer-feature.json, as JSON.parse built it: the array-index keys first, ascending, then the others as declared. Go sorted them."

- id: features-publish.missing-required
lane: contract
command: features-publish
Expand Down Expand Up @@ -1135,6 +1148,20 @@ initial_cases:
current_status: match
notes: "TS getDevcontainerMetadata writes the Feature's raw values to the devcontainer.metadata label (imageMetadata.ts uses .raw), so a Feature mount and lifecycle command keep ${localEnv:HOME} and are resolved on the host that runs `up`. Go resolved the Feature's mounts before building the label, baking the build host's HOME into the image."

- id: build.metadata-label-container-env-order-success
lane: runtime
command: build
priority: p1
docker_required: true
setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; IMG=parity-env-base-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/img $WORKSPACE/.devcontainer/f; printf 'FROM alpine:3\\nLABEL devcontainer.metadata=\\047[{\"containerEnv\":{\"ZB\":\"base\",\"10\":\"base\",\"AB\":\"base\"}}]\\047\\n' > $WORKSPACE/img/Dockerfile; docker build -q -t $IMG $WORKSPACE/img >/dev/null; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '#!/bin/sh\\ntrue\\n' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf '%s' '{\"image\":\"'$IMG'\",\"containerEnv\":{\"ZED\":\"cfg\",\"AB\":\"cfg\",\"2\":\"cfg\",\"ALPHA\":\"cfg\"},\"features\":{\"./f\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE"
ts_cmd: "build --workspace-folder ${WORKSPACE} --image-name parity-${PARITY_CASE_ID}-${PARITY_SIDE}"
verify_cmd: "docker image inspect -f '{{index .Config.Labels \"devcontainer.metadata\"}}' ${IMAGE_NAME}"
cleanup_cmd: "docker rmi -f parity-${PARITY_CASE_ID}-${PARITY_SIDE} parity-env-base-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true"
asserts: [exit_code, stdout_normalized]
class: build-runtime
current_status: match
notes: "TS getDevcontainerMetadata writes the base image's entries as JSON.parse read them from its label and the config's raw containerEnv as devcontainer.json declares it, so each containerEnv keeps JS property order (the array-index keys 2 and 10 first, ascending, then the others as declared). Go sorted each entry's containerEnv (10 before 2, AB before ZB, ALPHA before ZED)."

- id: up.compose-feature-host-variables-raw-label-success
lane: runtime
command: up
Expand All @@ -1149,6 +1176,20 @@ initial_cases:
current_status: match
notes: "The compose service's image and container labels keep the Feature's ${localEnv:HOME} raw, as TS writes them; the bind mount the container gets is resolved on this host (TS getDevcontainerMetadata's featureRaw.map(substitute)). Go resolved the Feature's mounts before building the label."

- id: up.compose-container-env-order-success
lane: runtime
command: up
priority: p1
docker_required: true
setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; IMG=parity-env-base-${PARITY_CASE_ID}-${PARITY_SIDE}; USER_DATA_FOLDER=$WORKSPACE/user-data; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/img $WORKSPACE/.devcontainer/f $USER_DATA_FOLDER; printf 'FROM alpine:3\\nLABEL devcontainer.metadata=\\047[{\"containerEnv\":{\"ZB\":\"base\",\"10\":\"base\",\"AB\":\"base\"}}]\\047\\n' > $WORKSPACE/img/Dockerfile; docker build -q -t $IMG $WORKSPACE/img >/dev/null; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\"}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '#!/bin/sh\\ntrue\\n' > $WORKSPACE/.devcontainer/f/install.sh; chmod +x $WORKSPACE/.devcontainer/f/install.sh; printf 'services:\\n app:\\n image: %s\\n command: sleep infinity\\n' $IMG > $WORKSPACE/.devcontainer/docker-compose.yml; printf '%s' '{\"dockerComposeFile\":\"docker-compose.yml\",\"service\":\"app\",\"workspaceFolder\":\"/\",\"containerEnv\":{\"ZED\":\"cfg\",\"AB\":\"cfg\",\"2\":\"cfg\",\"ALPHA\":\"cfg\"},\"features\":{\"./f\":{}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE; echo USER_DATA_FOLDER=$USER_DATA_FOLDER"
ts_cmd: "up --workspace-folder ${WORKSPACE} --skip-post-create --update-remote-user-uid-default never --id-label parity.case=${PARITY_CASE_ID}-${PARITY_SIDE} --user-data-folder ${USER_DATA_FOLDER}"
verify_cmd: "grep -A6 '^ environment:' ${USER_DATA_FOLDER}/docker-compose/docker-compose.devcontainer.containerFeatures-*; docker inspect -f '{{index .Config.Labels \"devcontainer.metadata\"}}' ${CONTAINER_ID}; docker image inspect -f '{{index .Config.Labels \"devcontainer.metadata\"}}' $(docker inspect -f '{{.Image}}' ${CONTAINER_ID})"
cleanup_cmd: "docker compose --project-name ${COMPOSE_PROJECT_NAME} -f ${WORKSPACE}/.devcontainer/docker-compose.yml down -t 0 >/dev/null 2>&1; docker rmi -f parity-env-base-${PARITY_CASE_ID}-${PARITY_SIDE} >/dev/null 2>&1; rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; true"
asserts: [exit_code, stdout_normalized]
class: up-runtime
current_status: match
notes: "TS writes the compose override's environment from the merged containerEnv (mergeConfiguration's Object.assign over the base image's label, the Features and the config): a key keeps the position it first had, the array-index keys 2 and 10 first, and the value of the last entry that sets it. The container and image labels keep each entry's containerEnv in JS property order. Go sorted the environment and each label entry's containerEnv."

- id: read-configuration.feature-host-variables-merged-success
lane: runtime
command: read-configuration
Expand Down Expand Up @@ -1396,6 +1437,20 @@ initial_cases:
current_status: match
notes: "A local Feature entry is devcontainer-feature.json as JSON.parse reads it, every property kept (keywords, x, empty, an empty option), a repeated one at its first position with its last value, the array-index properties 2 and 10 first, numbers as JS writes them (1.5), and the options' properties in the file's order; then name, value, included, cachePath and consecutiveId (processFeatureIdentifier, fetchFeatures). updateFromOldProperties (containerFeaturesConfiguration.ts) moves extensions and settings into customizations.vscode, settings spread under vscode's. Go used to write only the properties it has fields for, in the struct's order, with options sorted and the deprecated properties dropped."

- id: read-configuration.features-configuration-value-key-order-success
lane: contract
command: read-configuration
priority: p1
docker_required: false
setup_cmd: "WORKSPACE=/tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}; rm -rf $WORKSPACE; mkdir -p $WORKSPACE/.devcontainer/f; printf '%s' '{\"id\":\"f\",\"version\":\"1.0.0\",\"options\":{\"a\":{\"type\":\"string\",\"default\":\"z\"}}}' > $WORKSPACE/.devcontainer/f/devcontainer-feature.json; printf '%s\\n' '#!/bin/sh' 'true' > $WORKSPACE/.devcontainer/f/install.sh; printf '%s' '{\"image\":\"alpine:3\",\"features\":{\"./f\":{\"zz\":\"1\",\"10\":\"t\",\"a\":\"b\",\"2\":\"s\"}}}' > $WORKSPACE/.devcontainer/devcontainer.json; echo WORKSPACE=$WORKSPACE"
cleanup_cmd: "rm -rf /tmp/parity-${PARITY_CASE_ID}-${PARITY_SIDE}"
ts_cmd: "read-configuration --workspace-folder ${WORKSPACE} --include-features-configuration"
key_order: ["featuresConfiguration.featureSets.0.features.0.value"]
asserts: [exit_code, stdout_normalized]
class: output-shape
current_status: match
notes: "A Feature's value is the user's options object from devcontainer.json as JSON.parse built it, so it keeps JS property order: the array-index keys 2 and 10 first, ascending, then zz and a as declared. Go sorted it (10, 2, a, zz)."

- id: read-configuration.features-configuration-oci-feature-key-order-success
lane: runtime
command: read-configuration
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error {
defer engine.Close()

run := &buildRunner{log: logger, docker: dockerClient, engine: engine, opts: opts}
if run.configLabel, err = configLabelEntry(loadResult.RawConfig, false); err != nil {
if run.configLabel, err = configLabelEntry(loadResult.RawConfig, loadResult.Config.ContainerEnvOrder, false); err != nil {
return writeErrorResult(out, err.Error())
}

Expand Down
9 changes: 2 additions & 7 deletions internal/cli/compose_override.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import (
"fmt"
"os"
"regexp"
"slices"
"strings"

"github.com/devcontainers/cli/internal/features"
"github.com/devcontainers/cli/internal/imagemeta"
)

Expand Down Expand Up @@ -212,12 +212,7 @@ func generateComposeFeaturesOverride(in composeFeaturesOverride) string {
}
if len(merged.ContainerEnv) > 0 {
b.WriteString("\n environment:")
keys := make([]string, 0, len(merged.ContainerEnv))
for k := range merged.ContainerEnv {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
for _, k := range features.PropertyOrder(merged.ContainerEnv, merged.ContainerEnvOrder) {
v := strings.ReplaceAll(merged.ContainerEnv[k], "\n", `\n`)
v = escapeComposeDollar(v)
v = strings.ReplaceAll(v, "'", "''")
Expand Down
16 changes: 16 additions & 0 deletions internal/cli/compose_override_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,19 @@ func TestComposeFeaturesBuild(t *testing.T) {
t.Errorf("label-only content = %q, want %q", label.DockerfileContent, want)
}
}

func TestGenerateComposeFeaturesOverrideWritesTheMergedEnvInPropertyOrder(t *testing.T) {
entries := imagemeta.ReadMetadataFromLabels(map[string]string{
imagemeta.MetadataLabel: `[{"containerEnv":{"ZB":"base","10":"base","AB":"base"}}]`,
}, nil)
entries = append(entries, imagemeta.Entry{
ContainerEnv: map[string]string{"ZED": "cfg", "AB": "cfg", "2": "cfg", "ALPHA": "cfg"},
ContainerEnvOrder: []string{"ZED", "AB", "2", "ALPHA"},
})
got := generateComposeFeaturesOverride(composeFeaturesOverride{Service: "app", Merged: imagemeta.MergeConfiguration(entries)})
_, env, _ := strings.Cut(got, " environment:\n")
want := " - '2=cfg'\n - '10=base'\n - 'ZB=base'\n - 'AB=cfg'\n - 'ZED=cfg'\n - 'ALPHA=cfg'\n"
if env != want {
t.Errorf("environment:\n%s\nwant:\n%s", env, want)
}
}
4 changes: 3 additions & 1 deletion internal/cli/features_resolve_deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ func realFeaturesResolveDepsCmd() *cobra.Command {
}
var opts any
if len(fs.Features) > 0 {
opts = fs.Features[0].Value
if opts, err = features.JSValue(fs.Features[0].Value, fs.Features[0].ValueOrder); err != nil {
return err
}
}
entries = append(entries, installEntry{ID: id, Options: opts})
}
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/metadata_label.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ var labelConfigProperties = []string{
"otherPortsAttributes", "forwardPorts", "shutdownAction", "updateRemoteUserUID", "hostRequirements",
}

func configLabelEntry(raw map[string]any, omitRemoteEnv bool) (imagemeta.Entry, error) {
func configLabelEntry(raw map[string]any, envOrder []string, omitRemoteEnv bool) (imagemeta.Entry, error) {
picked := map[string]any{}
for _, k := range labelConfigProperties {
if v, ok := raw[k]; ok && (!omitRemoteEnv || k != "remoteEnv") {
Expand All @@ -32,6 +32,7 @@ func configLabelEntry(raw map[string]any, omitRemoteEnv bool) (imagemeta.Entry,
if err != nil {
return imagemeta.Entry{}, fmt.Errorf("devcontainer.metadata entry of the configuration: %w", err)
}
entry.ContainerEnvOrder = envOrder
return entry, nil
}

Expand Down Expand Up @@ -75,6 +76,9 @@ func hostMetadataSubstitution(hostSub config.HostSubContext, idLabels []string)
if err != nil {
return err
}
for i := range resolved {
resolved[i].ContainerEnvOrder = (*entries)[i].ContainerEnvOrder
}
*entries = resolved
return nil
}
Expand Down
31 changes: 28 additions & 3 deletions internal/cli/metadata_label_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestConfigLabelEntryPicksTheRawConfig(t *testing.T) {
}`), &raw); err != nil {
t.Fatal(err)
}
entry, err := configLabelEntry(raw, false)
entry, err := configLabelEntry(raw, nil, false)
if err != nil {
t.Fatal(err)
}
Expand All @@ -34,14 +34,14 @@ func TestConfigLabelEntryPicksTheRawConfig(t *testing.T) {
if string(got) != want {
t.Errorf("entry = %s\nwant %s", got, want)
}
omitted, err := configLabelEntry(raw, true)
omitted, err := configLabelEntry(raw, nil, true)
if err != nil {
t.Fatal(err)
}
if omitted.RemoteEnv != nil {
t.Errorf("remoteEnv kept with omitRemoteEnv: %v", omitted.RemoteEnv)
}
if _, err := configLabelEntry(map[string]any{"init": "yes"}, false); err == nil {
if _, err := configLabelEntry(map[string]any{"init": "yes"}, nil, false); err == nil {
t.Error("an init that is no boolean: want an error")
}
}
Expand Down Expand Up @@ -164,3 +164,28 @@ func TestHostMetadataSubstitutionLeavesTheRawEntriesRaw(t *testing.T) {
}
}
}

func TestMetadataEntriesKeepTheContainerEnvOrder(t *testing.T) {
raw := map[string]any{"containerEnv": map[string]any{"ZED": "1", "10": "2", "ALPHA": "${localEnv:U}", "2": "4"}}
order := []string{"ZED", "10", "ALPHA", "2"}
entry, err := configLabelEntry(raw, order, false)
if err != nil {
t.Fatal(err)
}
want := `[{"containerEnv":{"2":"4","10":"2","ZED":"1","ALPHA":"${localEnv:U}"}}]`
if got := containerMetadataLabel([]imagemeta.Entry{entry}); got != want {
t.Errorf("label = %s\nwant %s", got, want)
}
entries := []imagemeta.Entry{entry}
if err := hostMetadataSubstitution(config.HostSubContext{Env: map[string]string{"U": "me"}}, nil)(&entries); err != nil {
t.Fatal(err)
}
want = `[{"containerEnv":{"2":"4","10":"2","ZED":"1","ALPHA":"me"}}]`
if got := containerMetadataLabel(entries); got != want {
t.Errorf("substituted label = %s\nwant %s", got, want)
}
cfg := &config.DevContainer{ContainerEnv: map[string]string{"ZED": "1", "ALPHA": "3"}, ContainerEnvOrder: []string{"ZED", "ALPHA"}}
if got, want := containerMetadataLabel([]imagemeta.Entry{configMetadataEntry(cfg, false)}), `[{"containerEnv":{"ZED":"1","ALPHA":"3"}}]`; got != want {
t.Errorf("config entry label = %s\nwant %s", got, want)
}
}
1 change: 1 addition & 0 deletions internal/cli/read_configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ func configMetadataEntry(cfg *config.DevContainer, omitRemoteEnv bool) imagemeta
ShutdownAction: cfg.ShutdownAction,
UpdateRemoteUserUID: cfg.UpdateRemoteUserUID,
ContainerEnv: cfg.ContainerEnv,
ContainerEnvOrder: cfg.ContainerEnvOrder,
CapAdd: cfg.CapAdd,
SecurityOpt: cfg.SecurityOpt,
Mounts: metadataMounts(cfg),
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ func runUp(ctx context.Context, out Output, opts *upOpts) error {
}
run.idLabels = idLabels
run.substitute = hostMetadataSubstitution(loadResult.HostSub, idLabels)
if run.configLabel, err = configLabelEntry(loadResult.RawConfig, opts.omitConfigRemoteEnvFromMeta); err != nil {
if run.configLabel, err = configLabelEntry(loadResult.RawConfig, loadResult.Config.ContainerEnvOrder, opts.omitConfigRemoteEnvFromMeta); err != nil {
return writeErrorResult(out, err.Error())
}

Expand Down
18 changes: 18 additions & 0 deletions internal/features/jsobject.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,24 @@ func toJS(v any) (any, error) {
return parseJS(data)
}

// JSValue is toJS of v, with the keys of a map v in
// PropertyOrder(v, declared).
func JSValue(v any, declared []string) (any, error) {
m, ok := v.(map[string]any)
if !ok {
return toJS(v)
}
obj := newJSObject()
for _, k := range PropertyOrder(m, declared) {
js, err := toJS(m[k])
if err != nil {
return nil, err
}
obj.set(k, js)
}
return obj, nil
}

func jsTruthy(v any) bool {
switch x := v.(type) {
case nil:
Expand Down
21 changes: 21 additions & 0 deletions internal/features/jsobject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,24 @@ func TestPropertyOrderListsKeysAsObjectKeys(t *testing.T) {
t.Errorf("PropertyOrder of an empty map = %v", got)
}
}

func TestJSValueOrdersAMapsKeys(t *testing.T) {
for _, tc := range []struct {
v any
order []string
want string
}{
{map[string]any{"zz": "1", "10": "t", "a": map[string]any{"y": 1, "b": 2}, "2": "s", "m": nil}, []string{"zz", "gone", "10", "a", "2"}, `{"2":"s","10":"t","zz":"1","a":{"b":2,"y":1},"m":null}`},
{"latest", []string{"x"}, `"latest"`},
{true, nil, `true`},
{nil, nil, `null`},
} {
js, err := JSValue(tc.v, tc.order)
if err != nil {
t.Fatal(err)
}
if got, err := json.Marshal(js); err != nil || string(got) != tc.want {
t.Errorf("JSValue(%v, %v) = %s, %v; want %s", tc.v, tc.order, got, err, tc.want)
}
}
}
5 changes: 3 additions & 2 deletions internal/features/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,18 @@ func (f Feature) MarshalJSON() ([]byte, error) {
feature := newJSObject()
for _, k := range f.SourceKeys {
var v any
var order []string
switch k {
case "id":
v = f.ID
case "name":
v = f.Name
case "value":
v = f.Value
v, order = f.Value, f.ValueOrder
case "included":
v = f.Included
}
js, err := toJS(v)
js, err := JSValue(v, order)
if err != nil {
return nil, err
}
Expand Down
9 changes: 9 additions & 0 deletions internal/features/types_order_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,12 @@ func TestFeatureMarshalJSONMovesDeprecatedProperties(t *testing.T) {
t.Error("extensions concatenated onto a number, which has no concat: want an error")
}
}

func TestFeatureMarshalJSONWritesTheValueInPropertyOrder(t *testing.T) {
f := Feature{ID: "x", Included: true, SourceKeys: LocalFeatureKeys,
Value: map[string]any{"zz": "1", "10": "t", "a": true, "2": "s"}, ValueOrder: []string{"zz", "10", "a", "2"}}
got, err := json.Marshal(f)
if want := `{"id":"x","name":"","value":{"2":"s","10":"t","zz":"1","a":true},"included":true}`; err != nil || string(got) != want {
t.Errorf("got %s, %v\nwant %s", got, err, want)
}
}
Loading
Loading