| 1 | //go:build browser |
| 2 | |
| 3 | package slidestest |
| 4 | |
| 5 | // The image drive (plan task 6): add an image block, describe it, pick |
| 6 | // a real PNG off disk, and watch it go browser → app → object store → |
| 7 | // back onto the stage as an <img> the server rendered. Then reload, and |
| 8 | // find it still there. |
| 9 | // |
| 10 | // It runs against the store development binds (blobs.Dir), which has no |
| 11 | // presigning, so the PUT is the same-origin one this app accepts — |
| 12 | // exactly the path scripts/run-dev.sh takes. The presigned half is |
| 13 | // covered without a browser in assets_test.go, because a real bucket is |
| 14 | // not something a test can have. |
| 15 | |
| 16 | import ( |
| 17 | "os" |
| 18 | "path/filepath" |
| 19 | "strings" |
| 20 | "testing" |
| 21 | |
| 22 | "github.com/chromedp/chromedp" |
| 23 | ) |
| 24 | |
| 25 | // TestBrowserUploadsAnImage is the whole §2.7 path in a real browser. |
| 26 | func TestBrowserUploadsAnImage(t *testing.T) { |
| 27 | rig, mailer := newRigWithStore(t) |
| 28 | browserSignIn(t, rig, mailer) |
| 29 | |
| 30 | // A real file on disk: chromedp hands the <input type="file"> a |
| 31 | // path, the same as a person picking one. |
| 32 | png := filepath.Join(t.TempDir(), "dot.png") |
| 33 | if err := os.WriteFile(png, onePixelPNG, 0o600); err != nil { |
| 34 | t.Fatalf("write the test PNG: %v", err) |
| 35 | } |
| 36 | |
| 37 | var deck string |
| 38 | rig.Run(chromedp.Evaluate(`(document.querySelector('a[href^="/d/"]')?.getAttribute('href') ?? '')`, &deck)) |
| 39 | |
| 40 | const bound = `document.querySelector('[slides-stage] [data-block="heading"]')?.getAttribute('contenteditable') === 'true'` |
| 41 | const saved = `document.querySelector('[estilo-save-status]').dataset.state === 'saved'` |
| 42 | rig.Run( |
| 43 | chromedp.Navigate(rig.Origin+deck+"/edit"), |
| 44 | chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), |
| 45 | waitFor(bound), |
| 46 | ) |
| 47 | // With a store bound the page says so and the picker is live. |
| 48 | rig.Run( |
| 49 | waitFor(`document.querySelector('[slides-editor]').dataset.store === '1'`), |
| 50 | waitFor(`document.querySelector('[slides-asset-input]').disabled === false`), |
| 51 | ) |
| 52 | |
| 53 | // An image block, from the inspector's own Add block menu. The |
| 54 | // menu is a <details>, so it is opened before its button is |
| 55 | // pressed — the same two moves a person makes. |
| 56 | rig.Run( |
| 57 | chromedp.Evaluate(`(() => { |
| 58 | document.querySelector('[slides-add-block]').open = true |
| 59 | document.querySelector('[data-add-block="image"]').click() |
| 60 | return true |
| 61 | })()`, nil), |
| 62 | waitFor(`document.querySelector('[slides-stage] [data-block="image"]') !== null`), |
| 63 | waitFor(saved), |
| 64 | ) |
| 65 | // An empty frame until there is something in it (§2.4's |
| 66 | // data-missing-asset), which is what the person is about to fix. |
| 67 | rig.Run(waitFor(`document.querySelector('[slides-stage] [data-block="image"]').dataset.missingAsset === 'true'`)) |
| 68 | rig.Screen("[slides-editor]", "an empty image block") |
| 69 | |
| 70 | // Describe it first: the file input refuses to move bytes for an |
| 71 | // image with no alt text and no decorative mark, and says so. |
| 72 | rig.Run( |
| 73 | chromedp.SetUploadFiles(`[slides-asset-input]`, []string{png}, chromedp.ByQuery), |
| 74 | waitFor(`document.querySelector('[slides-asset-status]').dataset.state === 'error'`), |
| 75 | waitFor(`document.querySelector('[slides-asset-status]').textContent.trim().length > 0`), |
| 76 | ) |
| 77 | var refused string |
| 78 | rig.Run(chromedp.Text(`[slides-asset-status]`, &refused, chromedp.ByQuery)) |
| 79 | if refused == "" || strings.HasPrefix(refused, "slides.") { |
| 80 | t.Fatalf("the refusal reads %q, want a resolved catalog sentence", refused) |
| 81 | } |
| 82 | |
| 83 | rig.Run( |
| 84 | setValue(`#rst-block-alt`, "A single red dot"), |
| 85 | waitFor(saved), |
| 86 | waitFor(`document.querySelector('[slides-stage] [data-block="image"]') !== null`), |
| 87 | ) |
| 88 | |
| 89 | // Now the upload. The stage's <img> is the SERVER's rendering, |
| 90 | // re-fetched after the op saved — the browser never draws a slide. |
| 91 | rig.Run( |
| 92 | chromedp.SetUploadFiles(`[slides-asset-input]`, []string{png}, chromedp.ByQuery), |
| 93 | waitFor(`document.querySelector('[slides-stage] [data-block="image"] img') !== null`), |
| 94 | waitFor(saved), |
| 95 | ) |
| 96 | rig.Screen("[slides-editor]", "the image on the stage") |
| 97 | |
| 98 | var src, alt string |
| 99 | rig.Run( |
| 100 | chromedp.AttributeValue(`[slides-stage] [data-block="image"] img`, "src", &src, nil, chromedp.ByQuery), |
| 101 | chromedp.AttributeValue(`[slides-stage] [data-block="image"] img`, "alt", &alt, nil, chromedp.ByQuery), |
| 102 | ) |
| 103 | wantPrefix := deck + "/assets/" |
| 104 | if !strings.HasPrefix(src, wantPrefix) { |
| 105 | t.Errorf("the image src is %q, want it under %q", src, wantPrefix) |
| 106 | } |
| 107 | if alt != "A single red dot" { |
| 108 | t.Errorf("alt = %q, want the description that was typed", alt) |
| 109 | } |
| 110 | // The ref in the src is the content hash the store keyed the bytes |
| 111 | // under, not the deck's own id for the asset. |
| 112 | if ref := strings.TrimPrefix(src, wantPrefix); ref != hashOf(onePixelPNG) { |
| 113 | t.Errorf("the src names %q, want the content hash %s", ref, hashOf(onePixelPNG)) |
| 114 | } |
| 115 | // And the browser really fetched it: an <img> that 404'd would have |
| 116 | // failed the rig, and a decoded one has a natural width. |
| 117 | var decoded bool |
| 118 | rig.Run(chromedp.Evaluate(`(() => { |
| 119 | const i = document.querySelector('[slides-stage] [data-block="image"] img') |
| 120 | return !!i && i.complete && i.naturalWidth > 0 |
| 121 | })()`, &decoded)) |
| 122 | if !decoded { |
| 123 | t.Error("the browser did not decode the image it was served") |
| 124 | } |
| 125 | |
| 126 | // The status line says it finished, in the catalog's words. |
| 127 | var done string |
| 128 | rig.Run(chromedp.Text(`[slides-asset-status]`, &done, chromedp.ByQuery)) |
| 129 | if done == "" || strings.HasPrefix(done, "slides.") { |
| 130 | t.Errorf("the status line reads %q after a finished upload", done) |
| 131 | } |
| 132 | |
| 133 | // Nothing is unused while the block points at it. |
| 134 | var unused int |
| 135 | rig.Run(chromedp.Evaluate(`document.querySelectorAll('[slides-unused-list] > li').length`, &unused)) |
| 136 | if unused != 0 { |
| 137 | t.Errorf("%d unused images while one block uses the only one", unused) |
| 138 | } |
| 139 | |
| 140 | // A reload: the deck's own document carries it, so the server |
| 141 | // renders the same picture without the bundle having to. |
| 142 | rig.Run( |
| 143 | chromedp.Reload(), |
| 144 | chromedp.WaitVisible(`[slides-editor]`, chromedp.ByQuery), |
| 145 | waitFor(bound), |
| 146 | waitFor(`document.querySelector('[slides-stage] [data-block="image"] img') !== null`), |
| 147 | ) |
| 148 | rig.Screen("[slides-editor]", "the image after a reload") |
| 149 | var again string |
| 150 | rig.Run(chromedp.AttributeValue(`[slides-stage] [data-block="image"] img`, "src", &again, nil, chromedp.ByQuery)) |
| 151 | if again != src { |
| 152 | t.Errorf("after a reload the image src is %q, want %q", again, src) |
| 153 | } |
| 154 | |
| 155 | // Delete the block and the registration stays (§2.7), which is |
| 156 | // what the Unused images list is for — and Remove clears it. |
| 157 | rig.Run( |
| 158 | chromedp.Click(`[slides-stage] [data-block="image"]`, chromedp.ByQuery), |
| 159 | waitFor(`document.querySelector('[slides-inspector-form][data-section="block"]').checkVisibility() === true`), |
| 160 | chromedp.Click(`[data-command="block-delete"]`, chromedp.ByQuery), |
| 161 | waitFor(`document.querySelector('[slides-stage] [data-block="image"]') === null`), |
| 162 | waitFor(saved), |
| 163 | waitFor(`document.querySelectorAll('[slides-unused-list] > li').length === 1`), |
| 164 | ) |
| 165 | rig.Screen("[slides-editor]", "one unused image") |
| 166 | rig.Run( |
| 167 | chromedp.Click(`[slides-unused-list] [data-remove-asset]`, chromedp.ByQuery), |
| 168 | waitFor(`document.querySelectorAll('[slides-unused-list] > li').length === 0`), |
| 169 | waitFor(`document.querySelector('[slides-unused-none]').checkVisibility() === true`), |
| 170 | waitFor(saved), |
| 171 | ) |
| 172 | } |
| 173 | |