...

Source file src/oss.terrastruct.com/d2/e2etests-cli/main_test.go

Documentation: oss.terrastruct.com/d2/e2etests-cli

     1  package e2etests_cli
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"net/http"
     9  	"os"
    10  	"path/filepath"
    11  	"regexp"
    12  	"strings"
    13  	"testing"
    14  	"time"
    15  
    16  	"nhooyr.io/websocket"
    17  
    18  	"oss.terrastruct.com/util-go/assert"
    19  	"oss.terrastruct.com/util-go/diff"
    20  	"oss.terrastruct.com/util-go/xmain"
    21  	"oss.terrastruct.com/util-go/xos"
    22  
    23  	"oss.terrastruct.com/d2/d2cli"
    24  	"oss.terrastruct.com/d2/lib/pptx"
    25  	"oss.terrastruct.com/d2/lib/xgif"
    26  )
    27  
    28  func TestCLI_E2E(t *testing.T) {
    29  	t.Parallel()
    30  
    31  	tca := []struct {
    32  		name   string
    33  		serial bool
    34  		skipCI bool
    35  		skip   bool
    36  		run    func(t *testing.T, ctx context.Context, dir string, env *xos.Env)
    37  	}{
    38  		{
    39  			name:   "hello_world_png",
    40  			skipCI: true,
    41  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
    42  				writeFile(t, dir, "hello-world.d2", `x -> y`)
    43  				err := runTestMain(t, ctx, dir, env, "hello-world.d2", "hello-world.png")
    44  				assert.Success(t, err)
    45  				png := readFile(t, dir, "hello-world.png")
    46  				testdataIgnoreDiff(t, ".png", png)
    47  			},
    48  		},
    49  		{
    50  			name:   "hello_world_png_pad",
    51  			skipCI: true,
    52  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
    53  				writeFile(t, dir, "hello-world.d2", `x -> y`)
    54  				err := runTestMain(t, ctx, dir, env, "--pad=400", "hello-world.d2", "hello-world.png")
    55  				assert.Success(t, err)
    56  				png := readFile(t, dir, "hello-world.png")
    57  				testdataIgnoreDiff(t, ".png", png)
    58  			},
    59  		},
    60  		{
    61  			name: "center",
    62  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
    63  				writeFile(t, dir, "hello-world.d2", `x -> y`)
    64  				err := runTestMain(t, ctx, dir, env, "--center=true", "hello-world.d2")
    65  				assert.Success(t, err)
    66  				svg := readFile(t, dir, "hello-world.svg")
    67  				assert.Testdata(t, ".svg", svg)
    68  			},
    69  		},
    70  		{
    71  			name: "flags-panic",
    72  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
    73  				writeFile(t, dir, "hello-world.d2", `x -> y`)
    74  				err := runTestMain(t, ctx, dir, env, "layout", "dagre", "--dagre-nodesep", "50", "hello-world.d2")
    75  				assert.ErrorString(t, err, `failed to wait xmain test: e2etests-cli/d2: failed to unmarshal input to graph: `)
    76  			},
    77  		},
    78  		{
    79  			name: "empty-layer",
    80  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
    81  				writeFile(t, dir, "empty-layer.d2", `layers: { x: {} }`)
    82  				err := runTestMain(t, ctx, dir, env, "empty-layer.d2")
    83  				assert.Success(t, err)
    84  			},
    85  		},
    86  		{
    87  			name: "layer-link",
    88  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
    89  				writeFile(t, dir, "test.d2", `doh: { link: layers.test2 }; layers: { test2: @test2.d2 }`)
    90  				writeFile(t, dir, "test2.d2", `x: I'm a Mac { link: https://example.com }`)
    91  				err := runTestMain(t, ctx, dir, env, "test.d2", "layer-link.svg")
    92  				assert.Success(t, err)
    93  
    94  				assert.TestdataDir(t, filepath.Join(dir, "layer-link"))
    95  			},
    96  		},
    97  		{
    98  			// Skip the empty base board so the animation doesn't show blank for 1400ms
    99  			name: "empty-base",
   100  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   101  				writeFile(t, dir, "empty-base.d2", `steps: {
   102    1: {
   103      a -> b
   104    }
   105    2: {
   106      b -> d
   107      c -> d
   108    }
   109    3: {
   110      d -> e
   111    }
   112  }`)
   113  
   114  				err := runTestMain(t, ctx, dir, env, "--animate-interval=1400", "empty-base.d2")
   115  				assert.Success(t, err)
   116  				svg := readFile(t, dir, "empty-base.svg")
   117  				assert.Testdata(t, ".svg", svg)
   118  				assert.Equal(t, 3, getNumBoards(string(svg)))
   119  			},
   120  		},
   121  		{
   122  			name: "animation",
   123  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   124  				writeFile(t, dir, "animation.d2", `Chicken's plan: {
   125    style.font-size: 35
   126    near: top-center
   127    shape: text
   128  }
   129  
   130  steps: {
   131    1: {
   132      Approach road
   133    }
   134    2: {
   135      Approach road -> Cross road
   136    }
   137    3: {
   138      Cross road -> Make you wonder why
   139    }
   140  }
   141  `)
   142  				err := runTestMain(t, ctx, dir, env, "--animate-interval=1400", "animation.d2")
   143  				assert.Success(t, err)
   144  				svg := readFile(t, dir, "animation.svg")
   145  				assert.Testdata(t, ".svg", svg)
   146  			},
   147  		},
   148  		{
   149  			name: "vars-animation",
   150  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   151  				writeFile(t, dir, "animation.d2", `vars: {
   152    d2-config: {
   153      theme-id: 300
   154    }
   155  }
   156  Chicken's plan: {
   157    style.font-size: 35
   158    near: top-center
   159    shape: text
   160  }
   161  
   162  steps: {
   163    1: {
   164      Approach road
   165    }
   166    2: {
   167      Approach road -> Cross road
   168    }
   169    3: {
   170      Cross road -> Make you wonder why
   171    }
   172  }
   173  `)
   174  				err := runTestMain(t, ctx, dir, env, "--animate-interval=1400", "animation.d2")
   175  				assert.Success(t, err)
   176  				svg := readFile(t, dir, "animation.svg")
   177  				assert.Testdata(t, ".svg", svg)
   178  			},
   179  		},
   180  		{
   181  			name: "linked-path",
   182  			// TODO tempdir is random, resulting in different test results each time with the links
   183  			skip: true,
   184  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   185  				writeFile(t, dir, "linked.d2", `cat: how does the cat go? {
   186    link: layers.cat
   187  }
   188  layers: {
   189    cat: {
   190      home: {
   191        link: _
   192      }
   193      the cat -> meow: goes
   194  
   195      scenarios: {
   196        big cat: {
   197          the cat -> roar: goes
   198        }
   199      }
   200    }
   201  }
   202  `)
   203  				err := runTestMain(t, ctx, dir, env, "linked.d2")
   204  				assert.Success(t, err)
   205  
   206  				assert.TestdataDir(t, filepath.Join(dir, "linked"))
   207  			},
   208  		},
   209  		{
   210  			name: "with-font",
   211  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   212  				writeFile(t, dir, "font.d2", `a: Why do computers get sick often?
   213  b: Because their Windows are always open!
   214  a -> b: italic font
   215  `)
   216  				err := runTestMain(t, ctx, dir, env, "--font-bold=./RockSalt-Regular.ttf", "font.d2")
   217  				assert.Success(t, err)
   218  				svg := readFile(t, dir, "font.svg")
   219  				assert.Testdata(t, ".svg", svg)
   220  			},
   221  		},
   222  		{
   223  			name: "incompatible-animation",
   224  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   225  				writeFile(t, dir, "x.d2", `x -> y`)
   226  				err := runTestMain(t, ctx, dir, env, "--animate-interval=2", "x.d2", "x.png")
   227  				assert.ErrorString(t, err, `failed to wait xmain test: e2etests-cli/d2: bad usage: -animate-interval can only be used when exporting to SVG or GIF.
   228  You provided: .png`)
   229  			},
   230  		},
   231  		{
   232  			name:   "hello_world_png_sketch",
   233  			skipCI: true,
   234  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   235  				writeFile(t, dir, "hello-world.d2", `x -> y`)
   236  				err := runTestMain(t, ctx, dir, env, "--sketch", "hello-world.d2", "hello-world.png")
   237  				assert.Success(t, err)
   238  				png := readFile(t, dir, "hello-world.png")
   239  				// https://github.com/terrastruct/d2/pull/963#pullrequestreview-1323089392
   240  				testdataIgnoreDiff(t, ".png", png)
   241  			},
   242  		},
   243  		{
   244  			name: "target-root",
   245  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   246  				writeFile(t, dir, "target-root.d2", `title: {
   247  	label: Main Plan
   248  }
   249  scenarios: {
   250  	b: {
   251  	title.label: Backup Plan
   252  	}
   253  }`)
   254  				err := runTestMain(t, ctx, dir, env, "--target", "", "target-root.d2", "target-root.svg")
   255  				assert.Success(t, err)
   256  				svg := readFile(t, dir, "target-root.svg")
   257  				assert.Testdata(t, ".svg", svg)
   258  			},
   259  		},
   260  		{
   261  			name: "target-b",
   262  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   263  				writeFile(t, dir, "target-b.d2", `title: {
   264  	label: Main Plan
   265  }
   266  scenarios: {
   267  	b: {
   268  	title.label: Backup Plan
   269  	}
   270  }`)
   271  				err := runTestMain(t, ctx, dir, env, "--target", "b", "target-b.d2", "target-b.svg")
   272  				assert.Success(t, err)
   273  				svg := readFile(t, dir, "target-b.svg")
   274  				assert.Testdata(t, ".svg", svg)
   275  			},
   276  		},
   277  		{
   278  			name: "target-nested-with-special-chars",
   279  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   280  				writeFile(t, dir, "target-nested-with-special-chars.d2", `layers: {
   281  	a: {
   282  		layers: {
   283  			"x / y . z": {
   284  				mad
   285  			}
   286  		}
   287  	}
   288  }`)
   289  				err := runTestMain(t, ctx, dir, env, "--target", `layers.a.layers."x / y . z"`, "target-nested-with-special-chars.d2", "target-nested-with-special-chars.svg")
   290  				assert.Success(t, err)
   291  				svg := readFile(t, dir, "target-nested-with-special-chars.svg")
   292  				assert.Testdata(t, ".svg", svg)
   293  			},
   294  		},
   295  		{
   296  			name: "target-invalid",
   297  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   298  				writeFile(t, dir, "target-invalid.d2", `x -> y`)
   299  				err := runTestMain(t, ctx, dir, env, "--target", "b", "target-invalid.d2", "target-invalid.svg")
   300  				assert.ErrorString(t, err, `failed to wait xmain test: e2etests-cli/d2: failed to compile target-invalid.d2: render target "b" not found`)
   301  			},
   302  		},
   303  		{
   304  			name: "target-nested-index",
   305  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   306  				writeFile(t, dir, "target-nested-index.d2", `a
   307  layers: {
   308  	l1: {
   309  		b
   310  		layers: {
   311  			index: {
   312  				c
   313  				layers: {
   314  					l3: {
   315  						d
   316  					}
   317  				}
   318  			}
   319  		}
   320  	}
   321  }`)
   322  				err := runTestMain(t, ctx, dir, env, "--target", `l1.index.l3`, "target-nested-index.d2", "target-nested-index.svg")
   323  				assert.Success(t, err)
   324  				svg := readFile(t, dir, "target-nested-index.svg")
   325  				assert.Testdata(t, ".svg", svg)
   326  			},
   327  		},
   328  		{
   329  			name: "target-nested-index2",
   330  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   331  				writeFile(t, dir, "target-nested-index2.d2", `a
   332  layers: {
   333  	index: {
   334  		b
   335  		layers: {
   336  			nest1: {
   337  				c
   338  				scenarios: {
   339  					nest2: {
   340  						d
   341  					}
   342  				}
   343  			}
   344  		}
   345  	}
   346  }`)
   347  				err := runTestMain(t, ctx, dir, env, "--target", `index.nest1.nest2`, "target-nested-index2.d2", "target-nested-index2.svg")
   348  				assert.Success(t, err)
   349  				svg := readFile(t, dir, "target-nested-index2.svg")
   350  				assert.Testdata(t, ".svg", svg)
   351  			},
   352  		},
   353  		{
   354  			name: "theme-override",
   355  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   356  				writeFile(t, dir, "theme-override.d2", `
   357  direction: right
   358  vars: {
   359    d2-config: {
   360      theme-overrides: {
   361        B1: "#2E7D32"
   362        B2: "#66BB6A"
   363        B3: "#A5D6A7"
   364        B4: "#C5E1A5"
   365        B5: "#E6EE9C"
   366        B6: "#FFF59D"
   367  
   368        AA2: "#0D47A1"
   369        AA4: "#42A5F5"
   370        AA5: "#90CAF9"
   371  
   372        AB4: "#F44336"
   373        AB5: "#FFCDD2"
   374  
   375        N1: "#2E2E2E"
   376        N2: "#2E2E2E"
   377        N3: "#595959"
   378        N4: "#858585"
   379        N5: "#B1B1B1"
   380        N6: "#DCDCDC"
   381        N7: "#DCDCDC"
   382      }
   383      dark-theme-overrides: {
   384        B1: "#2E7D32"
   385        B2: "#66BB6A"
   386        B3: "#A5D6A7"
   387        B4: "#C5E1A5"
   388        B5: "#E6EE9C"
   389        B6: "#FFF59D"
   390  
   391        AA2: "#0D47A1"
   392        AA4: "#42A5F5"
   393        AA5: "#90CAF9"
   394  
   395        AB4: "#F44336"
   396        AB5: "#FFCDD2"
   397  
   398        N1: "#2E2E2E"
   399        N2: "#2E2E2E"
   400        N3: "#595959"
   401        N4: "#858585"
   402        N5: "#B1B1B1"
   403        N6: "#DCDCDC"
   404        N7: "#DCDCDC"
   405      }
   406    }
   407  }
   408  
   409  logs: {
   410    shape: page
   411    style.multiple: true
   412  }
   413  user: User {shape: person}
   414  network: Network {
   415    tower: Cell Tower {
   416      satellites: {
   417        shape: stored_data
   418        style.multiple: true
   419      }
   420  
   421      satellites -> transmitter
   422      satellites -> transmitter
   423      satellites -> transmitter
   424      transmitter
   425    }
   426    processor: Data Processor {
   427      storage: Storage {
   428        shape: cylinder
   429        style.multiple: true
   430      }
   431    }
   432    portal: Online Portal {
   433      UI
   434    }
   435  
   436    tower.transmitter -> processor: phone logs
   437  }
   438  server: API Server
   439  
   440  user -> network.tower: Make call
   441  network.processor -> server
   442  network.processor -> server
   443  network.processor -> server
   444  
   445  server -> logs
   446  server -> logs
   447  server -> logs: persist
   448  
   449  server -> network.portal.UI: display
   450  user -> network.portal.UI: access {
   451    style.stroke-dash: 3
   452  }
   453  
   454  costumes: {
   455    shape: sql_table
   456    id: int {constraint: primary_key}
   457    silliness: int
   458    monster: int
   459    last_updated: timestamp
   460  }
   461  
   462  monsters: {
   463    shape: sql_table
   464    id: int {constraint: primary_key}
   465    movie: string
   466    weight: int
   467    last_updated: timestamp
   468  }
   469  
   470  costumes.monster -> monsters.id
   471  `)
   472  				err := runTestMain(t, ctx, dir, env, "theme-override.d2", "theme-override.svg")
   473  				assert.Success(t, err)
   474  				svg := readFile(t, dir, "theme-override.svg")
   475  				assert.Testdata(t, ".svg", svg)
   476  				// theme color is used in SVG
   477  				assert.NotEqual(t, -1, strings.Index(string(svg), "#2E2E2E"))
   478  			},
   479  		},
   480  		{
   481  			name: "multiboard/life",
   482  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   483  				writeFile(t, dir, "life.d2", `x -> y
   484  layers: {
   485    core: {
   486      belief
   487      food
   488      diet
   489    }
   490    broker: {
   491      mortgage
   492      realtor
   493    }
   494    stocks: {
   495      TSX
   496      NYSE
   497      NASDAQ
   498    }
   499  }
   500  
   501  scenarios: {
   502    why: {
   503      y -> x
   504    }
   505  }
   506  `)
   507  				err := runTestMain(t, ctx, dir, env, "life.d2")
   508  				assert.Success(t, err)
   509  
   510  				assert.TestdataDir(t, filepath.Join(dir, "life"))
   511  			},
   512  		},
   513  		{
   514  			name: "multiboard/life_index_d2",
   515  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   516  				writeFile(t, dir, "life/index.d2", `x -> y
   517  layers: {
   518    core: {
   519      belief
   520      food
   521      diet
   522    }
   523    broker: {
   524      mortgage
   525      realtor
   526    }
   527    stocks: {
   528      TSX
   529      NYSE
   530      NASDAQ
   531    }
   532  }
   533  
   534  scenarios: {
   535    why: {
   536      y -> x
   537    }
   538  }
   539  `)
   540  				err := runTestMain(t, ctx, dir, env, "life")
   541  				assert.Success(t, err)
   542  
   543  				assert.TestdataDir(t, filepath.Join(dir, "life"))
   544  			},
   545  		},
   546  		{
   547  			name: "internal_linked_pdf",
   548  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   549  				writeFile(t, dir, "in.d2", `cat: how does the cat go? {
   550    link: layers.cat
   551  }
   552  layers: {
   553    cat: {
   554      home: {
   555        link: _
   556      }
   557      the cat -> meow: goes
   558    }
   559  }
   560  `)
   561  				err := runTestMain(t, ctx, dir, env, "in.d2", "out.pdf")
   562  				assert.Success(t, err)
   563  
   564  				pdf := readFile(t, dir, "out.pdf")
   565  				testdataIgnoreDiff(t, ".pdf", pdf)
   566  			},
   567  		},
   568  		{
   569  			name: "export_ppt",
   570  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   571  				writeFile(t, dir, "x.d2", `x -> y`)
   572  				err := runTestMain(t, ctx, dir, env, "x.d2", "x.ppt")
   573  				assert.ErrorString(t, err, `failed to wait xmain test: e2etests-cli/d2: bad usage: D2 does not support ppt exports, did you mean "pptx"?`)
   574  			},
   575  		},
   576  		{
   577  			name:   "how_to_solve_problems_pptx",
   578  			skipCI: true,
   579  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   580  				writeFile(t, dir, "in.d2", `how to solve a hard problem? {
   581  	link: steps.2
   582  }
   583  steps: {
   584  	1: {
   585  		w: write down the problem
   586  	}
   587  	2: {
   588  		w -> t
   589  		t: think really hard about it
   590  	}
   591  	3: {
   592  		t -> w2
   593  		w2: write down the solution
   594  		w2: {
   595  			link: https://d2lang.com
   596  		}
   597  	}
   598  }
   599  `)
   600  				err := runTestMain(t, ctx, dir, env, "in.d2", "how_to_solve_problems.pptx")
   601  				assert.Success(t, err)
   602  
   603  				file := readFile(t, dir, "how_to_solve_problems.pptx")
   604  				err = pptx.Validate(file, 4)
   605  				assert.Success(t, err)
   606  			},
   607  		},
   608  		{
   609  			name:   "how_to_solve_problems_gif",
   610  			skipCI: true,
   611  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   612  				writeFile(t, dir, "in.d2", `how to solve a hard problem? {
   613  	link: steps.2
   614  }
   615  steps: {
   616  	1: {
   617  		w: write down the problem
   618  	}
   619  	2: {
   620  		w -> t
   621  		t: think really hard about it
   622  	}
   623  	3: {
   624  		t -> w2
   625  		w2: write down the solution
   626  		w2: {
   627  			link: https://d2lang.com
   628  		}
   629  	}
   630  }
   631  `)
   632  				err := runTestMain(t, ctx, dir, env, "--animate-interval=10", "in.d2", "how_to_solve_problems.gif")
   633  				assert.Success(t, err)
   634  
   635  				gifBytes := readFile(t, dir, "how_to_solve_problems.gif")
   636  				err = xgif.Validate(gifBytes, 4, 10)
   637  				assert.Success(t, err)
   638  			},
   639  		},
   640  		{
   641  			name:   "one-layer-gif",
   642  			skipCI: true,
   643  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   644  				writeFile(t, dir, "in.d2", `x`)
   645  				err := runTestMain(t, ctx, dir, env, "--animate-interval=10", "in.d2", "out.gif")
   646  				assert.Success(t, err)
   647  
   648  				gifBytes := readFile(t, dir, "out.gif")
   649  				err = xgif.Validate(gifBytes, 1, 10)
   650  				assert.Success(t, err)
   651  			},
   652  		},
   653  		{
   654  			name: "stdin",
   655  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   656  				stdin := bytes.NewBufferString(`x -> y`)
   657  				stdout := &bytes.Buffer{}
   658  				tms := testMain(dir, env, "-")
   659  				tms.Stdin = stdin
   660  				tms.Stdout = stdout
   661  				tms.Start(t, ctx)
   662  				defer tms.Cleanup(t)
   663  				err := tms.Wait(ctx)
   664  				assert.Success(t, err)
   665  
   666  				assert.Testdata(t, ".svg", stdout.Bytes())
   667  			},
   668  		},
   669  		{
   670  			name: "abspath",
   671  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   672  				writeFile(t, dir, "hello-world.d2", `x -> y`)
   673  				err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
   674  				assert.Success(t, err)
   675  				svg := readFile(t, dir, "hello-world.svg")
   676  				assert.Testdata(t, ".svg", svg)
   677  			},
   678  		},
   679  		{
   680  			name: "import",
   681  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   682  				writeFile(t, dir, "hello-world.d2", `x: @x; y: @y; ...@p`)
   683  				writeFile(t, dir, "x.d2", `shape: circle`)
   684  				writeFile(t, dir, "y.d2", `shape: square`)
   685  				writeFile(t, dir, "p.d2", `x -> y`)
   686  				err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
   687  				assert.Success(t, err)
   688  				svg := readFile(t, dir, "hello-world.svg")
   689  				assert.Testdata(t, ".svg", svg)
   690  			},
   691  		},
   692  		{
   693  			name: "import_vars",
   694  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   695  				writeFile(t, dir, "hello-world.d2", `vars: { d2-config: @config }; x -> y`)
   696  				writeFile(t, dir, "config.d2", `theme-id: 200`)
   697  				err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
   698  				assert.Success(t, err)
   699  				svg := readFile(t, dir, "hello-world.svg")
   700  				assert.Testdata(t, ".svg", svg)
   701  			},
   702  		},
   703  		{
   704  			name: "import_spread_nested",
   705  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   706  				writeFile(t, dir, "hello-world.d2", `...@x.y`)
   707  				writeFile(t, dir, "x.d2", `y: { jon; jan }`)
   708  				err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
   709  				assert.Success(t, err)
   710  				svg := readFile(t, dir, "hello-world.svg")
   711  				assert.Testdata(t, ".svg", svg)
   712  			},
   713  		},
   714  		{
   715  			name: "chain_import",
   716  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   717  				writeFile(t, dir, "hello-world.d2", `...@x`)
   718  				writeFile(t, dir, "x.d2", `...@y`)
   719  				writeFile(t, dir, "y.d2", `meow`)
   720  				err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
   721  				assert.Success(t, err)
   722  				svg := readFile(t, dir, "hello-world.svg")
   723  				assert.Testdata(t, ".svg", svg)
   724  			},
   725  		},
   726  		{
   727  			name: "board_import",
   728  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   729  				writeFile(t, dir, "hello-world.d2", `x.link: layers.x; layers: { x: @x }`)
   730  				writeFile(t, dir, "x.d2", `y.link: layers.y; layers: { y: @y }`)
   731  				writeFile(t, dir, "y.d2", `meow`)
   732  				err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
   733  				assert.Success(t, err)
   734  				t.Run("hello-world-x-y", func(t *testing.T) {
   735  					svg := readFile(t, dir, "hello-world/x/y.svg")
   736  					assert.Testdata(t, ".svg", svg)
   737  				})
   738  				t.Run("hello-world-x", func(t *testing.T) {
   739  					svg := readFile(t, dir, "hello-world/x/index.svg")
   740  					assert.Testdata(t, ".svg", svg)
   741  				})
   742  				t.Run("hello-world", func(t *testing.T) {
   743  					svg := readFile(t, dir, "hello-world/index.svg")
   744  					assert.Testdata(t, ".svg", svg)
   745  				})
   746  			},
   747  		},
   748  		{
   749  			name: "vars-config",
   750  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   751  				writeFile(t, dir, "hello-world.d2", `vars: {
   752    d2-config: {
   753      sketch: true
   754      layout-engine: elk
   755    }
   756  }
   757  x -> y -> a.dream
   758  it -> was -> all -> a.dream
   759  i used to read
   760  `)
   761  				env.Setenv("D2_THEME", "1")
   762  				err := runTestMain(t, ctx, dir, env, "--pad=10", "hello-world.d2")
   763  				assert.Success(t, err)
   764  				svg := readFile(t, dir, "hello-world.svg")
   765  				assert.Testdata(t, ".svg", svg)
   766  			},
   767  		},
   768  		{
   769  			name:   "renamed-board",
   770  			skipCI: true,
   771  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   772  				writeFile(t, dir, "in.d2", `cat: how does the cat go? {
   773    link: layers.cat
   774  }
   775  a.link: "https://www.google.com/maps/place/Smoked+Out+BBQ/@37.3848007,-121.9513887,17z/data=!3m1!4b1!4m6!3m5!1s0x808fc9182ad4d38d:0x8e2f39c3e927b296!8m2!3d37.3848007!4d-121.9492!16s%2Fg%2F11gjt85zvf"
   776  label: blah
   777  layers: {
   778    cat: {
   779      label: dog
   780      home: {
   781        link: _
   782      }
   783      the cat -> meow: goes
   784    }
   785  }
   786  `)
   787  				err := runTestMain(t, ctx, dir, env, "in.d2", "out.pdf")
   788  				assert.Success(t, err)
   789  
   790  				pdf := readFile(t, dir, "out.pdf")
   791  				testdataIgnoreDiff(t, ".pdf", pdf)
   792  			},
   793  		},
   794  		{
   795  			name:   "no-nav-pdf",
   796  			skipCI: true,
   797  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   798  				writeFile(t, dir, "in.d2", `cat: how does the cat go? {
   799    link: layers.cat
   800  }
   801  a.link: "https://www.google.com/maps/place/Smoked+Out+BBQ/@37.3848007,-121.9513887,17z/data=!3m1!4b1!4m6!3m5!1s0x808fc9182ad4d38d:0x8e2f39c3e927b296!8m2!3d37.3848007!4d-121.9492!16s%2Fg%2F11gjt85zvf"
   802  label: ""
   803  layers: {
   804    cat: {
   805      label: dog
   806      home: {
   807        link: _
   808      }
   809      the cat -> meow: goes
   810    }
   811  }
   812  `)
   813  				err := runTestMain(t, ctx, dir, env, "in.d2", "out.pdf")
   814  				assert.Success(t, err)
   815  
   816  				pdf := readFile(t, dir, "out.pdf")
   817  				testdataIgnoreDiff(t, ".pdf", pdf)
   818  			},
   819  		},
   820  		{
   821  			name:   "no-nav-pptx",
   822  			skipCI: true,
   823  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   824  				writeFile(t, dir, "in.d2", `cat: how does the cat go? {
   825    link: layers.cat
   826  }
   827  a.link: "https://www.google.com/maps/place/Smoked+Out+BBQ/@37.3848007,-121.9513887,17z/data=!3m1!4b1!4m6!3m5!1s0x808fc9182ad4d38d:0x8e2f39c3e927b296!8m2!3d37.3848007!4d-121.9492!16s%2Fg%2F11gjt85zvf"
   828  label: ""
   829  layers: {
   830    cat: {
   831      label: dog
   832      home: {
   833        link: _
   834      }
   835      the cat -> meow: goes
   836    }
   837  }
   838  `)
   839  				err := runTestMain(t, ctx, dir, env, "in.d2", "out.pptx")
   840  				assert.Success(t, err)
   841  
   842  				file := readFile(t, dir, "out.pptx")
   843  				// err = pptx.Validate(file, 2)
   844  				assert.Success(t, err)
   845  				testdataIgnoreDiff(t, ".pptx", file)
   846  			},
   847  		},
   848  		{
   849  			name: "basic-fmt",
   850  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   851  				writeFile(t, dir, "hello-world.d2", `x ---> y`)
   852  				err := runTestMainPersist(t, ctx, dir, env, "fmt", "hello-world.d2")
   853  				assert.Success(t, err)
   854  				got := readFile(t, dir, "hello-world.d2")
   855  				assert.Equal(t, "x -> y\n", string(got))
   856  			},
   857  		},
   858  		{
   859  			name: "fmt-multiple-files",
   860  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   861  				writeFile(t, dir, "foo.d2", `a ---> b`)
   862  				writeFile(t, dir, "bar.d2", `x ---> y`)
   863  				err := runTestMainPersist(t, ctx, dir, env, "fmt", "foo.d2", "bar.d2")
   864  				assert.Success(t, err)
   865  				gotFoo := readFile(t, dir, "foo.d2")
   866  				gotBar := readFile(t, dir, "bar.d2")
   867  				assert.Equal(t, "a -> b\n", string(gotFoo))
   868  				assert.Equal(t, "x -> y\n", string(gotBar))
   869  			},
   870  		},
   871  		{
   872  			name:   "watch-regular",
   873  			serial: true,
   874  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   875  				writeFile(t, dir, "index.d2", `
   876  a -> b
   877  b.link: layers.cream
   878  
   879  layers: {
   880      cream: {
   881          c -> b
   882      }
   883  }`)
   884  				stderr := &bytes.Buffer{}
   885  				tms := testMain(dir, env, "--watch", "--browser=0", "index.d2")
   886  				tms.Stderr = stderr
   887  
   888  				tms.Start(t, ctx)
   889  				defer func() {
   890  					// Manually close, since watcher is daemon
   891  					err := tms.Signal(ctx, os.Interrupt)
   892  					assert.Success(t, err)
   893  				}()
   894  
   895  				// Wait for watch server to spin up and listen
   896  				urlRE := regexp.MustCompile(`127.0.0.1:([0-9]+)`)
   897  				watchURL, err := waitLogs(ctx, stderr, urlRE)
   898  				assert.Success(t, err)
   899  				stderr.Reset()
   900  
   901  				// Start a client
   902  				c, _, err := websocket.Dial(ctx, fmt.Sprintf("ws://%s/watch", watchURL), nil)
   903  				assert.Success(t, err)
   904  				defer c.CloseNow()
   905  
   906  				// Get the link
   907  				_, msg, err := c.Read(ctx)
   908  				assert.Success(t, err)
   909  				aRE := regexp.MustCompile(`href=\\"([^\"]*)\\"`)
   910  				match := aRE.FindSubmatch(msg)
   911  				assert.Equal(t, 2, len(match))
   912  				linkedPath := match[1]
   913  
   914  				err = getWatchPage(ctx, t, fmt.Sprintf("http://%s/%s", watchURL, linkedPath))
   915  				assert.Success(t, err)
   916  
   917  				successRE := regexp.MustCompile(`broadcasting update to 1 client`)
   918  				_, err = waitLogs(ctx, stderr, successRE)
   919  				assert.Success(t, err)
   920  			},
   921  		},
   922  		{
   923  			name:   "watch-ok-link",
   924  			serial: true,
   925  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   926  				// This link technically works because D2 interprets it as a URL,
   927  				// and on local filesystem, that is whe path where the compilation happens
   928  				// to output it to.
   929  				writeFile(t, dir, "index.d2", `
   930  a -> b
   931  b.link: cream
   932  
   933  layers: {
   934      cream: {
   935          c -> b
   936      }
   937  }`)
   938  				stderr := &bytes.Buffer{}
   939  				tms := testMain(dir, env, "--watch", "--browser=0", "index.d2")
   940  				tms.Stderr = stderr
   941  
   942  				tms.Start(t, ctx)
   943  				defer func() {
   944  					// Manually close, since watcher is daemon
   945  					err := tms.Signal(ctx, os.Interrupt)
   946  					assert.Success(t, err)
   947  				}()
   948  
   949  				// Wait for watch server to spin up and listen
   950  				urlRE := regexp.MustCompile(`127.0.0.1:([0-9]+)`)
   951  				watchURL, err := waitLogs(ctx, stderr, urlRE)
   952  				assert.Success(t, err)
   953  
   954  				stderr.Reset()
   955  
   956  				// Start a client
   957  				c, _, err := websocket.Dial(ctx, fmt.Sprintf("ws://%s/watch", watchURL), nil)
   958  				assert.Success(t, err)
   959  				defer c.CloseNow()
   960  
   961  				// Get the link
   962  				_, msg, err := c.Read(ctx)
   963  				assert.Success(t, err)
   964  				aRE := regexp.MustCompile(`href=\\"([^\"]*)\\"`)
   965  				match := aRE.FindSubmatch(msg)
   966  				assert.Equal(t, 2, len(match))
   967  				linkedPath := match[1]
   968  
   969  				err = getWatchPage(ctx, t, fmt.Sprintf("http://%s/%s", watchURL, linkedPath))
   970  				assert.Success(t, err)
   971  
   972  				successRE := regexp.MustCompile(`broadcasting update to 1 client`)
   973  				_, err = waitLogs(ctx, stderr, successRE)
   974  				assert.Success(t, err)
   975  			},
   976  		},
   977  		{
   978  			name:   "watch-bad-link",
   979  			serial: true,
   980  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
   981  				// Just verify we don't crash even with a bad link (it's treated as a URL, which users might have locally)
   982  				writeFile(t, dir, "index.d2", `
   983  a -> b
   984  b.link: dream
   985  
   986  layers: {
   987      cream: {
   988          c -> b
   989      }
   990  }`)
   991  				stderr := &bytes.Buffer{}
   992  				tms := testMain(dir, env, "--watch", "--browser=0", "index.d2")
   993  				tms.Stderr = stderr
   994  
   995  				tms.Start(t, ctx)
   996  				defer func() {
   997  					// Manually close, since watcher is daemon
   998  					err := tms.Signal(ctx, os.Interrupt)
   999  					assert.Success(t, err)
  1000  				}()
  1001  
  1002  				// Wait for watch server to spin up and listen
  1003  				urlRE := regexp.MustCompile(`127.0.0.1:([0-9]+)`)
  1004  				watchURL, err := waitLogs(ctx, stderr, urlRE)
  1005  				assert.Success(t, err)
  1006  				stderr.Reset()
  1007  
  1008  				// Start a client
  1009  				c, _, err := websocket.Dial(ctx, fmt.Sprintf("ws://%s/watch", watchURL), nil)
  1010  				assert.Success(t, err)
  1011  				defer c.CloseNow()
  1012  
  1013  				// Get the link
  1014  				_, msg, err := c.Read(ctx)
  1015  				assert.Success(t, err)
  1016  				aRE := regexp.MustCompile(`href=\\"([^\"]*)\\"`)
  1017  				match := aRE.FindSubmatch(msg)
  1018  				assert.Equal(t, 2, len(match))
  1019  				linkedPath := match[1]
  1020  
  1021  				err = getWatchPage(ctx, t, fmt.Sprintf("http://%s/%s", watchURL, linkedPath))
  1022  				assert.Success(t, err)
  1023  
  1024  				successRE := regexp.MustCompile(`broadcasting update to 1 client`)
  1025  				_, err = waitLogs(ctx, stderr, successRE)
  1026  				assert.Success(t, err)
  1027  			},
  1028  		},
  1029  		{
  1030  			name:   "watch-imported-file",
  1031  			serial: true,
  1032  			run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
  1033  				writeFile(t, dir, "a.d2", `
  1034  ...@b
  1035  `)
  1036  				writeFile(t, dir, "b.d2", `
  1037  x
  1038  `)
  1039  				stderr := &bytes.Buffer{}
  1040  				tms := testMain(dir, env, "--watch", "--browser=0", "a.d2")
  1041  				tms.Stderr = stderr
  1042  
  1043  				tms.Start(t, ctx)
  1044  				defer func() {
  1045  					err := tms.Signal(ctx, os.Interrupt)
  1046  					assert.Success(t, err)
  1047  				}()
  1048  
  1049  				// Wait for first compilation to finish
  1050  				doneRE := regexp.MustCompile(`successfully compiled a.d2`)
  1051  				_, err := waitLogs(ctx, stderr, doneRE)
  1052  				assert.Success(t, err)
  1053  				stderr.Reset()
  1054  
  1055  				// Test that writing an imported file will cause recompilation
  1056  				writeFile(t, dir, "b.d2", `
  1057  x -> y
  1058  `)
  1059  				bRE := regexp.MustCompile(`detected change in b.d2`)
  1060  				_, err = waitLogs(ctx, stderr, bRE)
  1061  				assert.Success(t, err)
  1062  				stderr.Reset()
  1063  
  1064  				// Test burst of both files changing
  1065  				writeFile(t, dir, "a.d2", `
  1066  ...@b
  1067  hey
  1068  `)
  1069  				writeFile(t, dir, "b.d2", `
  1070  x
  1071  hi
  1072  `)
  1073  				bothRE := regexp.MustCompile(`detected change in a.d2, b.d2`)
  1074  				_, err = waitLogs(ctx, stderr, bothRE)
  1075  				assert.Success(t, err)
  1076  
  1077  				// Wait for that compilation to fully finish
  1078  				_, err = waitLogs(ctx, stderr, doneRE)
  1079  				assert.Success(t, err)
  1080  				stderr.Reset()
  1081  
  1082  				// Update the main file to no longer have that dependency
  1083  				writeFile(t, dir, "a.d2", `
  1084  a
  1085  `)
  1086  				_, err = waitLogs(ctx, stderr, doneRE)
  1087  				assert.Success(t, err)
  1088  				stderr.Reset()
  1089  
  1090  				// Change b
  1091  				writeFile(t, dir, "b.d2", `
  1092  y
  1093  `)
  1094  				// Change a to retrigger compilation
  1095  				// The test works by seeing that the report only says "a" changed, otherwise testing for omission of compilation from "b" would require waiting
  1096  				writeFile(t, dir, "a.d2", `
  1097  c
  1098  `)
  1099  
  1100  				_, err = waitLogs(ctx, stderr, doneRE)
  1101  				assert.Success(t, err)
  1102  			},
  1103  		},
  1104  	}
  1105  
  1106  	ctx := context.Background()
  1107  	for _, tc := range tca {
  1108  		tc := tc
  1109  		t.Run(tc.name, func(t *testing.T) {
  1110  			if !tc.serial {
  1111  				t.Parallel()
  1112  			}
  1113  
  1114  			if tc.skipCI && os.Getenv("CI") != "" {
  1115  				t.SkipNow()
  1116  			}
  1117  			if tc.skip {
  1118  				t.SkipNow()
  1119  			}
  1120  
  1121  			ctx, cancel := context.WithTimeout(ctx, time.Minute*5)
  1122  			defer cancel()
  1123  
  1124  			dir, cleanup := assert.TempDir(t)
  1125  			defer cleanup()
  1126  
  1127  			env := xos.NewEnv(nil)
  1128  
  1129  			tc.run(t, ctx, dir, env)
  1130  		})
  1131  	}
  1132  }
  1133  
  1134  // We do not run the CLI in its own process even though that makes it not truly e2e to
  1135  // test whether we're cleaning up state correctly.
  1136  func testMain(dir string, env *xos.Env, args ...string) *xmain.TestState {
  1137  	return &xmain.TestState{
  1138  		Run:  d2cli.Run,
  1139  		Env:  env,
  1140  		Args: append([]string{"e2etests-cli/d2"}, args...),
  1141  		PWD:  dir,
  1142  	}
  1143  }
  1144  
  1145  func runTestMain(tb testing.TB, ctx context.Context, dir string, env *xos.Env, args ...string) error {
  1146  	err := runTestMainPersist(tb, ctx, dir, env, args...)
  1147  	if err != nil {
  1148  		return err
  1149  	}
  1150  	removeD2Files(tb, dir)
  1151  	return nil
  1152  }
  1153  
  1154  func runTestMainPersist(tb testing.TB, ctx context.Context, dir string, env *xos.Env, args ...string) error {
  1155  	tms := testMain(dir, env, args...)
  1156  	tms.Start(tb, ctx)
  1157  	defer tms.Cleanup(tb)
  1158  	err := tms.Wait(ctx)
  1159  	if err != nil {
  1160  		return err
  1161  	}
  1162  	return nil
  1163  }
  1164  
  1165  func writeFile(tb testing.TB, dir, fp, data string) {
  1166  	tb.Helper()
  1167  	err := os.MkdirAll(filepath.Dir(filepath.Join(dir, fp)), 0755)
  1168  	assert.Success(tb, err)
  1169  	assert.WriteFile(tb, filepath.Join(dir, fp), []byte(data), 0644)
  1170  }
  1171  
  1172  func readFile(tb testing.TB, dir, fp string) []byte {
  1173  	tb.Helper()
  1174  	return assert.ReadFile(tb, filepath.Join(dir, fp))
  1175  }
  1176  
  1177  func removeD2Files(tb testing.TB, dir string) {
  1178  	ea, err := os.ReadDir(dir)
  1179  	assert.Success(tb, err)
  1180  
  1181  	for _, e := range ea {
  1182  		if e.IsDir() {
  1183  			removeD2Files(tb, filepath.Join(dir, e.Name()))
  1184  			continue
  1185  		}
  1186  		ext := filepath.Ext(e.Name())
  1187  		if ext == ".d2" {
  1188  			assert.Remove(tb, filepath.Join(dir, e.Name()))
  1189  		}
  1190  	}
  1191  }
  1192  
  1193  func testdataIgnoreDiff(tb testing.TB, ext string, got []byte) {
  1194  	_ = diff.Testdata(filepath.Join("testdata", tb.Name()), ext, got)
  1195  }
  1196  
  1197  // getNumBoards gets the number of boards in an SVG file through a non-robust pattern search
  1198  // If the renderer changes, this must change
  1199  func getNumBoards(svg string) int {
  1200  	return strings.Count(svg, `class="d2`)
  1201  }
  1202  
  1203  var errRE = regexp.MustCompile(`err:`)
  1204  
  1205  func waitLogs(ctx context.Context, buf *bytes.Buffer, pattern *regexp.Regexp) (string, error) {
  1206  	ticker := time.NewTicker(10 * time.Millisecond)
  1207  	defer ticker.Stop()
  1208  	var match string
  1209  	for i := 0; i < 1000 && match == ""; i++ {
  1210  		select {
  1211  		case <-ticker.C:
  1212  			out := buf.String()
  1213  			match = pattern.FindString(out)
  1214  			errMatch := errRE.FindString(out)
  1215  			if errMatch != "" {
  1216  				return "", errors.New(buf.String())
  1217  			}
  1218  		case <-ctx.Done():
  1219  			ticker.Stop()
  1220  			return "", fmt.Errorf("could not match pattern in log. logs: %s", buf.String())
  1221  		}
  1222  	}
  1223  	if match == "" {
  1224  		return "", errors.New(buf.String())
  1225  	}
  1226  
  1227  	return match, nil
  1228  }
  1229  
  1230  func getWatchPage(ctx context.Context, t *testing.T, page string) error {
  1231  	req, err := http.NewRequestWithContext(ctx, "GET", page, nil)
  1232  	if err != nil {
  1233  		return err
  1234  	}
  1235  
  1236  	var httpClient = &http.Client{}
  1237  	resp, err := httpClient.Do(req)
  1238  	if err != nil {
  1239  		return err
  1240  	}
  1241  	defer resp.Body.Close()
  1242  	if resp.StatusCode != 200 {
  1243  		return fmt.Errorf("status code: %d", resp.StatusCode)
  1244  	}
  1245  	return nil
  1246  }
  1247  

View as plain text